ProcuremenSupplierScore.php 65 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683
  1. <?php
  2. namespace app\common\library;
  3. use think\Db;
  4. use think\Log;
  5. /**
  6. * 协助采购 — 供应商服务评分
  7. *
  8. * 表分工:
  9. * - supplier_service_score:订单明细(开标后按 ccydh×供应商写入,含本单总分 score)
  10. * - supplier_service_final_score:月度评审表(开标后写入本月询价供应商名称;分项与等级人工填写,已存在不覆盖)
  11. *
  12. * 总分 = 商务/技术得分×质量% + 价格得分 [+ 交货得分×交货%]
  13. * 价格得分 = (本单最低单价合计 / 本供应商单价合计) × 价格权重% × 100
  14. * (计算过程保留四位小数,得分保留两位)
  15. * 商务/技术得分 = 月度评审表「商务/技术得分」(当月按上月起往前查,都无则默认 50)
  16. * 权重取 supplier_score_rule 中 is_default=1 的规则;开标验证通过后写入订单明细评分,并补齐月度评审供应商名称
  17. */
  18. class ProcuremenSupplierScore
  19. {
  20. public const TABLE_RULE = 'supplier_score_rule';
  21. /** 订单明细评分表 */
  22. public const TABLE_SCORE = 'supplier_service_score';
  23. /** 月度汇总表(总分 + 最终得分) */
  24. public const TABLE_FINAL = 'supplier_service_final_score';
  25. /** @var bool|null */
  26. protected static $schemaReady = null;
  27. public static function ensureSchema(): void
  28. {
  29. if (self::$schemaReady === true) {
  30. return;
  31. }
  32. // 跨请求缓存:避免每次打开详情都 SHOW COLUMNS / ALTER 探测
  33. try {
  34. if (\think\Cache::get('procuremen_supplier_score_schema_ok_v1')) {
  35. self::$schemaReady = true;
  36. return;
  37. }
  38. } catch (\Throwable $e) {
  39. }
  40. try {
  41. Db::query('SELECT 1 FROM `' . self::TABLE_RULE . '` LIMIT 1');
  42. Db::query('SELECT 1 FROM `' . self::TABLE_SCORE . '` LIMIT 1');
  43. self::$schemaReady = true;
  44. self::ensureDefaultRule();
  45. self::ensureScoreWeightColumns();
  46. self::ensureLeadColumns();
  47. self::ensureIntegerColumns();
  48. self::ensureFinalScoreTable();
  49. try {
  50. \think\Cache::set('procuremen_supplier_score_schema_ok_v1', 1, 86400);
  51. } catch (\Throwable $e) {
  52. }
  53. return;
  54. } catch (\Throwable $e) {
  55. }
  56. $sqlFile = APP_PATH . 'extra' . DIRECTORY_SEPARATOR . 'supplier_score_install.sql';
  57. if (is_file($sqlFile)) {
  58. $sql = file_get_contents($sqlFile);
  59. if (is_string($sql) && $sql !== '') {
  60. foreach (preg_split('/;\s*[\r\n]+/', $sql) as $stmt) {
  61. $stmt = trim($stmt);
  62. if ($stmt === '' || stripos($stmt, 'CREATE TABLE') === false) {
  63. continue;
  64. }
  65. try {
  66. Db::execute($stmt);
  67. } catch (\Throwable $ignore) {
  68. }
  69. }
  70. }
  71. }
  72. self::$schemaReady = true;
  73. self::ensureDefaultRule();
  74. self::ensureScoreWeightColumns();
  75. self::ensureLeadColumns();
  76. self::ensureIntegerColumns();
  77. self::ensureFinalScoreTable();
  78. try {
  79. \think\Cache::set('procuremen_supplier_score_schema_ok_v1', 1, 86400);
  80. } catch (\Throwable $e) {
  81. }
  82. }
  83. /** 月度记录表(商务技术分 / 价格分 / 最终得分,仅本页展示与导出) */
  84. public static function ensureFinalScoreTable(): void
  85. {
  86. $ddl = "CREATE TABLE IF NOT EXISTS `" . self::TABLE_FINAL . "` (
  87. `id` int unsigned NOT NULL AUTO_INCREMENT,
  88. `ym` char(7) NOT NULL DEFAULT '' COMMENT '年月 YYYY-MM',
  89. `company_name` varchar(128) NOT NULL DEFAULT '' COMMENT '供应商名称',
  90. `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '月度总分(兼容旧字段)',
  91. `quality_score` decimal(10,2) DEFAULT NULL COMMENT '商务技术分(人工,空=未填)',
  92. `price_score` decimal(10,2) DEFAULT NULL COMMENT '价格分(人工,空=未填)',
  93. `delivery_score` decimal(10,2) DEFAULT NULL COMMENT '交货分(人工,空=未填)',
  94. `final_score` decimal(10,2) DEFAULT NULL COMMENT '最终得分(人工,空=未填)',
  95. `score_grade` char(1) NOT NULL DEFAULT '' COMMENT '评分等级 A/B/C/D',
  96. `qp_manual` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=商务技术分/价格分/交货分已人工修改',
  97. `createtime` datetime DEFAULT NULL,
  98. `updatetime` datetime DEFAULT NULL,
  99. PRIMARY KEY (`id`),
  100. UNIQUE KEY `uk_ym_company` (`ym`,`company_name`),
  101. KEY `idx_ym` (`ym`)
  102. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商月度评审记录'";
  103. try {
  104. Db::execute($ddl);
  105. } catch (\Throwable $e) {
  106. Log::write('supplier monthly score table: ' . $e->getMessage(), 'error');
  107. }
  108. self::ensureMonthlyScoreColumns();
  109. }
  110. /** 历史月度表补商务技术分/价格分等字段 */
  111. protected static function ensureMonthlyScoreColumns(): void
  112. {
  113. $adds = [
  114. 'score' => "ADD COLUMN `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '月度总分(兼容旧字段)' AFTER `company_name`",
  115. 'quality_score' => "ADD COLUMN `quality_score` decimal(10,2) DEFAULT NULL COMMENT '商务技术分(人工,空=未填)' AFTER `score`",
  116. 'price_score' => "ADD COLUMN `price_score` decimal(10,2) DEFAULT NULL COMMENT '价格分(人工,空=未填)' AFTER `quality_score`",
  117. 'delivery_score' => "ADD COLUMN `delivery_score` decimal(10,2) DEFAULT NULL COMMENT '交货分(人工,空=未填)' AFTER `price_score`",
  118. 'score_grade' => "ADD COLUMN `score_grade` char(1) NOT NULL DEFAULT '' COMMENT '评分等级 A/B/C/D' AFTER `final_score`",
  119. 'qp_manual' => "ADD COLUMN `qp_manual` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=商务技术分/价格分/交货分已人工修改' AFTER `score_grade`",
  120. ];
  121. foreach ($adds as $col => $ddl) {
  122. try {
  123. $cols = Db::query("SHOW COLUMNS FROM `" . self::TABLE_FINAL . "` LIKE '{$col}'");
  124. if (!is_array($cols) || $cols === []) {
  125. Db::execute("ALTER TABLE `" . self::TABLE_FINAL . "` {$ddl}");
  126. }
  127. } catch (\Throwable $e) {
  128. Log::write("supplier monthly {$col} column: " . $e->getMessage(), 'error');
  129. }
  130. }
  131. // 分数字段允许 NULL:空=未填,列表不显示 0
  132. foreach ([
  133. 'quality_score' => '商务技术分(人工,空=未填)',
  134. 'price_score' => '价格分(人工,空=未填)',
  135. 'delivery_score' => '交货分(人工,空=未填)',
  136. 'final_score' => '最终得分(人工,空=未填)',
  137. ] as $col => $comment) {
  138. try {
  139. $info = Db::query("SHOW COLUMNS FROM `" . self::TABLE_FINAL . "` LIKE '{$col}'");
  140. if (is_array($info) && $info !== []) {
  141. $null = strtoupper((string)($info[0]['Null'] ?? ''));
  142. if ($null !== 'YES') {
  143. Db::execute(
  144. "ALTER TABLE `" . self::TABLE_FINAL . "` MODIFY COLUMN `{$col}` decimal(10,2) DEFAULT NULL COMMENT '{$comment}'"
  145. );
  146. }
  147. }
  148. } catch (\Throwable $e) {
  149. Log::write("supplier monthly {$col} null: " . $e->getMessage(), 'error');
  150. }
  151. }
  152. }
  153. /** 规范化评分等级:空 / A / B / C / D */
  154. public static function normalizeScoreGrade($raw): string
  155. {
  156. $g = strtoupper(trim((string)$raw));
  157. if (in_array($g, ['A', 'B', 'C', 'D'], true)) {
  158. return $g;
  159. }
  160. return '';
  161. }
  162. /**
  163. * 按订单明细重算并写入某月评审记录(商务技术分/价格分;保留已填最终得分)
  164. *
  165. * @param array<int, string>|null $onlyCompanies 仅同步这些供应商;null=当月全部
  166. */
  167. public static function syncMonthlyScoresForYm(string $ym, ?array $onlyCompanies = null): void
  168. {
  169. $ym = trim($ym);
  170. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  171. return;
  172. }
  173. self::ensureSchema();
  174. self::ensureFinalScoreTable();
  175. try {
  176. $query = Db::table(self::TABLE_SCORE)
  177. ->where('ym', $ym)
  178. ->field('company_name,score,quality_score,price_score,lead_score');
  179. if (is_array($onlyCompanies) && $onlyCompanies !== []) {
  180. $names = [];
  181. foreach ($onlyCompanies as $n) {
  182. $n = trim((string)$n);
  183. if ($n !== '') {
  184. $names[$n] = true;
  185. }
  186. }
  187. if ($names === []) {
  188. return;
  189. }
  190. $query->where('company_name', 'in', array_keys($names));
  191. }
  192. $rows = $query->select();
  193. } catch (\Throwable $e) {
  194. // 无 lead_score 列时回退
  195. try {
  196. $query = Db::table(self::TABLE_SCORE)
  197. ->where('ym', $ym)
  198. ->field('company_name,score,quality_score,price_score');
  199. if (is_array($onlyCompanies) && $onlyCompanies !== []) {
  200. $names = [];
  201. foreach ($onlyCompanies as $n) {
  202. $n = trim((string)$n);
  203. if ($n !== '') {
  204. $names[$n] = true;
  205. }
  206. }
  207. if ($names === []) {
  208. return;
  209. }
  210. $query->where('company_name', 'in', array_keys($names));
  211. }
  212. $rows = $query->select();
  213. } catch (\Throwable $e2) {
  214. Log::write('supplier monthly sync read: ' . $e2->getMessage(), 'error');
  215. return;
  216. }
  217. }
  218. if (!is_array($rows)) {
  219. return;
  220. }
  221. $agg = [];
  222. foreach ($rows as $r) {
  223. if (!is_array($r)) {
  224. continue;
  225. }
  226. $cn = trim((string)($r['company_name'] ?? ''));
  227. if ($cn === '') {
  228. continue;
  229. }
  230. if (!isset($agg[$cn])) {
  231. $agg[$cn] = [
  232. 'score_sum' => 0.0,
  233. 'quality_sum' => 0.0,
  234. 'price_sum' => 0.0,
  235. 'delivery_sum' => 0.0,
  236. 'cnt' => 0,
  237. ];
  238. }
  239. $agg[$cn]['score_sum'] += (float)($r['score'] ?? 0);
  240. $agg[$cn]['quality_sum'] += (float)($r['quality_score'] ?? 0);
  241. $agg[$cn]['price_sum'] += (float)($r['price_score'] ?? 0);
  242. $agg[$cn]['delivery_sum'] += (float)($r['lead_score'] ?? 0);
  243. $agg[$cn]['cnt']++;
  244. }
  245. $now = date('Y-m-d H:i:s');
  246. foreach ($agg as $cn => $a) {
  247. $cnt = (int)$a['cnt'];
  248. $avgScore = $cnt > 0 ? round($a['score_sum'] / $cnt, 2) : 0.0;
  249. $avgQuality = $cnt > 0 ? round($a['quality_sum'] / $cnt, 2) : 0.0;
  250. $avgPrice = $cnt > 0 ? round($a['price_sum'] / $cnt, 2) : 0.0;
  251. $avgDelivery = $cnt > 0 ? round($a['delivery_sum'] / $cnt, 2) : 0.0;
  252. try {
  253. $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
  254. if (is_array($exists) && $exists !== []) {
  255. $upd = [
  256. 'score' => $avgScore,
  257. 'updatetime' => $now,
  258. ];
  259. // 人工改过商务技术分/价格分/交货分后,同步不再覆盖
  260. if ((int)($exists['qp_manual'] ?? 0) !== 1) {
  261. $upd['quality_score'] = $avgQuality;
  262. $upd['price_score'] = $avgPrice;
  263. $upd['delivery_score'] = $avgDelivery;
  264. }
  265. Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update($upd);
  266. } else {
  267. Db::table(self::TABLE_FINAL)->insert([
  268. 'ym' => $ym,
  269. 'company_name' => $cn,
  270. 'score' => $avgScore,
  271. 'quality_score' => $avgQuality,
  272. 'price_score' => $avgPrice,
  273. 'delivery_score' => $avgDelivery,
  274. 'final_score' => null,
  275. 'score_grade' => '',
  276. 'qp_manual' => 0,
  277. 'createtime' => $now,
  278. 'updatetime' => $now,
  279. ]);
  280. }
  281. } catch (\Throwable $e) {
  282. Log::write('supplier monthly sync write: ' . $e->getMessage(), 'error');
  283. }
  284. }
  285. }
  286. /**
  287. * 读取某月各供应商最终得分(仅已人工填写的)
  288. *
  289. * @return array<string, float> company_name => final_score
  290. */
  291. public static function loadFinalScoreMapByYm(string $ym): array
  292. {
  293. $ym = trim($ym);
  294. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  295. return [];
  296. }
  297. self::ensureFinalScoreTable();
  298. try {
  299. $rows = Db::table(self::TABLE_FINAL)
  300. ->where('ym', $ym)
  301. ->whereNotNull('final_score')
  302. ->field('company_name,final_score')
  303. ->select();
  304. } catch (\Throwable $e) {
  305. return [];
  306. }
  307. if (!is_array($rows)) {
  308. return [];
  309. }
  310. $out = [];
  311. foreach ($rows as $r) {
  312. if (!is_array($r)) {
  313. continue;
  314. }
  315. $cn = trim((string)($r['company_name'] ?? ''));
  316. if ($cn === '') {
  317. continue;
  318. }
  319. $out[$cn] = round((float)($r['final_score'] ?? 0), 2);
  320. }
  321. return $out;
  322. }
  323. /**
  324. * 读取库中可选分数:NULL/空串 → null;合法数字 → float
  325. *
  326. * @param mixed $raw
  327. */
  328. public static function readOptionalScoreValue($raw): ?float
  329. {
  330. if ($raw === null) {
  331. return null;
  332. }
  333. if (is_string($raw) && trim($raw) === '') {
  334. return null;
  335. }
  336. if (!is_numeric($raw)) {
  337. return null;
  338. }
  339. return round((float)$raw, 2);
  340. }
  341. /**
  342. * 可选分数展示:空不显示 0
  343. *
  344. * @param float|null $n
  345. */
  346. public static function formatOptionalScore(?float $n): string
  347. {
  348. if ($n === null) {
  349. return '';
  350. }
  351. return self::formatScore($n);
  352. }
  353. /**
  354. * 开标后把本月询价供应商名称写入月度评审表:不存在则插入空行,已存在不改(分数人工填)
  355. *
  356. * @param array<int, string> $companyNames
  357. * @return int 新插入条数
  358. */
  359. public static function ensureMonthlySupplierNames(string $ym, array $companyNames): int
  360. {
  361. $ym = trim($ym);
  362. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  363. return 0;
  364. }
  365. $uniq = [];
  366. foreach ($companyNames as $n) {
  367. $n = trim((string)$n);
  368. if ($n !== '') {
  369. $uniq[$n] = true;
  370. }
  371. }
  372. if ($uniq === []) {
  373. return 0;
  374. }
  375. self::ensureFinalScoreTable();
  376. $now = date('Y-m-d H:i:s');
  377. $inserted = 0;
  378. foreach (array_keys($uniq) as $cn) {
  379. try {
  380. $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
  381. if (is_array($exists) && $exists !== []) {
  382. continue;
  383. }
  384. Db::table(self::TABLE_FINAL)->insert([
  385. 'ym' => $ym,
  386. 'company_name' => $cn,
  387. 'score' => 0,
  388. 'quality_score' => null,
  389. 'price_score' => null,
  390. 'delivery_score' => null,
  391. 'final_score' => null,
  392. 'score_grade' => '',
  393. 'qp_manual' => 0,
  394. 'createtime' => $now,
  395. 'updatetime' => $now,
  396. ]);
  397. $inserted++;
  398. } catch (\Throwable $e) {
  399. // 并发下唯一键冲突视为已存在
  400. if (stripos($e->getMessage(), 'Duplicate') === false) {
  401. Log::write('supplier monthly ensure name: ' . $e->getMessage(), 'error');
  402. }
  403. }
  404. }
  405. return $inserted;
  406. }
  407. /**
  408. * 按当月订单评分表中的供应商,补齐月度评审表名称(仅新增空行)
  409. */
  410. public static function ensureMonthlySupplierNamesFromOrderYm(string $ym): void
  411. {
  412. $ym = trim($ym);
  413. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  414. return;
  415. }
  416. self::ensureSchema();
  417. self::ensureFinalScoreTable();
  418. try {
  419. $rows = Db::table(self::TABLE_SCORE)
  420. ->where('ym', $ym)
  421. ->field('company_name')
  422. ->group('company_name')
  423. ->select();
  424. } catch (\Throwable $e) {
  425. return;
  426. }
  427. if (!is_array($rows) || $rows === []) {
  428. return;
  429. }
  430. $names = [];
  431. foreach ($rows as $r) {
  432. if (!is_array($r)) {
  433. continue;
  434. }
  435. $cn = trim((string)($r['company_name'] ?? ''));
  436. if ($cn !== '') {
  437. $names[] = $cn;
  438. }
  439. }
  440. self::ensureMonthlySupplierNames($ym, $names);
  441. }
  442. /**
  443. * 读取某月评审记录(仅读月度表;先按当月开标/询价供应商补齐名称)
  444. *
  445. * @return array<string, array{score:float,quality_score:?float,price_score:?float,delivery_score:?float,final_score:?float,final_saved:int,score_grade:string,score_date:string}>
  446. */
  447. public static function loadMonthlyMapByYm(string $ym): array
  448. {
  449. $ym = trim($ym);
  450. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  451. return [];
  452. }
  453. self::ensureFinalScoreTable();
  454. // 本月询价(开标后已写入订单评分表)的供应商:名称不存在则加空行,已存在不动
  455. self::ensureMonthlySupplierNamesFromOrderYm($ym);
  456. try {
  457. $rows = Db::table(self::TABLE_FINAL)
  458. ->where('ym', $ym)
  459. ->field('company_name,score,quality_score,price_score,delivery_score,final_score,score_grade,ym')
  460. ->select();
  461. } catch (\Throwable $e) {
  462. return [];
  463. }
  464. if (!is_array($rows)) {
  465. return [];
  466. }
  467. $out = [];
  468. foreach ($rows as $r) {
  469. if (!is_array($r)) {
  470. continue;
  471. }
  472. $cn = trim((string)($r['company_name'] ?? $r['Company_name'] ?? ''));
  473. if ($cn === '') {
  474. continue;
  475. }
  476. $quality = self::readOptionalScoreValue($r['quality_score'] ?? null);
  477. $price = self::readOptionalScoreValue($r['price_score'] ?? null);
  478. $delivery = self::readOptionalScoreValue($r['delivery_score'] ?? null);
  479. $final = self::readOptionalScoreValue($r['final_score'] ?? null);
  480. $out[$cn] = [
  481. 'score' => round((float)($r['score'] ?? 0), 2),
  482. 'quality_score' => $quality,
  483. 'price_score' => $price,
  484. 'delivery_score' => $delivery,
  485. 'final_score' => $final,
  486. 'final_saved' => $final !== null ? 1 : 0,
  487. 'score_grade' => self::normalizeScoreGrade($r['score_grade'] ?? ''),
  488. // 列表「日期」列按 ym(YYYY-MM)展示,不到日
  489. 'score_date' => self::formatScoreYm($r['ym'] ?? $ym),
  490. ];
  491. }
  492. return $out;
  493. }
  494. /**
  495. * 规范为 YYYY-MM(月度评审日期)
  496. *
  497. * @param mixed $raw
  498. */
  499. public static function formatScoreYm($raw): string
  500. {
  501. $raw = trim((string)$raw);
  502. if (preg_match('/^\d{4}-\d{2}$/', $raw)) {
  503. return $raw;
  504. }
  505. if (preg_match('/^(\d{4})\D+(\d{1,2})/', $raw, $m)) {
  506. return sprintf('%04d-%02d', (int)$m[1], (int)$m[2]);
  507. }
  508. return '';
  509. }
  510. /**
  511. * @param mixed $raw
  512. */
  513. public static function formatScoreDateYmd($raw): string
  514. {
  515. $raw = trim((string)$raw);
  516. if ($raw === '' || stripos($raw, '0000-00-00') === 0) {
  517. return '';
  518. }
  519. $ts = strtotime(str_replace('T', ' ', $raw));
  520. return ($ts !== false && $ts > 0) ? date('Y-m-d', $ts) : '';
  521. }
  522. /**
  523. * 解析可选分数:空=null;非法=false;合法=float
  524. *
  525. * @param mixed $raw
  526. * @return float|null|false
  527. */
  528. protected static function parseOptionalScore($raw)
  529. {
  530. if ($raw === null) {
  531. return null;
  532. }
  533. if (is_string($raw) && trim($raw) === '') {
  534. return null;
  535. }
  536. if (!is_numeric($raw)) {
  537. return false;
  538. }
  539. return round((float)$raw, 2);
  540. }
  541. /**
  542. * 批量保存某月评审分:商务技术分 / 价格分 / 交货分 / 最终得分 / 评分等级
  543. *
  544. * @param array<int, array{company_name?:string,quality_score?:mixed,price_score?:mixed,delivery_score?:mixed,final_score?:mixed,score_grade?:mixed}> $items
  545. * @return array{done:int,saved:int,cleared:int,error:string}
  546. */
  547. public static function saveFinalScoresForYm(string $ym, array $items): array
  548. {
  549. $ym = trim($ym);
  550. $result = ['done' => 0, 'saved' => 0, 'cleared' => 0, 'error' => ''];
  551. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  552. $result['error'] = '月份无效';
  553. return $result;
  554. }
  555. if ($items === []) {
  556. $result['error'] = '没有可保存的数据';
  557. return $result;
  558. }
  559. self::ensureFinalScoreTable();
  560. try {
  561. Db::query('SELECT 1 FROM `' . self::TABLE_FINAL . '` LIMIT 1');
  562. } catch (\Throwable $e) {
  563. $result['error'] = '月度汇总表未创建,请联系管理员';
  564. return $result;
  565. }
  566. $now = date('Y-m-d H:i:s');
  567. foreach ($items as $it) {
  568. if (!is_array($it)) {
  569. continue;
  570. }
  571. $cn = trim((string)($it['company_name'] ?? ''));
  572. if ($cn === '') {
  573. continue;
  574. }
  575. $grade = self::normalizeScoreGrade($it['score_grade'] ?? '');
  576. $quality = self::parseOptionalScore($it['quality_score'] ?? null);
  577. $price = self::parseOptionalScore($it['price_score'] ?? null);
  578. $delivery = self::parseOptionalScore($it['delivery_score'] ?? null);
  579. $final = self::parseOptionalScore($it['final_score'] ?? null);
  580. if ($quality === false || $price === false || $delivery === false || $final === false) {
  581. continue;
  582. }
  583. $hasQp = array_key_exists('quality_score', $it)
  584. || array_key_exists('price_score', $it)
  585. || array_key_exists('delivery_score', $it);
  586. try {
  587. $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
  588. $upd = [
  589. 'final_score' => $final,
  590. 'score_grade' => $grade,
  591. 'updatetime' => $now,
  592. ];
  593. if ($hasQp) {
  594. // 空值存 NULL,列表显示空白而不是 0
  595. $upd['quality_score'] = $quality;
  596. $upd['price_score'] = $price;
  597. $upd['delivery_score'] = $delivery;
  598. $upd['score'] = round(($quality ?? 0) + ($price ?? 0) + ($delivery ?? 0), 2);
  599. $upd['qp_manual'] = 1;
  600. }
  601. if (is_array($exists) && $exists !== []) {
  602. Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update($upd);
  603. } else {
  604. Db::table(self::TABLE_FINAL)->insert([
  605. 'ym' => $ym,
  606. 'company_name' => $cn,
  607. 'score' => $hasQp ? round(($quality ?? 0) + ($price ?? 0) + ($delivery ?? 0), 2) : 0,
  608. 'quality_score' => $hasQp ? $quality : null,
  609. 'price_score' => $hasQp ? $price : null,
  610. 'delivery_score' => $hasQp ? $delivery : null,
  611. 'final_score' => $final,
  612. 'score_grade' => $grade,
  613. 'qp_manual' => $hasQp ? 1 : 0,
  614. 'createtime' => $now,
  615. 'updatetime' => $now,
  616. ]);
  617. }
  618. if ($final === null && $grade === '' && !$hasQp) {
  619. $result['cleared']++;
  620. } else {
  621. $result['saved']++;
  622. }
  623. $result['done']++;
  624. } catch (\Throwable $e) {
  625. Log::write('supplier final score save: ' . $e->getMessage(), 'error');
  626. $result['error'] = $e->getMessage();
  627. }
  628. }
  629. return $result;
  630. }
  631. /** 历史表补质量/价格百分比字段 */
  632. protected static function ensureScoreWeightColumns(): void
  633. {
  634. try {
  635. $cols = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'quality_weight'");
  636. if (!is_array($cols) || $cols === []) {
  637. Db::execute(
  638. "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `quality_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比' AFTER `quality_score`"
  639. );
  640. }
  641. $cols2 = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'price_weight'");
  642. if (!is_array($cols2) || $cols2 === []) {
  643. Db::execute(
  644. "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `price_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比' AFTER `price_sum`"
  645. );
  646. }
  647. } catch (\Throwable $e) {
  648. Log::write('supplier score weight columns: ' . $e->getMessage(), 'error');
  649. }
  650. }
  651. /** 历史表补交货期权重/得分字段 */
  652. protected static function ensureLeadColumns(): void
  653. {
  654. $adds = [
  655. self::TABLE_RULE => [
  656. 'lead_weight' => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)' AFTER `price_weight`",
  657. 'lead_enabled' => "ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=交货期纳入总分' AFTER `lead_weight`",
  658. ],
  659. self::TABLE_SCORE => [
  660. 'lead_days_sum' => "ADD COLUMN `lead_days_sum` int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)' AFTER `price_score`",
  661. 'lead_weight' => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比' AFTER `lead_days_sum`",
  662. 'lead_enabled' => "ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=当时纳入总分' AFTER `lead_weight`",
  663. 'lead_score' => "ADD COLUMN `lead_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '交货期评分值' AFTER `lead_enabled`",
  664. ],
  665. ];
  666. foreach ($adds as $table => $cols) {
  667. foreach ($cols as $col => $ddl) {
  668. try {
  669. $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
  670. if (is_array($info) && $info !== []) {
  671. continue;
  672. }
  673. Db::execute("ALTER TABLE `{$table}` {$ddl}");
  674. } catch (\Throwable $e) {
  675. Log::write("supplier score lead column {$table}.{$col}: " . $e->getMessage(), 'error');
  676. }
  677. }
  678. }
  679. }
  680. /** 将权重/质量分/单价合计等字段改为整数(去掉 .00) */
  681. protected static function ensureIntegerColumns(): void
  682. {
  683. $defs = [
  684. self::TABLE_RULE => [
  685. 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '上年度质量评分权重(%)'",
  686. 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '单价评分权重(%)'",
  687. 'lead_weight' => "int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)'",
  688. ],
  689. self::TABLE_SCORE => [
  690. 'quality_score' => "int NOT NULL DEFAULT 0 COMMENT '上年度质量评分'",
  691. 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比'",
  692. 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比'",
  693. 'lead_days_sum' => "int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)'",
  694. 'lead_weight' => "int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比'",
  695. ],
  696. ];
  697. foreach ($defs as $table => $cols) {
  698. foreach ($cols as $col => $def) {
  699. try {
  700. $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
  701. if (!is_array($info) || $info === []) {
  702. continue;
  703. }
  704. $type = strtolower((string)($info[0]['Type'] ?? $info[0]['type'] ?? ''));
  705. if (preg_match('/^(tiny|small|medium|big)?int\b/', $type)) {
  706. continue;
  707. }
  708. Db::execute("ALTER TABLE `{$table}` MODIFY COLUMN `{$col}` {$def}");
  709. } catch (\Throwable $e) {
  710. Log::write("supplier score int column {$table}.{$col}: " . $e->getMessage(), 'error');
  711. }
  712. }
  713. }
  714. // 单价合计需保留小数(如 0.25),不可用 int
  715. try {
  716. $info = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'price_sum'");
  717. if (is_array($info) && $info !== []) {
  718. $type = strtolower((string)($info[0]['Type'] ?? $info[0]['type'] ?? ''));
  719. if (preg_match('/^(tiny|small|medium|big)?int\b/', $type)) {
  720. Db::execute(
  721. "ALTER TABLE `" . self::TABLE_SCORE . "` MODIFY COLUMN `price_sum` decimal(12,4) NOT NULL DEFAULT 0.0000 COMMENT '本单工序单价合计'"
  722. );
  723. }
  724. }
  725. } catch (\Throwable $e) {
  726. Log::write('supplier score price_sum decimal: ' . $e->getMessage(), 'error');
  727. }
  728. }
  729. protected static function ensureDefaultRule(): void
  730. {
  731. try {
  732. $cnt = (int)Db::table(self::TABLE_RULE)->count();
  733. if ($cnt > 0) {
  734. $hasDefault = Db::table(self::TABLE_RULE)->where('is_default', 1)->find();
  735. if (!is_array($hasDefault)) {
  736. $first = Db::table(self::TABLE_RULE)->order('id', 'asc')->find();
  737. if (is_array($first)) {
  738. Db::table(self::TABLE_RULE)->where('id', (int)$first['id'])->update([
  739. 'is_default' => 1,
  740. 'updatetime' => date('Y-m-d H:i:s'),
  741. ]);
  742. }
  743. }
  744. return;
  745. }
  746. $now = date('Y-m-d H:i:s');
  747. Db::table(self::TABLE_RULE)->insert([
  748. 'name' => '默认规则(商务/技术50%+单价50%)',
  749. 'quality_weight' => 50,
  750. 'price_weight' => 50,
  751. 'is_default' => 1,
  752. 'status' => 'normal',
  753. 'createtime' => $now,
  754. 'updatetime' => $now,
  755. ]);
  756. } catch (\Throwable $e) {
  757. Log::write('supplier score ensureDefaultRule: ' . $e->getMessage(), 'error');
  758. }
  759. }
  760. /**
  761. * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
  762. */
  763. public static function getDefaultRule(): array
  764. {
  765. self::ensureSchema();
  766. try {
  767. $row = Db::table(self::TABLE_RULE)
  768. ->where('is_default', 1)
  769. ->where('status', 'normal')
  770. ->order('id', 'desc')
  771. ->find();
  772. if (!is_array($row)) {
  773. $row = Db::table(self::TABLE_RULE)->where('status', 'normal')->order('id', 'asc')->find();
  774. }
  775. if (is_array($row)) {
  776. $lw = (int)($row['lead_weight'] ?? 0);
  777. return [
  778. 'id' => (int)($row['id'] ?? 0),
  779. 'name' => trim((string)($row['name'] ?? '')),
  780. 'quality_weight' => (int)($row['quality_weight'] ?? 50),
  781. 'price_weight' => (int)($row['price_weight'] ?? 50),
  782. 'lead_weight' => $lw,
  783. // 权重大于 0 即纳入;兼容旧 lead_enabled 字段
  784. 'lead_enabled' => $lw > 0 ? 1 : 0,
  785. ];
  786. }
  787. } catch (\Throwable $e) {
  788. }
  789. return [
  790. 'id' => 0,
  791. 'name' => '内置默认',
  792. 'quality_weight' => 50,
  793. 'price_weight' => 50,
  794. 'lead_weight' => 0,
  795. 'lead_enabled' => 0,
  796. ];
  797. }
  798. /**
  799. * 规范年月;空则取当前月
  800. */
  801. public static function normalizeYm(?string $ym): string
  802. {
  803. $ym = trim((string)$ym);
  804. if (preg_match('/^\d{4}-\d{2}$/', $ym)) {
  805. return $ym;
  806. }
  807. return date('Y-m');
  808. }
  809. /**
  810. * 上一自然月 YYYY-MM
  811. */
  812. public static function previousYm(string $ym): string
  813. {
  814. $ym = self::normalizeYm($ym);
  815. $ts = strtotime($ym . '-01');
  816. if ($ts === false) {
  817. $ts = time();
  818. }
  819. return date('Y-m', strtotime('-1 month', $ts));
  820. }
  821. /**
  822. * 从月度评审表取商务技术分:当月按上月起往前查,都没有则默认 50
  823. *
  824. * @param array<int, string> $companyNames
  825. * @return array<string, float> company_name => quality_score
  826. */
  827. public static function loadReviewQualityScoreMap(array $companyNames, ?string $asOfYm = null): array
  828. {
  829. $asOfYm = self::normalizeYm($asOfYm);
  830. $default = 50.0;
  831. $map = [];
  832. $names = [];
  833. foreach ($companyNames as $n) {
  834. $n = trim((string)$n);
  835. if ($n !== '') {
  836. $names[$n] = true;
  837. $map[$n] = $default;
  838. }
  839. }
  840. if ($names === []) {
  841. return $map;
  842. }
  843. self::ensureFinalScoreTable();
  844. try {
  845. // 当月按上个月起查:ym < asOfYm,且商务技术分已填写
  846. $rows = Db::table(self::TABLE_FINAL)
  847. ->where('company_name', 'in', array_keys($names))
  848. ->where('ym', '<', $asOfYm)
  849. ->whereNotNull('quality_score')
  850. ->field('company_name,ym,quality_score')
  851. ->order('ym', 'desc')
  852. ->select();
  853. } catch (\Throwable $e) {
  854. return $map;
  855. }
  856. if (!is_array($rows)) {
  857. return $map;
  858. }
  859. $seen = [];
  860. foreach ($rows as $r) {
  861. if (!is_array($r)) {
  862. continue;
  863. }
  864. $cn = trim((string)($r['company_name'] ?? ''));
  865. if ($cn === '' || isset($seen[$cn])) {
  866. continue;
  867. }
  868. $score = self::readOptionalScoreValue($r['quality_score'] ?? null);
  869. if ($score === null) {
  870. continue;
  871. }
  872. $seen[$cn] = true;
  873. $map[$cn] = round((float)$score, 2);
  874. }
  875. return $map;
  876. }
  877. /**
  878. * 按公司名批量取供应商 id(兼容旧调用保留评分字段结构)
  879. *
  880. * @param array<int, string> $companyNames
  881. * @return array<string, array{id:int,score:float,monthly_score:?float,quality_avg:float}>
  882. */
  883. public static function loadCustomerScoreMap(array $companyNames): array
  884. {
  885. $map = [];
  886. $names = [];
  887. foreach ($companyNames as $n) {
  888. $n = trim((string)$n);
  889. if ($n !== '') {
  890. $names[$n] = true;
  891. }
  892. }
  893. if ($names === []) {
  894. return $map;
  895. }
  896. try {
  897. $rows = Db::table('customer')
  898. ->where('company_name', 'in', array_keys($names))
  899. ->field('id,company_name')
  900. ->select();
  901. } catch (\Throwable $e) {
  902. return $map;
  903. }
  904. if (!is_array($rows)) {
  905. return $map;
  906. }
  907. foreach ($rows as $r) {
  908. if (!is_array($r)) {
  909. continue;
  910. }
  911. $cn = trim((string)($r['company_name'] ?? ''));
  912. if ($cn === '') {
  913. continue;
  914. }
  915. $map[$cn] = [
  916. 'id' => (int)($r['id'] ?? 0),
  917. 'score' => 0.0,
  918. 'monthly_score' => null,
  919. 'quality_avg' => 50.0,
  920. ];
  921. }
  922. return $map;
  923. }
  924. /**
  925. * 从报价组计算本单各供应商得分(不写库)
  926. *
  927. * @param array<int, array<string, mixed>> $quoteGroups loadAuditSupplierQuoteGroups 结构(name/lines[].amount)
  928. * @param string|null $asOfYm 订单所属月;商务/技术分从该月的上月起往前查评审表
  929. * @return array<string, array{
  930. * company_name:string,customer_id:int,quality_score:float,price_sum:float,
  931. * price_score:float,score:float,rank_no:int,score_text:string,rank_text:string
  932. * }>
  933. */
  934. public static function calculateForQuoteGroups(array $quoteGroups, ?array $rule = null, ?string $asOfYm = null): array
  935. {
  936. $rule = $rule ?: self::getDefaultRule();
  937. $qw = max(0, (float)($rule['quality_weight'] ?? 50));
  938. $pw = max(0, (float)($rule['price_weight'] ?? 50));
  939. $lw = max(0, (float)($rule['lead_weight'] ?? 0));
  940. // 填 0 的权重不参与计算
  941. $leadEnabled = $lw > 0 ? 1 : 0;
  942. $wSum = $qw + $pw + $lw;
  943. if ($wSum <= 0) {
  944. $qw = 50;
  945. $pw = 50;
  946. $lw = 0;
  947. $leadEnabled = 0;
  948. $wSum = 100;
  949. }
  950. $names = [];
  951. foreach ($quoteGroups as $g) {
  952. if (!is_array($g)) {
  953. continue;
  954. }
  955. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  956. if ($cn !== '') {
  957. $names[] = $cn;
  958. }
  959. }
  960. $custMap = self::loadCustomerScoreMap($names);
  961. $reviewMap = self::loadReviewQualityScoreMap($names, $asOfYm);
  962. $items = [];
  963. $priceSums = [];
  964. $leadSums = [];
  965. foreach ($quoteGroups as $g) {
  966. if (!is_array($g)) {
  967. continue;
  968. }
  969. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  970. if ($cn === '') {
  971. continue;
  972. }
  973. $priceSum = 0.0;
  974. $hasPrice = false;
  975. $leadSum = 0;
  976. $hasLead = false;
  977. // 确认页用 lines,审批页用 pick_lines
  978. $lineRows = $g['lines'] ?? $g['pick_lines'] ?? [];
  979. if (!is_array($lineRows)) {
  980. $lineRows = [];
  981. }
  982. foreach ($lineRows as $ln) {
  983. if (!is_array($ln)) {
  984. continue;
  985. }
  986. if (empty($ln['amount_quote_pending'])) {
  987. $am = self::resolveQuoteLineAmountRaw($ln);
  988. if ($am !== '' && $am !== '0' && $am !== '0.00' && is_numeric($am)) {
  989. $priceSum += (float)$am;
  990. $hasPrice = true;
  991. }
  992. }
  993. if (empty($ln['lead_days_quote_pending'])) {
  994. $ldRaw = $ln['lead_days'] ?? null;
  995. if ($ldRaw !== null && $ldRaw !== '' && is_numeric($ldRaw)) {
  996. $ld = (int)$ldRaw;
  997. if ($ld > 0) {
  998. $leadSum += $ld;
  999. $hasLead = true;
  1000. }
  1001. }
  1002. }
  1003. }
  1004. // 商务/技术得分:月度评审「商务技术分」,当月按上月起往前,默认 50
  1005. $quality = (float)($reviewMap[$cn] ?? 50);
  1006. $psum = $hasPrice ? round($priceSum, 2) : 0;
  1007. $lsum = $hasLead ? (int)$leadSum : 0;
  1008. $items[$cn] = [
  1009. 'company_name' => $cn,
  1010. 'customer_id' => (int)($custMap[$cn]['id'] ?? 0),
  1011. 'quality_score' => $quality,
  1012. 'quality_score_text' => self::formatScore($quality),
  1013. 'quality_weight' => (int)$qw,
  1014. 'price_sum' => $psum,
  1015. 'price_sum_text' => self::formatScore($psum),
  1016. 'price_weight' => (int)$pw,
  1017. 'has_price' => $hasPrice,
  1018. 'price_score' => 0.0,
  1019. 'price_score_text' => '0',
  1020. 'lead_days_sum' => $lsum,
  1021. 'lead_days_sum_text' => (string)$lsum,
  1022. 'lead_weight' => (int)$lw,
  1023. 'lead_enabled' => $leadEnabled,
  1024. 'has_lead' => $hasLead,
  1025. 'lead_score' => 0.0,
  1026. 'lead_score_text' => '0',
  1027. 'score' => 0.0,
  1028. 'rank_no' => 0,
  1029. 'score_text' => '',
  1030. 'rank_text' => '',
  1031. ];
  1032. if ($hasPrice && $priceSum > 0) {
  1033. $priceSums[$cn] = $priceSum;
  1034. }
  1035. if ($hasLead && $leadSum > 0) {
  1036. $leadSums[$cn] = $leadSum;
  1037. }
  1038. }
  1039. $minSum = null;
  1040. foreach ($priceSums as $ps) {
  1041. if ($minSum === null || $ps < $minSum) {
  1042. $minSum = $ps;
  1043. }
  1044. }
  1045. $minLead = null;
  1046. foreach ($leadSums as $ls) {
  1047. if ($minLead === null || $ls < $minLead) {
  1048. $minLead = $ls;
  1049. }
  1050. }
  1051. foreach ($items as $cn => &$it) {
  1052. // 单价未填写:价格得分与总分均为 0
  1053. if ($pw > 0 && (empty($it['has_price']) || (float)($it['price_sum'] ?? 0) <= 0)) {
  1054. $it['price_score'] = 0.0;
  1055. $it['price_score_text'] = '0';
  1056. $it['lead_score'] = 0.0;
  1057. $it['lead_score_text'] = self::formatScore(0);
  1058. $it['score'] = 0.0;
  1059. $it['score_text'] = '0';
  1060. continue;
  1061. }
  1062. // 价格得分 = (最低单价合计 / 本供应商单价合计) × 价格权重% × 100
  1063. if ($pw > 0 && !empty($it['has_price']) && $it['price_sum'] > 0 && $minSum !== null && $minSum > 0) {
  1064. $ratio = round($minSum / (float)$it['price_sum'], 4);
  1065. $it['price_score'] = round($ratio * ($pw / 100) * 100, 2);
  1066. } else {
  1067. $it['price_score'] = 0.0;
  1068. }
  1069. $it['price_score_text'] = self::formatScore($it['price_score']);
  1070. if ($leadEnabled && !empty($it['has_lead']) && $it['lead_days_sum'] > 0 && $minLead !== null && $minLead > 0) {
  1071. $leadRatio = round($minLead / (float)$it['lead_days_sum'], 4);
  1072. $it['lead_score'] = round($leadRatio * 100, 2);
  1073. } else {
  1074. $it['lead_score'] = 0.0;
  1075. }
  1076. $it['lead_score_text'] = self::formatScore($it['lead_score']);
  1077. // 总分 = 商务/技术得分×质量% + 价格得分(已含价格权重) [+ 交货得分×交货%]
  1078. $total = 0.0;
  1079. if ($qw > 0) {
  1080. $total += $it['quality_score'] * ($qw / 100);
  1081. }
  1082. if ($pw > 0) {
  1083. $total += (float)$it['price_score'];
  1084. }
  1085. if ($lw > 0) {
  1086. $total += $it['lead_score'] * ($lw / 100);
  1087. }
  1088. $it['score'] = round($total, 2);
  1089. $it['score_text'] = self::formatScore($it['score']);
  1090. }
  1091. unset($it);
  1092. $sorted = $items;
  1093. uasort($sorted, function ($a, $b) {
  1094. $sa = (float)($a['score'] ?? 0);
  1095. $sb = (float)($b['score'] ?? 0);
  1096. if ($sa !== $sb) {
  1097. return $sb <=> $sa;
  1098. }
  1099. return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
  1100. });
  1101. $rank = 0;
  1102. foreach ($sorted as $cn => $row) {
  1103. $rank++;
  1104. $items[$cn]['rank_no'] = $rank;
  1105. $items[$cn]['rank_text'] = (string)$rank;
  1106. }
  1107. return $items;
  1108. }
  1109. public static function formatScore($n): string
  1110. {
  1111. $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
  1112. return $s === '' ? '0' : $s;
  1113. }
  1114. /**
  1115. * 报价行单价原文(兼容确认页 amount、审批页 unit_price_text/amount_text)
  1116. *
  1117. * @param array<string, mixed> $ln
  1118. */
  1119. protected static function resolveQuoteLineAmountRaw(array $ln): string
  1120. {
  1121. foreach (['amount', 'unit_price_text', 'amount_text'] as $k) {
  1122. if (!array_key_exists($k, $ln) || $ln[$k] === null) {
  1123. continue;
  1124. }
  1125. $am = trim((string)$ln[$k]);
  1126. if ($am === '' || $am === '未填写' || $am === '未开标验证' || $am === '开标验证后查看') {
  1127. continue;
  1128. }
  1129. // 去掉千分位等
  1130. $am = str_replace([',', ',', ' '], '', $am);
  1131. if ($am !== '' && is_numeric($am)) {
  1132. return $am;
  1133. }
  1134. }
  1135. return '';
  1136. }
  1137. /**
  1138. * 总分简要文案(仅总分数字;分项见独立字段)
  1139. *
  1140. * @param array<string, mixed> $hit calculate/loadSaved 单项
  1141. */
  1142. public static function formatScoreDetailText(array $hit): string
  1143. {
  1144. return self::formatScore($hit['score'] ?? 0);
  1145. }
  1146. /**
  1147. * 分项得分展示文案(确认/详情表格用)
  1148. *
  1149. * @param array<string, mixed> $hit
  1150. */
  1151. public static function formatScorePartsHtml(array $hit): string
  1152. {
  1153. $qw = (float)($hit['quality_weight'] ?? 0);
  1154. $pw = (float)($hit['price_weight'] ?? 0);
  1155. $lw = (float)($hit['lead_weight'] ?? 0);
  1156. $lines = [];
  1157. if ($qw > 0) {
  1158. $lines[] = '商务/技术 ' . self::formatScore($hit['quality_score'] ?? 0);
  1159. }
  1160. if ($pw > 0) {
  1161. $lines[] = '价格得分 ' . self::formatScore($hit['price_score'] ?? 0);
  1162. }
  1163. if ($lw > 0) {
  1164. $lines[] = '交货得分 ' . self::formatScore($hit['lead_score'] ?? 0);
  1165. }
  1166. $lines[] = '总分 ' . self::formatScore($hit['score'] ?? 0);
  1167. return implode("\n", $lines);
  1168. }
  1169. /**
  1170. * 评分规则文案(用于确认页 / 详情展示)
  1171. * 百分比取当前默认规则;权重大于 0 的项才写入公式
  1172. *
  1173. * @param array{quality_weight?:float|int|string,price_weight?:float|int|string,lead_weight?:float|int|string}|null $rule
  1174. */
  1175. public static function formatRuleFormulaText(?array $rule = null): string
  1176. {
  1177. $rule = $rule ?: self::getDefaultRule();
  1178. $qw = (float)($rule['quality_weight'] ?? 50);
  1179. $pw = (float)($rule['price_weight'] ?? 50);
  1180. $lw = (float)($rule['lead_weight'] ?? 0);
  1181. $parts = [];
  1182. if ($qw > 0) {
  1183. $parts[] = '(商务/技术得分×' . self::formatWeightPercent($qw) . '%)';
  1184. }
  1185. if ($pw > 0) {
  1186. $parts[] = '(价格得分)';
  1187. }
  1188. if ($lw > 0) {
  1189. $parts[] = '(交货得分×' . self::formatWeightPercent($lw) . '%)';
  1190. }
  1191. $formula = $parts === []
  1192. ? '(总分)=(商务/技术得分×50%)+(价格得分)'
  1193. : '(总分)=' . implode('+', $parts);
  1194. $formula .= ';商务/技术得分取月度评审「商务/技术得分」(当月按上月起往前查,无则默认50)';
  1195. if ($pw > 0) {
  1196. $formula .= ';价格得分=(最低单价合计/本供应商单价合计)×'
  1197. . self::formatWeightPercent($pw) . '%×100';
  1198. }
  1199. return $formula;
  1200. }
  1201. /**
  1202. * 报价组是否展示交货期留痕列(已落库纳入,或尚无落库且当前默认规则交货分>0)
  1203. *
  1204. * @param array<int, array<string, mixed>> $quoteGroups
  1205. */
  1206. public static function detectShowLeadScore(array $quoteGroups = [], ?array $rule = null): bool
  1207. {
  1208. $hasSavedScore = false;
  1209. foreach ($quoteGroups as $g) {
  1210. if (!is_array($g)) {
  1211. continue;
  1212. }
  1213. if (!empty($g['show_service_lead_score'])) {
  1214. return true;
  1215. }
  1216. if (!empty($g['show_service_score'])) {
  1217. $hasSavedScore = true;
  1218. }
  1219. }
  1220. // 历史单已按旧规则落库且交货期权重为 0:不显示这两列
  1221. if ($hasSavedScore) {
  1222. return false;
  1223. }
  1224. $rule = $rule ?: self::getDefaultRule();
  1225. return (float)($rule['lead_weight'] ?? 0) > 0;
  1226. }
  1227. /**
  1228. * 评分规则提示:始终按当前默认规则比例显示(切换默认后即时变化)
  1229. *
  1230. * @param array<int, array<string, mixed>> $quoteGroups 保留参数兼容调用方,不参与文案
  1231. */
  1232. public static function formatRuleFormulaTextForGroups(array $quoteGroups = []): string
  1233. {
  1234. return self::formatRuleFormulaText();
  1235. }
  1236. protected static function formatWeightPercent($n): string
  1237. {
  1238. $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
  1239. return $s === '' ? '0' : $s;
  1240. }
  1241. /**
  1242. * 开标验证通过后:计算并写入供应商服务评分表
  1243. *
  1244. * @param array<int, array<string, mixed>> $quoteGroups
  1245. */
  1246. public static function saveForOrder(string $ccydh, array $quoteGroups, ?string $ym = null): void
  1247. {
  1248. $ccydh = trim($ccydh);
  1249. if ($ccydh === '') {
  1250. return;
  1251. }
  1252. self::ensureSchema();
  1253. $rule = self::getDefaultRule();
  1254. if ($ym === null || $ym === '') {
  1255. $ym = date('Y-m');
  1256. }
  1257. $ym = self::normalizeYm($ym);
  1258. $calc = self::calculateForQuoteGroups($quoteGroups, $rule, $ym);
  1259. if ($calc === []) {
  1260. return;
  1261. }
  1262. $now = date('Y-m-d H:i:s');
  1263. try {
  1264. Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->delete();
  1265. $rows = [];
  1266. foreach ($calc as $it) {
  1267. $rows[] = [
  1268. 'customer_id' => (int)($it['customer_id'] ?? 0),
  1269. 'company_name' => (string)($it['company_name'] ?? ''),
  1270. 'ym' => $ym,
  1271. 'ccydh' => $ccydh,
  1272. 'score' => (float)($it['score'] ?? 0),
  1273. 'rank_no' => (int)($it['rank_no'] ?? 0),
  1274. 'quality_score' => (int)round((float)($it['quality_score'] ?? 0)),
  1275. 'quality_weight' => (int)($rule['quality_weight'] ?? 50),
  1276. 'price_sum' => round((float)($it['price_sum'] ?? 0), 4),
  1277. 'price_weight' => (int)($rule['price_weight'] ?? 50),
  1278. 'price_score' => (float)($it['price_score'] ?? 0),
  1279. 'lead_days_sum' => (int)round((float)($it['lead_days_sum'] ?? 0)),
  1280. 'lead_weight' => (int)($rule['lead_weight'] ?? 0),
  1281. 'lead_enabled' => ((int)($rule['lead_weight'] ?? 0) > 0) ? 1 : 0,
  1282. 'lead_score' => (float)($it['lead_score'] ?? 0),
  1283. 'rule_id' => (int)($rule['id'] ?? 0),
  1284. 'createtime' => $now,
  1285. 'updatetime' => $now,
  1286. ];
  1287. }
  1288. if ($rows !== []) {
  1289. Db::table(self::TABLE_SCORE)->insertAll($rows);
  1290. }
  1291. // 开标后:本月询价供应商名称写入月度评审表(不存在才加空行,已有记录不覆盖)
  1292. $monthNames = [];
  1293. foreach ($quoteGroups as $g) {
  1294. if (!is_array($g)) {
  1295. continue;
  1296. }
  1297. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  1298. if ($cn !== '') {
  1299. $monthNames[] = $cn;
  1300. }
  1301. }
  1302. foreach ($rows as $row) {
  1303. $cn = trim((string)($row['company_name'] ?? ''));
  1304. if ($cn !== '') {
  1305. $monthNames[] = $cn;
  1306. }
  1307. }
  1308. self::ensureMonthlySupplierNames($ym, $monthNames);
  1309. } catch (\Throwable $e) {
  1310. Log::write('supplier service score save: ' . $e->getMessage(), 'error');
  1311. }
  1312. }
  1313. /**
  1314. * 读取某订单已落库评分;无则返回空
  1315. *
  1316. * @return array<string, array<string, mixed>>
  1317. */
  1318. public static function loadSavedByCcydh(string $ccydh): array
  1319. {
  1320. $ccydh = trim($ccydh);
  1321. if ($ccydh === '') {
  1322. return [];
  1323. }
  1324. try {
  1325. $rows = Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->select();
  1326. } catch (\Throwable $e) {
  1327. try {
  1328. self::ensureSchema();
  1329. $rows = Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->select();
  1330. } catch (\Throwable $e2) {
  1331. return [];
  1332. }
  1333. }
  1334. if (!is_array($rows)) {
  1335. return [];
  1336. }
  1337. $out = [];
  1338. foreach ($rows as $r) {
  1339. if (!is_array($r)) {
  1340. continue;
  1341. }
  1342. $cn = trim((string)($r['company_name'] ?? ''));
  1343. if ($cn === '') {
  1344. continue;
  1345. }
  1346. $qualityScore = (int)round((float)($r['quality_score'] ?? 0));
  1347. $priceSum = round((float)($r['price_sum'] ?? 0), 4);
  1348. $qw = (int)($r['quality_weight'] ?? 50);
  1349. $pw = (int)($r['price_weight'] ?? 50);
  1350. $lw = (int)($r['lead_weight'] ?? 0);
  1351. $leadEnabled = $lw > 0 ? 1 : 0;
  1352. $leadSum = (int)round((float)($r['lead_days_sum'] ?? 0));
  1353. $leadScore = (float)($r['lead_score'] ?? 0);
  1354. $out[$cn] = [
  1355. 'company_name' => $cn,
  1356. 'customer_id' => (int)($r['customer_id'] ?? 0),
  1357. 'quality_score' => $qualityScore,
  1358. 'quality_score_text' => (string)$qualityScore,
  1359. 'quality_weight' => $qw,
  1360. 'price_sum' => $priceSum,
  1361. 'price_sum_text' => self::formatScore($priceSum),
  1362. 'price_weight' => $pw,
  1363. 'price_score' => (float)($r['price_score'] ?? 0),
  1364. 'price_score_text' => self::formatScore($r['price_score'] ?? 0),
  1365. 'lead_days_sum' => $leadSum,
  1366. 'lead_days_sum_text' => $leadEnabled ? (string)$leadSum : '',
  1367. 'lead_weight' => $lw,
  1368. 'lead_enabled' => $leadEnabled,
  1369. 'lead_score' => $leadScore,
  1370. 'lead_score_text' => $leadEnabled ? self::formatScore($leadScore) : '',
  1371. 'score' => (float)($r['score'] ?? 0),
  1372. 'rank_no' => (int)($r['rank_no'] ?? 0),
  1373. 'score_text' => self::formatScore($r['score'] ?? 0),
  1374. 'rank_text' => ((int)($r['rank_no'] ?? 0)) > 0 ? (string)(int)($r['rank_no'] ?? 0) : '',
  1375. '_row_id' => (int)($r['id'] ?? 0),
  1376. ];
  1377. }
  1378. // 按单价合计重算价格得分/总分(兼容历史把权重算进价格得分的数据)
  1379. $minSum = null;
  1380. foreach ($out as $it) {
  1381. $ps = (float)($it['price_sum'] ?? 0);
  1382. if ($ps > 0 && ($minSum === null || $ps < $minSum)) {
  1383. $minSum = $ps;
  1384. }
  1385. }
  1386. foreach ($out as $cn => &$it) {
  1387. $qw = (int)($it['quality_weight'] ?? 50);
  1388. $pw = (int)($it['price_weight'] ?? 50);
  1389. $lw = (int)($it['lead_weight'] ?? 0);
  1390. $priceSum = (float)($it['price_sum'] ?? 0);
  1391. // 单价未填写:总分为 0
  1392. if ($pw > 0 && $priceSum <= 0) {
  1393. $it['price_score'] = 0.0;
  1394. $it['price_score_text'] = '0';
  1395. $it['score'] = 0.0;
  1396. $it['score_text'] = '0';
  1397. continue;
  1398. }
  1399. if ($pw > 0 && $priceSum > 0 && $minSum !== null && $minSum > 0) {
  1400. $ratio = round($minSum / $priceSum, 4);
  1401. $it['price_score'] = round($ratio * ($pw / 100) * 100, 2);
  1402. } else {
  1403. $it['price_score'] = 0.0;
  1404. }
  1405. $it['price_score_text'] = self::formatScore($it['price_score']);
  1406. $total = 0.0;
  1407. if ($qw > 0) {
  1408. $total += (float)$it['quality_score'] * ($qw / 100);
  1409. }
  1410. if ($pw > 0) {
  1411. $total += (float)$it['price_score'];
  1412. }
  1413. if ($lw > 0) {
  1414. $total += (float)$it['lead_score'] * ($lw / 100);
  1415. }
  1416. $it['score'] = round($total, 2);
  1417. $it['score_text'] = self::formatScore($it['score']);
  1418. }
  1419. unset($it);
  1420. $sorted = $out;
  1421. uasort($sorted, static function ($a, $b) {
  1422. $sa = (float)($a['score'] ?? 0);
  1423. $sb = (float)($b['score'] ?? 0);
  1424. if ($sa !== $sb) {
  1425. return $sb <=> $sa;
  1426. }
  1427. return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
  1428. });
  1429. $rank = 0;
  1430. foreach ($sorted as $cn => $row) {
  1431. $rank++;
  1432. $oldRank = (int)($out[$cn]['rank_no'] ?? 0);
  1433. $out[$cn]['rank_no'] = $rank;
  1434. $out[$cn]['rank_text'] = (string)$rank;
  1435. $rowId = (int)($out[$cn]['_row_id'] ?? 0);
  1436. if ($rowId > 0) {
  1437. $upd = [];
  1438. if ($oldRank !== $rank) {
  1439. $upd['rank_no'] = $rank;
  1440. }
  1441. foreach ($rows as $r) {
  1442. if (!is_array($r) || (int)($r['id'] ?? 0) !== $rowId) {
  1443. continue;
  1444. }
  1445. if (abs((float)($r['price_score'] ?? 0) - (float)$out[$cn]['price_score']) > 0.001) {
  1446. $upd['price_score'] = $out[$cn]['price_score'];
  1447. }
  1448. if (abs((float)($r['score'] ?? 0) - (float)$out[$cn]['score']) > 0.001) {
  1449. $upd['score'] = $out[$cn]['score'];
  1450. }
  1451. break;
  1452. }
  1453. if ($upd !== []) {
  1454. try {
  1455. Db::table(self::TABLE_SCORE)->where('id', $rowId)->update($upd);
  1456. } catch (\Throwable $e) {
  1457. }
  1458. }
  1459. }
  1460. unset($out[$cn]['_row_id']);
  1461. }
  1462. return $out;
  1463. }
  1464. /**
  1465. * 清空报价组上的评分留痕字段
  1466. *
  1467. * @param array<string, mixed> $g
  1468. */
  1469. protected static function clearScoreTrailOnGroup(array &$g, int $showFlag = 0): void
  1470. {
  1471. $g['service_rank'] = '';
  1472. $g['service_score'] = '';
  1473. $g['service_rank_text'] = '';
  1474. $g['service_score_text'] = '';
  1475. $g['service_score_parts_text'] = '';
  1476. $g['service_quality_score'] = '';
  1477. $g['service_quality_score_text'] = '';
  1478. $g['service_price_sum'] = '';
  1479. $g['service_price_sum_text'] = '';
  1480. $g['service_price_score'] = '';
  1481. $g['service_price_score_text'] = '';
  1482. $g['service_lead_days_sum'] = '';
  1483. $g['service_lead_days_sum_text'] = '';
  1484. $g['service_lead_score'] = '';
  1485. $g['service_lead_score_text'] = '';
  1486. $g['service_lead_enabled'] = 0;
  1487. $g['service_quality_weight'] = '';
  1488. $g['service_price_weight'] = '';
  1489. $g['service_lead_weight'] = '';
  1490. $g['show_service_score'] = $showFlag;
  1491. $g['show_service_lead_score'] = 0;
  1492. }
  1493. /**
  1494. * 把排名/评分挂到报价组上(开标后可见)
  1495. * 详情等读路径:优先读已落库评分,避免每次打开重算写库拖慢弹窗
  1496. *
  1497. * @param array<int, array<string, mixed>> $quoteGroups
  1498. * @return array<int, array<string, mixed>>
  1499. */
  1500. public static function attachToQuoteGroups(array $quoteGroups, string $ccydh, bool $quoteVisible): array
  1501. {
  1502. if (!$quoteVisible || $quoteGroups === []) {
  1503. foreach ($quoteGroups as &$g) {
  1504. if (!is_array($g)) {
  1505. continue;
  1506. }
  1507. self::clearScoreTrailOnGroup($g, 0);
  1508. }
  1509. unset($g);
  1510. return $quoteGroups;
  1511. }
  1512. $saved = self::loadSavedByCcydh($ccydh);
  1513. $needCalc = ($saved === []);
  1514. if (!$needCalc) {
  1515. $anyHit = false;
  1516. foreach ($quoteGroups as $g) {
  1517. if (!is_array($g)) {
  1518. continue;
  1519. }
  1520. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  1521. if ($cn !== '' && isset($saved[$cn])) {
  1522. $anyHit = true;
  1523. break;
  1524. }
  1525. }
  1526. $needCalc = !$anyHit;
  1527. }
  1528. // 无落库记录时仅内存计算用于展示;落库由开标验证 saveForOrder 负责
  1529. if ($needCalc) {
  1530. $saved = self::calculateForQuoteGroups($quoteGroups, null, date('Y-m'));
  1531. }
  1532. foreach ($quoteGroups as &$g) {
  1533. if (!is_array($g)) {
  1534. continue;
  1535. }
  1536. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  1537. $hit = ($cn !== '' && isset($saved[$cn]) && is_array($saved[$cn])) ? $saved[$cn] : null;
  1538. if (is_array($hit)) {
  1539. $g['service_rank'] = (int)($hit['rank_no'] ?? 0);
  1540. $g['service_score'] = (float)($hit['score'] ?? 0);
  1541. $g['service_rank_text'] = (string)($hit['rank_text'] ?? '');
  1542. $g['service_score_text'] = self::formatScoreDetailText($hit);
  1543. $g['service_score_parts_text'] = self::formatScorePartsHtml($hit);
  1544. $qs = round((float)($hit['quality_score'] ?? 0), 2);
  1545. $ps = (float)($hit['price_score'] ?? 0);
  1546. $psum = round((float)($hit['price_sum'] ?? 0), 2);
  1547. $leadEnabled = ((int)($hit['lead_weight'] ?? 0) > 0) ? 1 : 0;
  1548. $ls = (float)($hit['lead_score'] ?? 0);
  1549. $lsum = (int)round((float)($hit['lead_days_sum'] ?? 0));
  1550. $g['service_quality_score'] = $qs;
  1551. $g['service_quality_score_text'] = (string)($hit['quality_score_text'] ?? self::formatScore($qs));
  1552. $g['service_price_sum'] = $psum;
  1553. $g['service_price_sum_text'] = (string)($hit['price_sum_text'] ?? $psum);
  1554. $g['service_price_score'] = $ps;
  1555. $g['service_price_score_text'] = (string)($hit['price_score_text'] ?? self::formatScore($ps));
  1556. $g['service_lead_enabled'] = $leadEnabled;
  1557. $g['show_service_lead_score'] = $leadEnabled;
  1558. $g['service_lead_days_sum'] = $lsum;
  1559. $g['service_lead_days_sum_text'] = $leadEnabled ? (string)($hit['lead_days_sum_text'] ?? $lsum) : '';
  1560. $g['service_lead_score'] = $ls;
  1561. $g['service_lead_score_text'] = $leadEnabled ? (string)($hit['lead_score_text'] ?? self::formatScore($ls)) : '';
  1562. $g['service_quality_weight'] = (int)($hit['quality_weight'] ?? 50);
  1563. $g['service_price_weight'] = (int)($hit['price_weight'] ?? 50);
  1564. $g['service_lead_weight'] = (int)($hit['lead_weight'] ?? 0);
  1565. $g['show_service_score'] = 1;
  1566. } else {
  1567. self::clearScoreTrailOnGroup($g, 1);
  1568. }
  1569. }
  1570. unset($g);
  1571. // 开标后按排名升序(第1名在最前);无排名的放最后
  1572. usort($quoteGroups, function ($a, $b) {
  1573. $ra = is_array($a) ? (int)($a['service_rank'] ?? 0) : 0;
  1574. $rb = is_array($b) ? (int)($b['service_rank'] ?? 0) : 0;
  1575. $ha = $ra > 0;
  1576. $hb = $rb > 0;
  1577. if ($ha !== $hb) {
  1578. return $ha ? -1 : 1;
  1579. }
  1580. if ($ha && $ra !== $rb) {
  1581. return $ra <=> $rb;
  1582. }
  1583. $sa = is_array($a) ? (float)($a['service_score'] ?? 0) : 0.0;
  1584. $sb = is_array($b) ? (float)($b['service_score'] ?? 0) : 0.0;
  1585. if ($sa !== $sb) {
  1586. return $sb <=> $sa;
  1587. }
  1588. $na = is_array($a) ? trim((string)($a['name'] ?? $a['company_name'] ?? '')) : '';
  1589. $nb = is_array($b) ? trim((string)($b['name'] ?? $b['company_name'] ?? '')) : '';
  1590. return strcmp($na, $nb);
  1591. });
  1592. return $quoteGroups;
  1593. }
  1594. }