ProcuremenSupplierScore.php 117 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092
  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. * 工序完结年月(YYYY-MM)
  611. * 优先:采购确认 / 直接完结操作时间;其次采购单 pick_time/createtime/dStamp;再无:入库评分时间
  612. *
  613. * @param int[] $scydgyIds
  614. * @param array<int, array{createtime?:mixed,updatetime?:mixed}> $inboundTimeBySid
  615. * @return array<int, string> scydgy_id => YYYY-MM
  616. */
  617. public static function loadCompletedYmMapByScydgyIds(array $scydgyIds, array $inboundTimeBySid = []): array
  618. {
  619. $ids = [];
  620. foreach ($scydgyIds as $sid) {
  621. $sid = (int)$sid;
  622. if ($sid > 0) {
  623. $ids[$sid] = true;
  624. }
  625. }
  626. if ($ids === []) {
  627. return [];
  628. }
  629. $sidList = array_keys($ids);
  630. $completeTsMap = ProcuremenTime::loadOperLogTimestampMap($sidList, ['purchase_confirm', 'mark_complete']);
  631. $poBySid = [];
  632. try {
  633. $poRows = Db::table('purchase_order')
  634. ->where('scydgy_id', 'in', $sidList)
  635. ->field('scydgy_id,pick_time,createtime,dStamp')
  636. ->select();
  637. } catch (\Throwable $e) {
  638. $poRows = [];
  639. }
  640. if (is_array($poRows)) {
  641. foreach ($poRows as $po) {
  642. if (!is_array($po)) {
  643. continue;
  644. }
  645. $sid = (int)($po['scydgy_id'] ?? 0);
  646. if ($sid > 0) {
  647. $poBySid[$sid] = $po;
  648. }
  649. }
  650. }
  651. $out = [];
  652. foreach ($sidList as $sid) {
  653. $row = $poBySid[$sid] ?? ['scydgy_id' => $sid];
  654. $done = ProcuremenTime::resolveCompletedDone($row, $completeTsMap);
  655. $ts = (int)($done['ts'] ?? 0);
  656. if ($ts <= 946684800 && isset($inboundTimeBySid[$sid])) {
  657. $t1 = ProcuremenTime::parseToTimestamp($inboundTimeBySid[$sid]['updatetime'] ?? null);
  658. $t2 = ProcuremenTime::parseToTimestamp($inboundTimeBySid[$sid]['createtime'] ?? null);
  659. $ts = max($t1, $t2);
  660. }
  661. if ($ts > 946684800) {
  662. $out[$sid] = date('Y-m', $ts);
  663. }
  664. }
  665. return $out;
  666. }
  667. /**
  668. * 某供应商某月入库评分订单聚合(按订单号去重)
  669. * 月份按工序「完结时间」归属,不用质量评分创建/更新时间
  670. *
  671. * @return array<string, array{result:string,delivery_status:string,customer_complaint:int,order_interrupt:int}>
  672. */
  673. public static function loadInboundOrderAggByCompanyYm(string $companyName, string $ym): array
  674. {
  675. $companyName = trim($companyName);
  676. $ym = self::formatScoreYm($ym);
  677. if ($companyName === '' || $ym === '') {
  678. return [];
  679. }
  680. /** @var array<string, array{result:string,delivery_status:string,customer_complaint:int,order_interrupt:int}> $byOrder */
  681. $byOrder = [];
  682. try {
  683. $rows = Db::table('purchase_order_inbound_score')
  684. ->where('company_name', $companyName)
  685. ->field('scydgy_id,ccydh,result,delivery_status,customer_complaint,order_interrupt,createtime,updatetime')
  686. ->select();
  687. } catch (\Throwable $e) {
  688. try {
  689. $rows = Db::table('purchase_order_inbound_score')
  690. ->where('company_name', $companyName)
  691. ->field('scydgy_id,ccydh,result,createtime,updatetime')
  692. ->select();
  693. } catch (\Throwable $e2) {
  694. $rows = [];
  695. Log::write('loadInboundOrderAgg table: ' . $e2->getMessage(), 'error');
  696. }
  697. }
  698. if (!is_array($rows) || $rows === []) {
  699. $legacy = self::loadInboundOrderResultsFromOperLogs($companyName, $ym);
  700. foreach ($legacy as $ccydh => $res) {
  701. $byOrder[$ccydh] = [
  702. 'result' => $res,
  703. 'delivery_status' => '',
  704. 'customer_complaint' => 0,
  705. 'order_interrupt' => 0,
  706. ];
  707. }
  708. return $byOrder;
  709. }
  710. $sidSet = [];
  711. $inboundTimeBySid = [];
  712. foreach ($rows as $r) {
  713. if (!is_array($r)) {
  714. continue;
  715. }
  716. $sid = (int)($r['scydgy_id'] ?? 0);
  717. if ($sid <= 0) {
  718. continue;
  719. }
  720. $sidSet[$sid] = true;
  721. $inboundTimeBySid[$sid] = [
  722. 'createtime' => $r['createtime'] ?? null,
  723. 'updatetime' => $r['updatetime'] ?? null,
  724. ];
  725. }
  726. $completedYmMap = self::loadCompletedYmMapByScydgyIds(array_keys($sidSet), $inboundTimeBySid);
  727. foreach ($rows as $r) {
  728. if (!is_array($r)) {
  729. continue;
  730. }
  731. $sid = (int)($r['scydgy_id'] ?? 0);
  732. $rowYm = $sid > 0 ? (string)($completedYmMap[$sid] ?? '') : '';
  733. if ($rowYm === '' && $sid <= 0) {
  734. // 无工序 id:回退评分时间
  735. $t1 = ProcuremenTime::parseToTimestamp($r['updatetime'] ?? null);
  736. $t2 = ProcuremenTime::parseToTimestamp($r['createtime'] ?? null);
  737. $ts = max($t1, $t2);
  738. $rowYm = $ts > 946684800 ? date('Y-m', $ts) : '';
  739. }
  740. if ($rowYm !== $ym) {
  741. continue;
  742. }
  743. $ccydh = trim((string)($r['ccydh'] ?? ''));
  744. $res = trim((string)($r['result'] ?? ''));
  745. if ($ccydh === '' || ($res !== '合格' && $res !== '不合格')) {
  746. continue;
  747. }
  748. $ds = trim((string)($r['delivery_status'] ?? ''));
  749. if ($ds !== '准时' && $ds !== '滞后') {
  750. $ds = '';
  751. }
  752. $complaint = (int)($r['customer_complaint'] ?? 0) === 1 ? 1 : 0;
  753. $interrupt = (int)($r['order_interrupt'] ?? 0) === 1 ? 1 : 0;
  754. if (!isset($byOrder[$ccydh])) {
  755. $byOrder[$ccydh] = [
  756. 'result' => $res,
  757. 'delivery_status' => $ds,
  758. 'customer_complaint' => $complaint,
  759. 'order_interrupt' => $interrupt,
  760. ];
  761. continue;
  762. }
  763. if ($res === '不合格') {
  764. $byOrder[$ccydh]['result'] = '不合格';
  765. }
  766. if ($byOrder[$ccydh]['delivery_status'] === '' && $ds !== '') {
  767. $byOrder[$ccydh]['delivery_status'] = $ds;
  768. }
  769. if ($complaint === 1) {
  770. $byOrder[$ccydh]['customer_complaint'] = 1;
  771. }
  772. if ($interrupt === 1) {
  773. $byOrder[$ccydh]['order_interrupt'] = 1;
  774. }
  775. }
  776. if ($byOrder !== []) {
  777. return $byOrder;
  778. }
  779. // 回退:操作日志仅能还原合格/不合格(同样按完结月过滤)
  780. $legacy = self::loadInboundOrderResultsFromOperLogs($companyName, $ym);
  781. foreach ($legacy as $ccydh => $res) {
  782. $byOrder[$ccydh] = [
  783. 'result' => $res,
  784. 'delivery_status' => '',
  785. 'customer_complaint' => 0,
  786. 'order_interrupt' => 0,
  787. ];
  788. }
  789. return $byOrder;
  790. }
  791. /**
  792. * 某供应商某月:订单号 => 合格|不合格(兼容旧调用)
  793. *
  794. * @return array<string, string>
  795. */
  796. public static function loadInboundOrderResultsByCompanyYm(string $companyName, string $ym): array
  797. {
  798. $out = [];
  799. foreach (self::loadInboundOrderAggByCompanyYm($companyName, $ym) as $ccydh => $agg) {
  800. $out[$ccydh] = (string)($agg['result'] ?? '');
  801. }
  802. return $out;
  803. }
  804. /**
  805. * 操作日志回退:订单号 => 合格|不合格(按工序完结月过滤)
  806. *
  807. * @return array<string, string>
  808. */
  809. protected static function loadInboundOrderResultsFromOperLogs(string $companyName, string $ym): array
  810. {
  811. $byOrder = [];
  812. try {
  813. $logs = Db::table('purchase_order_oper_log')
  814. ->where(function ($q) {
  815. $q->where('action', '入库评分')->whereOr('action', 'inbound_score');
  816. })
  817. ->field('scydgy_id,content,createtime')
  818. ->order('id', 'asc')
  819. ->select();
  820. } catch (\Throwable $e) {
  821. Log::write('loadInboundOrderResults logs: ' . $e->getMessage(), 'error');
  822. return [];
  823. }
  824. if (!is_array($logs) || $logs === []) {
  825. return [];
  826. }
  827. $sidResult = [];
  828. $inboundTimeBySid = [];
  829. foreach ($logs as $lg) {
  830. if (!is_array($lg)) {
  831. continue;
  832. }
  833. $sid = (int)($lg['scydgy_id'] ?? 0);
  834. if ($sid <= 0) {
  835. continue;
  836. }
  837. $content = trim((string)($lg['content'] ?? ''));
  838. if (!preg_match('/(合格|不合格)/u', $content, $m)) {
  839. continue;
  840. }
  841. $sidResult[$sid] = $m[1];
  842. $inboundTimeBySid[$sid] = [
  843. 'createtime' => $lg['createtime'] ?? null,
  844. 'updatetime' => $lg['createtime'] ?? null,
  845. ];
  846. }
  847. if ($sidResult === []) {
  848. return [];
  849. }
  850. try {
  851. $poRows = Db::table('purchase_order')
  852. ->where('scydgy_id', 'in', array_keys($sidResult))
  853. ->where('pick_company_name', $companyName)
  854. ->field('scydgy_id,CCYDH,pick_company_name')
  855. ->select();
  856. } catch (\Throwable $e) {
  857. return [];
  858. }
  859. if (!is_array($poRows)) {
  860. return [];
  861. }
  862. $completedYmMap = self::loadCompletedYmMapByScydgyIds(array_keys($sidResult), $inboundTimeBySid);
  863. foreach ($poRows as $po) {
  864. if (!is_array($po)) {
  865. continue;
  866. }
  867. $sid = (int)($po['scydgy_id'] ?? 0);
  868. $ccydh = trim((string)($po['CCYDH'] ?? ''));
  869. $res = $sidResult[$sid] ?? '';
  870. if ($sid <= 0 || $ccydh === '' || ($res !== '合格' && $res !== '不合格')) {
  871. continue;
  872. }
  873. if ((string)($completedYmMap[$sid] ?? '') !== $ym) {
  874. continue;
  875. }
  876. if (!isset($byOrder[$ccydh]) || $res === '不合格') {
  877. $byOrder[$ccydh] = $res;
  878. }
  879. }
  880. return $byOrder;
  881. }
  882. /**
  883. * 质量得分(满分 40)= 合格率分(20) + 客户投诉(10) + 生产中断(10)
  884. *
  885. * @return float|null 无入库评分订单时返回 null
  886. */
  887. public static function calcQualityScoreFromInbound(string $companyName, string $ym): ?float
  888. {
  889. $companyName = trim($companyName);
  890. $ym = self::formatScoreYm($ym);
  891. if ($companyName === '' || $ym === '') {
  892. return null;
  893. }
  894. $byOrder = self::loadInboundOrderAggByCompanyYm($companyName, $ym);
  895. $total = count($byOrder);
  896. if ($total < 1) {
  897. return null;
  898. }
  899. $fail = 0;
  900. $complaintAny = false;
  901. $interruptCnt = 0;
  902. foreach ($byOrder as $agg) {
  903. if (($agg['result'] ?? '') === '不合格') {
  904. $fail++;
  905. }
  906. if ((int)($agg['customer_complaint'] ?? 0) === 1) {
  907. $complaintAny = true;
  908. }
  909. if ((int)($agg['order_interrupt'] ?? 0) === 1) {
  910. $interruptCnt++;
  911. }
  912. }
  913. $passRate = (1 - ($fail / $total)) * 100;
  914. $passScore = self::mapPassRatePercentToQualityScore($passRate);
  915. $complaintScore = $complaintAny ? 0.0 : 10.0;
  916. if ($interruptCnt >= 3) {
  917. $interruptScore = 0.0;
  918. } else {
  919. $interruptScore = max(0.0, 10.0 - 2.0 * $interruptCnt);
  920. }
  921. return round($passScore + $complaintScore + $interruptScore, 2);
  922. }
  923. /**
  924. * 交货得分(满分 40)= 准时率分(30) + 生产中断(10,出现一次得 0)
  925. *
  926. * @return float|null 无入库评分订单时返回 null
  927. */
  928. public static function calcDeliveryScoreFromInbound(string $companyName, string $ym): ?float
  929. {
  930. $companyName = trim($companyName);
  931. $ym = self::formatScoreYm($ym);
  932. if ($companyName === '' || $ym === '') {
  933. return null;
  934. }
  935. $byOrder = self::loadInboundOrderAggByCompanyYm($companyName, $ym);
  936. $total = count($byOrder);
  937. if ($total < 1) {
  938. return null;
  939. }
  940. $late = 0;
  941. $interruptAny = false;
  942. foreach ($byOrder as $agg) {
  943. if (($agg['delivery_status'] ?? '') === '滞后') {
  944. $late++;
  945. }
  946. if ((int)($agg['order_interrupt'] ?? 0) === 1) {
  947. $interruptAny = true;
  948. }
  949. }
  950. $onTimeRate = (1 - ($late / $total)) * 100;
  951. $onTimeScore = self::mapOnTimeRatePercentToDeliveryScore($onTimeRate);
  952. $interruptScore = $interruptAny ? 0.0 : 10.0;
  953. return round($onTimeScore + $interruptScore, 2);
  954. }
  955. /**
  956. * 价格得分(满分 15):全部严格低于最高限价→15;任一单等于限价或限价为空→10
  957. *
  958. * @return float|null 无比价订单时返回 null
  959. */
  960. public static function calcPriceScoreFromCeiling(string $companyName, string $ym): ?float
  961. {
  962. $companyName = trim($companyName);
  963. $ym = self::formatScoreYm($ym);
  964. if ($companyName === '' || $ym === '') {
  965. return null;
  966. }
  967. $byOrder = self::loadInboundOrderAggByCompanyYm($companyName, $ym);
  968. if ($byOrder === []) {
  969. return null;
  970. }
  971. $ccydhList = array_keys($byOrder);
  972. $compared = 0;
  973. $allStrictlyBelow = true;
  974. try {
  975. $poRows = Db::table('purchase_order')
  976. ->alias('po')
  977. ->join('purchase_order_detail d', 'd.scydgy_id = po.scydgy_id')
  978. ->where('po.CCYDH', 'in', $ccydhList)
  979. ->where('d.company_name', $companyName)
  980. ->whereRaw(
  981. '(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%\')'
  982. )
  983. ->field('po.CCYDH,po.ceilingPrice,d.amount')
  984. ->select();
  985. } catch (\Throwable $e) {
  986. try {
  987. $poRows = Db::table('purchase_order')
  988. ->alias('po')
  989. ->join('purchase_order_detail d', 'd.scydgy_id = po.scydgy_id')
  990. ->where('po.CCYDH', 'in', $ccydhList)
  991. ->where('d.company_name', $companyName)
  992. ->field('po.CCYDH,po.ceilingPrice,d.amount')
  993. ->select();
  994. } catch (\Throwable $e2) {
  995. Log::write('calcPriceScoreFromCeiling: ' . $e2->getMessage(), 'error');
  996. return null;
  997. }
  998. }
  999. if (!is_array($poRows) || $poRows === []) {
  1000. return null;
  1001. }
  1002. /** @var array<string, bool> $orderStrictBelow 订单是否全部严格低于限价 */
  1003. $orderStrictBelow = [];
  1004. foreach ($poRows as $r) {
  1005. if (!is_array($r)) {
  1006. continue;
  1007. }
  1008. $ccydh = trim((string)($r['CCYDH'] ?? $r['ccydh'] ?? ''));
  1009. if ($ccydh === '' || !isset($byOrder[$ccydh])) {
  1010. continue;
  1011. }
  1012. if (self::isBlankQuoteAmount($r['amount'] ?? null)) {
  1013. continue;
  1014. }
  1015. $amount = self::parseMoneyValue($r['amount'] ?? null);
  1016. if ($amount === null) {
  1017. continue;
  1018. }
  1019. $compared++;
  1020. if (!isset($orderStrictBelow[$ccydh])) {
  1021. $orderStrictBelow[$ccydh] = true;
  1022. }
  1023. $ceiling = self::parseMoneyValue($r['ceilingPrice'] ?? $r['ceiling_price'] ?? null);
  1024. if ($ceiling === null) {
  1025. // 限价空 → 该单非严格低于
  1026. $orderStrictBelow[$ccydh] = false;
  1027. continue;
  1028. }
  1029. // 严格低于才算;等于或高于都不算
  1030. if ($amount >= $ceiling - 0.0000001) {
  1031. $orderStrictBelow[$ccydh] = false;
  1032. }
  1033. }
  1034. if ($compared < 1 || $orderStrictBelow === []) {
  1035. return null;
  1036. }
  1037. foreach ($orderStrictBelow as $ok) {
  1038. if (!$ok) {
  1039. $allStrictlyBelow = false;
  1040. break;
  1041. }
  1042. }
  1043. return $allStrictlyBelow ? 15.0 : 10.0;
  1044. }
  1045. /**
  1046. * 增值服务(满分 10):未报价订单数(按订单号去重)≤3 →10;>3 →0
  1047. * 须当月已有完结/入库评分订单才计分(与本月订单明细同源);仅有询价无完结单 → null
  1048. *
  1049. * @return float|null 无本月完结订单或无询价记录时返回 null
  1050. */
  1051. public static function calcValueAddedScoreFromUnquoted(string $companyName, string $ym): ?float
  1052. {
  1053. $companyName = trim($companyName);
  1054. $ym = self::formatScoreYm($ym);
  1055. if ($companyName === '' || $ym === '') {
  1056. return null;
  1057. }
  1058. // 无本月入库完结订单则不计增值(避免「明细 0 单却有 10 分」)
  1059. $inboundOrders = self::loadInboundOrderAggByCompanyYm($companyName, $ym);
  1060. if ($inboundOrders === []) {
  1061. return null;
  1062. }
  1063. $stats = self::loadUnquotedOrderStatsByCompanyYm($companyName, $ym);
  1064. if ($stats === null) {
  1065. return null;
  1066. }
  1067. $unquoted = (int)($stats['unquoted'] ?? 0);
  1068. return $unquoted <= 3 ? 10.0 : 0.0;
  1069. }
  1070. /**
  1071. * 某供应商某月询价订单:总数 / 未报价数(均按 CCYDH 去重)
  1072. *
  1073. * @return array{total:int,unquoted:int}|null 无询价记录时 null
  1074. */
  1075. public static function loadUnquotedOrderStatsByCompanyYm(string $companyName, string $ym): ?array
  1076. {
  1077. $companyName = trim($companyName);
  1078. $ym = self::formatScoreYm($ym);
  1079. if ($companyName === '' || $ym === '') {
  1080. return null;
  1081. }
  1082. $ymEsc = str_replace(["\\", "'", '%', '_'], ["\\\\", "''", '\\%', '\\_'], $ym);
  1083. try {
  1084. $rows = Db::table('purchase_order_detail')
  1085. ->alias('d')
  1086. ->join('purchase_order po', 'po.scydgy_id = d.scydgy_id')
  1087. ->where('d.company_name', $companyName)
  1088. ->whereRaw(
  1089. "("
  1090. . "CASE "
  1091. . "WHEN po.createtime IS NOT NULL AND CAST(po.createtime AS CHAR(32)) REGEXP '^[0-9]{10,}$' "
  1092. . "THEN FROM_UNIXTIME(CAST(po.createtime AS UNSIGNED)) "
  1093. . "WHEN po.createtime IS NOT NULL AND TRIM(CAST(po.createtime AS CHAR(32))) <> '' "
  1094. . "AND TRIM(CAST(po.createtime AS CHAR(32))) NOT LIKE '0000-00-00%' "
  1095. . "THEN TRIM(CAST(po.createtime AS CHAR(32))) "
  1096. . "ELSE TRIM(CAST(IFNULL(po.dStamp, '') AS CHAR(32))) "
  1097. . "END"
  1098. . ") LIKE '{$ymEsc}-%'"
  1099. )
  1100. ->whereRaw(
  1101. '(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%\')'
  1102. )
  1103. ->field('po.CCYDH,d.amount')
  1104. ->select();
  1105. } catch (\Throwable $e) {
  1106. Log::write('loadUnquotedOrderStats: ' . $e->getMessage(), 'error');
  1107. return null;
  1108. }
  1109. if (!is_array($rows) || $rows === []) {
  1110. return null;
  1111. }
  1112. /** @var array<string, bool> $quotedByOrder 订单是否至少有一条有效报价 */
  1113. $quotedByOrder = [];
  1114. foreach ($rows as $r) {
  1115. if (!is_array($r)) {
  1116. continue;
  1117. }
  1118. $ccydh = trim((string)($r['CCYDH'] ?? $r['ccydh'] ?? ''));
  1119. if ($ccydh === '') {
  1120. continue;
  1121. }
  1122. if (!isset($quotedByOrder[$ccydh])) {
  1123. $quotedByOrder[$ccydh] = false;
  1124. }
  1125. if (!self::isBlankQuoteAmount($r['amount'] ?? null)) {
  1126. $quotedByOrder[$ccydh] = true;
  1127. }
  1128. }
  1129. if ($quotedByOrder === []) {
  1130. return null;
  1131. }
  1132. $total = count($quotedByOrder);
  1133. $unquoted = 0;
  1134. foreach ($quotedByOrder as $quoted) {
  1135. if (!$quoted) {
  1136. $unquoted++;
  1137. }
  1138. }
  1139. return ['total' => $total, 'unquoted' => $unquoted];
  1140. }
  1141. /**
  1142. * 收集某月需重算的供应商名称
  1143. * (按完结月有入库评分的供应商 + 当月询价明细 + 月度表已有行,便于清掉错月脏数据)
  1144. *
  1145. * @return string[]
  1146. */
  1147. public static function collectMonthlyReviewCompanyNames(string $ym, ?string $onlyCompany = null): array
  1148. {
  1149. $ym = self::formatScoreYm($ym);
  1150. if ($ym === '') {
  1151. return [];
  1152. }
  1153. $onlyCompany = $onlyCompany !== null ? trim($onlyCompany) : '';
  1154. if ($onlyCompany !== '') {
  1155. return [$onlyCompany];
  1156. }
  1157. $companies = [];
  1158. // 1) 当月完结(采购确认/直接完结)的工序 → 供应商
  1159. $ymEsc = str_replace(["\\", "'", '%', '_'], ["\\\\", "''", '\\%', '\\_'], $ym);
  1160. try {
  1161. $completeActs = ProcuremenOperLog::expandActionQueryValues(['purchase_confirm', 'mark_complete']);
  1162. $clog = Db::table('purchase_order_oper_log')
  1163. ->where(ProcuremenOperLog::COL_ACTION, 'in', $completeActs)
  1164. ->where(ProcuremenOperLog::COL_TIME, 'like', $ym . '%')
  1165. ->field(ProcuremenOperLog::COL_SCYDGY_ID)
  1166. ->select();
  1167. } catch (\Throwable $e) {
  1168. $clog = [];
  1169. }
  1170. $completeSids = [];
  1171. if (is_array($clog)) {
  1172. foreach ($clog as $lg) {
  1173. if (!is_array($lg)) {
  1174. continue;
  1175. }
  1176. $sid = (int)($lg[ProcuremenOperLog::COL_SCYDGY_ID] ?? $lg['scydgy_id'] ?? 0);
  1177. if ($sid > 0) {
  1178. $completeSids[$sid] = true;
  1179. }
  1180. }
  1181. }
  1182. // 2) 全部有入库评分的供应商:再按完结月精确过滤
  1183. try {
  1184. $scoreRows = Db::table('purchase_order_inbound_score')
  1185. ->field('scydgy_id,company_name,createtime,updatetime')
  1186. ->select();
  1187. } catch (\Throwable $e) {
  1188. $scoreRows = [];
  1189. Log::write('collectMonthlyReviewCompanyNames inbound: ' . $e->getMessage(), 'error');
  1190. }
  1191. $sidSet = $completeSids;
  1192. $inboundTimeBySid = [];
  1193. $companyBySid = [];
  1194. if (is_array($scoreRows)) {
  1195. foreach ($scoreRows as $r) {
  1196. if (!is_array($r)) {
  1197. continue;
  1198. }
  1199. $sid = (int)($r['scydgy_id'] ?? 0);
  1200. $cn = trim((string)($r['company_name'] ?? ''));
  1201. if ($sid > 0) {
  1202. $sidSet[$sid] = true;
  1203. $inboundTimeBySid[$sid] = [
  1204. 'createtime' => $r['createtime'] ?? null,
  1205. 'updatetime' => $r['updatetime'] ?? null,
  1206. ];
  1207. if ($cn !== '') {
  1208. $companyBySid[$sid] = $cn;
  1209. }
  1210. } elseif ($cn !== '') {
  1211. // 无工序 id:按评分时间归月
  1212. $t1 = ProcuremenTime::parseToTimestamp($r['updatetime'] ?? null);
  1213. $t2 = ProcuremenTime::parseToTimestamp($r['createtime'] ?? null);
  1214. $ts = max($t1, $t2);
  1215. if ($ts > 946684800 && date('Y-m', $ts) === $ym) {
  1216. $companies[$cn] = true;
  1217. }
  1218. }
  1219. }
  1220. }
  1221. if ($sidSet !== []) {
  1222. $completedYmMap = self::loadCompletedYmMapByScydgyIds(array_keys($sidSet), $inboundTimeBySid);
  1223. $needPoNames = [];
  1224. foreach ($completedYmMap as $sid => $rowYm) {
  1225. if ($rowYm !== $ym) {
  1226. continue;
  1227. }
  1228. if (isset($companyBySid[$sid]) && $companyBySid[$sid] !== '') {
  1229. $companies[$companyBySid[$sid]] = true;
  1230. } else {
  1231. $needPoNames[(int)$sid] = true;
  1232. }
  1233. }
  1234. if ($needPoNames !== []) {
  1235. try {
  1236. $poRows = Db::table('purchase_order')
  1237. ->where('scydgy_id', 'in', array_keys($needPoNames))
  1238. ->field('scydgy_id,pick_company_name')
  1239. ->select();
  1240. } catch (\Throwable $e) {
  1241. $poRows = [];
  1242. }
  1243. if (is_array($poRows)) {
  1244. foreach ($poRows as $po) {
  1245. if (!is_array($po)) {
  1246. continue;
  1247. }
  1248. $cn = trim((string)($po['pick_company_name'] ?? ''));
  1249. if ($cn !== '') {
  1250. $companies[$cn] = true;
  1251. }
  1252. }
  1253. }
  1254. }
  1255. }
  1256. // 3) 并入当月有询价明细的供应商(增值服务未报价统计)
  1257. try {
  1258. $detailRows = Db::table('purchase_order_detail')
  1259. ->alias('d')
  1260. ->join('purchase_order po', 'po.scydgy_id = d.scydgy_id')
  1261. ->whereRaw(
  1262. "("
  1263. . "CASE "
  1264. . "WHEN po.createtime IS NOT NULL AND CAST(po.createtime AS CHAR(32)) REGEXP '^[0-9]{10,}$' "
  1265. . "THEN FROM_UNIXTIME(CAST(po.createtime AS UNSIGNED)) "
  1266. . "WHEN po.createtime IS NOT NULL AND TRIM(CAST(po.createtime AS CHAR(32))) <> '' "
  1267. . "AND TRIM(CAST(po.createtime AS CHAR(32))) NOT LIKE '0000-00-00%' "
  1268. . "THEN TRIM(CAST(po.createtime AS CHAR(32))) "
  1269. . "ELSE TRIM(CAST(IFNULL(po.dStamp, '') AS CHAR(32))) "
  1270. . "END"
  1271. . ") LIKE '{$ymEsc}-%'"
  1272. )
  1273. ->field('d.company_name')
  1274. ->group('d.company_name')
  1275. ->select();
  1276. } catch (\Throwable $e) {
  1277. $detailRows = [];
  1278. }
  1279. if (is_array($detailRows)) {
  1280. foreach ($detailRows as $dr) {
  1281. if (!is_array($dr)) {
  1282. continue;
  1283. }
  1284. $cn = trim((string)($dr['company_name'] ?? ''));
  1285. if ($cn !== '') {
  1286. $companies[$cn] = true;
  1287. }
  1288. }
  1289. }
  1290. // 4) 月度表已有行:重算时可清掉错月残留
  1291. try {
  1292. $finalRows = Db::table(self::TABLE_FINAL)
  1293. ->where('ym', $ym)
  1294. ->field('company_name')
  1295. ->select();
  1296. } catch (\Throwable $e) {
  1297. $finalRows = [];
  1298. }
  1299. if (is_array($finalRows)) {
  1300. foreach ($finalRows as $fr) {
  1301. if (!is_array($fr)) {
  1302. continue;
  1303. }
  1304. $cn = trim((string)($fr['company_name'] ?? ''));
  1305. if ($cn !== '') {
  1306. $companies[$cn] = true;
  1307. }
  1308. }
  1309. }
  1310. return array_keys($companies);
  1311. }
  1312. /**
  1313. * 按新月度规则重算四项得分 + 最终分/等级(满分 105)
  1314. * 已手工保存(qp_manual=1)的供应商跳过
  1315. *
  1316. * @param string|null $onlyCompany 仅同步该供应商;null=当月相关全部
  1317. * @return array{updated:int,skipped:int}
  1318. */
  1319. public static function syncMonthlyReviewScoresFromRules(string $ym, ?string $onlyCompany = null): array
  1320. {
  1321. $ym = self::formatScoreYm($ym);
  1322. $result = ['updated' => 0, 'skipped' => 0];
  1323. if ($ym === '') {
  1324. return $result;
  1325. }
  1326. self::ensureFinalScoreTable();
  1327. self::repairInboundScoreFromOperLogs($ym);
  1328. $companies = self::collectMonthlyReviewCompanyNames($ym, $onlyCompany);
  1329. if ($companies === []) {
  1330. return $result;
  1331. }
  1332. self::ensureMonthlySupplierNames($ym, $companies);
  1333. $now = date('Y-m-d H:i:s');
  1334. foreach ($companies as $cn) {
  1335. $cn = trim((string)$cn);
  1336. if ($cn === '') {
  1337. continue;
  1338. }
  1339. try {
  1340. $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
  1341. if (is_array($exists) && $exists !== [] && (int)($exists['qp_manual'] ?? 0) === 1) {
  1342. $result['skipped']++;
  1343. continue;
  1344. }
  1345. $quality = self::calcQualityScoreFromInbound($cn, $ym);
  1346. $price = self::calcPriceScoreFromCeiling($cn, $ym);
  1347. $delivery = self::calcDeliveryScoreFromInbound($cn, $ym);
  1348. // 与「本月订单明细」一致:当月无完结/入库评分订单时,增值也不单独给分
  1349. $valueAdded = null;
  1350. if ($quality !== null || $price !== null || $delivery !== null) {
  1351. $valueAdded = self::calcValueAddedScoreFromUnquoted($cn, $ym);
  1352. }
  1353. $final = self::sumFinalScore($quality, $price, $delivery, $valueAdded);
  1354. $grade = $final !== null ? self::gradeFromFinalScore($final) : '';
  1355. $scoreCompat = $final !== null ? $final : 0.0;
  1356. if (!is_array($exists) || $exists === []) {
  1357. Db::table(self::TABLE_FINAL)->insert([
  1358. 'ym' => $ym,
  1359. 'company_name' => $cn,
  1360. 'score' => $scoreCompat,
  1361. 'quality_score' => $quality,
  1362. 'price_score' => $price,
  1363. 'delivery_score' => $delivery,
  1364. 'value_added_score' => $valueAdded,
  1365. 'final_score' => $final,
  1366. 'score_grade' => $grade,
  1367. 'qp_manual' => 0,
  1368. 'createtime' => $now,
  1369. 'updatetime' => $now,
  1370. ]);
  1371. } else {
  1372. Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update([
  1373. 'score' => $scoreCompat,
  1374. 'quality_score' => $quality,
  1375. 'price_score' => $price,
  1376. 'delivery_score' => $delivery,
  1377. 'value_added_score' => $valueAdded,
  1378. 'final_score' => $final,
  1379. 'score_grade' => $grade,
  1380. 'updatetime' => $now,
  1381. ]);
  1382. }
  1383. $result['updated']++;
  1384. } catch (\Throwable $e) {
  1385. Log::write('syncMonthlyReviewScoresFromRules write: ' . $e->getMessage(), 'error');
  1386. }
  1387. }
  1388. return $result;
  1389. }
  1390. /**
  1391. * 兼容旧入口:改为按月度四项规则同步
  1392. *
  1393. * @param string|null $onlyCompany 仅同步该供应商;null=当月相关全部
  1394. */
  1395. public static function syncQualityScoreFromInbound(string $ym, ?string $onlyCompany = null): void
  1396. {
  1397. self::syncMonthlyReviewScoresFromRules($ym, $onlyCompany);
  1398. }
  1399. /**
  1400. * 操作日志有入库评分、评分表缺失时,回填 purchase_order_inbound_score
  1401. */
  1402. public static function repairInboundScoreFromOperLogs(string $ym): void
  1403. {
  1404. $ym = self::formatScoreYm($ym);
  1405. if ($ym === '') {
  1406. return;
  1407. }
  1408. try {
  1409. $logs = Db::table('purchase_order_oper_log')
  1410. ->where(function ($q) {
  1411. $q->where('action', '入库评分')->whereOr('action', 'inbound_score');
  1412. })
  1413. ->where('createtime', 'like', $ym . '%')
  1414. ->field('scydgy_id,content,createtime,admin_id,admin_name,purchase_order_id')
  1415. ->order('id', 'asc')
  1416. ->select();
  1417. } catch (\Throwable $e) {
  1418. return;
  1419. }
  1420. if (!is_array($logs) || $logs === []) {
  1421. return;
  1422. }
  1423. $best = [];
  1424. foreach ($logs as $lg) {
  1425. if (!is_array($lg)) {
  1426. continue;
  1427. }
  1428. $sid = (int)($lg['scydgy_id'] ?? 0);
  1429. if ($sid <= 0) {
  1430. continue;
  1431. }
  1432. $content = trim((string)($lg['content'] ?? ''));
  1433. if (!preg_match('/(合格|不合格)/u', $content, $m)) {
  1434. continue;
  1435. }
  1436. $best[$sid] = [
  1437. 'result' => $m[1],
  1438. 'createtime' => trim((string)($lg['createtime'] ?? '')),
  1439. 'admin_id' => (int)($lg['admin_id'] ?? 0),
  1440. 'admin_name' => trim((string)($lg['admin_name'] ?? '')),
  1441. 'purchase_order_id' => (int)($lg['purchase_order_id'] ?? 0),
  1442. ];
  1443. }
  1444. if ($best === []) {
  1445. return;
  1446. }
  1447. try {
  1448. $existRows = Db::table('purchase_order_inbound_score')
  1449. ->where('scydgy_id', 'in', array_keys($best))
  1450. ->field('scydgy_id')
  1451. ->select();
  1452. } catch (\Throwable $e) {
  1453. return;
  1454. }
  1455. $existSids = [];
  1456. if (is_array($existRows)) {
  1457. foreach ($existRows as $er) {
  1458. if (is_array($er)) {
  1459. $existSids[(int)($er['scydgy_id'] ?? 0)] = true;
  1460. }
  1461. }
  1462. }
  1463. $needSids = [];
  1464. foreach (array_keys($best) as $sid) {
  1465. if (empty($existSids[$sid])) {
  1466. $needSids[] = $sid;
  1467. }
  1468. }
  1469. if ($needSids === []) {
  1470. return;
  1471. }
  1472. try {
  1473. $poRows = Db::table('purchase_order')
  1474. ->where('scydgy_id', 'in', $needSids)
  1475. ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_company_name')
  1476. ->select();
  1477. } catch (\Throwable $e) {
  1478. return;
  1479. }
  1480. if (!is_array($poRows)) {
  1481. return;
  1482. }
  1483. foreach ($poRows as $po) {
  1484. if (!is_array($po)) {
  1485. continue;
  1486. }
  1487. $sid = (int)($po['scydgy_id'] ?? 0);
  1488. if ($sid <= 0 || empty($best[$sid])) {
  1489. continue;
  1490. }
  1491. $b = $best[$sid];
  1492. $now = $b['createtime'] !== '' ? $b['createtime'] : date('Y-m-d H:i:s');
  1493. $data = [
  1494. 'scydgy_id' => $sid,
  1495. 'purchase_order_id' => (int)($po['id'] ?? 0) > 0 ? (int)$po['id'] : (int)$b['purchase_order_id'],
  1496. 'ccydh' => trim((string)($po['CCYDH'] ?? '')),
  1497. 'cyjmc' => trim((string)($po['CYJMC'] ?? '')),
  1498. 'cgymc' => trim((string)($po['CGYMC'] ?? '')),
  1499. 'company_name' => trim((string)($po['pick_company_name'] ?? '')),
  1500. 'result' => $b['result'],
  1501. 'admin_id' => (int)$b['admin_id'],
  1502. 'admin_name' => (string)$b['admin_name'],
  1503. 'createtime' => $now,
  1504. 'updatetime' => $now,
  1505. ];
  1506. if ($data['company_name'] === '' || $data['ccydh'] === '') {
  1507. continue;
  1508. }
  1509. try {
  1510. Db::table('purchase_order_inbound_score')->insert($data);
  1511. } catch (\Throwable $e) {
  1512. // 唯一键冲突忽略
  1513. if (stripos($e->getMessage(), 'Duplicate') === false) {
  1514. Log::write('repairInboundScoreFromOperLogs: ' . $e->getMessage(), 'error');
  1515. }
  1516. }
  1517. }
  1518. }
  1519. /**
  1520. * 读取某月评审记录(仅读月度表)
  1521. *
  1522. * @param bool $resync true=先补齐供应商并按规则重算;false=只读库(导出用,避免卡顿)
  1523. * @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}>
  1524. */
  1525. public static function loadMonthlyMapByYm(string $ym, bool $resync = true): array
  1526. {
  1527. $ym = trim($ym);
  1528. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  1529. return [];
  1530. }
  1531. self::ensureFinalScoreTable();
  1532. if ($resync) {
  1533. // 本月询价(开标后已写入订单评分表)的供应商:名称不存在则加空行,已存在不动
  1534. self::ensureMonthlySupplierNamesFromOrderYm($ym);
  1535. // 按月度规则重算质量/价格/交货/增值(满分105;qp_manual=1 跳过)
  1536. self::syncMonthlyReviewScoresFromRules($ym);
  1537. }
  1538. try {
  1539. $rows = Db::table(self::TABLE_FINAL)
  1540. ->where('ym', $ym)
  1541. ->field('company_name,score,quality_score,price_score,delivery_score,value_added_score,final_score,score_grade,ym')
  1542. ->select();
  1543. } catch (\Throwable $e) {
  1544. return [];
  1545. }
  1546. if (!is_array($rows)) {
  1547. return [];
  1548. }
  1549. $out = [];
  1550. foreach ($rows as $r) {
  1551. if (!is_array($r)) {
  1552. continue;
  1553. }
  1554. $cn = trim((string)($r['company_name'] ?? $r['Company_name'] ?? ''));
  1555. if ($cn === '') {
  1556. continue;
  1557. }
  1558. $quality = self::readOptionalScoreValue($r['quality_score'] ?? null);
  1559. $price = self::readOptionalScoreValue($r['price_score'] ?? null);
  1560. $delivery = self::readOptionalScoreValue($r['delivery_score'] ?? null);
  1561. $valueAdded = self::readOptionalScoreValue($r['value_added_score'] ?? null);
  1562. $final = self::readOptionalScoreValue($r['final_score'] ?? null);
  1563. $out[$cn] = [
  1564. 'score' => round((float)($r['score'] ?? 0), 2),
  1565. 'quality_score' => $quality,
  1566. 'price_score' => $price,
  1567. 'delivery_score' => $delivery,
  1568. 'value_added_score' => $valueAdded,
  1569. 'final_score' => $final,
  1570. 'final_saved' => $final !== null ? 1 : 0,
  1571. 'score_grade' => self::normalizeScoreGrade($r['score_grade'] ?? ''),
  1572. // 列表「日期」列按 ym(YYYY-MM)展示,不到日
  1573. 'score_date' => self::formatScoreYm($r['ym'] ?? $ym),
  1574. ];
  1575. }
  1576. return $out;
  1577. }
  1578. /**
  1579. * 规范为 YYYY-MM(月度评审日期)
  1580. *
  1581. * @param mixed $raw
  1582. */
  1583. public static function formatScoreYm($raw): string
  1584. {
  1585. $raw = trim((string)$raw);
  1586. if (preg_match('/^\d{4}-\d{2}$/', $raw)) {
  1587. return $raw;
  1588. }
  1589. if (preg_match('/^(\d{4})\D+(\d{1,2})/', $raw, $m)) {
  1590. return sprintf('%04d-%02d', (int)$m[1], (int)$m[2]);
  1591. }
  1592. return '';
  1593. }
  1594. /**
  1595. * @param mixed $raw
  1596. */
  1597. public static function formatScoreDateYmd($raw): string
  1598. {
  1599. $raw = trim((string)$raw);
  1600. if ($raw === '' || stripos($raw, '0000-00-00') === 0) {
  1601. return '';
  1602. }
  1603. $ts = strtotime(str_replace('T', ' ', $raw));
  1604. return ($ts !== false && $ts > 0) ? date('Y-m-d', $ts) : '';
  1605. }
  1606. /**
  1607. * 解析可选分数:空=null;非法=false;合法=float
  1608. *
  1609. * @param mixed $raw
  1610. * @return float|null|false
  1611. */
  1612. protected static function parseOptionalScore($raw)
  1613. {
  1614. if ($raw === null) {
  1615. return null;
  1616. }
  1617. if (is_string($raw) && trim($raw) === '') {
  1618. return null;
  1619. }
  1620. if (!is_numeric($raw)) {
  1621. return false;
  1622. }
  1623. return round((float)$raw, 2);
  1624. }
  1625. /**
  1626. * 批量保存某月评审分:商务技术分 / 价格分 / 交货分 / 最终得分 / 评分等级
  1627. *
  1628. * @param array<int, array{company_name?:string,quality_score?:mixed,price_score?:mixed,delivery_score?:mixed,final_score?:mixed,score_grade?:mixed}> $items
  1629. * @return array{done:int,saved:int,cleared:int,error:string}
  1630. */
  1631. public static function saveFinalScoresForYm(string $ym, array $items): array
  1632. {
  1633. $ym = trim($ym);
  1634. $result = ['done' => 0, 'saved' => 0, 'cleared' => 0, 'error' => ''];
  1635. if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
  1636. $result['error'] = '月份无效';
  1637. return $result;
  1638. }
  1639. if ($items === []) {
  1640. $result['error'] = '没有可保存的数据';
  1641. return $result;
  1642. }
  1643. self::ensureFinalScoreTable();
  1644. try {
  1645. Db::query('SELECT 1 FROM `' . self::TABLE_FINAL . '` LIMIT 1');
  1646. } catch (\Throwable $e) {
  1647. $result['error'] = '月度汇总表未创建,请联系管理员';
  1648. return $result;
  1649. }
  1650. $now = date('Y-m-d H:i:s');
  1651. foreach ($items as $it) {
  1652. if (!is_array($it)) {
  1653. continue;
  1654. }
  1655. $cn = trim((string)($it['company_name'] ?? ''));
  1656. if ($cn === '') {
  1657. continue;
  1658. }
  1659. $grade = self::normalizeScoreGrade($it['score_grade'] ?? '');
  1660. $quality = self::parseOptionalScore($it['quality_score'] ?? null);
  1661. $price = self::parseOptionalScore($it['price_score'] ?? null);
  1662. $delivery = self::parseOptionalScore($it['delivery_score'] ?? null);
  1663. $valueAdded = self::parseOptionalScore($it['value_added_score'] ?? null);
  1664. $final = self::parseOptionalScore($it['final_score'] ?? null);
  1665. if ($quality === false || $price === false || $delivery === false || $valueAdded === false || $final === false) {
  1666. continue;
  1667. }
  1668. // 未填最终得分时,按四项自动汇总
  1669. if ($final === null) {
  1670. $final = self::sumFinalScore($quality, $price, $delivery, $valueAdded);
  1671. }
  1672. // 未选等级时,按最终得分自动定级
  1673. if ($grade === '' && $final !== null) {
  1674. $grade = self::gradeFromFinalScore($final);
  1675. }
  1676. $hasQp = array_key_exists('quality_score', $it)
  1677. || array_key_exists('price_score', $it)
  1678. || array_key_exists('delivery_score', $it)
  1679. || array_key_exists('value_added_score', $it);
  1680. $hasAny = $final !== null || $grade !== ''
  1681. || $quality !== null || $price !== null || $delivery !== null || $valueAdded !== null;
  1682. // 全空行不写入,避免误把未改供应商标成手工锁定
  1683. if (!$hasAny) {
  1684. continue;
  1685. }
  1686. try {
  1687. $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
  1688. $upd = [
  1689. 'final_score' => $final,
  1690. 'score_grade' => $grade,
  1691. 'updatetime' => $now,
  1692. // 保存即视为手工确认,之后不再跑查询计算规则覆盖
  1693. 'qp_manual' => 1,
  1694. ];
  1695. if ($hasQp) {
  1696. // 空值存 NULL,列表显示空白而不是 0
  1697. $upd['quality_score'] = $quality;
  1698. $upd['price_score'] = $price;
  1699. $upd['delivery_score'] = $delivery;
  1700. $upd['value_added_score'] = $valueAdded;
  1701. $upd['score'] = self::sumFinalScore($quality, $price, $delivery, $valueAdded) ?? 0;
  1702. }
  1703. if (is_array($exists) && $exists !== []) {
  1704. Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update($upd);
  1705. } else {
  1706. Db::table(self::TABLE_FINAL)->insert([
  1707. 'ym' => $ym,
  1708. 'company_name' => $cn,
  1709. 'score' => $hasQp ? (self::sumFinalScore($quality, $price, $delivery, $valueAdded) ?? 0) : 0,
  1710. 'quality_score' => $hasQp ? $quality : null,
  1711. 'price_score' => $hasQp ? $price : null,
  1712. 'delivery_score' => $hasQp ? $delivery : null,
  1713. 'value_added_score' => $hasQp ? $valueAdded : null,
  1714. 'final_score' => $final,
  1715. 'score_grade' => $grade,
  1716. 'qp_manual' => 1,
  1717. 'createtime' => $now,
  1718. 'updatetime' => $now,
  1719. ]);
  1720. }
  1721. if ($final === null && $grade === '' && !$hasQp) {
  1722. $result['cleared']++;
  1723. } else {
  1724. $result['saved']++;
  1725. }
  1726. $result['done']++;
  1727. } catch (\Throwable $e) {
  1728. Log::write('supplier final score save: ' . $e->getMessage(), 'error');
  1729. $result['error'] = $e->getMessage();
  1730. }
  1731. }
  1732. return $result;
  1733. }
  1734. /** 历史表补质量/价格百分比字段 */
  1735. protected static function ensureScoreWeightColumns(): void
  1736. {
  1737. try {
  1738. $cols = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'quality_weight'");
  1739. if (!is_array($cols) || $cols === []) {
  1740. Db::execute(
  1741. "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `quality_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比' AFTER `quality_score`"
  1742. );
  1743. }
  1744. $cols2 = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'price_weight'");
  1745. if (!is_array($cols2) || $cols2 === []) {
  1746. Db::execute(
  1747. "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `price_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比' AFTER `price_sum`"
  1748. );
  1749. }
  1750. } catch (\Throwable $e) {
  1751. Log::write('supplier score weight columns: ' . $e->getMessage(), 'error');
  1752. }
  1753. }
  1754. /** 历史表补交货期权重/得分字段 */
  1755. protected static function ensureLeadColumns(): void
  1756. {
  1757. $adds = [
  1758. self::TABLE_RULE => [
  1759. 'lead_weight' => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)' AFTER `price_weight`",
  1760. ],
  1761. self::TABLE_SCORE => [
  1762. 'lead_days_sum' => "ADD COLUMN `lead_days_sum` int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)' AFTER `price_score`",
  1763. 'lead_weight' => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比' AFTER `lead_days_sum`",
  1764. 'lead_enabled' => "ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=当时纳入总分' AFTER `lead_weight`",
  1765. 'lead_score' => "ADD COLUMN `lead_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '交货期评分值' AFTER `lead_enabled`",
  1766. ],
  1767. ];
  1768. foreach ($adds as $table => $cols) {
  1769. foreach ($cols as $col => $ddl) {
  1770. try {
  1771. $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
  1772. if (is_array($info) && $info !== []) {
  1773. continue;
  1774. }
  1775. Db::execute("ALTER TABLE `{$table}` {$ddl}");
  1776. } catch (\Throwable $e) {
  1777. Log::write("supplier score lead column {$table}.{$col}: " . $e->getMessage(), 'error');
  1778. }
  1779. }
  1780. }
  1781. }
  1782. /** 将权重/质量分/单价合计等字段改为整数(去掉 .00) */
  1783. protected static function ensureIntegerColumns(): void
  1784. {
  1785. $defs = [
  1786. self::TABLE_RULE => [
  1787. 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '上年度质量评分权重(%)'",
  1788. 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '单价评分权重(%)'",
  1789. 'lead_weight' => "int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)'",
  1790. ],
  1791. self::TABLE_SCORE => [
  1792. 'quality_score' => "int NOT NULL DEFAULT 0 COMMENT '上年度质量评分'",
  1793. 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比'",
  1794. 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比'",
  1795. 'lead_days_sum' => "int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)'",
  1796. 'lead_weight' => "int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比'",
  1797. ],
  1798. ];
  1799. foreach ($defs as $table => $cols) {
  1800. foreach ($cols as $col => $def) {
  1801. try {
  1802. $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
  1803. if (!is_array($info) || $info === []) {
  1804. continue;
  1805. }
  1806. $type = strtolower((string)($info[0]['Type'] ?? $info[0]['type'] ?? ''));
  1807. if (preg_match('/^(tiny|small|medium|big)?int\b/', $type)) {
  1808. continue;
  1809. }
  1810. Db::execute("ALTER TABLE `{$table}` MODIFY COLUMN `{$col}` {$def}");
  1811. } catch (\Throwable $e) {
  1812. Log::write("supplier score int column {$table}.{$col}: " . $e->getMessage(), 'error');
  1813. }
  1814. }
  1815. }
  1816. // 单价合计需保留小数(如 0.25),不可用 int
  1817. try {
  1818. $info = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'price_sum'");
  1819. if (is_array($info) && $info !== []) {
  1820. $type = strtolower((string)($info[0]['Type'] ?? $info[0]['type'] ?? ''));
  1821. if (preg_match('/^(tiny|small|medium|big)?int\b/', $type)) {
  1822. Db::execute(
  1823. "ALTER TABLE `" . self::TABLE_SCORE . "` MODIFY COLUMN `price_sum` decimal(12,4) NOT NULL DEFAULT 0.0000 COMMENT '本单工序单价合计'"
  1824. );
  1825. }
  1826. }
  1827. } catch (\Throwable $e) {
  1828. Log::write('supplier score price_sum decimal: ' . $e->getMessage(), 'error');
  1829. }
  1830. }
  1831. protected static function ensureDefaultRule(): void
  1832. {
  1833. self::ensureRuleNameColumn();
  1834. self::ensureOrderTypeRules();
  1835. }
  1836. /**
  1837. * 规则表去掉页面未用字段:lead_enabled、status
  1838. * (交期是否纳入由 lead_weight>0 判断;删除用物理删除)
  1839. */
  1840. protected static function dropObsoleteRuleColumns(): void
  1841. {
  1842. static $done = false;
  1843. if ($done) {
  1844. return;
  1845. }
  1846. $done = true;
  1847. foreach (['lead_enabled', 'status'] as $col) {
  1848. try {
  1849. $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'' . $col . '\'');
  1850. if (is_array($cols) && $cols !== []) {
  1851. Db::execute('ALTER TABLE `' . self::TABLE_RULE . '` DROP COLUMN `' . $col . '`');
  1852. }
  1853. } catch (\Throwable $e) {
  1854. Log::write('supplier_score_rule drop ' . $col . ': ' . $e->getMessage(), 'error');
  1855. }
  1856. }
  1857. }
  1858. /**
  1859. * 规则表补「规则名称/订单类型」字段(历史库可能缺此列)
  1860. */
  1861. protected static function ensureRuleNameColumn(): void
  1862. {
  1863. static $done = false;
  1864. if ($done) {
  1865. return;
  1866. }
  1867. try {
  1868. $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'name\'');
  1869. if (!is_array($cols) || $cols === []) {
  1870. Db::execute(
  1871. 'ALTER TABLE `' . self::TABLE_RULE . '` ADD COLUMN `name` varchar(100) NOT NULL DEFAULT \'\' COMMENT \'规则名称/订单类型\' AFTER `id`'
  1872. );
  1873. $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'name\'');
  1874. }
  1875. if (is_array($cols) && $cols !== []) {
  1876. $done = true;
  1877. }
  1878. } catch (\Throwable $e) {
  1879. Log::write('supplier_score_rule.name: ' . $e->getMessage(), 'error');
  1880. }
  1881. }
  1882. /**
  1883. * 写入/对齐「不同订单供应商评分权重方案」三条规则
  1884. */
  1885. protected static function ensureOrderTypeRules(): void
  1886. {
  1887. static $done = false;
  1888. if ($done) {
  1889. return;
  1890. }
  1891. try {
  1892. if (\think\Cache::get('procuremen_supplier_order_type_rules_ok_v1')) {
  1893. $done = true;
  1894. return;
  1895. }
  1896. } catch (\Throwable $e) {
  1897. }
  1898. self::ensureRuleNameColumn();
  1899. $schemes = [
  1900. [
  1901. 'name' => '普通订单(价格优先)',
  1902. 'quality_weight' => 30,
  1903. 'price_weight' => 50,
  1904. 'lead_weight' => 20,
  1905. ],
  1906. [
  1907. 'name' => '急单(交期优先)',
  1908. 'quality_weight' => 30,
  1909. 'price_weight' => 20,
  1910. 'lead_weight' => 50,
  1911. ],
  1912. [
  1913. 'name' => '重点产品(质量优先)',
  1914. 'quality_weight' => 50,
  1915. 'price_weight' => 20,
  1916. 'lead_weight' => 30,
  1917. ],
  1918. ];
  1919. $now = date('Y-m-d H:i:s');
  1920. try {
  1921. // 确认 name 列已存在再继续
  1922. $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'name\'');
  1923. if (!is_array($cols) || $cols === []) {
  1924. Log::write('supplier_score_rule.name still missing, skip seed', 'error');
  1925. return;
  1926. }
  1927. foreach ($schemes as $s) {
  1928. $exist = Db::table(self::TABLE_RULE)->where('name', $s['name'])->find();
  1929. if (!is_array($exist)) {
  1930. // 兼容旧数据:无 name 时按三档百分比匹配
  1931. $exist = Db::table(self::TABLE_RULE)
  1932. ->where('quality_weight', (int)$s['quality_weight'])
  1933. ->where('price_weight', (int)$s['price_weight'])
  1934. ->where('lead_weight', (int)$s['lead_weight'])
  1935. ->where(function ($q) {
  1936. $q->whereNull('name')->whereOr('name', '');
  1937. })
  1938. ->order('id', 'asc')
  1939. ->find();
  1940. }
  1941. $payload = [
  1942. 'name' => $s['name'],
  1943. 'quality_weight' => (int)$s['quality_weight'],
  1944. 'price_weight' => (int)$s['price_weight'],
  1945. 'lead_weight' => (int)$s['lead_weight'],
  1946. 'updatetime' => $now,
  1947. ];
  1948. if (is_array($exist)) {
  1949. // 已存在:只补空名称,不覆盖人工改过的权重
  1950. $existName = trim((string)($exist['name'] ?? ''));
  1951. if ($existName === '') {
  1952. Db::table(self::TABLE_RULE)->where('id', (int)$exist['id'])->update([
  1953. 'name' => $s['name'],
  1954. 'updatetime' => $now,
  1955. ]);
  1956. }
  1957. } else {
  1958. $payload['is_default'] = 0;
  1959. $payload['createtime'] = $now;
  1960. Db::table(self::TABLE_RULE)->insert($payload);
  1961. }
  1962. }
  1963. // 非方案内的旧行直接删除,列表只保留三条
  1964. $names = [];
  1965. foreach ($schemes as $s) {
  1966. $names[] = $s['name'];
  1967. }
  1968. $nameIn = "'" . implode("','", array_map('addslashes', $names)) . "'";
  1969. Db::execute(
  1970. 'DELETE FROM `' . self::TABLE_RULE . '` '
  1971. . 'WHERE `name` NOT IN (' . $nameIn . ') OR `name` IS NULL OR `name`=\'\''
  1972. );
  1973. $hasDefault = Db::table(self::TABLE_RULE)->where('is_default', 1)->find();
  1974. if (!is_array($hasDefault)) {
  1975. $def = Db::table(self::TABLE_RULE)->where('name', '普通订单(价格优先)')->find();
  1976. if (!is_array($def)) {
  1977. $def = Db::table(self::TABLE_RULE)->order('id', 'asc')->find();
  1978. }
  1979. if (is_array($def)) {
  1980. Db::table(self::TABLE_RULE)->where('id', (int)$def['id'])->update([
  1981. 'is_default' => 1,
  1982. 'updatetime' => $now,
  1983. ]);
  1984. }
  1985. }
  1986. $done = true;
  1987. try {
  1988. \think\Cache::set('procuremen_supplier_order_type_rules_ok_v1', 1, 86400);
  1989. } catch (\Throwable $e) {
  1990. }
  1991. } catch (\Throwable $e) {
  1992. Log::write('supplier score ensureOrderTypeRules: ' . $e->getMessage(), 'error');
  1993. }
  1994. }
  1995. /**
  1996. * 下拉用:正常状态的评分规则(订单类型)
  1997. *
  1998. * @return array<int, array{id:int,name:string,label:string,quality_weight:int,price_weight:int,lead_weight:int,is_default:int}>
  1999. */
  2000. public static function listRulesForSelect(): array
  2001. {
  2002. self::ensureSchema();
  2003. $out = [];
  2004. try {
  2005. $rows = Db::table(self::TABLE_RULE)
  2006. ->order('is_default', 'desc')
  2007. ->order('id', 'asc')
  2008. ->select();
  2009. if (!is_array($rows)) {
  2010. return $out;
  2011. }
  2012. foreach ($rows as $row) {
  2013. if (!is_array($row)) {
  2014. continue;
  2015. }
  2016. $name = trim((string)($row['name'] ?? ''));
  2017. if ($name === '') {
  2018. continue;
  2019. }
  2020. $qw = (int)($row['quality_weight'] ?? 0);
  2021. $pw = (int)($row['price_weight'] ?? 0);
  2022. $lw = (int)($row['lead_weight'] ?? 0);
  2023. $out[] = [
  2024. 'id' => (int)($row['id'] ?? 0),
  2025. 'name' => $name,
  2026. 'label' => $name . '|质量' . $qw . '%|价格' . $pw . '%|交期' . $lw . '%',
  2027. 'quality_weight' => $qw,
  2028. 'price_weight' => $pw,
  2029. 'lead_weight' => $lw,
  2030. 'is_default' => !empty($row['is_default']) ? 1 : 0,
  2031. ];
  2032. }
  2033. } catch (\Throwable $e) {
  2034. }
  2035. return $out;
  2036. }
  2037. /**
  2038. * 按订单号解析评分规则:优先下发时写入的权重快照,否则按 score_rule_id 读规则表,再否则默认
  2039. *
  2040. * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
  2041. */
  2042. public static function resolveRuleForCcydh(string $ccydh, ?array $poHint = null): array
  2043. {
  2044. $ccydh = trim($ccydh);
  2045. if ($ccydh === '' && (!is_array($poHint) || $poHint === [])) {
  2046. return self::getDefaultRule();
  2047. }
  2048. try {
  2049. $po = is_array($poHint) && $poHint !== [] ? $poHint : null;
  2050. if ($po === null && $ccydh !== '') {
  2051. self::ensureSchema();
  2052. $po = Db::table('purchase_order')->where('CCYDH', $ccydh)->order('id', 'desc')->find();
  2053. }
  2054. if (is_array($po)) {
  2055. $ruleId = (int)($po['score_rule_id'] ?? 0);
  2056. $qw = (int)($po['score_quality_weight'] ?? 0);
  2057. $pw = (int)($po['score_price_weight'] ?? 0);
  2058. $lw = (int)($po['score_lead_weight'] ?? 0);
  2059. // 下发时已快照:开标/展示一律用快照,不受后续规则修改影响(无需 ensureSchema)
  2060. if ($ruleId > 0 && ($qw + $pw + $lw) > 0) {
  2061. $name = trim((string)($po['score_rule_name'] ?? ''));
  2062. if ($name === '') {
  2063. $live = self::getRuleById($ruleId);
  2064. $name = (string)($live['name'] ?? '');
  2065. }
  2066. return [
  2067. 'id' => $ruleId,
  2068. 'name' => $name,
  2069. 'quality_weight' => max(0, $qw),
  2070. 'price_weight' => max(0, $pw),
  2071. 'lead_weight' => max(0, $lw),
  2072. 'lead_enabled' => $lw > 0 ? 1 : 0,
  2073. ];
  2074. }
  2075. if ($ruleId > 0) {
  2076. $rule = self::getRuleById($ruleId);
  2077. if ((int)($rule['id'] ?? 0) > 0) {
  2078. return $rule;
  2079. }
  2080. }
  2081. }
  2082. } catch (\Throwable $e) {
  2083. }
  2084. return self::getDefaultRule();
  2085. }
  2086. /**
  2087. * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
  2088. */
  2089. public static function getRuleById(int $id): array
  2090. {
  2091. if ($id <= 0) {
  2092. return self::getDefaultRule();
  2093. }
  2094. self::ensureSchema();
  2095. try {
  2096. $row = Db::table(self::TABLE_RULE)
  2097. ->where('id', $id)
  2098. ->find();
  2099. if (is_array($row)) {
  2100. $lw = (int)($row['lead_weight'] ?? 0);
  2101. return [
  2102. 'id' => (int)($row['id'] ?? 0),
  2103. 'name' => trim((string)($row['name'] ?? '')),
  2104. 'quality_weight' => (int)($row['quality_weight'] ?? 50),
  2105. 'price_weight' => (int)($row['price_weight'] ?? 50),
  2106. 'lead_weight' => $lw,
  2107. 'lead_enabled' => $lw > 0 ? 1 : 0,
  2108. ];
  2109. }
  2110. } catch (\Throwable $e) {
  2111. }
  2112. return self::getDefaultRule();
  2113. }
  2114. /**
  2115. * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
  2116. */
  2117. public static function getDefaultRule(): array
  2118. {
  2119. self::ensureSchema();
  2120. try {
  2121. $row = Db::table(self::TABLE_RULE)
  2122. ->where('is_default', 1)
  2123. ->order('id', 'desc')
  2124. ->find();
  2125. if (!is_array($row)) {
  2126. $row = Db::table(self::TABLE_RULE)->order('id', 'asc')->find();
  2127. }
  2128. if (is_array($row)) {
  2129. $lw = (int)($row['lead_weight'] ?? 0);
  2130. return [
  2131. 'id' => (int)($row['id'] ?? 0),
  2132. 'name' => trim((string)($row['name'] ?? '')),
  2133. 'quality_weight' => (int)($row['quality_weight'] ?? 50),
  2134. 'price_weight' => (int)($row['price_weight'] ?? 50),
  2135. 'lead_weight' => $lw,
  2136. 'lead_enabled' => $lw > 0 ? 1 : 0,
  2137. ];
  2138. }
  2139. } catch (\Throwable $e) {
  2140. }
  2141. return [
  2142. 'id' => 0,
  2143. 'name' => '内置默认',
  2144. 'quality_weight' => 50,
  2145. 'price_weight' => 50,
  2146. 'lead_weight' => 0,
  2147. 'lead_enabled' => 0,
  2148. ];
  2149. }
  2150. /**
  2151. * 规范年月;空则取当前月
  2152. */
  2153. public static function normalizeYm(?string $ym): string
  2154. {
  2155. $ym = trim((string)$ym);
  2156. if (preg_match('/^\d{4}-\d{2}$/', $ym)) {
  2157. return $ym;
  2158. }
  2159. return date('Y-m');
  2160. }
  2161. /**
  2162. * 上一自然月 YYYY-MM
  2163. */
  2164. public static function previousYm(string $ym): string
  2165. {
  2166. $ym = self::normalizeYm($ym);
  2167. $ts = strtotime($ym . '-01');
  2168. if ($ts === false) {
  2169. $ts = time();
  2170. }
  2171. return date('Y-m', strtotime('-1 month', $ts));
  2172. }
  2173. /**
  2174. * 从月度评审表取商务技术分:当月按上月起往前查,都没有则默认 50
  2175. *
  2176. * @param array<int, string> $companyNames
  2177. * @return array<string, float> company_name => quality_score
  2178. */
  2179. public static function loadReviewQualityScoreMap(array $companyNames, ?string $asOfYm = null): array
  2180. {
  2181. $asOfYm = self::normalizeYm($asOfYm);
  2182. $default = 50.0;
  2183. $map = [];
  2184. $names = [];
  2185. foreach ($companyNames as $n) {
  2186. $n = trim((string)$n);
  2187. if ($n !== '') {
  2188. $names[$n] = true;
  2189. $map[$n] = $default;
  2190. }
  2191. }
  2192. if ($names === []) {
  2193. return $map;
  2194. }
  2195. self::ensureFinalScoreTable();
  2196. try {
  2197. // 当月按上个月起查:ym < asOfYm,且商务技术分已填写
  2198. $rows = Db::table(self::TABLE_FINAL)
  2199. ->where('company_name', 'in', array_keys($names))
  2200. ->where('ym', '<', $asOfYm)
  2201. ->whereNotNull('quality_score')
  2202. ->field('company_name,ym,quality_score')
  2203. ->order('ym', 'desc')
  2204. ->select();
  2205. } catch (\Throwable $e) {
  2206. return $map;
  2207. }
  2208. if (!is_array($rows)) {
  2209. return $map;
  2210. }
  2211. $seen = [];
  2212. foreach ($rows as $r) {
  2213. if (!is_array($r)) {
  2214. continue;
  2215. }
  2216. $cn = trim((string)($r['company_name'] ?? ''));
  2217. if ($cn === '' || isset($seen[$cn])) {
  2218. continue;
  2219. }
  2220. $score = self::readOptionalScoreValue($r['quality_score'] ?? null);
  2221. if ($score === null) {
  2222. continue;
  2223. }
  2224. $seen[$cn] = true;
  2225. $map[$cn] = round((float)$score, 2);
  2226. }
  2227. return $map;
  2228. }
  2229. /**
  2230. * 按公司名批量取供应商 id(兼容旧调用保留评分字段结构)
  2231. *
  2232. * @param array<int, string> $companyNames
  2233. * @return array<string, array{id:int,score:float,monthly_score:?float,quality_avg:float}>
  2234. */
  2235. public static function loadCustomerScoreMap(array $companyNames): array
  2236. {
  2237. $map = [];
  2238. $names = [];
  2239. foreach ($companyNames as $n) {
  2240. $n = trim((string)$n);
  2241. if ($n !== '') {
  2242. $names[$n] = true;
  2243. }
  2244. }
  2245. if ($names === []) {
  2246. return $map;
  2247. }
  2248. try {
  2249. $rows = Db::table('customer')
  2250. ->where('company_name', 'in', array_keys($names))
  2251. ->field('id,company_name')
  2252. ->select();
  2253. } catch (\Throwable $e) {
  2254. return $map;
  2255. }
  2256. if (!is_array($rows)) {
  2257. return $map;
  2258. }
  2259. foreach ($rows as $r) {
  2260. if (!is_array($r)) {
  2261. continue;
  2262. }
  2263. $cn = trim((string)($r['company_name'] ?? ''));
  2264. if ($cn === '') {
  2265. continue;
  2266. }
  2267. $map[$cn] = [
  2268. 'id' => (int)($r['id'] ?? 0),
  2269. 'score' => 0.0,
  2270. 'monthly_score' => null,
  2271. 'quality_avg' => 50.0,
  2272. ];
  2273. }
  2274. return $map;
  2275. }
  2276. /**
  2277. * 从报价组计算本单各供应商得分(不写库)
  2278. *
  2279. * @param array<int, array<string, mixed>> $quoteGroups loadAuditSupplierQuoteGroups 结构(name/lines[].amount)
  2280. * @param string|null $asOfYm 订单所属月;商务/技术分从该月的上月起往前查评审表
  2281. * @return array<string, array{
  2282. * company_name:string,customer_id:int,quality_score:float,price_sum:float,
  2283. * price_score:float,score:float,rank_no:int,score_text:string,rank_text:string
  2284. * }>
  2285. */
  2286. public static function calculateForQuoteGroups(array $quoteGroups, ?array $rule = null, ?string $asOfYm = null): array
  2287. {
  2288. $rule = $rule ?: self::getDefaultRule();
  2289. $qw = max(0, (float)($rule['quality_weight'] ?? 50));
  2290. $pw = max(0, (float)($rule['price_weight'] ?? 50));
  2291. $lw = max(0, (float)($rule['lead_weight'] ?? 0));
  2292. // 填 0 的权重不参与计算
  2293. $leadEnabled = $lw > 0 ? 1 : 0;
  2294. $wSum = $qw + $pw + $lw;
  2295. if ($wSum <= 0) {
  2296. $qw = 50;
  2297. $pw = 50;
  2298. $lw = 0;
  2299. $leadEnabled = 0;
  2300. $wSum = 100;
  2301. }
  2302. $names = [];
  2303. foreach ($quoteGroups as $g) {
  2304. if (!is_array($g)) {
  2305. continue;
  2306. }
  2307. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  2308. if ($cn !== '') {
  2309. $names[] = $cn;
  2310. }
  2311. }
  2312. $custMap = self::loadCustomerScoreMap($names);
  2313. $reviewMap = self::loadReviewQualityScoreMap($names, $asOfYm);
  2314. $items = [];
  2315. $priceSums = [];
  2316. $leadSums = [];
  2317. foreach ($quoteGroups as $g) {
  2318. if (!is_array($g)) {
  2319. continue;
  2320. }
  2321. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  2322. if ($cn === '') {
  2323. continue;
  2324. }
  2325. $priceSum = 0.0;
  2326. $hasPrice = false;
  2327. $leadSum = 0;
  2328. $hasLead = false;
  2329. // 确认页用 lines,审批页用 pick_lines
  2330. $lineRows = $g['lines'] ?? $g['pick_lines'] ?? [];
  2331. if (!is_array($lineRows)) {
  2332. $lineRows = [];
  2333. }
  2334. foreach ($lineRows as $ln) {
  2335. if (!is_array($ln)) {
  2336. continue;
  2337. }
  2338. if (empty($ln['amount_quote_pending'])) {
  2339. $am = self::resolveQuoteLineAmountRaw($ln);
  2340. if ($am !== '' && $am !== '0' && $am !== '0.00' && is_numeric($am)) {
  2341. $priceSum += (float)$am;
  2342. $hasPrice = true;
  2343. }
  2344. }
  2345. if (empty($ln['lead_days_quote_pending'])) {
  2346. $ldRaw = $ln['lead_days'] ?? null;
  2347. if ($ldRaw !== null && $ldRaw !== '' && is_numeric($ldRaw)) {
  2348. $ld = (int)$ldRaw;
  2349. if ($ld > 0) {
  2350. $leadSum += $ld;
  2351. $hasLead = true;
  2352. }
  2353. }
  2354. }
  2355. }
  2356. // 商务/技术得分:月度评审「商务技术分」,当月按上月起往前,默认 50
  2357. $quality = (float)($reviewMap[$cn] ?? 50);
  2358. $psum = $hasPrice ? round($priceSum, 2) : 0;
  2359. $lsum = $hasLead ? (int)$leadSum : 0;
  2360. $items[$cn] = [
  2361. 'company_name' => $cn,
  2362. 'customer_id' => (int)($custMap[$cn]['id'] ?? 0),
  2363. 'quality_score' => $quality,
  2364. 'quality_score_text' => self::formatScore($quality),
  2365. 'quality_weight' => (int)$qw,
  2366. 'price_sum' => $psum,
  2367. 'price_sum_text' => self::formatScore($psum),
  2368. 'price_weight' => (int)$pw,
  2369. 'has_price' => $hasPrice,
  2370. 'price_score' => 0.0,
  2371. 'price_score_text' => '0',
  2372. 'lead_days_sum' => $lsum,
  2373. 'lead_days_sum_text' => (string)$lsum,
  2374. 'lead_weight' => (int)$lw,
  2375. 'lead_enabled' => $leadEnabled,
  2376. 'has_lead' => $hasLead,
  2377. 'lead_score' => 0.0,
  2378. 'lead_score_text' => '0',
  2379. 'score' => 0.0,
  2380. 'rank_no' => 0,
  2381. 'score_text' => '',
  2382. 'rank_text' => '',
  2383. ];
  2384. if ($hasPrice && $priceSum > 0) {
  2385. $priceSums[$cn] = $priceSum;
  2386. }
  2387. if ($hasLead && $leadSum > 0) {
  2388. $leadSums[$cn] = $leadSum;
  2389. }
  2390. }
  2391. $minSum = null;
  2392. foreach ($priceSums as $ps) {
  2393. if ($minSum === null || $ps < $minSum) {
  2394. $minSum = $ps;
  2395. }
  2396. }
  2397. $minLead = null;
  2398. foreach ($leadSums as $ls) {
  2399. if ($minLead === null || $ls < $minLead) {
  2400. $minLead = $ls;
  2401. }
  2402. }
  2403. foreach ($items as $cn => &$it) {
  2404. // 单价未填写:价格得分与总分均为 0
  2405. if ($pw > 0 && (empty($it['has_price']) || (float)($it['price_sum'] ?? 0) <= 0)) {
  2406. $it['price_score'] = 0.0;
  2407. $it['price_score_text'] = '0';
  2408. $it['lead_score'] = 0.0;
  2409. $it['lead_score_text'] = self::formatScore(0);
  2410. $it['score'] = 0.0;
  2411. $it['score_text'] = '0';
  2412. continue;
  2413. }
  2414. // 价格得分 = (最低单价合计 / 本供应商单价合计) × 价格权重% × 100
  2415. if ($pw > 0 && !empty($it['has_price']) && $it['price_sum'] > 0 && $minSum !== null && $minSum > 0) {
  2416. $ratio = round($minSum / (float)$it['price_sum'], 4);
  2417. $it['price_score'] = round($ratio * ($pw / 100) * 100, 2);
  2418. } else {
  2419. $it['price_score'] = 0.0;
  2420. }
  2421. $it['price_score_text'] = self::formatScore($it['price_score']);
  2422. if ($leadEnabled && !empty($it['has_lead']) && $it['lead_days_sum'] > 0 && $minLead !== null && $minLead > 0) {
  2423. $leadRatio = round($minLead / (float)$it['lead_days_sum'], 4);
  2424. $it['lead_score'] = round($leadRatio * 100, 2);
  2425. } else {
  2426. $it['lead_score'] = 0.0;
  2427. }
  2428. $it['lead_score_text'] = self::formatScore($it['lead_score']);
  2429. // 总分 = 商务/技术得分×质量% + 价格得分(已含价格权重) [+ 交货得分×交货%]
  2430. $total = 0.0;
  2431. if ($qw > 0) {
  2432. $total += $it['quality_score'] * ($qw / 100);
  2433. }
  2434. if ($pw > 0) {
  2435. $total += (float)$it['price_score'];
  2436. }
  2437. if ($lw > 0) {
  2438. $total += $it['lead_score'] * ($lw / 100);
  2439. }
  2440. $it['score'] = round($total, 2);
  2441. $it['score_text'] = self::formatScore($it['score']);
  2442. }
  2443. unset($it);
  2444. $sorted = $items;
  2445. uasort($sorted, function ($a, $b) {
  2446. $sa = (float)($a['score'] ?? 0);
  2447. $sb = (float)($b['score'] ?? 0);
  2448. if ($sa !== $sb) {
  2449. return $sb <=> $sa;
  2450. }
  2451. return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
  2452. });
  2453. $rank = 0;
  2454. foreach ($sorted as $cn => $row) {
  2455. $rank++;
  2456. $items[$cn]['rank_no'] = $rank;
  2457. $items[$cn]['rank_text'] = (string)$rank;
  2458. }
  2459. return $items;
  2460. }
  2461. public static function formatScore($n): string
  2462. {
  2463. $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
  2464. return $s === '' ? '0' : $s;
  2465. }
  2466. /**
  2467. * 报价行单价原文(兼容确认页 amount、审批页 unit_price_text/amount_text)
  2468. *
  2469. * @param array<string, mixed> $ln
  2470. */
  2471. protected static function resolveQuoteLineAmountRaw(array $ln): string
  2472. {
  2473. foreach (['amount', 'unit_price_text', 'amount_text'] as $k) {
  2474. if (!array_key_exists($k, $ln) || $ln[$k] === null) {
  2475. continue;
  2476. }
  2477. $am = trim((string)$ln[$k]);
  2478. if ($am === '' || $am === '未填写' || $am === '未开标验证' || $am === '开标验证后查看') {
  2479. continue;
  2480. }
  2481. // 去掉千分位等
  2482. $am = str_replace([',', ',', ' '], '', $am);
  2483. if ($am !== '' && is_numeric($am)) {
  2484. return $am;
  2485. }
  2486. }
  2487. return '';
  2488. }
  2489. /**
  2490. * 总分简要文案(仅总分数字;分项见独立字段)
  2491. *
  2492. * @param array<string, mixed> $hit calculate/loadSaved 单项
  2493. */
  2494. public static function formatScoreDetailText(array $hit): string
  2495. {
  2496. return self::formatScore($hit['score'] ?? 0);
  2497. }
  2498. /**
  2499. * 分项得分展示文案(确认/详情表格用)
  2500. *
  2501. * @param array<string, mixed> $hit
  2502. */
  2503. public static function formatScorePartsHtml(array $hit): string
  2504. {
  2505. $qw = (float)($hit['quality_weight'] ?? 0);
  2506. $pw = (float)($hit['price_weight'] ?? 0);
  2507. $lw = (float)($hit['lead_weight'] ?? 0);
  2508. $lines = [];
  2509. if ($qw > 0) {
  2510. $lines[] = '质量得分 ' . self::formatScore($hit['quality_score'] ?? 0);
  2511. }
  2512. if ($pw > 0) {
  2513. $lines[] = '价格得分 ' . self::formatScore($hit['price_score'] ?? 0);
  2514. }
  2515. if ($lw > 0) {
  2516. $lines[] = '交货得分 ' . self::formatScore($hit['lead_score'] ?? 0);
  2517. }
  2518. $lines[] = '总分 ' . self::formatScore($hit['score'] ?? 0);
  2519. return implode("\n", $lines);
  2520. }
  2521. /**
  2522. * 评分规则文案(用于确认页 / 详情展示)
  2523. * 百分比取当前默认规则;权重大于 0 的项才写入公式
  2524. *
  2525. * @param array{quality_weight?:float|int|string,price_weight?:float|int|string,lead_weight?:float|int|string}|null $rule
  2526. */
  2527. public static function formatRuleFormulaText(?array $rule = null): string
  2528. {
  2529. $rule = $rule ?: self::getDefaultRule();
  2530. $qw = (float)($rule['quality_weight'] ?? 50);
  2531. $pw = (float)($rule['price_weight'] ?? 50);
  2532. $lw = (float)($rule['lead_weight'] ?? 0);
  2533. $parts = [];
  2534. if ($qw > 0) {
  2535. $parts[] = '(质量得分×' . self::formatWeightPercent($qw) . '%)';
  2536. }
  2537. if ($pw > 0) {
  2538. $parts[] = '(价格得分)';
  2539. }
  2540. if ($lw > 0) {
  2541. $parts[] = '(交货得分×' . self::formatWeightPercent($lw) . '%)';
  2542. }
  2543. $formula = $parts === []
  2544. ? '(总分)=(质量得分×50%)+(价格得分)'
  2545. : '(总分)=' . implode('+', $parts);
  2546. $formula .= ';质量得分取月度评审「质量得分」(当月按上月起往前查,无则默认50)';
  2547. if ($pw > 0) {
  2548. $formula .= ';价格得分=(最低单价合计/本供应商单价合计)×'
  2549. . self::formatWeightPercent($pw) . '%×100';
  2550. }
  2551. return $formula;
  2552. }
  2553. /**
  2554. * 报价组是否展示交货期留痕列(已落库纳入,或尚无落库且当前默认规则交货分>0)
  2555. *
  2556. * @param array<int, array<string, mixed>> $quoteGroups
  2557. */
  2558. public static function detectShowLeadScore(array $quoteGroups = [], ?array $rule = null): bool
  2559. {
  2560. $hasSavedScore = false;
  2561. foreach ($quoteGroups as $g) {
  2562. if (!is_array($g)) {
  2563. continue;
  2564. }
  2565. if (!empty($g['show_service_lead_score'])) {
  2566. return true;
  2567. }
  2568. if (!empty($g['show_service_score'])) {
  2569. $hasSavedScore = true;
  2570. }
  2571. }
  2572. // 历史单已按旧规则落库且交货期权重为 0:不显示这两列
  2573. if ($hasSavedScore) {
  2574. return false;
  2575. }
  2576. $rule = $rule ?: self::getDefaultRule();
  2577. return (float)($rule['lead_weight'] ?? 0) > 0;
  2578. }
  2579. /**
  2580. * 评分规则提示:优先用传入规则;无则用默认规则
  2581. *
  2582. * @param array<int, array<string, mixed>> $quoteGroups 保留参数兼容调用方
  2583. * @param array{quality_weight?:float|int|string,price_weight?:float|int|string,lead_weight?:float|int|string}|null $rule
  2584. */
  2585. public static function formatRuleFormulaTextForGroups(array $quoteGroups = [], ?array $rule = null): string
  2586. {
  2587. return self::formatRuleFormulaText($rule);
  2588. }
  2589. protected static function formatWeightPercent($n): string
  2590. {
  2591. $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
  2592. return $s === '' ? '0' : $s;
  2593. }
  2594. /**
  2595. * 开标验证通过后:计算并写入供应商服务评分表
  2596. *
  2597. * @param array<int, array<string, mixed>> $quoteGroups
  2598. */
  2599. public static function saveForOrder(string $ccydh, array $quoteGroups, ?string $ym = null): void
  2600. {
  2601. $ccydh = trim($ccydh);
  2602. if ($ccydh === '') {
  2603. return;
  2604. }
  2605. self::ensureSchema();
  2606. $rule = self::resolveRuleForCcydh($ccydh);
  2607. if ($ym === null || $ym === '') {
  2608. $ym = date('Y-m');
  2609. }
  2610. $ym = self::normalizeYm($ym);
  2611. $calc = self::calculateForQuoteGroups($quoteGroups, $rule, $ym);
  2612. if ($calc === []) {
  2613. return;
  2614. }
  2615. $now = date('Y-m-d H:i:s');
  2616. try {
  2617. Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->delete();
  2618. $rows = [];
  2619. foreach ($calc as $it) {
  2620. $rows[] = [
  2621. 'customer_id' => (int)($it['customer_id'] ?? 0),
  2622. 'company_name' => (string)($it['company_name'] ?? ''),
  2623. 'ym' => $ym,
  2624. 'ccydh' => $ccydh,
  2625. 'score' => (float)($it['score'] ?? 0),
  2626. 'rank_no' => (int)($it['rank_no'] ?? 0),
  2627. 'quality_score' => (int)round((float)($it['quality_score'] ?? 0)),
  2628. 'quality_weight' => (int)($rule['quality_weight'] ?? 50),
  2629. 'price_sum' => round((float)($it['price_sum'] ?? 0), 4),
  2630. 'price_weight' => (int)($rule['price_weight'] ?? 50),
  2631. 'price_score' => (float)($it['price_score'] ?? 0),
  2632. 'lead_days_sum' => (int)round((float)($it['lead_days_sum'] ?? 0)),
  2633. 'lead_weight' => (int)($rule['lead_weight'] ?? 0),
  2634. 'lead_enabled' => ((int)($rule['lead_weight'] ?? 0) > 0) ? 1 : 0,
  2635. 'lead_score' => (float)($it['lead_score'] ?? 0),
  2636. 'rule_id' => (int)($rule['id'] ?? 0),
  2637. 'createtime' => $now,
  2638. 'updatetime' => $now,
  2639. ];
  2640. }
  2641. if ($rows !== []) {
  2642. Db::table(self::TABLE_SCORE)->insertAll($rows);
  2643. }
  2644. // 开标后:本月询价供应商名称写入月度评审表(不存在才加空行,已有记录不覆盖)
  2645. $monthNames = [];
  2646. foreach ($quoteGroups as $g) {
  2647. if (!is_array($g)) {
  2648. continue;
  2649. }
  2650. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  2651. if ($cn !== '') {
  2652. $monthNames[] = $cn;
  2653. }
  2654. }
  2655. foreach ($rows as $row) {
  2656. $cn = trim((string)($row['company_name'] ?? ''));
  2657. if ($cn !== '') {
  2658. $monthNames[] = $cn;
  2659. }
  2660. }
  2661. self::ensureMonthlySupplierNames($ym, $monthNames);
  2662. } catch (\Throwable $e) {
  2663. Log::write('supplier service score save: ' . $e->getMessage(), 'error');
  2664. }
  2665. }
  2666. /**
  2667. * 读取某订单已落库评分;无则返回空
  2668. *
  2669. * @return array<string, array<string, mixed>>
  2670. */
  2671. public static function loadSavedByCcydh(string $ccydh): array
  2672. {
  2673. $ccydh = trim($ccydh);
  2674. if ($ccydh === '') {
  2675. return [];
  2676. }
  2677. try {
  2678. $rows = Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->select();
  2679. } catch (\Throwable $e) {
  2680. try {
  2681. self::ensureSchema();
  2682. $rows = Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->select();
  2683. } catch (\Throwable $e2) {
  2684. return [];
  2685. }
  2686. }
  2687. if (!is_array($rows)) {
  2688. return [];
  2689. }
  2690. $out = [];
  2691. foreach ($rows as $r) {
  2692. if (!is_array($r)) {
  2693. continue;
  2694. }
  2695. $cn = trim((string)($r['company_name'] ?? ''));
  2696. if ($cn === '') {
  2697. continue;
  2698. }
  2699. $qualityScore = (int)round((float)($r['quality_score'] ?? 0));
  2700. $priceSum = round((float)($r['price_sum'] ?? 0), 4);
  2701. $qw = (int)($r['quality_weight'] ?? 50);
  2702. $pw = (int)($r['price_weight'] ?? 50);
  2703. $lw = (int)($r['lead_weight'] ?? 0);
  2704. $leadEnabled = $lw > 0 ? 1 : 0;
  2705. $leadSum = (int)round((float)($r['lead_days_sum'] ?? 0));
  2706. $leadScore = (float)($r['lead_score'] ?? 0);
  2707. $out[$cn] = [
  2708. 'company_name' => $cn,
  2709. 'customer_id' => (int)($r['customer_id'] ?? 0),
  2710. 'quality_score' => $qualityScore,
  2711. 'quality_score_text' => (string)$qualityScore,
  2712. 'quality_weight' => $qw,
  2713. 'price_sum' => $priceSum,
  2714. 'price_sum_text' => self::formatScore($priceSum),
  2715. 'price_weight' => $pw,
  2716. 'price_score' => (float)($r['price_score'] ?? 0),
  2717. 'price_score_text' => self::formatScore($r['price_score'] ?? 0),
  2718. 'lead_days_sum' => $leadSum,
  2719. 'lead_days_sum_text' => $leadEnabled ? (string)$leadSum : '',
  2720. 'lead_weight' => $lw,
  2721. 'lead_enabled' => $leadEnabled,
  2722. 'lead_score' => $leadScore,
  2723. 'lead_score_text' => $leadEnabled ? self::formatScore($leadScore) : '',
  2724. 'score' => (float)($r['score'] ?? 0),
  2725. 'rank_no' => (int)($r['rank_no'] ?? 0),
  2726. 'score_text' => self::formatScore($r['score'] ?? 0),
  2727. 'rank_text' => ((int)($r['rank_no'] ?? 0)) > 0 ? (string)(int)($r['rank_no'] ?? 0) : '',
  2728. '_row_id' => (int)($r['id'] ?? 0),
  2729. ];
  2730. }
  2731. // 按单价合计重算价格得分/总分(兼容历史把权重算进价格得分的数据)
  2732. $minSum = null;
  2733. foreach ($out as $it) {
  2734. $ps = (float)($it['price_sum'] ?? 0);
  2735. if ($ps > 0 && ($minSum === null || $ps < $minSum)) {
  2736. $minSum = $ps;
  2737. }
  2738. }
  2739. foreach ($out as $cn => &$it) {
  2740. $qw = (int)($it['quality_weight'] ?? 50);
  2741. $pw = (int)($it['price_weight'] ?? 50);
  2742. $lw = (int)($it['lead_weight'] ?? 0);
  2743. $priceSum = (float)($it['price_sum'] ?? 0);
  2744. // 单价未填写:总分为 0
  2745. if ($pw > 0 && $priceSum <= 0) {
  2746. $it['price_score'] = 0.0;
  2747. $it['price_score_text'] = '0';
  2748. $it['score'] = 0.0;
  2749. $it['score_text'] = '0';
  2750. continue;
  2751. }
  2752. if ($pw > 0 && $priceSum > 0 && $minSum !== null && $minSum > 0) {
  2753. $ratio = round($minSum / $priceSum, 4);
  2754. $it['price_score'] = round($ratio * ($pw / 100) * 100, 2);
  2755. } else {
  2756. $it['price_score'] = 0.0;
  2757. }
  2758. $it['price_score_text'] = self::formatScore($it['price_score']);
  2759. $total = 0.0;
  2760. if ($qw > 0) {
  2761. $total += (float)$it['quality_score'] * ($qw / 100);
  2762. }
  2763. if ($pw > 0) {
  2764. $total += (float)$it['price_score'];
  2765. }
  2766. if ($lw > 0) {
  2767. $total += (float)$it['lead_score'] * ($lw / 100);
  2768. }
  2769. $it['score'] = round($total, 2);
  2770. $it['score_text'] = self::formatScore($it['score']);
  2771. }
  2772. unset($it);
  2773. $sorted = $out;
  2774. uasort($sorted, static function ($a, $b) {
  2775. $sa = (float)($a['score'] ?? 0);
  2776. $sb = (float)($b['score'] ?? 0);
  2777. if ($sa !== $sb) {
  2778. return $sb <=> $sa;
  2779. }
  2780. return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
  2781. });
  2782. $rank = 0;
  2783. foreach ($sorted as $cn => $row) {
  2784. $rank++;
  2785. $oldRank = (int)($out[$cn]['rank_no'] ?? 0);
  2786. $out[$cn]['rank_no'] = $rank;
  2787. $out[$cn]['rank_text'] = (string)$rank;
  2788. $rowId = (int)($out[$cn]['_row_id'] ?? 0);
  2789. if ($rowId > 0) {
  2790. $upd = [];
  2791. if ($oldRank !== $rank) {
  2792. $upd['rank_no'] = $rank;
  2793. }
  2794. foreach ($rows as $r) {
  2795. if (!is_array($r) || (int)($r['id'] ?? 0) !== $rowId) {
  2796. continue;
  2797. }
  2798. if (abs((float)($r['price_score'] ?? 0) - (float)$out[$cn]['price_score']) > 0.001) {
  2799. $upd['price_score'] = $out[$cn]['price_score'];
  2800. }
  2801. if (abs((float)($r['score'] ?? 0) - (float)$out[$cn]['score']) > 0.001) {
  2802. $upd['score'] = $out[$cn]['score'];
  2803. }
  2804. break;
  2805. }
  2806. if ($upd !== []) {
  2807. try {
  2808. Db::table(self::TABLE_SCORE)->where('id', $rowId)->update($upd);
  2809. } catch (\Throwable $e) {
  2810. }
  2811. }
  2812. }
  2813. unset($out[$cn]['_row_id']);
  2814. }
  2815. return $out;
  2816. }
  2817. /**
  2818. * 清空报价组上的评分留痕字段
  2819. *
  2820. * @param array<string, mixed> $g
  2821. */
  2822. protected static function clearScoreTrailOnGroup(array &$g, int $showFlag = 0): void
  2823. {
  2824. $g['service_rank'] = '';
  2825. $g['service_score'] = '';
  2826. $g['service_rank_text'] = '';
  2827. $g['service_score_text'] = '';
  2828. $g['service_score_parts_text'] = '';
  2829. $g['service_quality_score'] = '';
  2830. $g['service_quality_score_text'] = '';
  2831. $g['service_price_sum'] = '';
  2832. $g['service_price_sum_text'] = '';
  2833. $g['service_price_score'] = '';
  2834. $g['service_price_score_text'] = '';
  2835. $g['service_lead_days_sum'] = '';
  2836. $g['service_lead_days_sum_text'] = '';
  2837. $g['service_lead_score'] = '';
  2838. $g['service_lead_score_text'] = '';
  2839. $g['service_lead_enabled'] = 0;
  2840. $g['service_quality_weight'] = '';
  2841. $g['service_price_weight'] = '';
  2842. $g['service_lead_weight'] = '';
  2843. $g['show_service_score'] = $showFlag;
  2844. $g['show_service_lead_score'] = 0;
  2845. }
  2846. /**
  2847. * 把排名/评分挂到报价组上(开标后可见)
  2848. * 详情等读路径:优先读已落库评分,避免每次打开重算写库拖慢弹窗
  2849. *
  2850. * @param array<int, array<string, mixed>> $quoteGroups
  2851. * @return array<int, array<string, mixed>>
  2852. */
  2853. public static function attachToQuoteGroups(array $quoteGroups, string $ccydh, bool $quoteVisible): array
  2854. {
  2855. if (!$quoteVisible || $quoteGroups === []) {
  2856. foreach ($quoteGroups as &$g) {
  2857. if (!is_array($g)) {
  2858. continue;
  2859. }
  2860. self::clearScoreTrailOnGroup($g, 0);
  2861. }
  2862. unset($g);
  2863. return $quoteGroups;
  2864. }
  2865. $saved = self::loadSavedByCcydh($ccydh);
  2866. $needCalc = ($saved === []);
  2867. if (!$needCalc) {
  2868. $anyHit = false;
  2869. foreach ($quoteGroups as $g) {
  2870. if (!is_array($g)) {
  2871. continue;
  2872. }
  2873. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  2874. if ($cn !== '' && isset($saved[$cn])) {
  2875. $anyHit = true;
  2876. break;
  2877. }
  2878. }
  2879. $needCalc = !$anyHit;
  2880. }
  2881. // 无落库记录时仅内存计算用于展示;落库由开标验证 saveForOrder 负责
  2882. if ($needCalc) {
  2883. $saved = self::calculateForQuoteGroups($quoteGroups, null, date('Y-m'));
  2884. }
  2885. foreach ($quoteGroups as &$g) {
  2886. if (!is_array($g)) {
  2887. continue;
  2888. }
  2889. $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
  2890. $hit = ($cn !== '' && isset($saved[$cn]) && is_array($saved[$cn])) ? $saved[$cn] : null;
  2891. if (is_array($hit)) {
  2892. $g['service_rank'] = (int)($hit['rank_no'] ?? 0);
  2893. $g['service_score'] = (float)($hit['score'] ?? 0);
  2894. $g['service_rank_text'] = (string)($hit['rank_text'] ?? '');
  2895. $g['service_score_text'] = self::formatScoreDetailText($hit);
  2896. $g['service_score_parts_text'] = self::formatScorePartsHtml($hit);
  2897. $qs = round((float)($hit['quality_score'] ?? 0), 2);
  2898. $ps = (float)($hit['price_score'] ?? 0);
  2899. $psum = round((float)($hit['price_sum'] ?? 0), 2);
  2900. $leadEnabled = ((int)($hit['lead_weight'] ?? 0) > 0) ? 1 : 0;
  2901. $ls = (float)($hit['lead_score'] ?? 0);
  2902. $lsum = (int)round((float)($hit['lead_days_sum'] ?? 0));
  2903. $g['service_quality_score'] = $qs;
  2904. $g['service_quality_score_text'] = (string)($hit['quality_score_text'] ?? self::formatScore($qs));
  2905. $g['service_price_sum'] = $psum;
  2906. $g['service_price_sum_text'] = (string)($hit['price_sum_text'] ?? $psum);
  2907. $g['service_price_score'] = $ps;
  2908. $g['service_price_score_text'] = (string)($hit['price_score_text'] ?? self::formatScore($ps));
  2909. $g['service_lead_enabled'] = $leadEnabled;
  2910. $g['show_service_lead_score'] = $leadEnabled;
  2911. $g['service_lead_days_sum'] = $lsum;
  2912. $g['service_lead_days_sum_text'] = $leadEnabled ? (string)($hit['lead_days_sum_text'] ?? $lsum) : '';
  2913. $g['service_lead_score'] = $ls;
  2914. $g['service_lead_score_text'] = $leadEnabled ? (string)($hit['lead_score_text'] ?? self::formatScore($ls)) : '';
  2915. $g['service_quality_weight'] = (int)($hit['quality_weight'] ?? 50);
  2916. $g['service_price_weight'] = (int)($hit['price_weight'] ?? 50);
  2917. $g['service_lead_weight'] = (int)($hit['lead_weight'] ?? 0);
  2918. $g['show_service_score'] = 1;
  2919. } else {
  2920. self::clearScoreTrailOnGroup($g, 1);
  2921. }
  2922. }
  2923. unset($g);
  2924. // 开标后按排名升序(第1名在最前);无排名的放最后
  2925. usort($quoteGroups, function ($a, $b) {
  2926. $ra = is_array($a) ? (int)($a['service_rank'] ?? 0) : 0;
  2927. $rb = is_array($b) ? (int)($b['service_rank'] ?? 0) : 0;
  2928. $ha = $ra > 0;
  2929. $hb = $rb > 0;
  2930. if ($ha !== $hb) {
  2931. return $ha ? -1 : 1;
  2932. }
  2933. if ($ha && $ra !== $rb) {
  2934. return $ra <=> $rb;
  2935. }
  2936. $sa = is_array($a) ? (float)($a['service_score'] ?? 0) : 0.0;
  2937. $sb = is_array($b) ? (float)($b['service_score'] ?? 0) : 0.0;
  2938. if ($sa !== $sb) {
  2939. return $sb <=> $sa;
  2940. }
  2941. $na = is_array($a) ? trim((string)($a['name'] ?? $a['company_name'] ?? '')) : '';
  2942. $nb = is_array($b) ? trim((string)($b['name'] ?? $b['company_name'] ?? '')) : '';
  2943. return strcmp($na, $nb);
  2944. });
  2945. return $quoteGroups;
  2946. }
  2947. }