ProcuremenSupplierScore.php 110 KB

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