ProcuremenSupplierScore.php 118 KB

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