ProcuremenSupplierScore.php 60 KB

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