ProcuremenSupplierScore.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. <?php
  2. namespace app\common\library;
  3. use think\Db;
  4. use think\Log;
  5. /**
  6. * 协助采购 — 供应商服务评分
  7. *
  8. * 总分 = 上年度质量评分×质量权重% + 单价评分值×单价权重%
  9. * 单价合计 = 同一供应商本单各工序单价之和;单价评分值 = (本单最低合计 / 本供应商合计) × 100
  10. * 权重取 supplier_score_rule 中 is_default=1 的规则;开标验证通过后写入 supplier_service_score
  11. */
  12. class ProcuremenSupplierScore
  13. {
  14. public const TABLE_RULE = 'supplier_score_rule';
  15. public const TABLE_SCORE = 'supplier_service_score';
  16. /** @var bool|null */
  17. protected static $schemaReady = null;
  18. public static function ensureSchema(): void
  19. {
  20. if (self::$schemaReady === true) {
  21. return;
  22. }
  23. try {
  24. Db::query('SELECT 1 FROM `' . self::TABLE_RULE . '` LIMIT 1');
  25. Db::query('SELECT 1 FROM `' . self::TABLE_SCORE . '` LIMIT 1');
  26. self::$schemaReady = true;
  27. self::ensureDefaultRule();
  28. self::ensureScoreWeightColumns();
  29. self::ensureIntegerColumns();
  30. return;
  31. } catch (\Throwable $e) {
  32. }
  33. $sqlFile = APP_PATH . 'extra' . DIRECTORY_SEPARATOR . 'supplier_score_install.sql';
  34. if (is_file($sqlFile)) {
  35. $sql = file_get_contents($sqlFile);
  36. if (is_string($sql) && $sql !== '') {
  37. foreach (preg_split('/;\s*[\r\n]+/', $sql) as $stmt) {
  38. $stmt = trim($stmt);
  39. if ($stmt === '' || stripos($stmt, 'CREATE TABLE') === false) {
  40. continue;
  41. }
  42. try {
  43. Db::execute($stmt);
  44. } catch (\Throwable $ignore) {
  45. }
  46. }
  47. }
  48. }
  49. self::$schemaReady = true;
  50. self::ensureDefaultRule();
  51. self::ensureScoreWeightColumns();
  52. self::ensureIntegerColumns();
  53. }
  54. /** 历史表补质量/价格百分比字段 */
  55. protected static function ensureScoreWeightColumns(): void
  56. {
  57. try {
  58. $cols = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'quality_weight'");
  59. if (!is_array($cols) || $cols === []) {
  60. Db::execute(
  61. "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `quality_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比' AFTER `quality_score`"
  62. );
  63. }
  64. $cols2 = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'price_weight'");
  65. if (!is_array($cols2) || $cols2 === []) {
  66. Db::execute(
  67. "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `price_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比' AFTER `price_sum`"
  68. );
  69. }
  70. } catch (\Throwable $e) {
  71. Log::write('supplier score weight columns: ' . $e->getMessage(), 'error');
  72. }
  73. }
  74. /** 将权重/质量分/单价合计等字段改为整数(去掉 .00) */
  75. protected static function ensureIntegerColumns(): void
  76. {
  77. $defs = [
  78. self::TABLE_RULE => [
  79. 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '上年度质量评分权重(%)'",
  80. 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '单价评分权重(%)'",
  81. ],
  82. self::TABLE_SCORE => [
  83. 'quality_score' => "int NOT NULL DEFAULT 0 COMMENT '上年度质量评分'",
  84. 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比'",
  85. 'price_sum' => "int NOT NULL DEFAULT 0 COMMENT '本单工序单价合计'",
  86. 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比'",
  87. ],
  88. ];
  89. foreach ($defs as $table => $cols) {
  90. foreach ($cols as $col => $def) {
  91. try {
  92. $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
  93. if (!is_array($info) || $info === []) {
  94. continue;
  95. }
  96. $type = strtolower((string)($info[0]['Type'] ?? $info[0]['type'] ?? ''));
  97. if (preg_match('/^(tiny|small|medium|big)?int\b/', $type)) {
  98. continue;
  99. }
  100. Db::execute("ALTER TABLE `{$table}` MODIFY COLUMN `{$col}` {$def}");
  101. } catch (\Throwable $e) {
  102. Log::write("supplier score int column {$table}.{$col}: " . $e->getMessage(), 'error');
  103. }
  104. }
  105. }
  106. }
  107. protected static function ensureDefaultRule(): void
  108. {
  109. try {
  110. $cnt = (int)Db::table(self::TABLE_RULE)->count();
  111. if ($cnt > 0) {
  112. $hasDefault = Db::table(self::TABLE_RULE)->where('is_default', 1)->find();
  113. if (!is_array($hasDefault)) {
  114. $first = Db::table(self::TABLE_RULE)->order('id', 'asc')->find();
  115. if (is_array($first)) {
  116. Db::table(self::TABLE_RULE)->where('id', (int)$first['id'])->update([
  117. 'is_default' => 1,
  118. 'updatetime' => date('Y-m-d H:i:s'),
  119. ]);
  120. }
  121. }
  122. return;
  123. }
  124. $now = date('Y-m-d H:i:s');
  125. Db::table(self::TABLE_RULE)->insert([
  126. 'name' => '默认规则(质量50%+单价50%)',
  127. 'quality_weight' => 50,
  128. 'price_weight' => 50,
  129. 'is_default' => 1,
  130. 'status' => 'normal',
  131. 'createtime' => $now,
  132. 'updatetime' => $now,
  133. ]);
  134. } catch (\Throwable $e) {
  135. Log::write('supplier score ensureDefaultRule: ' . $e->getMessage(), 'error');
  136. }
  137. }
  138. /**
  139. * @return array{id:int,name:string,quality_weight:int,price_weight:int}
  140. */
  141. public static function getDefaultRule(): array
  142. {
  143. self::ensureSchema();
  144. try {
  145. $row = Db::table(self::TABLE_RULE)
  146. ->where('is_default', 1)
  147. ->where('status', 'normal')
  148. ->order('id', 'desc')
  149. ->find();
  150. if (!is_array($row)) {
  151. $row = Db::table(self::TABLE_RULE)->where('status', 'normal')->order('id', 'asc')->find();
  152. }
  153. if (is_array($row)) {
  154. return [
  155. 'id' => (int)($row['id'] ?? 0),
  156. 'name' => trim((string)($row['name'] ?? '')),
  157. 'quality_weight' => (int)($row['quality_weight'] ?? 50),
  158. 'price_weight' => (int)($row['price_weight'] ?? 50),
  159. ];
  160. }
  161. } catch (\Throwable $e) {
  162. }
  163. return [
  164. 'id' => 0,
  165. 'name' => '内置默认',
  166. 'quality_weight' => 50,
  167. 'price_weight' => 50,
  168. ];
  169. }
  170. /**
  171. * 按公司名批量取供应商 id / 上年度质量评分
  172. *
  173. * @param array<int, string> $companyNames
  174. * @return array<string, array{id:int,score:float}>
  175. */
  176. public static function loadCustomerScoreMap(array $companyNames): array
  177. {
  178. $map = [];
  179. $names = [];
  180. foreach ($companyNames as $n) {
  181. $n = trim((string)$n);
  182. if ($n !== '') {
  183. $names[$n] = true;
  184. }
  185. }
  186. if ($names === []) {
  187. return $map;
  188. }
  189. try {
  190. $rows = Db::table('customer')
  191. ->where('company_name', 'in', array_keys($names))
  192. ->field('id,company_name,score')
  193. ->select();
  194. } catch (\Throwable $e) {
  195. return $map;
  196. }
  197. if (!is_array($rows)) {
  198. return $map;
  199. }
  200. foreach ($rows as $r) {
  201. if (!is_array($r)) {
  202. continue;
  203. }
  204. $cn = trim((string)($r['company_name'] ?? ''));
  205. if ($cn === '') {
  206. continue;
  207. }
  208. $raw = $r['score'] ?? null;
  209. $score = ($raw === null || $raw === '') ? 0 : (int)round((float)$raw);
  210. $map[$cn] = [
  211. 'id' => (int)($r['id'] ?? 0),
  212. 'score' => $score,
  213. ];
  214. }
  215. return $map;
  216. }
  217. /**
  218. * 从报价组计算本单各供应商得分(不写库)
  219. *
  220. * @param array<int, array<string, mixed>> $quoteGroups loadAuditSupplierQuoteGroups 结构(name/lines[].amount)
  221. * @return array<string, array{
  222. * company_name:string,customer_id:int,quality_score:float,price_sum:float,
  223. * price_score:float,score:float,rank_no:int,score_text:string,rank_text:string
  224. * }>
  225. */
  226. public static function calculateForQuoteGroups(array $quoteGroups, ?array $rule = null): array
  227. {
  228. $rule = $rule ?: self::getDefaultRule();
  229. $qw = (float)($rule['quality_weight'] ?? 50);
  230. $pw = (float)($rule['price_weight'] ?? 50);
  231. $wSum = $qw + $pw;
  232. if ($wSum <= 0) {
  233. $qw = 50;
  234. $pw = 50;
  235. $wSum = 100;
  236. }
  237. $names = [];
  238. foreach ($quoteGroups as $g) {
  239. if (!is_array($g)) {
  240. continue;
  241. }
  242. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  243. if ($cn !== '') {
  244. $names[] = $cn;
  245. }
  246. }
  247. $custMap = self::loadCustomerScoreMap($names);
  248. $items = [];
  249. $priceSums = [];
  250. foreach ($quoteGroups as $g) {
  251. if (!is_array($g)) {
  252. continue;
  253. }
  254. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  255. if ($cn === '') {
  256. continue;
  257. }
  258. $priceSum = 0.0;
  259. $hasPrice = false;
  260. foreach (($g['lines'] ?? []) as $ln) {
  261. if (!is_array($ln)) {
  262. continue;
  263. }
  264. if (!empty($ln['amount_quote_pending'])) {
  265. continue;
  266. }
  267. $am = trim((string)($ln['amount'] ?? ''));
  268. if ($am === '' || $am === '0' || $am === '0.00') {
  269. continue;
  270. }
  271. if (!is_numeric($am)) {
  272. continue;
  273. }
  274. $priceSum += (float)$am;
  275. $hasPrice = true;
  276. }
  277. $quality = (int)round((float)($custMap[$cn]['score'] ?? 0));
  278. $items[$cn] = [
  279. 'company_name' => $cn,
  280. 'customer_id' => (int)($custMap[$cn]['id'] ?? 0),
  281. 'quality_score' => $quality,
  282. 'price_sum' => $hasPrice ? (int)round($priceSum) : 0,
  283. 'has_price' => $hasPrice,
  284. 'price_score' => 0.0,
  285. 'score' => 0.0,
  286. 'rank_no' => 0,
  287. 'score_text' => '',
  288. 'rank_text' => '',
  289. ];
  290. if ($hasPrice && $priceSum > 0) {
  291. $priceSums[$cn] = $priceSum;
  292. }
  293. }
  294. $minSum = null;
  295. foreach ($priceSums as $ps) {
  296. if ($minSum === null || $ps < $minSum) {
  297. $minSum = $ps;
  298. }
  299. }
  300. foreach ($items as $cn => &$it) {
  301. if (!empty($it['has_price']) && $it['price_sum'] > 0 && $minSum !== null && $minSum > 0) {
  302. $it['price_score'] = round(($minSum / $it['price_sum']) * 100, 2);
  303. } else {
  304. $it['price_score'] = 0.0;
  305. }
  306. $it['score'] = round(
  307. $it['quality_score'] * ($qw / $wSum) + $it['price_score'] * ($pw / $wSum),
  308. 2
  309. );
  310. $it['score_text'] = self::formatScore($it['score']);
  311. }
  312. unset($it);
  313. $sorted = $items;
  314. uasort($sorted, function ($a, $b) {
  315. $sa = (float)($a['score'] ?? 0);
  316. $sb = (float)($b['score'] ?? 0);
  317. if ($sa !== $sb) {
  318. return $sb <=> $sa;
  319. }
  320. return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
  321. });
  322. $rank = 0;
  323. foreach ($sorted as $cn => $row) {
  324. $rank++;
  325. $items[$cn]['rank_no'] = $rank;
  326. $items[$cn]['rank_text'] = (string)$rank;
  327. }
  328. return $items;
  329. }
  330. public static function formatScore($n): string
  331. {
  332. $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
  333. return $s === '' ? '0' : $s;
  334. }
  335. /**
  336. * 评分规则文案(用于确认页 / 详情展示)
  337. *
  338. * @param array{quality_weight?:float|int|string,price_weight?:float|int|string}|null $rule
  339. */
  340. public static function formatRuleFormulaText(?array $rule = null): string
  341. {
  342. $rule = $rule ?: self::getDefaultRule();
  343. $qw = self::formatWeightPercent($rule['quality_weight'] ?? 50);
  344. $pw = self::formatWeightPercent($rule['price_weight'] ?? 50);
  345. return '(总分)=(质量分×' . $qw . '%)+(价格分×' . $pw . '%)';
  346. }
  347. protected static function formatWeightPercent($n): string
  348. {
  349. $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
  350. return $s === '' ? '0' : $s;
  351. }
  352. /**
  353. * 开标验证通过后:计算并写入供应商服务评分表
  354. *
  355. * @param array<int, array<string, mixed>> $quoteGroups
  356. */
  357. public static function saveForOrder(string $ccydh, array $quoteGroups, ?string $ym = null): void
  358. {
  359. $ccydh = trim($ccydh);
  360. if ($ccydh === '') {
  361. return;
  362. }
  363. self::ensureSchema();
  364. $rule = self::getDefaultRule();
  365. $calc = self::calculateForQuoteGroups($quoteGroups, $rule);
  366. if ($calc === []) {
  367. return;
  368. }
  369. if ($ym === null || $ym === '') {
  370. $ym = date('Y-m');
  371. }
  372. $now = date('Y-m-d H:i:s');
  373. try {
  374. Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->delete();
  375. $rows = [];
  376. foreach ($calc as $it) {
  377. $rows[] = [
  378. 'customer_id' => (int)($it['customer_id'] ?? 0),
  379. 'company_name' => (string)($it['company_name'] ?? ''),
  380. 'ym' => $ym,
  381. 'ccydh' => $ccydh,
  382. 'score' => (float)($it['score'] ?? 0),
  383. 'rank_no' => (int)($it['rank_no'] ?? 0),
  384. 'quality_score' => (int)round((float)($it['quality_score'] ?? 0)),
  385. 'quality_weight' => (int)($rule['quality_weight'] ?? 50),
  386. 'price_sum' => (int)round((float)($it['price_sum'] ?? 0)),
  387. 'price_weight' => (int)($rule['price_weight'] ?? 50),
  388. 'price_score' => (float)($it['price_score'] ?? 0),
  389. 'rule_id' => (int)($rule['id'] ?? 0),
  390. 'createtime' => $now,
  391. 'updatetime' => $now,
  392. ];
  393. }
  394. if ($rows !== []) {
  395. Db::table(self::TABLE_SCORE)->insertAll($rows);
  396. }
  397. } catch (\Throwable $e) {
  398. Log::write('supplier service score save: ' . $e->getMessage(), 'error');
  399. }
  400. }
  401. /**
  402. * 读取某订单已落库评分;无则返回空
  403. *
  404. * @return array<string, array<string, mixed>>
  405. */
  406. public static function loadSavedByCcydh(string $ccydh): array
  407. {
  408. $ccydh = trim($ccydh);
  409. if ($ccydh === '') {
  410. return [];
  411. }
  412. self::ensureSchema();
  413. try {
  414. $rows = Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->select();
  415. } catch (\Throwable $e) {
  416. return [];
  417. }
  418. if (!is_array($rows)) {
  419. return [];
  420. }
  421. $out = [];
  422. foreach ($rows as $r) {
  423. if (!is_array($r)) {
  424. continue;
  425. }
  426. $cn = trim((string)($r['company_name'] ?? ''));
  427. if ($cn === '') {
  428. continue;
  429. }
  430. $score = (float)($r['score'] ?? 0);
  431. $rank = (int)($r['rank_no'] ?? 0);
  432. $out[$cn] = [
  433. 'company_name' => $cn,
  434. 'customer_id' => (int)($r['customer_id'] ?? 0),
  435. 'quality_score' => (float)($r['quality_score'] ?? 0),
  436. 'price_sum' => (float)($r['price_sum'] ?? 0),
  437. 'price_score' => (float)($r['price_score'] ?? 0),
  438. 'score' => $score,
  439. 'rank_no' => $rank,
  440. 'score_text' => self::formatScore($score),
  441. 'rank_text' => $rank > 0 ? (string)$rank : '',
  442. ];
  443. }
  444. return $out;
  445. }
  446. /**
  447. * 把排名/评分挂到报价组上(开标后可见)
  448. *
  449. * @param array<int, array<string, mixed>> $quoteGroups
  450. * @return array<int, array<string, mixed>>
  451. */
  452. public static function attachToQuoteGroups(array $quoteGroups, string $ccydh, bool $quoteVisible): array
  453. {
  454. if (!$quoteVisible || $quoteGroups === []) {
  455. foreach ($quoteGroups as &$g) {
  456. if (!is_array($g)) {
  457. continue;
  458. }
  459. $g['service_rank'] = '';
  460. $g['service_score'] = '';
  461. $g['service_rank_text'] = '';
  462. $g['service_score_text'] = '';
  463. $g['show_service_score'] = 0;
  464. }
  465. unset($g);
  466. return $quoteGroups;
  467. }
  468. $saved = self::loadSavedByCcydh($ccydh);
  469. if ($saved === []) {
  470. $saved = self::calculateForQuoteGroups($quoteGroups);
  471. // 历史已开标但尚未落库:补写一次
  472. try {
  473. self::saveForOrder($ccydh, $quoteGroups);
  474. $saved = self::loadSavedByCcydh($ccydh) ?: $saved;
  475. } catch (\Throwable $e) {
  476. }
  477. }
  478. foreach ($quoteGroups as &$g) {
  479. if (!is_array($g)) {
  480. continue;
  481. }
  482. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  483. $hit = $saved[$cn] ?? null;
  484. if (is_array($hit)) {
  485. $g['service_rank'] = (int)($hit['rank_no'] ?? 0);
  486. $g['service_score'] = (float)($hit['score'] ?? 0);
  487. $g['service_rank_text'] = (string)($hit['rank_text'] ?? '');
  488. $g['service_score_text'] = (string)($hit['score_text'] ?? '');
  489. $g['show_service_score'] = 1;
  490. } else {
  491. $g['service_rank'] = '';
  492. $g['service_score'] = '';
  493. $g['service_rank_text'] = '';
  494. $g['service_score_text'] = '';
  495. $g['show_service_score'] = 1;
  496. }
  497. }
  498. unset($g);
  499. // 开标后按排名升序(第1名在最前);无排名的放最后
  500. usort($quoteGroups, function ($a, $b) {
  501. $ra = is_array($a) ? (int)($a['service_rank'] ?? 0) : 0;
  502. $rb = is_array($b) ? (int)($b['service_rank'] ?? 0) : 0;
  503. $ha = $ra > 0;
  504. $hb = $rb > 0;
  505. if ($ha !== $hb) {
  506. return $ha ? -1 : 1;
  507. }
  508. if ($ha && $ra !== $rb) {
  509. return $ra <=> $rb;
  510. }
  511. $sa = is_array($a) ? (float)($a['service_score'] ?? 0) : 0.0;
  512. $sb = is_array($b) ? (float)($b['service_score'] ?? 0) : 0.0;
  513. if ($sa !== $sb) {
  514. return $sb <=> $sa;
  515. }
  516. $na = is_array($a) ? trim((string)($a['name'] ?? $a['company_name'] ?? '')) : '';
  517. $nb = is_array($b) ? trim((string)($b['name'] ?? $b['company_name'] ?? '')) : '';
  518. return strcmp($na, $nb);
  519. });
  520. return $quoteGroups;
  521. }
  522. }