ProcuremenSupplierScore.php 94 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489
  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. * 权重优先取 purchase_order 下发时快照(score_*_weight);无快照再按 score_rule_id 读规则表;再否则 is_default=1
  17. * 开标验证通过后写入订单明细评分,并补齐月度评审供应商名称
  18. */
  19. class ProcuremenSupplierScore
  20. {
  21. public const TABLE_RULE = 'supplier_score_rule';
  22. /** 订单明细评分表 */
  23. public const TABLE_SCORE = 'supplier_service_score';
  24. /** 月度汇总表(总分 + 最终得分) */
  25. public const TABLE_FINAL = 'supplier_service_final_score';
  26. /** @var bool|null */
  27. protected static $schemaReady = null;
  28. /** 结构探测缓存键(升版本可强制再跑一遍迁移) */
  29. const SCHEMA_CACHE_KEY = 'procuremen_supplier_score_schema_ok_v6';
  30. public static function ensureSchema(): void
  31. {
  32. if (self::$schemaReady === true) {
  33. return;
  34. }
  35. // 跨请求缓存:命中后直接返回,禁止再跑 SHOW COLUMNS / ALTER / 规则种子
  36. try {
  37. if (\think\Cache::get(self::SCHEMA_CACHE_KEY)) {
  38. self::$schemaReady = true;
  39. return;
  40. }
  41. try {
  42. \think\Cache::rm('procuremen_supplier_score_schema_ok_v1');
  43. \think\Cache::rm('procuremen_supplier_score_schema_ok_v2');
  44. \think\Cache::rm('procuremen_supplier_score_schema_ok_v3');
  45. \think\Cache::rm('procuremen_supplier_score_schema_ok_v4');
  46. \think\Cache::rm('procuremen_supplier_score_schema_ok_v5');
  47. } catch (\Throwable $e) {
  48. }
  49. } catch (\Throwable $e) {
  50. }
  51. try {
  52. Db::query('SELECT 1 FROM `' . self::TABLE_RULE . '` LIMIT 1');
  53. Db::query('SELECT 1 FROM `' . self::TABLE_SCORE . '` LIMIT 1');
  54. self::runSchemaMigrations();
  55. self::$schemaReady = true;
  56. try {
  57. \think\Cache::set(self::SCHEMA_CACHE_KEY, 1, 86400);
  58. } catch (\Throwable $e) {
  59. }
  60. return;
  61. } catch (\Throwable $e) {
  62. }
  63. $sqlFile = APP_PATH . 'extra' . DIRECTORY_SEPARATOR . 'supplier_score_install.sql';
  64. if (is_file($sqlFile)) {
  65. $sql = file_get_contents($sqlFile);
  66. if (is_string($sql) && $sql !== '') {
  67. foreach (preg_split('/;\s*[\r\n]+/', $sql) as $stmt) {
  68. $stmt = trim($stmt);
  69. if ($stmt === '' || stripos($stmt, 'CREATE TABLE') === false) {
  70. continue;
  71. }
  72. try {
  73. Db::execute($stmt);
  74. } catch (\Throwable $ignore) {
  75. }
  76. }
  77. }
  78. }
  79. self::runSchemaMigrations();
  80. self::$schemaReady = true;
  81. try {
  82. \think\Cache::set(self::SCHEMA_CACHE_KEY, 1, 86400);
  83. } catch (\Throwable $e) {
  84. }
  85. }
  86. /** 首次(或缓存失效)时执行表结构迁移与默认规则种子 */
  87. protected static function runSchemaMigrations(): void
  88. {
  89. self::ensureRuleNameColumn();
  90. self::dropObsoleteRuleColumns();
  91. self::ensureDefaultRule();
  92. self::ensureOrderTypeRules();
  93. self::ensureScoreWeightColumns();
  94. self::ensureLeadColumns();
  95. self::ensureIntegerColumns();
  96. self::ensureFinalScoreTable();
  97. }
  98. /** 月度记录表(商务技术分 / 价格分 / 最终得分,仅本页展示与导出) */
  99. public static function ensureFinalScoreTable(): void
  100. {
  101. static $done = false;
  102. if ($done) {
  103. return;
  104. }
  105. try {
  106. if (\think\Cache::get('procuremen_supplier_final_score_table_ok_v1')) {
  107. $done = true;
  108. return;
  109. }
  110. } catch (\Throwable $e) {
  111. }
  112. $ddl = "CREATE TABLE IF NOT EXISTS `" . self::TABLE_FINAL . "` (
  113. `id` int unsigned NOT NULL AUTO_INCREMENT,
  114. `ym` char(7) NOT NULL DEFAULT '' COMMENT '年月 YYYY-MM',
  115. `company_name` varchar(128) NOT NULL DEFAULT '' COMMENT '供应商名称',
  116. `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '月度总分(兼容旧字段)',
  117. `quality_score` decimal(10,2) DEFAULT NULL COMMENT '商务技术分(人工,空=未填)',
  118. `price_score` decimal(10,2) DEFAULT NULL COMMENT '价格分(人工,空=未填)',
  119. `delivery_score` decimal(10,2) DEFAULT NULL COMMENT '交货分(人工,空=未填)',
  120. `value_added_score` decimal(10,2) DEFAULT NULL COMMENT '增值服务(人工,空=未填)',
  121. `final_score` decimal(10,2) DEFAULT NULL COMMENT '最终得分(人工,空=未填)',
  122. `score_grade` char(1) NOT NULL DEFAULT '' COMMENT '评分等级 A/B/C/D',
  123. `qp_manual` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=商务技术分/价格分/交货分已人工修改',
  124. `createtime` datetime DEFAULT NULL,
  125. `updatetime` datetime DEFAULT NULL,
  126. PRIMARY KEY (`id`),
  127. UNIQUE KEY `uk_ym_company` (`ym`,`company_name`),
  128. KEY `idx_ym` (`ym`)
  129. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商月度评审记录'";
  130. try {
  131. Db::execute($ddl);
  132. } catch (\Throwable $e) {
  133. Log::write('supplier monthly score table: ' . $e->getMessage(), 'error');
  134. }
  135. self::ensureMonthlyScoreColumns();
  136. $done = true;
  137. try {
  138. \think\Cache::set('procuremen_supplier_final_score_table_ok_v1', 1, 86400);
  139. } catch (\Throwable $e) {
  140. }
  141. }
  142. /** 历史月度表补商务技术分/价格分等字段 */
  143. protected static function ensureMonthlyScoreColumns(): void
  144. {
  145. $adds = [
  146. 'score' => "ADD COLUMN `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '月度总分(兼容旧字段)' AFTER `company_name`",
  147. 'quality_score' => "ADD COLUMN `quality_score` decimal(10,2) DEFAULT NULL COMMENT '商务技术分(人工,空=未填)' AFTER `score`",
  148. 'price_score' => "ADD COLUMN `price_score` decimal(10,2) DEFAULT NULL COMMENT '价格分(人工,空=未填)' AFTER `quality_score`",
  149. 'delivery_score' => "ADD COLUMN `delivery_score` decimal(10,2) DEFAULT NULL COMMENT '交货分(人工,空=未填)' AFTER `price_score`",
  150. 'value_added_score' => "ADD COLUMN `value_added_score` decimal(10,2) DEFAULT NULL COMMENT '增值服务(人工,空=未填)' AFTER `delivery_score`",
  151. 'score_grade' => "ADD COLUMN `score_grade` char(1) NOT NULL DEFAULT '' COMMENT '评分等级 A/B/C/D' AFTER `final_score`",
  152. 'qp_manual' => "ADD COLUMN `qp_manual` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=商务技术分/价格分/交货分/增值服务已人工修改' AFTER `score_grade`",
  153. ];
  154. foreach ($adds as $col => $ddl) {
  155. try {
  156. $cols = Db::query("SHOW COLUMNS FROM `" . self::TABLE_FINAL . "` LIKE '{$col}'");
  157. if (!is_array($cols) || $cols === []) {
  158. Db::execute("ALTER TABLE `" . self::TABLE_FINAL . "` {$ddl}");
  159. }
  160. } catch (\Throwable $e) {
  161. Log::write("supplier monthly {$col} column: " . $e->getMessage(), 'error');
  162. }
  163. }
  164. // 分数字段允许 NULL:空=未填,列表不显示 0
  165. foreach ([
  166. 'quality_score' => '商务技术分(人工,空=未填)',
  167. 'price_score' => '价格分(人工,空=未填)',
  168. 'delivery_score' => '交货分(人工,空=未填)',
  169. 'value_added_score' => '增值服务(人工,空=未填)',
  170. 'final_score' => '最终得分(人工,空=未填)',
  171. ] as $col => $comment) {
  172. try {
  173. $info = Db::query("SHOW COLUMNS FROM `" . self::TABLE_FINAL . "` LIKE '{$col}'");
  174. if (is_array($info) && $info !== []) {
  175. $null = strtoupper((string)($info[0]['Null'] ?? ''));
  176. if ($null !== 'YES') {
  177. Db::execute(
  178. "ALTER TABLE `" . self::TABLE_FINAL . "` MODIFY COLUMN `{$col}` decimal(10,2) DEFAULT NULL COMMENT '{$comment}'"
  179. );
  180. }
  181. }
  182. } catch (\Throwable $e) {
  183. Log::write("supplier monthly {$col} null: " . $e->getMessage(), 'error');
  184. }
  185. }
  186. }
  187. /** 规范化评分等级:空 / A / B / C / D */
  188. public static function normalizeScoreGrade($raw): string
  189. {
  190. $g = strtoupper(trim((string)$raw));
  191. if (in_array($g, ['A', 'B', 'C', 'D'], true)) {
  192. return $g;
  193. }
  194. return '';
  195. }
  196. /**
  197. * 综合评分标准:A≥90 B 70-89 C 60-69 D<60
  198. *
  199. * @param mixed $score
  200. */
  201. public static function gradeFromFinalScore($score): string
  202. {
  203. if ($score === null || $score === '') {
  204. return '';
  205. }
  206. if (!is_numeric($score)) {
  207. return '';
  208. }
  209. $n = (float)$score;
  210. if ($n >= 90) {
  211. return 'A';
  212. }
  213. if ($n >= 70) {
  214. return 'B';
  215. }
  216. if ($n >= 60) {
  217. return 'C';
  218. }
  219. return 'D';
  220. }
  221. /**
  222. * 最终得分 = 质量 + 价格 + 交货 + 增值服务(空项按 0;四项全空则 null)
  223. *
  224. * @param mixed $quality
  225. * @param mixed $price
  226. * @param mixed $delivery
  227. * @param mixed $valueAdded
  228. */
  229. public static function sumFinalScore($quality, $price, $delivery, $valueAdded): ?float
  230. {
  231. $parts = [$quality, $price, $delivery, $valueAdded];
  232. $any = false;
  233. $sum = 0.0;
  234. foreach ($parts as $p) {
  235. if ($p === null || $p === '') {
  236. continue;
  237. }
  238. if (!is_numeric($p)) {
  239. continue;
  240. }
  241. $any = true;
  242. $sum += (float)$p;
  243. }
  244. return $any ? round($sum, 2) : null;
  245. }
  246. /**
  247. * 按订单明细重算并写入某月评审记录(商务技术分/价格分;保留已填最终得分)
  248. *
  249. * @param array<int, string>|null $onlyCompanies 仅同步这些供应商;null=当月全部
  250. */
  251. public static function syncMonthlyScoresForYm(string $ym, ?array $onlyCompanies = null): void
  252. {
  253. $ym = trim($ym);
  254. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  255. return;
  256. }
  257. self::ensureSchema();
  258. self::ensureFinalScoreTable();
  259. try {
  260. $query = Db::table(self::TABLE_SCORE)
  261. ->where('ym', $ym)
  262. ->field('company_name,score,quality_score,price_score,lead_score');
  263. if (is_array($onlyCompanies) && $onlyCompanies !== []) {
  264. $names = [];
  265. foreach ($onlyCompanies as $n) {
  266. $n = trim((string)$n);
  267. if ($n !== '') {
  268. $names[$n] = true;
  269. }
  270. }
  271. if ($names === []) {
  272. return;
  273. }
  274. $query->where('company_name', 'in', array_keys($names));
  275. }
  276. $rows = $query->select();
  277. } catch (\Throwable $e) {
  278. // 无 lead_score 列时回退
  279. try {
  280. $query = Db::table(self::TABLE_SCORE)
  281. ->where('ym', $ym)
  282. ->field('company_name,score,quality_score,price_score');
  283. if (is_array($onlyCompanies) && $onlyCompanies !== []) {
  284. $names = [];
  285. foreach ($onlyCompanies as $n) {
  286. $n = trim((string)$n);
  287. if ($n !== '') {
  288. $names[$n] = true;
  289. }
  290. }
  291. if ($names === []) {
  292. return;
  293. }
  294. $query->where('company_name', 'in', array_keys($names));
  295. }
  296. $rows = $query->select();
  297. } catch (\Throwable $e2) {
  298. Log::write('supplier monthly sync read: ' . $e2->getMessage(), 'error');
  299. return;
  300. }
  301. }
  302. if (!is_array($rows)) {
  303. return;
  304. }
  305. $agg = [];
  306. foreach ($rows as $r) {
  307. if (!is_array($r)) {
  308. continue;
  309. }
  310. $cn = trim((string)($r['company_name'] ?? ''));
  311. if ($cn === '') {
  312. continue;
  313. }
  314. if (!isset($agg[$cn])) {
  315. $agg[$cn] = [
  316. 'score_sum' => 0.0,
  317. 'quality_sum' => 0.0,
  318. 'price_sum' => 0.0,
  319. 'delivery_sum' => 0.0,
  320. 'cnt' => 0,
  321. ];
  322. }
  323. $agg[$cn]['score_sum'] += (float)($r['score'] ?? 0);
  324. $agg[$cn]['quality_sum'] += (float)($r['quality_score'] ?? 0);
  325. $agg[$cn]['price_sum'] += (float)($r['price_score'] ?? 0);
  326. $agg[$cn]['delivery_sum'] += (float)($r['lead_score'] ?? 0);
  327. $agg[$cn]['cnt']++;
  328. }
  329. $now = date('Y-m-d H:i:s');
  330. foreach ($agg as $cn => $a) {
  331. $cnt = (int)$a['cnt'];
  332. $avgScore = $cnt > 0 ? round($a['score_sum'] / $cnt, 2) : 0.0;
  333. $avgPrice = $cnt > 0 ? round($a['price_sum'] / $cnt, 2) : 0.0;
  334. $avgDelivery = $cnt > 0 ? round($a['delivery_sum'] / $cnt, 2) : 0.0;
  335. try {
  336. $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
  337. if (is_array($exists) && $exists !== []) {
  338. // 已手工保存:不再按订单明细重算覆盖
  339. if ((int)($exists['qp_manual'] ?? 0) === 1) {
  340. continue;
  341. }
  342. $upd = [
  343. 'score' => $avgScore,
  344. 'price_score' => $avgPrice,
  345. 'delivery_score' => $avgDelivery,
  346. 'updatetime' => $now,
  347. ];
  348. Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update($upd);
  349. } else {
  350. Db::table(self::TABLE_FINAL)->insert([
  351. 'ym' => $ym,
  352. 'company_name' => $cn,
  353. 'score' => $avgScore,
  354. 'quality_score' => null,
  355. 'price_score' => $avgPrice,
  356. 'delivery_score' => $avgDelivery,
  357. 'value_added_score' => null,
  358. 'final_score' => null,
  359. 'score_grade' => '',
  360. 'qp_manual' => 0,
  361. 'createtime' => $now,
  362. 'updatetime' => $now,
  363. ]);
  364. }
  365. } catch (\Throwable $e) {
  366. Log::write('supplier monthly sync write: ' . $e->getMessage(), 'error');
  367. }
  368. }
  369. }
  370. /**
  371. * 读取某月各供应商最终得分(仅已人工填写的)
  372. *
  373. * @return array<string, float> company_name => final_score
  374. */
  375. public static function loadFinalScoreMapByYm(string $ym): array
  376. {
  377. $ym = trim($ym);
  378. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  379. return [];
  380. }
  381. self::ensureFinalScoreTable();
  382. try {
  383. $rows = Db::table(self::TABLE_FINAL)
  384. ->where('ym', $ym)
  385. ->whereNotNull('final_score')
  386. ->field('company_name,final_score')
  387. ->select();
  388. } catch (\Throwable $e) {
  389. return [];
  390. }
  391. if (!is_array($rows)) {
  392. return [];
  393. }
  394. $out = [];
  395. foreach ($rows as $r) {
  396. if (!is_array($r)) {
  397. continue;
  398. }
  399. $cn = trim((string)($r['company_name'] ?? ''));
  400. if ($cn === '') {
  401. continue;
  402. }
  403. $out[$cn] = round((float)($r['final_score'] ?? 0), 2);
  404. }
  405. return $out;
  406. }
  407. /**
  408. * 读取库中可选分数:NULL/空串 → null;合法数字 → float
  409. *
  410. * @param mixed $raw
  411. */
  412. public static function readOptionalScoreValue($raw): ?float
  413. {
  414. if ($raw === null) {
  415. return null;
  416. }
  417. if (is_string($raw) && trim($raw) === '') {
  418. return null;
  419. }
  420. if (!is_numeric($raw)) {
  421. return null;
  422. }
  423. return round((float)$raw, 2);
  424. }
  425. /**
  426. * 可选分数展示:空不显示 0
  427. *
  428. * @param float|null $n
  429. */
  430. public static function formatOptionalScore(?float $n): string
  431. {
  432. if ($n === null) {
  433. return '';
  434. }
  435. return self::formatScore($n);
  436. }
  437. /**
  438. * 开标后把本月询价供应商名称写入月度评审表:不存在则插入空行,已存在不改(分数人工填)
  439. *
  440. * @param array<int, string> $companyNames
  441. * @return int 新插入条数
  442. */
  443. public static function ensureMonthlySupplierNames(string $ym, array $companyNames): int
  444. {
  445. $ym = trim($ym);
  446. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  447. return 0;
  448. }
  449. $uniq = [];
  450. foreach ($companyNames as $n) {
  451. $n = trim((string)$n);
  452. if ($n !== '') {
  453. $uniq[$n] = true;
  454. }
  455. }
  456. if ($uniq === []) {
  457. return 0;
  458. }
  459. self::ensureFinalScoreTable();
  460. $now = date('Y-m-d H:i:s');
  461. $inserted = 0;
  462. foreach (array_keys($uniq) as $cn) {
  463. try {
  464. $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
  465. if (is_array($exists) && $exists !== []) {
  466. continue;
  467. }
  468. Db::table(self::TABLE_FINAL)->insert([
  469. 'ym' => $ym,
  470. 'company_name' => $cn,
  471. 'score' => 0,
  472. 'quality_score' => null,
  473. 'price_score' => null,
  474. 'delivery_score' => null,
  475. 'value_added_score' => null,
  476. 'final_score' => null,
  477. 'score_grade' => '',
  478. 'qp_manual' => 0,
  479. 'createtime' => $now,
  480. 'updatetime' => $now,
  481. ]);
  482. $inserted++;
  483. } catch (\Throwable $e) {
  484. // 并发下唯一键冲突视为已存在
  485. if (stripos($e->getMessage(), 'Duplicate') === false) {
  486. Log::write('supplier monthly ensure name: ' . $e->getMessage(), 'error');
  487. }
  488. }
  489. }
  490. return $inserted;
  491. }
  492. /**
  493. * 按当月订单评分表中的供应商,补齐月度评审表名称(仅新增空行)
  494. */
  495. public static function ensureMonthlySupplierNamesFromOrderYm(string $ym): void
  496. {
  497. $ym = trim($ym);
  498. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  499. return;
  500. }
  501. self::ensureSchema();
  502. self::ensureFinalScoreTable();
  503. try {
  504. $rows = Db::table(self::TABLE_SCORE)
  505. ->where('ym', $ym)
  506. ->field('company_name')
  507. ->group('company_name')
  508. ->select();
  509. } catch (\Throwable $e) {
  510. return;
  511. }
  512. if (!is_array($rows) || $rows === []) {
  513. return;
  514. }
  515. $names = [];
  516. foreach ($rows as $r) {
  517. if (!is_array($r)) {
  518. continue;
  519. }
  520. $cn = trim((string)($r['company_name'] ?? ''));
  521. if ($cn !== '') {
  522. $names[] = $cn;
  523. }
  524. }
  525. self::ensureMonthlySupplierNames($ym, $names);
  526. }
  527. /**
  528. * 合格率百分比 → 质量得分(图三对照)
  529. * 100→20;95-99→18;90-94→16;85-89→14;70-84→12;&lt;70→10
  530. */
  531. public static function mapPassRatePercentToQualityScore(float $passRatePercent): float
  532. {
  533. if ($passRatePercent >= 100) {
  534. return 20.0;
  535. }
  536. if ($passRatePercent >= 95) {
  537. return 18.0;
  538. }
  539. if ($passRatePercent >= 90) {
  540. return 16.0;
  541. }
  542. if ($passRatePercent >= 85) {
  543. return 14.0;
  544. }
  545. if ($passRatePercent >= 70) {
  546. return 12.0;
  547. }
  548. return 10.0;
  549. }
  550. /**
  551. * 按入库评分统计某供应商某月质量得分
  552. * 不合格率=(不合格订单数/总订单数)×100%;合格率=100%-不合格率;再对照得分表
  553. * 同一订单号多工序只计 1 单;任一工序不合格则整单不合格
  554. * 优先读入库评分表;表空时回退操作日志(入库评分)
  555. *
  556. * @return float|null 无入库评分订单时返回 null
  557. */
  558. public static function calcQualityScoreFromInbound(string $companyName, string $ym): ?float
  559. {
  560. $companyName = trim($companyName);
  561. $ym = self::formatScoreYm($ym);
  562. if ($companyName === '' || $ym === '') {
  563. return null;
  564. }
  565. $byOrder = self::loadInboundOrderResultsByCompanyYm($companyName, $ym);
  566. $total = count($byOrder);
  567. if ($total < 1) {
  568. return null;
  569. }
  570. $fail = 0;
  571. foreach ($byOrder as $res) {
  572. if ($res === '不合格') {
  573. $fail++;
  574. }
  575. }
  576. // 不合格率 = 不合格次数/总订单×100%;合格率 = 100% - 不合格率
  577. $passRate = (1 - ($fail / $total)) * 100;
  578. return self::mapPassRatePercentToQualityScore($passRate);
  579. }
  580. /**
  581. * 某供应商某月:订单号 => 合格|不合格
  582. *
  583. * @return array<string, string>
  584. */
  585. public static function loadInboundOrderResultsByCompanyYm(string $companyName, string $ym): array
  586. {
  587. $companyName = trim($companyName);
  588. $ym = self::formatScoreYm($ym);
  589. if ($companyName === '' || $ym === '') {
  590. return [];
  591. }
  592. $byOrder = [];
  593. // 1) 入库评分表
  594. try {
  595. $rows = Db::table('purchase_order_inbound_score')
  596. ->where('company_name', $companyName)
  597. ->where(function ($q) use ($ym) {
  598. $q->where('updatetime', 'like', $ym . '%')
  599. ->whereOr('createtime', 'like', $ym . '%');
  600. })
  601. ->field('ccydh,result')
  602. ->select();
  603. } catch (\Throwable $e) {
  604. $rows = [];
  605. Log::write('loadInboundOrderResults table: ' . $e->getMessage(), 'error');
  606. }
  607. if (is_array($rows)) {
  608. foreach ($rows as $r) {
  609. if (!is_array($r)) {
  610. continue;
  611. }
  612. $ccydh = trim((string)($r['ccydh'] ?? ''));
  613. $res = trim((string)($r['result'] ?? ''));
  614. if ($ccydh === '' || ($res !== '合格' && $res !== '不合格')) {
  615. continue;
  616. }
  617. if (!isset($byOrder[$ccydh]) || $res === '不合格') {
  618. $byOrder[$ccydh] = $res;
  619. }
  620. }
  621. }
  622. if ($byOrder !== []) {
  623. return $byOrder;
  624. }
  625. // 2) 回退:操作日志「入库评分」+ 采购单供应商
  626. try {
  627. $logs = Db::table('purchase_order_oper_log')
  628. ->where(function ($q) {
  629. $q->where('action', '入库评分')->whereOr('action', 'inbound_score');
  630. })
  631. ->where('createtime', 'like', $ym . '%')
  632. ->field('scydgy_id,content,createtime')
  633. ->order('id', 'asc')
  634. ->select();
  635. } catch (\Throwable $e) {
  636. Log::write('loadInboundOrderResults logs: ' . $e->getMessage(), 'error');
  637. return [];
  638. }
  639. if (!is_array($logs) || $logs === []) {
  640. return [];
  641. }
  642. $sidResult = [];
  643. foreach ($logs as $lg) {
  644. if (!is_array($lg)) {
  645. continue;
  646. }
  647. $sid = (int)($lg['scydgy_id'] ?? 0);
  648. if ($sid <= 0) {
  649. continue;
  650. }
  651. $content = trim((string)($lg['content'] ?? ''));
  652. if (!preg_match('/(合格|不合格)/u', $content, $m)) {
  653. continue;
  654. }
  655. $sidResult[$sid] = $m[1];
  656. }
  657. if ($sidResult === []) {
  658. return [];
  659. }
  660. try {
  661. $poRows = Db::table('purchase_order')
  662. ->where('scydgy_id', 'in', array_keys($sidResult))
  663. ->where('pick_company_name', $companyName)
  664. ->field('scydgy_id,CCYDH,pick_company_name')
  665. ->select();
  666. } catch (\Throwable $e) {
  667. return [];
  668. }
  669. if (!is_array($poRows)) {
  670. return [];
  671. }
  672. foreach ($poRows as $po) {
  673. if (!is_array($po)) {
  674. continue;
  675. }
  676. $sid = (int)($po['scydgy_id'] ?? 0);
  677. $ccydh = trim((string)($po['CCYDH'] ?? ''));
  678. $res = $sidResult[$sid] ?? '';
  679. if ($sid <= 0 || $ccydh === '' || ($res !== '合格' && $res !== '不合格')) {
  680. continue;
  681. }
  682. if (!isset($byOrder[$ccydh]) || $res === '不合格') {
  683. $byOrder[$ccydh] = $res;
  684. }
  685. }
  686. return $byOrder;
  687. }
  688. /**
  689. * 用入库合格/不合格重算并写入月度「质量得分」
  690. * 已手工保存(qp_manual=1)的供应商跳过,不再覆盖
  691. *
  692. * @param string|null $onlyCompany 仅同步该供应商;null=当月有入库评分的全部
  693. */
  694. public static function syncQualityScoreFromInbound(string $ym, ?string $onlyCompany = null): void
  695. {
  696. $ym = self::formatScoreYm($ym);
  697. if ($ym === '') {
  698. return;
  699. }
  700. self::ensureFinalScoreTable();
  701. // 先尽量从操作日志回填入库评分表(防止表数据丢失导致档案/计算为空)
  702. self::repairInboundScoreFromOperLogs($ym);
  703. $companies = [];
  704. $onlyCompany = $onlyCompany !== null ? trim($onlyCompany) : '';
  705. if ($onlyCompany !== '') {
  706. $companies = [$onlyCompany];
  707. } else {
  708. try {
  709. $rows = Db::table('purchase_order_inbound_score')
  710. ->where(function ($q) use ($ym) {
  711. $q->where('updatetime', 'like', $ym . '%')
  712. ->whereOr('createtime', 'like', $ym . '%');
  713. })
  714. ->field('company_name')
  715. ->group('company_name')
  716. ->select();
  717. } catch (\Throwable $e) {
  718. $rows = [];
  719. Log::write('syncQualityScoreFromInbound list: ' . $e->getMessage(), 'error');
  720. }
  721. if (is_array($rows)) {
  722. foreach ($rows as $r) {
  723. if (!is_array($r)) {
  724. continue;
  725. }
  726. $cn = trim((string)($r['company_name'] ?? ''));
  727. if ($cn !== '') {
  728. $companies[$cn] = true;
  729. }
  730. }
  731. }
  732. // 再并入操作日志涉及的供应商
  733. try {
  734. $logs = Db::table('purchase_order_oper_log')
  735. ->where(function ($q) {
  736. $q->where('action', '入库评分')->whereOr('action', 'inbound_score');
  737. })
  738. ->where('createtime', 'like', $ym . '%')
  739. ->field('scydgy_id')
  740. ->select();
  741. } catch (\Throwable $e) {
  742. $logs = [];
  743. }
  744. $sids = [];
  745. if (is_array($logs)) {
  746. foreach ($logs as $lg) {
  747. if (!is_array($lg)) {
  748. continue;
  749. }
  750. $sid = (int)($lg['scydgy_id'] ?? 0);
  751. if ($sid > 0) {
  752. $sids[$sid] = true;
  753. }
  754. }
  755. }
  756. if ($sids !== []) {
  757. try {
  758. $poRows = Db::table('purchase_order')
  759. ->where('scydgy_id', 'in', array_keys($sids))
  760. ->field('pick_company_name')
  761. ->group('pick_company_name')
  762. ->select();
  763. } catch (\Throwable $e) {
  764. $poRows = [];
  765. }
  766. if (is_array($poRows)) {
  767. foreach ($poRows as $po) {
  768. if (!is_array($po)) {
  769. continue;
  770. }
  771. $cn = trim((string)($po['pick_company_name'] ?? ''));
  772. if ($cn !== '') {
  773. $companies[$cn] = true;
  774. }
  775. }
  776. }
  777. }
  778. $companies = array_keys($companies);
  779. }
  780. if ($companies === []) {
  781. return;
  782. }
  783. self::ensureMonthlySupplierNames($ym, $companies);
  784. $now = date('Y-m-d H:i:s');
  785. foreach ($companies as $cn) {
  786. $cn = trim((string)$cn);
  787. if ($cn === '') {
  788. continue;
  789. }
  790. $score = self::calcQualityScoreFromInbound($cn, $ym);
  791. if ($score === null) {
  792. continue;
  793. }
  794. try {
  795. $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
  796. if (!is_array($exists) || $exists === []) {
  797. Db::table(self::TABLE_FINAL)->insert([
  798. 'ym' => $ym,
  799. 'company_name' => $cn,
  800. 'score' => 0,
  801. 'quality_score' => $score,
  802. 'price_score' => null,
  803. 'delivery_score' => null,
  804. 'value_added_score' => null,
  805. 'final_score' => null,
  806. 'score_grade' => '',
  807. 'qp_manual' => 0,
  808. 'createtime' => $now,
  809. 'updatetime' => $now,
  810. ]);
  811. continue;
  812. }
  813. // 已手工保存:不再按入库规则覆盖质量分
  814. if ((int)($exists['qp_manual'] ?? 0) === 1) {
  815. continue;
  816. }
  817. Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update([
  818. 'quality_score' => $score,
  819. 'updatetime' => $now,
  820. ]);
  821. } catch (\Throwable $e) {
  822. Log::write('syncQualityScoreFromInbound write: ' . $e->getMessage(), 'error');
  823. }
  824. }
  825. }
  826. /**
  827. * 操作日志有入库评分、评分表缺失时,回填 purchase_order_inbound_score
  828. */
  829. public static function repairInboundScoreFromOperLogs(string $ym): void
  830. {
  831. $ym = self::formatScoreYm($ym);
  832. if ($ym === '') {
  833. return;
  834. }
  835. try {
  836. $logs = Db::table('purchase_order_oper_log')
  837. ->where(function ($q) {
  838. $q->where('action', '入库评分')->whereOr('action', 'inbound_score');
  839. })
  840. ->where('createtime', 'like', $ym . '%')
  841. ->field('scydgy_id,content,createtime,admin_id,admin_name,purchase_order_id')
  842. ->order('id', 'asc')
  843. ->select();
  844. } catch (\Throwable $e) {
  845. return;
  846. }
  847. if (!is_array($logs) || $logs === []) {
  848. return;
  849. }
  850. $best = [];
  851. foreach ($logs as $lg) {
  852. if (!is_array($lg)) {
  853. continue;
  854. }
  855. $sid = (int)($lg['scydgy_id'] ?? 0);
  856. if ($sid <= 0) {
  857. continue;
  858. }
  859. $content = trim((string)($lg['content'] ?? ''));
  860. if (!preg_match('/(合格|不合格)/u', $content, $m)) {
  861. continue;
  862. }
  863. $best[$sid] = [
  864. 'result' => $m[1],
  865. 'createtime' => trim((string)($lg['createtime'] ?? '')),
  866. 'admin_id' => (int)($lg['admin_id'] ?? 0),
  867. 'admin_name' => trim((string)($lg['admin_name'] ?? '')),
  868. 'purchase_order_id' => (int)($lg['purchase_order_id'] ?? 0),
  869. ];
  870. }
  871. if ($best === []) {
  872. return;
  873. }
  874. try {
  875. $existRows = Db::table('purchase_order_inbound_score')
  876. ->where('scydgy_id', 'in', array_keys($best))
  877. ->field('scydgy_id')
  878. ->select();
  879. } catch (\Throwable $e) {
  880. return;
  881. }
  882. $existSids = [];
  883. if (is_array($existRows)) {
  884. foreach ($existRows as $er) {
  885. if (is_array($er)) {
  886. $existSids[(int)($er['scydgy_id'] ?? 0)] = true;
  887. }
  888. }
  889. }
  890. $needSids = [];
  891. foreach (array_keys($best) as $sid) {
  892. if (empty($existSids[$sid])) {
  893. $needSids[] = $sid;
  894. }
  895. }
  896. if ($needSids === []) {
  897. return;
  898. }
  899. try {
  900. $poRows = Db::table('purchase_order')
  901. ->where('scydgy_id', 'in', $needSids)
  902. ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_company_name')
  903. ->select();
  904. } catch (\Throwable $e) {
  905. return;
  906. }
  907. if (!is_array($poRows)) {
  908. return;
  909. }
  910. foreach ($poRows as $po) {
  911. if (!is_array($po)) {
  912. continue;
  913. }
  914. $sid = (int)($po['scydgy_id'] ?? 0);
  915. if ($sid <= 0 || empty($best[$sid])) {
  916. continue;
  917. }
  918. $b = $best[$sid];
  919. $now = $b['createtime'] !== '' ? $b['createtime'] : date('Y-m-d H:i:s');
  920. $data = [
  921. 'scydgy_id' => $sid,
  922. 'purchase_order_id' => (int)($po['id'] ?? 0) > 0 ? (int)$po['id'] : (int)$b['purchase_order_id'],
  923. 'ccydh' => trim((string)($po['CCYDH'] ?? '')),
  924. 'cyjmc' => trim((string)($po['CYJMC'] ?? '')),
  925. 'cgymc' => trim((string)($po['CGYMC'] ?? '')),
  926. 'company_name' => trim((string)($po['pick_company_name'] ?? '')),
  927. 'result' => $b['result'],
  928. 'admin_id' => (int)$b['admin_id'],
  929. 'admin_name' => (string)$b['admin_name'],
  930. 'createtime' => $now,
  931. 'updatetime' => $now,
  932. ];
  933. if ($data['company_name'] === '' || $data['ccydh'] === '') {
  934. continue;
  935. }
  936. try {
  937. Db::table('purchase_order_inbound_score')->insert($data);
  938. } catch (\Throwable $e) {
  939. // 唯一键冲突忽略
  940. if (stripos($e->getMessage(), 'Duplicate') === false) {
  941. Log::write('repairInboundScoreFromOperLogs: ' . $e->getMessage(), 'error');
  942. }
  943. }
  944. }
  945. }
  946. /**
  947. * 读取某月评审记录(仅读月度表;先按当月开标/询价供应商补齐名称)
  948. *
  949. * @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}>
  950. */
  951. public static function loadMonthlyMapByYm(string $ym): array
  952. {
  953. $ym = trim($ym);
  954. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  955. return [];
  956. }
  957. self::ensureFinalScoreTable();
  958. // 本月询价(开标后已写入订单评分表)的供应商:名称不存在则加空行,已存在不动
  959. self::ensureMonthlySupplierNamesFromOrderYm($ym);
  960. // 按入库合格/不合格重算质量得分(未人工改过的)
  961. self::syncQualityScoreFromInbound($ym);
  962. try {
  963. $rows = Db::table(self::TABLE_FINAL)
  964. ->where('ym', $ym)
  965. ->field('company_name,score,quality_score,price_score,delivery_score,value_added_score,final_score,score_grade,ym')
  966. ->select();
  967. } catch (\Throwable $e) {
  968. return [];
  969. }
  970. if (!is_array($rows)) {
  971. return [];
  972. }
  973. $out = [];
  974. foreach ($rows as $r) {
  975. if (!is_array($r)) {
  976. continue;
  977. }
  978. $cn = trim((string)($r['company_name'] ?? $r['Company_name'] ?? ''));
  979. if ($cn === '') {
  980. continue;
  981. }
  982. $quality = self::readOptionalScoreValue($r['quality_score'] ?? null);
  983. $price = self::readOptionalScoreValue($r['price_score'] ?? null);
  984. $delivery = self::readOptionalScoreValue($r['delivery_score'] ?? null);
  985. $valueAdded = self::readOptionalScoreValue($r['value_added_score'] ?? null);
  986. $final = self::readOptionalScoreValue($r['final_score'] ?? null);
  987. $out[$cn] = [
  988. 'score' => round((float)($r['score'] ?? 0), 2),
  989. 'quality_score' => $quality,
  990. 'price_score' => $price,
  991. 'delivery_score' => $delivery,
  992. 'value_added_score' => $valueAdded,
  993. 'final_score' => $final,
  994. 'final_saved' => $final !== null ? 1 : 0,
  995. 'score_grade' => self::normalizeScoreGrade($r['score_grade'] ?? ''),
  996. // 列表「日期」列按 ym(YYYY-MM)展示,不到日
  997. 'score_date' => self::formatScoreYm($r['ym'] ?? $ym),
  998. ];
  999. }
  1000. return $out;
  1001. }
  1002. /**
  1003. * 规范为 YYYY-MM(月度评审日期)
  1004. *
  1005. * @param mixed $raw
  1006. */
  1007. public static function formatScoreYm($raw): string
  1008. {
  1009. $raw = trim((string)$raw);
  1010. if (preg_match('/^\d{4}-\d{2}$/', $raw)) {
  1011. return $raw;
  1012. }
  1013. if (preg_match('/^(\d{4})\D+(\d{1,2})/', $raw, $m)) {
  1014. return sprintf('%04d-%02d', (int)$m[1], (int)$m[2]);
  1015. }
  1016. return '';
  1017. }
  1018. /**
  1019. * @param mixed $raw
  1020. */
  1021. public static function formatScoreDateYmd($raw): string
  1022. {
  1023. $raw = trim((string)$raw);
  1024. if ($raw === '' || stripos($raw, '0000-00-00') === 0) {
  1025. return '';
  1026. }
  1027. $ts = strtotime(str_replace('T', ' ', $raw));
  1028. return ($ts !== false && $ts > 0) ? date('Y-m-d', $ts) : '';
  1029. }
  1030. /**
  1031. * 解析可选分数:空=null;非法=false;合法=float
  1032. *
  1033. * @param mixed $raw
  1034. * @return float|null|false
  1035. */
  1036. protected static function parseOptionalScore($raw)
  1037. {
  1038. if ($raw === null) {
  1039. return null;
  1040. }
  1041. if (is_string($raw) && trim($raw) === '') {
  1042. return null;
  1043. }
  1044. if (!is_numeric($raw)) {
  1045. return false;
  1046. }
  1047. return round((float)$raw, 2);
  1048. }
  1049. /**
  1050. * 批量保存某月评审分:商务技术分 / 价格分 / 交货分 / 最终得分 / 评分等级
  1051. *
  1052. * @param array<int, array{company_name?:string,quality_score?:mixed,price_score?:mixed,delivery_score?:mixed,final_score?:mixed,score_grade?:mixed}> $items
  1053. * @return array{done:int,saved:int,cleared:int,error:string}
  1054. */
  1055. public static function saveFinalScoresForYm(string $ym, array $items): array
  1056. {
  1057. $ym = trim($ym);
  1058. $result = ['done' => 0, 'saved' => 0, 'cleared' => 0, 'error' => ''];
  1059. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  1060. $result['error'] = '月份无效';
  1061. return $result;
  1062. }
  1063. if ($items === []) {
  1064. $result['error'] = '没有可保存的数据';
  1065. return $result;
  1066. }
  1067. self::ensureFinalScoreTable();
  1068. try {
  1069. Db::query('SELECT 1 FROM `' . self::TABLE_FINAL . '` LIMIT 1');
  1070. } catch (\Throwable $e) {
  1071. $result['error'] = '月度汇总表未创建,请联系管理员';
  1072. return $result;
  1073. }
  1074. $now = date('Y-m-d H:i:s');
  1075. foreach ($items as $it) {
  1076. if (!is_array($it)) {
  1077. continue;
  1078. }
  1079. $cn = trim((string)($it['company_name'] ?? ''));
  1080. if ($cn === '') {
  1081. continue;
  1082. }
  1083. $grade = self::normalizeScoreGrade($it['score_grade'] ?? '');
  1084. $quality = self::parseOptionalScore($it['quality_score'] ?? null);
  1085. $price = self::parseOptionalScore($it['price_score'] ?? null);
  1086. $delivery = self::parseOptionalScore($it['delivery_score'] ?? null);
  1087. $valueAdded = self::parseOptionalScore($it['value_added_score'] ?? null);
  1088. $final = self::parseOptionalScore($it['final_score'] ?? null);
  1089. if ($quality === false || $price === false || $delivery === false || $valueAdded === false || $final === false) {
  1090. continue;
  1091. }
  1092. // 未填最终得分时,按四项自动汇总
  1093. if ($final === null) {
  1094. $final = self::sumFinalScore($quality, $price, $delivery, $valueAdded);
  1095. }
  1096. // 未选等级时,按最终得分自动定级
  1097. if ($grade === '' && $final !== null) {
  1098. $grade = self::gradeFromFinalScore($final);
  1099. }
  1100. $hasQp = array_key_exists('quality_score', $it)
  1101. || array_key_exists('price_score', $it)
  1102. || array_key_exists('delivery_score', $it)
  1103. || array_key_exists('value_added_score', $it);
  1104. $hasAny = $final !== null || $grade !== ''
  1105. || $quality !== null || $price !== null || $delivery !== null || $valueAdded !== null;
  1106. // 全空行不写入,避免误把未改供应商标成手工锁定
  1107. if (!$hasAny) {
  1108. continue;
  1109. }
  1110. try {
  1111. $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
  1112. $upd = [
  1113. 'final_score' => $final,
  1114. 'score_grade' => $grade,
  1115. 'updatetime' => $now,
  1116. // 保存即视为手工确认,之后不再跑查询计算规则覆盖
  1117. 'qp_manual' => 1,
  1118. ];
  1119. if ($hasQp) {
  1120. // 空值存 NULL,列表显示空白而不是 0
  1121. $upd['quality_score'] = $quality;
  1122. $upd['price_score'] = $price;
  1123. $upd['delivery_score'] = $delivery;
  1124. $upd['value_added_score'] = $valueAdded;
  1125. $upd['score'] = self::sumFinalScore($quality, $price, $delivery, $valueAdded) ?? 0;
  1126. }
  1127. if (is_array($exists) && $exists !== []) {
  1128. Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update($upd);
  1129. } else {
  1130. Db::table(self::TABLE_FINAL)->insert([
  1131. 'ym' => $ym,
  1132. 'company_name' => $cn,
  1133. 'score' => $hasQp ? (self::sumFinalScore($quality, $price, $delivery, $valueAdded) ?? 0) : 0,
  1134. 'quality_score' => $hasQp ? $quality : null,
  1135. 'price_score' => $hasQp ? $price : null,
  1136. 'delivery_score' => $hasQp ? $delivery : null,
  1137. 'value_added_score' => $hasQp ? $valueAdded : null,
  1138. 'final_score' => $final,
  1139. 'score_grade' => $grade,
  1140. 'qp_manual' => 1,
  1141. 'createtime' => $now,
  1142. 'updatetime' => $now,
  1143. ]);
  1144. }
  1145. if ($final === null && $grade === '' && !$hasQp) {
  1146. $result['cleared']++;
  1147. } else {
  1148. $result['saved']++;
  1149. }
  1150. $result['done']++;
  1151. } catch (\Throwable $e) {
  1152. Log::write('supplier final score save: ' . $e->getMessage(), 'error');
  1153. $result['error'] = $e->getMessage();
  1154. }
  1155. }
  1156. return $result;
  1157. }
  1158. /** 历史表补质量/价格百分比字段 */
  1159. protected static function ensureScoreWeightColumns(): void
  1160. {
  1161. try {
  1162. $cols = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'quality_weight'");
  1163. if (!is_array($cols) || $cols === []) {
  1164. Db::execute(
  1165. "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `quality_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比' AFTER `quality_score`"
  1166. );
  1167. }
  1168. $cols2 = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'price_weight'");
  1169. if (!is_array($cols2) || $cols2 === []) {
  1170. Db::execute(
  1171. "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `price_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比' AFTER `price_sum`"
  1172. );
  1173. }
  1174. } catch (\Throwable $e) {
  1175. Log::write('supplier score weight columns: ' . $e->getMessage(), 'error');
  1176. }
  1177. }
  1178. /** 历史表补交货期权重/得分字段 */
  1179. protected static function ensureLeadColumns(): void
  1180. {
  1181. $adds = [
  1182. self::TABLE_RULE => [
  1183. 'lead_weight' => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)' AFTER `price_weight`",
  1184. ],
  1185. self::TABLE_SCORE => [
  1186. 'lead_days_sum' => "ADD COLUMN `lead_days_sum` int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)' AFTER `price_score`",
  1187. 'lead_weight' => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比' AFTER `lead_days_sum`",
  1188. 'lead_enabled' => "ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=当时纳入总分' AFTER `lead_weight`",
  1189. 'lead_score' => "ADD COLUMN `lead_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '交货期评分值' AFTER `lead_enabled`",
  1190. ],
  1191. ];
  1192. foreach ($adds as $table => $cols) {
  1193. foreach ($cols as $col => $ddl) {
  1194. try {
  1195. $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
  1196. if (is_array($info) && $info !== []) {
  1197. continue;
  1198. }
  1199. Db::execute("ALTER TABLE `{$table}` {$ddl}");
  1200. } catch (\Throwable $e) {
  1201. Log::write("supplier score lead column {$table}.{$col}: " . $e->getMessage(), 'error');
  1202. }
  1203. }
  1204. }
  1205. }
  1206. /** 将权重/质量分/单价合计等字段改为整数(去掉 .00) */
  1207. protected static function ensureIntegerColumns(): void
  1208. {
  1209. $defs = [
  1210. self::TABLE_RULE => [
  1211. 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '上年度质量评分权重(%)'",
  1212. 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '单价评分权重(%)'",
  1213. 'lead_weight' => "int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)'",
  1214. ],
  1215. self::TABLE_SCORE => [
  1216. 'quality_score' => "int NOT NULL DEFAULT 0 COMMENT '上年度质量评分'",
  1217. 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比'",
  1218. 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比'",
  1219. 'lead_days_sum' => "int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)'",
  1220. 'lead_weight' => "int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比'",
  1221. ],
  1222. ];
  1223. foreach ($defs as $table => $cols) {
  1224. foreach ($cols as $col => $def) {
  1225. try {
  1226. $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
  1227. if (!is_array($info) || $info === []) {
  1228. continue;
  1229. }
  1230. $type = strtolower((string)($info[0]['Type'] ?? $info[0]['type'] ?? ''));
  1231. if (preg_match('/^(tiny|small|medium|big)?int\b/', $type)) {
  1232. continue;
  1233. }
  1234. Db::execute("ALTER TABLE `{$table}` MODIFY COLUMN `{$col}` {$def}");
  1235. } catch (\Throwable $e) {
  1236. Log::write("supplier score int column {$table}.{$col}: " . $e->getMessage(), 'error');
  1237. }
  1238. }
  1239. }
  1240. // 单价合计需保留小数(如 0.25),不可用 int
  1241. try {
  1242. $info = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'price_sum'");
  1243. if (is_array($info) && $info !== []) {
  1244. $type = strtolower((string)($info[0]['Type'] ?? $info[0]['type'] ?? ''));
  1245. if (preg_match('/^(tiny|small|medium|big)?int\b/', $type)) {
  1246. Db::execute(
  1247. "ALTER TABLE `" . self::TABLE_SCORE . "` MODIFY COLUMN `price_sum` decimal(12,4) NOT NULL DEFAULT 0.0000 COMMENT '本单工序单价合计'"
  1248. );
  1249. }
  1250. }
  1251. } catch (\Throwable $e) {
  1252. Log::write('supplier score price_sum decimal: ' . $e->getMessage(), 'error');
  1253. }
  1254. }
  1255. protected static function ensureDefaultRule(): void
  1256. {
  1257. self::ensureRuleNameColumn();
  1258. self::ensureOrderTypeRules();
  1259. }
  1260. /**
  1261. * 规则表去掉页面未用字段:lead_enabled、status
  1262. * (交期是否纳入由 lead_weight>0 判断;删除用物理删除)
  1263. */
  1264. protected static function dropObsoleteRuleColumns(): void
  1265. {
  1266. static $done = false;
  1267. if ($done) {
  1268. return;
  1269. }
  1270. $done = true;
  1271. foreach (['lead_enabled', 'status'] as $col) {
  1272. try {
  1273. $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'' . $col . '\'');
  1274. if (is_array($cols) && $cols !== []) {
  1275. Db::execute('ALTER TABLE `' . self::TABLE_RULE . '` DROP COLUMN `' . $col . '`');
  1276. }
  1277. } catch (\Throwable $e) {
  1278. Log::write('supplier_score_rule drop ' . $col . ': ' . $e->getMessage(), 'error');
  1279. }
  1280. }
  1281. }
  1282. /**
  1283. * 规则表补「规则名称/订单类型」字段(历史库可能缺此列)
  1284. */
  1285. protected static function ensureRuleNameColumn(): void
  1286. {
  1287. static $done = false;
  1288. if ($done) {
  1289. return;
  1290. }
  1291. try {
  1292. $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'name\'');
  1293. if (!is_array($cols) || $cols === []) {
  1294. Db::execute(
  1295. 'ALTER TABLE `' . self::TABLE_RULE . '` ADD COLUMN `name` varchar(100) NOT NULL DEFAULT \'\' COMMENT \'规则名称/订单类型\' AFTER `id`'
  1296. );
  1297. $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'name\'');
  1298. }
  1299. if (is_array($cols) && $cols !== []) {
  1300. $done = true;
  1301. }
  1302. } catch (\Throwable $e) {
  1303. Log::write('supplier_score_rule.name: ' . $e->getMessage(), 'error');
  1304. }
  1305. }
  1306. /**
  1307. * 写入/对齐「不同订单供应商评分权重方案」三条规则
  1308. */
  1309. protected static function ensureOrderTypeRules(): void
  1310. {
  1311. static $done = false;
  1312. if ($done) {
  1313. return;
  1314. }
  1315. try {
  1316. if (\think\Cache::get('procuremen_supplier_order_type_rules_ok_v1')) {
  1317. $done = true;
  1318. return;
  1319. }
  1320. } catch (\Throwable $e) {
  1321. }
  1322. self::ensureRuleNameColumn();
  1323. $schemes = [
  1324. [
  1325. 'name' => '普通订单(价格优先)',
  1326. 'quality_weight' => 30,
  1327. 'price_weight' => 50,
  1328. 'lead_weight' => 20,
  1329. ],
  1330. [
  1331. 'name' => '急单(交期优先)',
  1332. 'quality_weight' => 30,
  1333. 'price_weight' => 20,
  1334. 'lead_weight' => 50,
  1335. ],
  1336. [
  1337. 'name' => '重点产品(质量优先)',
  1338. 'quality_weight' => 50,
  1339. 'price_weight' => 20,
  1340. 'lead_weight' => 30,
  1341. ],
  1342. ];
  1343. $now = date('Y-m-d H:i:s');
  1344. try {
  1345. // 确认 name 列已存在再继续
  1346. $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'name\'');
  1347. if (!is_array($cols) || $cols === []) {
  1348. Log::write('supplier_score_rule.name still missing, skip seed', 'error');
  1349. return;
  1350. }
  1351. foreach ($schemes as $s) {
  1352. $exist = Db::table(self::TABLE_RULE)->where('name', $s['name'])->find();
  1353. if (!is_array($exist)) {
  1354. // 兼容旧数据:无 name 时按三档百分比匹配
  1355. $exist = Db::table(self::TABLE_RULE)
  1356. ->where('quality_weight', (int)$s['quality_weight'])
  1357. ->where('price_weight', (int)$s['price_weight'])
  1358. ->where('lead_weight', (int)$s['lead_weight'])
  1359. ->where(function ($q) {
  1360. $q->whereNull('name')->whereOr('name', '');
  1361. })
  1362. ->order('id', 'asc')
  1363. ->find();
  1364. }
  1365. $payload = [
  1366. 'name' => $s['name'],
  1367. 'quality_weight' => (int)$s['quality_weight'],
  1368. 'price_weight' => (int)$s['price_weight'],
  1369. 'lead_weight' => (int)$s['lead_weight'],
  1370. 'updatetime' => $now,
  1371. ];
  1372. if (is_array($exist)) {
  1373. // 已存在:只补空名称,不覆盖人工改过的权重
  1374. $existName = trim((string)($exist['name'] ?? ''));
  1375. if ($existName === '') {
  1376. Db::table(self::TABLE_RULE)->where('id', (int)$exist['id'])->update([
  1377. 'name' => $s['name'],
  1378. 'updatetime' => $now,
  1379. ]);
  1380. }
  1381. } else {
  1382. $payload['is_default'] = 0;
  1383. $payload['createtime'] = $now;
  1384. Db::table(self::TABLE_RULE)->insert($payload);
  1385. }
  1386. }
  1387. // 非方案内的旧行直接删除,列表只保留三条
  1388. $names = [];
  1389. foreach ($schemes as $s) {
  1390. $names[] = $s['name'];
  1391. }
  1392. $nameIn = "'" . implode("','", array_map('addslashes', $names)) . "'";
  1393. Db::execute(
  1394. 'DELETE FROM `' . self::TABLE_RULE . '` '
  1395. . 'WHERE `name` NOT IN (' . $nameIn . ') OR `name` IS NULL OR `name`=\'\''
  1396. );
  1397. $hasDefault = Db::table(self::TABLE_RULE)->where('is_default', 1)->find();
  1398. if (!is_array($hasDefault)) {
  1399. $def = Db::table(self::TABLE_RULE)->where('name', '普通订单(价格优先)')->find();
  1400. if (!is_array($def)) {
  1401. $def = Db::table(self::TABLE_RULE)->order('id', 'asc')->find();
  1402. }
  1403. if (is_array($def)) {
  1404. Db::table(self::TABLE_RULE)->where('id', (int)$def['id'])->update([
  1405. 'is_default' => 1,
  1406. 'updatetime' => $now,
  1407. ]);
  1408. }
  1409. }
  1410. $done = true;
  1411. try {
  1412. \think\Cache::set('procuremen_supplier_order_type_rules_ok_v1', 1, 86400);
  1413. } catch (\Throwable $e) {
  1414. }
  1415. } catch (\Throwable $e) {
  1416. Log::write('supplier score ensureOrderTypeRules: ' . $e->getMessage(), 'error');
  1417. }
  1418. }
  1419. /**
  1420. * 下拉用:正常状态的评分规则(订单类型)
  1421. *
  1422. * @return array<int, array{id:int,name:string,label:string,quality_weight:int,price_weight:int,lead_weight:int,is_default:int}>
  1423. */
  1424. public static function listRulesForSelect(): array
  1425. {
  1426. self::ensureSchema();
  1427. $out = [];
  1428. try {
  1429. $rows = Db::table(self::TABLE_RULE)
  1430. ->order('is_default', 'desc')
  1431. ->order('id', 'asc')
  1432. ->select();
  1433. if (!is_array($rows)) {
  1434. return $out;
  1435. }
  1436. foreach ($rows as $row) {
  1437. if (!is_array($row)) {
  1438. continue;
  1439. }
  1440. $name = trim((string)($row['name'] ?? ''));
  1441. if ($name === '') {
  1442. continue;
  1443. }
  1444. $qw = (int)($row['quality_weight'] ?? 0);
  1445. $pw = (int)($row['price_weight'] ?? 0);
  1446. $lw = (int)($row['lead_weight'] ?? 0);
  1447. $out[] = [
  1448. 'id' => (int)($row['id'] ?? 0),
  1449. 'name' => $name,
  1450. 'label' => $name . '|质量' . $qw . '%|价格' . $pw . '%|交期' . $lw . '%',
  1451. 'quality_weight' => $qw,
  1452. 'price_weight' => $pw,
  1453. 'lead_weight' => $lw,
  1454. 'is_default' => !empty($row['is_default']) ? 1 : 0,
  1455. ];
  1456. }
  1457. } catch (\Throwable $e) {
  1458. }
  1459. return $out;
  1460. }
  1461. /**
  1462. * 按订单号解析评分规则:优先下发时写入的权重快照,否则按 score_rule_id 读规则表,再否则默认
  1463. *
  1464. * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
  1465. */
  1466. public static function resolveRuleForCcydh(string $ccydh, ?array $poHint = null): array
  1467. {
  1468. $ccydh = trim($ccydh);
  1469. if ($ccydh === '' && (!is_array($poHint) || $poHint === [])) {
  1470. return self::getDefaultRule();
  1471. }
  1472. try {
  1473. $po = is_array($poHint) && $poHint !== [] ? $poHint : null;
  1474. if ($po === null && $ccydh !== '') {
  1475. self::ensureSchema();
  1476. $po = Db::table('purchase_order')->where('CCYDH', $ccydh)->order('id', 'desc')->find();
  1477. }
  1478. if (is_array($po)) {
  1479. $ruleId = (int)($po['score_rule_id'] ?? 0);
  1480. $qw = (int)($po['score_quality_weight'] ?? 0);
  1481. $pw = (int)($po['score_price_weight'] ?? 0);
  1482. $lw = (int)($po['score_lead_weight'] ?? 0);
  1483. // 下发时已快照:开标/展示一律用快照,不受后续规则修改影响(无需 ensureSchema)
  1484. if ($ruleId > 0 && ($qw + $pw + $lw) > 0) {
  1485. $name = trim((string)($po['score_rule_name'] ?? ''));
  1486. if ($name === '') {
  1487. $live = self::getRuleById($ruleId);
  1488. $name = (string)($live['name'] ?? '');
  1489. }
  1490. return [
  1491. 'id' => $ruleId,
  1492. 'name' => $name,
  1493. 'quality_weight' => max(0, $qw),
  1494. 'price_weight' => max(0, $pw),
  1495. 'lead_weight' => max(0, $lw),
  1496. 'lead_enabled' => $lw > 0 ? 1 : 0,
  1497. ];
  1498. }
  1499. if ($ruleId > 0) {
  1500. $rule = self::getRuleById($ruleId);
  1501. if ((int)($rule['id'] ?? 0) > 0) {
  1502. return $rule;
  1503. }
  1504. }
  1505. }
  1506. } catch (\Throwable $e) {
  1507. }
  1508. return self::getDefaultRule();
  1509. }
  1510. /**
  1511. * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
  1512. */
  1513. public static function getRuleById(int $id): array
  1514. {
  1515. if ($id <= 0) {
  1516. return self::getDefaultRule();
  1517. }
  1518. self::ensureSchema();
  1519. try {
  1520. $row = Db::table(self::TABLE_RULE)
  1521. ->where('id', $id)
  1522. ->find();
  1523. if (is_array($row)) {
  1524. $lw = (int)($row['lead_weight'] ?? 0);
  1525. return [
  1526. 'id' => (int)($row['id'] ?? 0),
  1527. 'name' => trim((string)($row['name'] ?? '')),
  1528. 'quality_weight' => (int)($row['quality_weight'] ?? 50),
  1529. 'price_weight' => (int)($row['price_weight'] ?? 50),
  1530. 'lead_weight' => $lw,
  1531. 'lead_enabled' => $lw > 0 ? 1 : 0,
  1532. ];
  1533. }
  1534. } catch (\Throwable $e) {
  1535. }
  1536. return self::getDefaultRule();
  1537. }
  1538. /**
  1539. * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
  1540. */
  1541. public static function getDefaultRule(): array
  1542. {
  1543. self::ensureSchema();
  1544. try {
  1545. $row = Db::table(self::TABLE_RULE)
  1546. ->where('is_default', 1)
  1547. ->order('id', 'desc')
  1548. ->find();
  1549. if (!is_array($row)) {
  1550. $row = Db::table(self::TABLE_RULE)->order('id', 'asc')->find();
  1551. }
  1552. if (is_array($row)) {
  1553. $lw = (int)($row['lead_weight'] ?? 0);
  1554. return [
  1555. 'id' => (int)($row['id'] ?? 0),
  1556. 'name' => trim((string)($row['name'] ?? '')),
  1557. 'quality_weight' => (int)($row['quality_weight'] ?? 50),
  1558. 'price_weight' => (int)($row['price_weight'] ?? 50),
  1559. 'lead_weight' => $lw,
  1560. 'lead_enabled' => $lw > 0 ? 1 : 0,
  1561. ];
  1562. }
  1563. } catch (\Throwable $e) {
  1564. }
  1565. return [
  1566. 'id' => 0,
  1567. 'name' => '内置默认',
  1568. 'quality_weight' => 50,
  1569. 'price_weight' => 50,
  1570. 'lead_weight' => 0,
  1571. 'lead_enabled' => 0,
  1572. ];
  1573. }
  1574. /**
  1575. * 规范年月;空则取当前月
  1576. */
  1577. public static function normalizeYm(?string $ym): string
  1578. {
  1579. $ym = trim((string)$ym);
  1580. if (preg_match('/^\d{4}-\d{2}$/', $ym)) {
  1581. return $ym;
  1582. }
  1583. return date('Y-m');
  1584. }
  1585. /**
  1586. * 上一自然月 YYYY-MM
  1587. */
  1588. public static function previousYm(string $ym): string
  1589. {
  1590. $ym = self::normalizeYm($ym);
  1591. $ts = strtotime($ym . '-01');
  1592. if ($ts === false) {
  1593. $ts = time();
  1594. }
  1595. return date('Y-m', strtotime('-1 month', $ts));
  1596. }
  1597. /**
  1598. * 从月度评审表取商务技术分:当月按上月起往前查,都没有则默认 50
  1599. *
  1600. * @param array<int, string> $companyNames
  1601. * @return array<string, float> company_name => quality_score
  1602. */
  1603. public static function loadReviewQualityScoreMap(array $companyNames, ?string $asOfYm = null): array
  1604. {
  1605. $asOfYm = self::normalizeYm($asOfYm);
  1606. $default = 50.0;
  1607. $map = [];
  1608. $names = [];
  1609. foreach ($companyNames as $n) {
  1610. $n = trim((string)$n);
  1611. if ($n !== '') {
  1612. $names[$n] = true;
  1613. $map[$n] = $default;
  1614. }
  1615. }
  1616. if ($names === []) {
  1617. return $map;
  1618. }
  1619. self::ensureFinalScoreTable();
  1620. try {
  1621. // 当月按上个月起查:ym < asOfYm,且商务技术分已填写
  1622. $rows = Db::table(self::TABLE_FINAL)
  1623. ->where('company_name', 'in', array_keys($names))
  1624. ->where('ym', '<', $asOfYm)
  1625. ->whereNotNull('quality_score')
  1626. ->field('company_name,ym,quality_score')
  1627. ->order('ym', 'desc')
  1628. ->select();
  1629. } catch (\Throwable $e) {
  1630. return $map;
  1631. }
  1632. if (!is_array($rows)) {
  1633. return $map;
  1634. }
  1635. $seen = [];
  1636. foreach ($rows as $r) {
  1637. if (!is_array($r)) {
  1638. continue;
  1639. }
  1640. $cn = trim((string)($r['company_name'] ?? ''));
  1641. if ($cn === '' || isset($seen[$cn])) {
  1642. continue;
  1643. }
  1644. $score = self::readOptionalScoreValue($r['quality_score'] ?? null);
  1645. if ($score === null) {
  1646. continue;
  1647. }
  1648. $seen[$cn] = true;
  1649. $map[$cn] = round((float)$score, 2);
  1650. }
  1651. return $map;
  1652. }
  1653. /**
  1654. * 按公司名批量取供应商 id(兼容旧调用保留评分字段结构)
  1655. *
  1656. * @param array<int, string> $companyNames
  1657. * @return array<string, array{id:int,score:float,monthly_score:?float,quality_avg:float}>
  1658. */
  1659. public static function loadCustomerScoreMap(array $companyNames): array
  1660. {
  1661. $map = [];
  1662. $names = [];
  1663. foreach ($companyNames as $n) {
  1664. $n = trim((string)$n);
  1665. if ($n !== '') {
  1666. $names[$n] = true;
  1667. }
  1668. }
  1669. if ($names === []) {
  1670. return $map;
  1671. }
  1672. try {
  1673. $rows = Db::table('customer')
  1674. ->where('company_name', 'in', array_keys($names))
  1675. ->field('id,company_name')
  1676. ->select();
  1677. } catch (\Throwable $e) {
  1678. return $map;
  1679. }
  1680. if (!is_array($rows)) {
  1681. return $map;
  1682. }
  1683. foreach ($rows as $r) {
  1684. if (!is_array($r)) {
  1685. continue;
  1686. }
  1687. $cn = trim((string)($r['company_name'] ?? ''));
  1688. if ($cn === '') {
  1689. continue;
  1690. }
  1691. $map[$cn] = [
  1692. 'id' => (int)($r['id'] ?? 0),
  1693. 'score' => 0.0,
  1694. 'monthly_score' => null,
  1695. 'quality_avg' => 50.0,
  1696. ];
  1697. }
  1698. return $map;
  1699. }
  1700. /**
  1701. * 从报价组计算本单各供应商得分(不写库)
  1702. *
  1703. * @param array<int, array<string, mixed>> $quoteGroups loadAuditSupplierQuoteGroups 结构(name/lines[].amount)
  1704. * @param string|null $asOfYm 订单所属月;商务/技术分从该月的上月起往前查评审表
  1705. * @return array<string, array{
  1706. * company_name:string,customer_id:int,quality_score:float,price_sum:float,
  1707. * price_score:float,score:float,rank_no:int,score_text:string,rank_text:string
  1708. * }>
  1709. */
  1710. public static function calculateForQuoteGroups(array $quoteGroups, ?array $rule = null, ?string $asOfYm = null): array
  1711. {
  1712. $rule = $rule ?: self::getDefaultRule();
  1713. $qw = max(0, (float)($rule['quality_weight'] ?? 50));
  1714. $pw = max(0, (float)($rule['price_weight'] ?? 50));
  1715. $lw = max(0, (float)($rule['lead_weight'] ?? 0));
  1716. // 填 0 的权重不参与计算
  1717. $leadEnabled = $lw > 0 ? 1 : 0;
  1718. $wSum = $qw + $pw + $lw;
  1719. if ($wSum <= 0) {
  1720. $qw = 50;
  1721. $pw = 50;
  1722. $lw = 0;
  1723. $leadEnabled = 0;
  1724. $wSum = 100;
  1725. }
  1726. $names = [];
  1727. foreach ($quoteGroups as $g) {
  1728. if (!is_array($g)) {
  1729. continue;
  1730. }
  1731. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  1732. if ($cn !== '') {
  1733. $names[] = $cn;
  1734. }
  1735. }
  1736. $custMap = self::loadCustomerScoreMap($names);
  1737. $reviewMap = self::loadReviewQualityScoreMap($names, $asOfYm);
  1738. $items = [];
  1739. $priceSums = [];
  1740. $leadSums = [];
  1741. foreach ($quoteGroups as $g) {
  1742. if (!is_array($g)) {
  1743. continue;
  1744. }
  1745. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  1746. if ($cn === '') {
  1747. continue;
  1748. }
  1749. $priceSum = 0.0;
  1750. $hasPrice = false;
  1751. $leadSum = 0;
  1752. $hasLead = false;
  1753. // 确认页用 lines,审批页用 pick_lines
  1754. $lineRows = $g['lines'] ?? $g['pick_lines'] ?? [];
  1755. if (!is_array($lineRows)) {
  1756. $lineRows = [];
  1757. }
  1758. foreach ($lineRows as $ln) {
  1759. if (!is_array($ln)) {
  1760. continue;
  1761. }
  1762. if (empty($ln['amount_quote_pending'])) {
  1763. $am = self::resolveQuoteLineAmountRaw($ln);
  1764. if ($am !== '' && $am !== '0' && $am !== '0.00' && is_numeric($am)) {
  1765. $priceSum += (float)$am;
  1766. $hasPrice = true;
  1767. }
  1768. }
  1769. if (empty($ln['lead_days_quote_pending'])) {
  1770. $ldRaw = $ln['lead_days'] ?? null;
  1771. if ($ldRaw !== null && $ldRaw !== '' && is_numeric($ldRaw)) {
  1772. $ld = (int)$ldRaw;
  1773. if ($ld > 0) {
  1774. $leadSum += $ld;
  1775. $hasLead = true;
  1776. }
  1777. }
  1778. }
  1779. }
  1780. // 商务/技术得分:月度评审「商务技术分」,当月按上月起往前,默认 50
  1781. $quality = (float)($reviewMap[$cn] ?? 50);
  1782. $psum = $hasPrice ? round($priceSum, 2) : 0;
  1783. $lsum = $hasLead ? (int)$leadSum : 0;
  1784. $items[$cn] = [
  1785. 'company_name' => $cn,
  1786. 'customer_id' => (int)($custMap[$cn]['id'] ?? 0),
  1787. 'quality_score' => $quality,
  1788. 'quality_score_text' => self::formatScore($quality),
  1789. 'quality_weight' => (int)$qw,
  1790. 'price_sum' => $psum,
  1791. 'price_sum_text' => self::formatScore($psum),
  1792. 'price_weight' => (int)$pw,
  1793. 'has_price' => $hasPrice,
  1794. 'price_score' => 0.0,
  1795. 'price_score_text' => '0',
  1796. 'lead_days_sum' => $lsum,
  1797. 'lead_days_sum_text' => (string)$lsum,
  1798. 'lead_weight' => (int)$lw,
  1799. 'lead_enabled' => $leadEnabled,
  1800. 'has_lead' => $hasLead,
  1801. 'lead_score' => 0.0,
  1802. 'lead_score_text' => '0',
  1803. 'score' => 0.0,
  1804. 'rank_no' => 0,
  1805. 'score_text' => '',
  1806. 'rank_text' => '',
  1807. ];
  1808. if ($hasPrice && $priceSum > 0) {
  1809. $priceSums[$cn] = $priceSum;
  1810. }
  1811. if ($hasLead && $leadSum > 0) {
  1812. $leadSums[$cn] = $leadSum;
  1813. }
  1814. }
  1815. $minSum = null;
  1816. foreach ($priceSums as $ps) {
  1817. if ($minSum === null || $ps < $minSum) {
  1818. $minSum = $ps;
  1819. }
  1820. }
  1821. $minLead = null;
  1822. foreach ($leadSums as $ls) {
  1823. if ($minLead === null || $ls < $minLead) {
  1824. $minLead = $ls;
  1825. }
  1826. }
  1827. foreach ($items as $cn => &$it) {
  1828. // 单价未填写:价格得分与总分均为 0
  1829. if ($pw > 0 && (empty($it['has_price']) || (float)($it['price_sum'] ?? 0) <= 0)) {
  1830. $it['price_score'] = 0.0;
  1831. $it['price_score_text'] = '0';
  1832. $it['lead_score'] = 0.0;
  1833. $it['lead_score_text'] = self::formatScore(0);
  1834. $it['score'] = 0.0;
  1835. $it['score_text'] = '0';
  1836. continue;
  1837. }
  1838. // 价格得分 = (最低单价合计 / 本供应商单价合计) × 价格权重% × 100
  1839. if ($pw > 0 && !empty($it['has_price']) && $it['price_sum'] > 0 && $minSum !== null && $minSum > 0) {
  1840. $ratio = round($minSum / (float)$it['price_sum'], 4);
  1841. $it['price_score'] = round($ratio * ($pw / 100) * 100, 2);
  1842. } else {
  1843. $it['price_score'] = 0.0;
  1844. }
  1845. $it['price_score_text'] = self::formatScore($it['price_score']);
  1846. if ($leadEnabled && !empty($it['has_lead']) && $it['lead_days_sum'] > 0 && $minLead !== null && $minLead > 0) {
  1847. $leadRatio = round($minLead / (float)$it['lead_days_sum'], 4);
  1848. $it['lead_score'] = round($leadRatio * 100, 2);
  1849. } else {
  1850. $it['lead_score'] = 0.0;
  1851. }
  1852. $it['lead_score_text'] = self::formatScore($it['lead_score']);
  1853. // 总分 = 商务/技术得分×质量% + 价格得分(已含价格权重) [+ 交货得分×交货%]
  1854. $total = 0.0;
  1855. if ($qw > 0) {
  1856. $total += $it['quality_score'] * ($qw / 100);
  1857. }
  1858. if ($pw > 0) {
  1859. $total += (float)$it['price_score'];
  1860. }
  1861. if ($lw > 0) {
  1862. $total += $it['lead_score'] * ($lw / 100);
  1863. }
  1864. $it['score'] = round($total, 2);
  1865. $it['score_text'] = self::formatScore($it['score']);
  1866. }
  1867. unset($it);
  1868. $sorted = $items;
  1869. uasort($sorted, function ($a, $b) {
  1870. $sa = (float)($a['score'] ?? 0);
  1871. $sb = (float)($b['score'] ?? 0);
  1872. if ($sa !== $sb) {
  1873. return $sb <=> $sa;
  1874. }
  1875. return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
  1876. });
  1877. $rank = 0;
  1878. foreach ($sorted as $cn => $row) {
  1879. $rank++;
  1880. $items[$cn]['rank_no'] = $rank;
  1881. $items[$cn]['rank_text'] = (string)$rank;
  1882. }
  1883. return $items;
  1884. }
  1885. public static function formatScore($n): string
  1886. {
  1887. $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
  1888. return $s === '' ? '0' : $s;
  1889. }
  1890. /**
  1891. * 报价行单价原文(兼容确认页 amount、审批页 unit_price_text/amount_text)
  1892. *
  1893. * @param array<string, mixed> $ln
  1894. */
  1895. protected static function resolveQuoteLineAmountRaw(array $ln): string
  1896. {
  1897. foreach (['amount', 'unit_price_text', 'amount_text'] as $k) {
  1898. if (!array_key_exists($k, $ln) || $ln[$k] === null) {
  1899. continue;
  1900. }
  1901. $am = trim((string)$ln[$k]);
  1902. if ($am === '' || $am === '未填写' || $am === '未开标验证' || $am === '开标验证后查看') {
  1903. continue;
  1904. }
  1905. // 去掉千分位等
  1906. $am = str_replace([',', ',', ' '], '', $am);
  1907. if ($am !== '' && is_numeric($am)) {
  1908. return $am;
  1909. }
  1910. }
  1911. return '';
  1912. }
  1913. /**
  1914. * 总分简要文案(仅总分数字;分项见独立字段)
  1915. *
  1916. * @param array<string, mixed> $hit calculate/loadSaved 单项
  1917. */
  1918. public static function formatScoreDetailText(array $hit): string
  1919. {
  1920. return self::formatScore($hit['score'] ?? 0);
  1921. }
  1922. /**
  1923. * 分项得分展示文案(确认/详情表格用)
  1924. *
  1925. * @param array<string, mixed> $hit
  1926. */
  1927. public static function formatScorePartsHtml(array $hit): string
  1928. {
  1929. $qw = (float)($hit['quality_weight'] ?? 0);
  1930. $pw = (float)($hit['price_weight'] ?? 0);
  1931. $lw = (float)($hit['lead_weight'] ?? 0);
  1932. $lines = [];
  1933. if ($qw > 0) {
  1934. $lines[] = '质量得分 ' . self::formatScore($hit['quality_score'] ?? 0);
  1935. }
  1936. if ($pw > 0) {
  1937. $lines[] = '价格得分 ' . self::formatScore($hit['price_score'] ?? 0);
  1938. }
  1939. if ($lw > 0) {
  1940. $lines[] = '交货得分 ' . self::formatScore($hit['lead_score'] ?? 0);
  1941. }
  1942. $lines[] = '总分 ' . self::formatScore($hit['score'] ?? 0);
  1943. return implode("\n", $lines);
  1944. }
  1945. /**
  1946. * 评分规则文案(用于确认页 / 详情展示)
  1947. * 百分比取当前默认规则;权重大于 0 的项才写入公式
  1948. *
  1949. * @param array{quality_weight?:float|int|string,price_weight?:float|int|string,lead_weight?:float|int|string}|null $rule
  1950. */
  1951. public static function formatRuleFormulaText(?array $rule = null): string
  1952. {
  1953. $rule = $rule ?: self::getDefaultRule();
  1954. $qw = (float)($rule['quality_weight'] ?? 50);
  1955. $pw = (float)($rule['price_weight'] ?? 50);
  1956. $lw = (float)($rule['lead_weight'] ?? 0);
  1957. $parts = [];
  1958. if ($qw > 0) {
  1959. $parts[] = '(质量得分×' . self::formatWeightPercent($qw) . '%)';
  1960. }
  1961. if ($pw > 0) {
  1962. $parts[] = '(价格得分)';
  1963. }
  1964. if ($lw > 0) {
  1965. $parts[] = '(交货得分×' . self::formatWeightPercent($lw) . '%)';
  1966. }
  1967. $formula = $parts === []
  1968. ? '(总分)=(质量得分×50%)+(价格得分)'
  1969. : '(总分)=' . implode('+', $parts);
  1970. $formula .= ';质量得分取月度评审「质量得分」(当月按上月起往前查,无则默认50)';
  1971. if ($pw > 0) {
  1972. $formula .= ';价格得分=(最低单价合计/本供应商单价合计)×'
  1973. . self::formatWeightPercent($pw) . '%×100';
  1974. }
  1975. return $formula;
  1976. }
  1977. /**
  1978. * 报价组是否展示交货期留痕列(已落库纳入,或尚无落库且当前默认规则交货分>0)
  1979. *
  1980. * @param array<int, array<string, mixed>> $quoteGroups
  1981. */
  1982. public static function detectShowLeadScore(array $quoteGroups = [], ?array $rule = null): bool
  1983. {
  1984. $hasSavedScore = false;
  1985. foreach ($quoteGroups as $g) {
  1986. if (!is_array($g)) {
  1987. continue;
  1988. }
  1989. if (!empty($g['show_service_lead_score'])) {
  1990. return true;
  1991. }
  1992. if (!empty($g['show_service_score'])) {
  1993. $hasSavedScore = true;
  1994. }
  1995. }
  1996. // 历史单已按旧规则落库且交货期权重为 0:不显示这两列
  1997. if ($hasSavedScore) {
  1998. return false;
  1999. }
  2000. $rule = $rule ?: self::getDefaultRule();
  2001. return (float)($rule['lead_weight'] ?? 0) > 0;
  2002. }
  2003. /**
  2004. * 评分规则提示:优先用传入规则;无则用默认规则
  2005. *
  2006. * @param array<int, array<string, mixed>> $quoteGroups 保留参数兼容调用方
  2007. * @param array{quality_weight?:float|int|string,price_weight?:float|int|string,lead_weight?:float|int|string}|null $rule
  2008. */
  2009. public static function formatRuleFormulaTextForGroups(array $quoteGroups = [], ?array $rule = null): string
  2010. {
  2011. return self::formatRuleFormulaText($rule);
  2012. }
  2013. protected static function formatWeightPercent($n): string
  2014. {
  2015. $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
  2016. return $s === '' ? '0' : $s;
  2017. }
  2018. /**
  2019. * 开标验证通过后:计算并写入供应商服务评分表
  2020. *
  2021. * @param array<int, array<string, mixed>> $quoteGroups
  2022. */
  2023. public static function saveForOrder(string $ccydh, array $quoteGroups, ?string $ym = null): void
  2024. {
  2025. $ccydh = trim($ccydh);
  2026. if ($ccydh === '') {
  2027. return;
  2028. }
  2029. self::ensureSchema();
  2030. $rule = self::resolveRuleForCcydh($ccydh);
  2031. if ($ym === null || $ym === '') {
  2032. $ym = date('Y-m');
  2033. }
  2034. $ym = self::normalizeYm($ym);
  2035. $calc = self::calculateForQuoteGroups($quoteGroups, $rule, $ym);
  2036. if ($calc === []) {
  2037. return;
  2038. }
  2039. $now = date('Y-m-d H:i:s');
  2040. try {
  2041. Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->delete();
  2042. $rows = [];
  2043. foreach ($calc as $it) {
  2044. $rows[] = [
  2045. 'customer_id' => (int)($it['customer_id'] ?? 0),
  2046. 'company_name' => (string)($it['company_name'] ?? ''),
  2047. 'ym' => $ym,
  2048. 'ccydh' => $ccydh,
  2049. 'score' => (float)($it['score'] ?? 0),
  2050. 'rank_no' => (int)($it['rank_no'] ?? 0),
  2051. 'quality_score' => (int)round((float)($it['quality_score'] ?? 0)),
  2052. 'quality_weight' => (int)($rule['quality_weight'] ?? 50),
  2053. 'price_sum' => round((float)($it['price_sum'] ?? 0), 4),
  2054. 'price_weight' => (int)($rule['price_weight'] ?? 50),
  2055. 'price_score' => (float)($it['price_score'] ?? 0),
  2056. 'lead_days_sum' => (int)round((float)($it['lead_days_sum'] ?? 0)),
  2057. 'lead_weight' => (int)($rule['lead_weight'] ?? 0),
  2058. 'lead_enabled' => ((int)($rule['lead_weight'] ?? 0) > 0) ? 1 : 0,
  2059. 'lead_score' => (float)($it['lead_score'] ?? 0),
  2060. 'rule_id' => (int)($rule['id'] ?? 0),
  2061. 'createtime' => $now,
  2062. 'updatetime' => $now,
  2063. ];
  2064. }
  2065. if ($rows !== []) {
  2066. Db::table(self::TABLE_SCORE)->insertAll($rows);
  2067. }
  2068. // 开标后:本月询价供应商名称写入月度评审表(不存在才加空行,已有记录不覆盖)
  2069. $monthNames = [];
  2070. foreach ($quoteGroups as $g) {
  2071. if (!is_array($g)) {
  2072. continue;
  2073. }
  2074. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  2075. if ($cn !== '') {
  2076. $monthNames[] = $cn;
  2077. }
  2078. }
  2079. foreach ($rows as $row) {
  2080. $cn = trim((string)($row['company_name'] ?? ''));
  2081. if ($cn !== '') {
  2082. $monthNames[] = $cn;
  2083. }
  2084. }
  2085. self::ensureMonthlySupplierNames($ym, $monthNames);
  2086. } catch (\Throwable $e) {
  2087. Log::write('supplier service score save: ' . $e->getMessage(), 'error');
  2088. }
  2089. }
  2090. /**
  2091. * 读取某订单已落库评分;无则返回空
  2092. *
  2093. * @return array<string, array<string, mixed>>
  2094. */
  2095. public static function loadSavedByCcydh(string $ccydh): array
  2096. {
  2097. $ccydh = trim($ccydh);
  2098. if ($ccydh === '') {
  2099. return [];
  2100. }
  2101. try {
  2102. $rows = Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->select();
  2103. } catch (\Throwable $e) {
  2104. try {
  2105. self::ensureSchema();
  2106. $rows = Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->select();
  2107. } catch (\Throwable $e2) {
  2108. return [];
  2109. }
  2110. }
  2111. if (!is_array($rows)) {
  2112. return [];
  2113. }
  2114. $out = [];
  2115. foreach ($rows as $r) {
  2116. if (!is_array($r)) {
  2117. continue;
  2118. }
  2119. $cn = trim((string)($r['company_name'] ?? ''));
  2120. if ($cn === '') {
  2121. continue;
  2122. }
  2123. $qualityScore = (int)round((float)($r['quality_score'] ?? 0));
  2124. $priceSum = round((float)($r['price_sum'] ?? 0), 4);
  2125. $qw = (int)($r['quality_weight'] ?? 50);
  2126. $pw = (int)($r['price_weight'] ?? 50);
  2127. $lw = (int)($r['lead_weight'] ?? 0);
  2128. $leadEnabled = $lw > 0 ? 1 : 0;
  2129. $leadSum = (int)round((float)($r['lead_days_sum'] ?? 0));
  2130. $leadScore = (float)($r['lead_score'] ?? 0);
  2131. $out[$cn] = [
  2132. 'company_name' => $cn,
  2133. 'customer_id' => (int)($r['customer_id'] ?? 0),
  2134. 'quality_score' => $qualityScore,
  2135. 'quality_score_text' => (string)$qualityScore,
  2136. 'quality_weight' => $qw,
  2137. 'price_sum' => $priceSum,
  2138. 'price_sum_text' => self::formatScore($priceSum),
  2139. 'price_weight' => $pw,
  2140. 'price_score' => (float)($r['price_score'] ?? 0),
  2141. 'price_score_text' => self::formatScore($r['price_score'] ?? 0),
  2142. 'lead_days_sum' => $leadSum,
  2143. 'lead_days_sum_text' => $leadEnabled ? (string)$leadSum : '',
  2144. 'lead_weight' => $lw,
  2145. 'lead_enabled' => $leadEnabled,
  2146. 'lead_score' => $leadScore,
  2147. 'lead_score_text' => $leadEnabled ? self::formatScore($leadScore) : '',
  2148. 'score' => (float)($r['score'] ?? 0),
  2149. 'rank_no' => (int)($r['rank_no'] ?? 0),
  2150. 'score_text' => self::formatScore($r['score'] ?? 0),
  2151. 'rank_text' => ((int)($r['rank_no'] ?? 0)) > 0 ? (string)(int)($r['rank_no'] ?? 0) : '',
  2152. '_row_id' => (int)($r['id'] ?? 0),
  2153. ];
  2154. }
  2155. // 按单价合计重算价格得分/总分(兼容历史把权重算进价格得分的数据)
  2156. $minSum = null;
  2157. foreach ($out as $it) {
  2158. $ps = (float)($it['price_sum'] ?? 0);
  2159. if ($ps > 0 && ($minSum === null || $ps < $minSum)) {
  2160. $minSum = $ps;
  2161. }
  2162. }
  2163. foreach ($out as $cn => &$it) {
  2164. $qw = (int)($it['quality_weight'] ?? 50);
  2165. $pw = (int)($it['price_weight'] ?? 50);
  2166. $lw = (int)($it['lead_weight'] ?? 0);
  2167. $priceSum = (float)($it['price_sum'] ?? 0);
  2168. // 单价未填写:总分为 0
  2169. if ($pw > 0 && $priceSum <= 0) {
  2170. $it['price_score'] = 0.0;
  2171. $it['price_score_text'] = '0';
  2172. $it['score'] = 0.0;
  2173. $it['score_text'] = '0';
  2174. continue;
  2175. }
  2176. if ($pw > 0 && $priceSum > 0 && $minSum !== null && $minSum > 0) {
  2177. $ratio = round($minSum / $priceSum, 4);
  2178. $it['price_score'] = round($ratio * ($pw / 100) * 100, 2);
  2179. } else {
  2180. $it['price_score'] = 0.0;
  2181. }
  2182. $it['price_score_text'] = self::formatScore($it['price_score']);
  2183. $total = 0.0;
  2184. if ($qw > 0) {
  2185. $total += (float)$it['quality_score'] * ($qw / 100);
  2186. }
  2187. if ($pw > 0) {
  2188. $total += (float)$it['price_score'];
  2189. }
  2190. if ($lw > 0) {
  2191. $total += (float)$it['lead_score'] * ($lw / 100);
  2192. }
  2193. $it['score'] = round($total, 2);
  2194. $it['score_text'] = self::formatScore($it['score']);
  2195. }
  2196. unset($it);
  2197. $sorted = $out;
  2198. uasort($sorted, static function ($a, $b) {
  2199. $sa = (float)($a['score'] ?? 0);
  2200. $sb = (float)($b['score'] ?? 0);
  2201. if ($sa !== $sb) {
  2202. return $sb <=> $sa;
  2203. }
  2204. return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
  2205. });
  2206. $rank = 0;
  2207. foreach ($sorted as $cn => $row) {
  2208. $rank++;
  2209. $oldRank = (int)($out[$cn]['rank_no'] ?? 0);
  2210. $out[$cn]['rank_no'] = $rank;
  2211. $out[$cn]['rank_text'] = (string)$rank;
  2212. $rowId = (int)($out[$cn]['_row_id'] ?? 0);
  2213. if ($rowId > 0) {
  2214. $upd = [];
  2215. if ($oldRank !== $rank) {
  2216. $upd['rank_no'] = $rank;
  2217. }
  2218. foreach ($rows as $r) {
  2219. if (!is_array($r) || (int)($r['id'] ?? 0) !== $rowId) {
  2220. continue;
  2221. }
  2222. if (abs((float)($r['price_score'] ?? 0) - (float)$out[$cn]['price_score']) > 0.001) {
  2223. $upd['price_score'] = $out[$cn]['price_score'];
  2224. }
  2225. if (abs((float)($r['score'] ?? 0) - (float)$out[$cn]['score']) > 0.001) {
  2226. $upd['score'] = $out[$cn]['score'];
  2227. }
  2228. break;
  2229. }
  2230. if ($upd !== []) {
  2231. try {
  2232. Db::table(self::TABLE_SCORE)->where('id', $rowId)->update($upd);
  2233. } catch (\Throwable $e) {
  2234. }
  2235. }
  2236. }
  2237. unset($out[$cn]['_row_id']);
  2238. }
  2239. return $out;
  2240. }
  2241. /**
  2242. * 清空报价组上的评分留痕字段
  2243. *
  2244. * @param array<string, mixed> $g
  2245. */
  2246. protected static function clearScoreTrailOnGroup(array &$g, int $showFlag = 0): void
  2247. {
  2248. $g['service_rank'] = '';
  2249. $g['service_score'] = '';
  2250. $g['service_rank_text'] = '';
  2251. $g['service_score_text'] = '';
  2252. $g['service_score_parts_text'] = '';
  2253. $g['service_quality_score'] = '';
  2254. $g['service_quality_score_text'] = '';
  2255. $g['service_price_sum'] = '';
  2256. $g['service_price_sum_text'] = '';
  2257. $g['service_price_score'] = '';
  2258. $g['service_price_score_text'] = '';
  2259. $g['service_lead_days_sum'] = '';
  2260. $g['service_lead_days_sum_text'] = '';
  2261. $g['service_lead_score'] = '';
  2262. $g['service_lead_score_text'] = '';
  2263. $g['service_lead_enabled'] = 0;
  2264. $g['service_quality_weight'] = '';
  2265. $g['service_price_weight'] = '';
  2266. $g['service_lead_weight'] = '';
  2267. $g['show_service_score'] = $showFlag;
  2268. $g['show_service_lead_score'] = 0;
  2269. }
  2270. /**
  2271. * 把排名/评分挂到报价组上(开标后可见)
  2272. * 详情等读路径:优先读已落库评分,避免每次打开重算写库拖慢弹窗
  2273. *
  2274. * @param array<int, array<string, mixed>> $quoteGroups
  2275. * @return array<int, array<string, mixed>>
  2276. */
  2277. public static function attachToQuoteGroups(array $quoteGroups, string $ccydh, bool $quoteVisible): array
  2278. {
  2279. if (!$quoteVisible || $quoteGroups === []) {
  2280. foreach ($quoteGroups as &$g) {
  2281. if (!is_array($g)) {
  2282. continue;
  2283. }
  2284. self::clearScoreTrailOnGroup($g, 0);
  2285. }
  2286. unset($g);
  2287. return $quoteGroups;
  2288. }
  2289. $saved = self::loadSavedByCcydh($ccydh);
  2290. $needCalc = ($saved === []);
  2291. if (!$needCalc) {
  2292. $anyHit = false;
  2293. foreach ($quoteGroups as $g) {
  2294. if (!is_array($g)) {
  2295. continue;
  2296. }
  2297. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  2298. if ($cn !== '' && isset($saved[$cn])) {
  2299. $anyHit = true;
  2300. break;
  2301. }
  2302. }
  2303. $needCalc = !$anyHit;
  2304. }
  2305. // 无落库记录时仅内存计算用于展示;落库由开标验证 saveForOrder 负责
  2306. if ($needCalc) {
  2307. $saved = self::calculateForQuoteGroups($quoteGroups, null, date('Y-m'));
  2308. }
  2309. foreach ($quoteGroups as &$g) {
  2310. if (!is_array($g)) {
  2311. continue;
  2312. }
  2313. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  2314. $hit = ($cn !== '' && isset($saved[$cn]) && is_array($saved[$cn])) ? $saved[$cn] : null;
  2315. if (is_array($hit)) {
  2316. $g['service_rank'] = (int)($hit['rank_no'] ?? 0);
  2317. $g['service_score'] = (float)($hit['score'] ?? 0);
  2318. $g['service_rank_text'] = (string)($hit['rank_text'] ?? '');
  2319. $g['service_score_text'] = self::formatScoreDetailText($hit);
  2320. $g['service_score_parts_text'] = self::formatScorePartsHtml($hit);
  2321. $qs = round((float)($hit['quality_score'] ?? 0), 2);
  2322. $ps = (float)($hit['price_score'] ?? 0);
  2323. $psum = round((float)($hit['price_sum'] ?? 0), 2);
  2324. $leadEnabled = ((int)($hit['lead_weight'] ?? 0) > 0) ? 1 : 0;
  2325. $ls = (float)($hit['lead_score'] ?? 0);
  2326. $lsum = (int)round((float)($hit['lead_days_sum'] ?? 0));
  2327. $g['service_quality_score'] = $qs;
  2328. $g['service_quality_score_text'] = (string)($hit['quality_score_text'] ?? self::formatScore($qs));
  2329. $g['service_price_sum'] = $psum;
  2330. $g['service_price_sum_text'] = (string)($hit['price_sum_text'] ?? $psum);
  2331. $g['service_price_score'] = $ps;
  2332. $g['service_price_score_text'] = (string)($hit['price_score_text'] ?? self::formatScore($ps));
  2333. $g['service_lead_enabled'] = $leadEnabled;
  2334. $g['show_service_lead_score'] = $leadEnabled;
  2335. $g['service_lead_days_sum'] = $lsum;
  2336. $g['service_lead_days_sum_text'] = $leadEnabled ? (string)($hit['lead_days_sum_text'] ?? $lsum) : '';
  2337. $g['service_lead_score'] = $ls;
  2338. $g['service_lead_score_text'] = $leadEnabled ? (string)($hit['lead_score_text'] ?? self::formatScore($ls)) : '';
  2339. $g['service_quality_weight'] = (int)($hit['quality_weight'] ?? 50);
  2340. $g['service_price_weight'] = (int)($hit['price_weight'] ?? 50);
  2341. $g['service_lead_weight'] = (int)($hit['lead_weight'] ?? 0);
  2342. $g['show_service_score'] = 1;
  2343. } else {
  2344. self::clearScoreTrailOnGroup($g, 1);
  2345. }
  2346. }
  2347. unset($g);
  2348. // 开标后按排名升序(第1名在最前);无排名的放最后
  2349. usort($quoteGroups, function ($a, $b) {
  2350. $ra = is_array($a) ? (int)($a['service_rank'] ?? 0) : 0;
  2351. $rb = is_array($b) ? (int)($b['service_rank'] ?? 0) : 0;
  2352. $ha = $ra > 0;
  2353. $hb = $rb > 0;
  2354. if ($ha !== $hb) {
  2355. return $ha ? -1 : 1;
  2356. }
  2357. if ($ha && $ra !== $rb) {
  2358. return $ra <=> $rb;
  2359. }
  2360. $sa = is_array($a) ? (float)($a['service_score'] ?? 0) : 0.0;
  2361. $sb = is_array($b) ? (float)($b['service_score'] ?? 0) : 0.0;
  2362. if ($sa !== $sb) {
  2363. return $sb <=> $sa;
  2364. }
  2365. $na = is_array($a) ? trim((string)($a['name'] ?? $a['company_name'] ?? '')) : '';
  2366. $nb = is_array($b) ? trim((string)($b['name'] ?? $b['company_name'] ?? '')) : '';
  2367. return strcmp($na, $nb);
  2368. });
  2369. return $quoteGroups;
  2370. }
  2371. }