ProcuremenSupplierScore.php 136 KB

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