ProcuremenSupplierScore.php 122 KB

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