ProcuremenSupplierScore.php 18 KB

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