| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489 |
- <?php
- namespace app\common\library;
- use think\Db;
- use think\Log;
- /**
- * 协助采购 — 供应商服务评分
- *
- * 表分工:
- * - supplier_service_score:订单明细(开标后按 ccydh×供应商写入,含本单总分 score)
- * - supplier_service_final_score:月度评审表(开标后写入本月询价供应商名称;分项与等级人工填写,已存在不覆盖)
- *
- * 总分 = 质量得分×质量% + 价格得分 [+ 交货得分×交货%]
- * 价格得分 = (本单最低单价合计 / 本供应商单价合计) × 价格权重% × 100
- * (计算过程保留四位小数,得分保留两位)
- * 质量得分 = 月度评审表「质量得分」(当月按上月起往前查,都无则默认 50)
- * 权重优先取 purchase_order 下发时快照(score_*_weight);无快照再按 score_rule_id 读规则表;再否则 is_default=1
- * 开标验证通过后写入订单明细评分,并补齐月度评审供应商名称
- */
- class ProcuremenSupplierScore
- {
- public const TABLE_RULE = 'supplier_score_rule';
- /** 订单明细评分表 */
- public const TABLE_SCORE = 'supplier_service_score';
- /** 月度汇总表(总分 + 最终得分) */
- public const TABLE_FINAL = 'supplier_service_final_score';
- /** @var bool|null */
- protected static $schemaReady = null;
- /** 结构探测缓存键(升版本可强制再跑一遍迁移) */
- const SCHEMA_CACHE_KEY = 'procuremen_supplier_score_schema_ok_v6';
- public static function ensureSchema(): void
- {
- if (self::$schemaReady === true) {
- return;
- }
- // 跨请求缓存:命中后直接返回,禁止再跑 SHOW COLUMNS / ALTER / 规则种子
- try {
- if (\think\Cache::get(self::SCHEMA_CACHE_KEY)) {
- self::$schemaReady = true;
- return;
- }
- try {
- \think\Cache::rm('procuremen_supplier_score_schema_ok_v1');
- \think\Cache::rm('procuremen_supplier_score_schema_ok_v2');
- \think\Cache::rm('procuremen_supplier_score_schema_ok_v3');
- \think\Cache::rm('procuremen_supplier_score_schema_ok_v4');
- \think\Cache::rm('procuremen_supplier_score_schema_ok_v5');
- } catch (\Throwable $e) {
- }
- } catch (\Throwable $e) {
- }
- try {
- Db::query('SELECT 1 FROM `' . self::TABLE_RULE . '` LIMIT 1');
- Db::query('SELECT 1 FROM `' . self::TABLE_SCORE . '` LIMIT 1');
- self::runSchemaMigrations();
- self::$schemaReady = true;
- try {
- \think\Cache::set(self::SCHEMA_CACHE_KEY, 1, 86400);
- } catch (\Throwable $e) {
- }
- return;
- } catch (\Throwable $e) {
- }
- $sqlFile = APP_PATH . 'extra' . DIRECTORY_SEPARATOR . 'supplier_score_install.sql';
- if (is_file($sqlFile)) {
- $sql = file_get_contents($sqlFile);
- if (is_string($sql) && $sql !== '') {
- foreach (preg_split('/;\s*[\r\n]+/', $sql) as $stmt) {
- $stmt = trim($stmt);
- if ($stmt === '' || stripos($stmt, 'CREATE TABLE') === false) {
- continue;
- }
- try {
- Db::execute($stmt);
- } catch (\Throwable $ignore) {
- }
- }
- }
- }
- self::runSchemaMigrations();
- self::$schemaReady = true;
- try {
- \think\Cache::set(self::SCHEMA_CACHE_KEY, 1, 86400);
- } catch (\Throwable $e) {
- }
- }
- /** 首次(或缓存失效)时执行表结构迁移与默认规则种子 */
- protected static function runSchemaMigrations(): void
- {
- self::ensureRuleNameColumn();
- self::dropObsoleteRuleColumns();
- self::ensureDefaultRule();
- self::ensureOrderTypeRules();
- self::ensureScoreWeightColumns();
- self::ensureLeadColumns();
- self::ensureIntegerColumns();
- self::ensureFinalScoreTable();
- }
- /** 月度记录表(商务技术分 / 价格分 / 最终得分,仅本页展示与导出) */
- public static function ensureFinalScoreTable(): void
- {
- static $done = false;
- if ($done) {
- return;
- }
- try {
- if (\think\Cache::get('procuremen_supplier_final_score_table_ok_v1')) {
- $done = true;
- return;
- }
- } catch (\Throwable $e) {
- }
- $ddl = "CREATE TABLE IF NOT EXISTS `" . self::TABLE_FINAL . "` (
- `id` int unsigned NOT NULL AUTO_INCREMENT,
- `ym` char(7) NOT NULL DEFAULT '' COMMENT '年月 YYYY-MM',
- `company_name` varchar(128) NOT NULL DEFAULT '' COMMENT '供应商名称',
- `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '月度总分(兼容旧字段)',
- `quality_score` decimal(10,2) DEFAULT NULL COMMENT '商务技术分(人工,空=未填)',
- `price_score` decimal(10,2) DEFAULT NULL COMMENT '价格分(人工,空=未填)',
- `delivery_score` decimal(10,2) DEFAULT NULL COMMENT '交货分(人工,空=未填)',
- `value_added_score` decimal(10,2) DEFAULT NULL COMMENT '增值服务(人工,空=未填)',
- `final_score` decimal(10,2) DEFAULT NULL COMMENT '最终得分(人工,空=未填)',
- `score_grade` char(1) NOT NULL DEFAULT '' COMMENT '评分等级 A/B/C/D',
- `qp_manual` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=商务技术分/价格分/交货分已人工修改',
- `createtime` datetime DEFAULT NULL,
- `updatetime` datetime DEFAULT NULL,
- PRIMARY KEY (`id`),
- UNIQUE KEY `uk_ym_company` (`ym`,`company_name`),
- KEY `idx_ym` (`ym`)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商月度评审记录'";
- try {
- Db::execute($ddl);
- } catch (\Throwable $e) {
- Log::write('supplier monthly score table: ' . $e->getMessage(), 'error');
- }
- self::ensureMonthlyScoreColumns();
- $done = true;
- try {
- \think\Cache::set('procuremen_supplier_final_score_table_ok_v1', 1, 86400);
- } catch (\Throwable $e) {
- }
- }
- /** 历史月度表补商务技术分/价格分等字段 */
- protected static function ensureMonthlyScoreColumns(): void
- {
- $adds = [
- 'score' => "ADD COLUMN `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '月度总分(兼容旧字段)' AFTER `company_name`",
- 'quality_score' => "ADD COLUMN `quality_score` decimal(10,2) DEFAULT NULL COMMENT '商务技术分(人工,空=未填)' AFTER `score`",
- 'price_score' => "ADD COLUMN `price_score` decimal(10,2) DEFAULT NULL COMMENT '价格分(人工,空=未填)' AFTER `quality_score`",
- 'delivery_score' => "ADD COLUMN `delivery_score` decimal(10,2) DEFAULT NULL COMMENT '交货分(人工,空=未填)' AFTER `price_score`",
- 'value_added_score' => "ADD COLUMN `value_added_score` decimal(10,2) DEFAULT NULL COMMENT '增值服务(人工,空=未填)' AFTER `delivery_score`",
- 'score_grade' => "ADD COLUMN `score_grade` char(1) NOT NULL DEFAULT '' COMMENT '评分等级 A/B/C/D' AFTER `final_score`",
- 'qp_manual' => "ADD COLUMN `qp_manual` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=商务技术分/价格分/交货分/增值服务已人工修改' AFTER `score_grade`",
- ];
- foreach ($adds as $col => $ddl) {
- try {
- $cols = Db::query("SHOW COLUMNS FROM `" . self::TABLE_FINAL . "` LIKE '{$col}'");
- if (!is_array($cols) || $cols === []) {
- Db::execute("ALTER TABLE `" . self::TABLE_FINAL . "` {$ddl}");
- }
- } catch (\Throwable $e) {
- Log::write("supplier monthly {$col} column: " . $e->getMessage(), 'error');
- }
- }
- // 分数字段允许 NULL:空=未填,列表不显示 0
- foreach ([
- 'quality_score' => '商务技术分(人工,空=未填)',
- 'price_score' => '价格分(人工,空=未填)',
- 'delivery_score' => '交货分(人工,空=未填)',
- 'value_added_score' => '增值服务(人工,空=未填)',
- 'final_score' => '最终得分(人工,空=未填)',
- ] as $col => $comment) {
- try {
- $info = Db::query("SHOW COLUMNS FROM `" . self::TABLE_FINAL . "` LIKE '{$col}'");
- if (is_array($info) && $info !== []) {
- $null = strtoupper((string)($info[0]['Null'] ?? ''));
- if ($null !== 'YES') {
- Db::execute(
- "ALTER TABLE `" . self::TABLE_FINAL . "` MODIFY COLUMN `{$col}` decimal(10,2) DEFAULT NULL COMMENT '{$comment}'"
- );
- }
- }
- } catch (\Throwable $e) {
- Log::write("supplier monthly {$col} null: " . $e->getMessage(), 'error');
- }
- }
- }
- /** 规范化评分等级:空 / A / B / C / D */
- public static function normalizeScoreGrade($raw): string
- {
- $g = strtoupper(trim((string)$raw));
- if (in_array($g, ['A', 'B', 'C', 'D'], true)) {
- return $g;
- }
- return '';
- }
- /**
- * 综合评分标准:A≥90 B 70-89 C 60-69 D<60
- *
- * @param mixed $score
- */
- public static function gradeFromFinalScore($score): string
- {
- if ($score === null || $score === '') {
- return '';
- }
- if (!is_numeric($score)) {
- return '';
- }
- $n = (float)$score;
- if ($n >= 90) {
- return 'A';
- }
- if ($n >= 70) {
- return 'B';
- }
- if ($n >= 60) {
- return 'C';
- }
- return 'D';
- }
- /**
- * 最终得分 = 质量 + 价格 + 交货 + 增值服务(空项按 0;四项全空则 null)
- *
- * @param mixed $quality
- * @param mixed $price
- * @param mixed $delivery
- * @param mixed $valueAdded
- */
- public static function sumFinalScore($quality, $price, $delivery, $valueAdded): ?float
- {
- $parts = [$quality, $price, $delivery, $valueAdded];
- $any = false;
- $sum = 0.0;
- foreach ($parts as $p) {
- if ($p === null || $p === '') {
- continue;
- }
- if (!is_numeric($p)) {
- continue;
- }
- $any = true;
- $sum += (float)$p;
- }
- return $any ? round($sum, 2) : null;
- }
- /**
- * 按订单明细重算并写入某月评审记录(商务技术分/价格分;保留已填最终得分)
- *
- * @param array<int, string>|null $onlyCompanies 仅同步这些供应商;null=当月全部
- */
- public static function syncMonthlyScoresForYm(string $ym, ?array $onlyCompanies = null): void
- {
- $ym = trim($ym);
- if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
- return;
- }
- self::ensureSchema();
- self::ensureFinalScoreTable();
- try {
- $query = Db::table(self::TABLE_SCORE)
- ->where('ym', $ym)
- ->field('company_name,score,quality_score,price_score,lead_score');
- if (is_array($onlyCompanies) && $onlyCompanies !== []) {
- $names = [];
- foreach ($onlyCompanies as $n) {
- $n = trim((string)$n);
- if ($n !== '') {
- $names[$n] = true;
- }
- }
- if ($names === []) {
- return;
- }
- $query->where('company_name', 'in', array_keys($names));
- }
- $rows = $query->select();
- } catch (\Throwable $e) {
- // 无 lead_score 列时回退
- try {
- $query = Db::table(self::TABLE_SCORE)
- ->where('ym', $ym)
- ->field('company_name,score,quality_score,price_score');
- if (is_array($onlyCompanies) && $onlyCompanies !== []) {
- $names = [];
- foreach ($onlyCompanies as $n) {
- $n = trim((string)$n);
- if ($n !== '') {
- $names[$n] = true;
- }
- }
- if ($names === []) {
- return;
- }
- $query->where('company_name', 'in', array_keys($names));
- }
- $rows = $query->select();
- } catch (\Throwable $e2) {
- Log::write('supplier monthly sync read: ' . $e2->getMessage(), 'error');
- return;
- }
- }
- if (!is_array($rows)) {
- return;
- }
- $agg = [];
- foreach ($rows as $r) {
- if (!is_array($r)) {
- continue;
- }
- $cn = trim((string)($r['company_name'] ?? ''));
- if ($cn === '') {
- continue;
- }
- if (!isset($agg[$cn])) {
- $agg[$cn] = [
- 'score_sum' => 0.0,
- 'quality_sum' => 0.0,
- 'price_sum' => 0.0,
- 'delivery_sum' => 0.0,
- 'cnt' => 0,
- ];
- }
- $agg[$cn]['score_sum'] += (float)($r['score'] ?? 0);
- $agg[$cn]['quality_sum'] += (float)($r['quality_score'] ?? 0);
- $agg[$cn]['price_sum'] += (float)($r['price_score'] ?? 0);
- $agg[$cn]['delivery_sum'] += (float)($r['lead_score'] ?? 0);
- $agg[$cn]['cnt']++;
- }
- $now = date('Y-m-d H:i:s');
- foreach ($agg as $cn => $a) {
- $cnt = (int)$a['cnt'];
- $avgScore = $cnt > 0 ? round($a['score_sum'] / $cnt, 2) : 0.0;
- $avgPrice = $cnt > 0 ? round($a['price_sum'] / $cnt, 2) : 0.0;
- $avgDelivery = $cnt > 0 ? round($a['delivery_sum'] / $cnt, 2) : 0.0;
- try {
- $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
- if (is_array($exists) && $exists !== []) {
- // 已手工保存:不再按订单明细重算覆盖
- if ((int)($exists['qp_manual'] ?? 0) === 1) {
- continue;
- }
- $upd = [
- 'score' => $avgScore,
- 'price_score' => $avgPrice,
- 'delivery_score' => $avgDelivery,
- 'updatetime' => $now,
- ];
- Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update($upd);
- } else {
- Db::table(self::TABLE_FINAL)->insert([
- 'ym' => $ym,
- 'company_name' => $cn,
- 'score' => $avgScore,
- 'quality_score' => null,
- 'price_score' => $avgPrice,
- 'delivery_score' => $avgDelivery,
- 'value_added_score' => null,
- 'final_score' => null,
- 'score_grade' => '',
- 'qp_manual' => 0,
- 'createtime' => $now,
- 'updatetime' => $now,
- ]);
- }
- } catch (\Throwable $e) {
- Log::write('supplier monthly sync write: ' . $e->getMessage(), 'error');
- }
- }
- }
- /**
- * 读取某月各供应商最终得分(仅已人工填写的)
- *
- * @return array<string, float> company_name => final_score
- */
- public static function loadFinalScoreMapByYm(string $ym): array
- {
- $ym = trim($ym);
- if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
- return [];
- }
- self::ensureFinalScoreTable();
- try {
- $rows = Db::table(self::TABLE_FINAL)
- ->where('ym', $ym)
- ->whereNotNull('final_score')
- ->field('company_name,final_score')
- ->select();
- } catch (\Throwable $e) {
- return [];
- }
- if (!is_array($rows)) {
- return [];
- }
- $out = [];
- foreach ($rows as $r) {
- if (!is_array($r)) {
- continue;
- }
- $cn = trim((string)($r['company_name'] ?? ''));
- if ($cn === '') {
- continue;
- }
- $out[$cn] = round((float)($r['final_score'] ?? 0), 2);
- }
- return $out;
- }
- /**
- * 读取库中可选分数:NULL/空串 → null;合法数字 → float
- *
- * @param mixed $raw
- */
- public static function readOptionalScoreValue($raw): ?float
- {
- if ($raw === null) {
- return null;
- }
- if (is_string($raw) && trim($raw) === '') {
- return null;
- }
- if (!is_numeric($raw)) {
- return null;
- }
- return round((float)$raw, 2);
- }
- /**
- * 可选分数展示:空不显示 0
- *
- * @param float|null $n
- */
- public static function formatOptionalScore(?float $n): string
- {
- if ($n === null) {
- return '';
- }
- return self::formatScore($n);
- }
- /**
- * 开标后把本月询价供应商名称写入月度评审表:不存在则插入空行,已存在不改(分数人工填)
- *
- * @param array<int, string> $companyNames
- * @return int 新插入条数
- */
- public static function ensureMonthlySupplierNames(string $ym, array $companyNames): int
- {
- $ym = trim($ym);
- if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
- return 0;
- }
- $uniq = [];
- foreach ($companyNames as $n) {
- $n = trim((string)$n);
- if ($n !== '') {
- $uniq[$n] = true;
- }
- }
- if ($uniq === []) {
- return 0;
- }
- self::ensureFinalScoreTable();
- $now = date('Y-m-d H:i:s');
- $inserted = 0;
- foreach (array_keys($uniq) as $cn) {
- try {
- $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
- if (is_array($exists) && $exists !== []) {
- continue;
- }
- Db::table(self::TABLE_FINAL)->insert([
- 'ym' => $ym,
- 'company_name' => $cn,
- 'score' => 0,
- 'quality_score' => null,
- 'price_score' => null,
- 'delivery_score' => null,
- 'value_added_score' => null,
- 'final_score' => null,
- 'score_grade' => '',
- 'qp_manual' => 0,
- 'createtime' => $now,
- 'updatetime' => $now,
- ]);
- $inserted++;
- } catch (\Throwable $e) {
- // 并发下唯一键冲突视为已存在
- if (stripos($e->getMessage(), 'Duplicate') === false) {
- Log::write('supplier monthly ensure name: ' . $e->getMessage(), 'error');
- }
- }
- }
- return $inserted;
- }
- /**
- * 按当月订单评分表中的供应商,补齐月度评审表名称(仅新增空行)
- */
- public static function ensureMonthlySupplierNamesFromOrderYm(string $ym): void
- {
- $ym = trim($ym);
- if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
- return;
- }
- self::ensureSchema();
- self::ensureFinalScoreTable();
- try {
- $rows = Db::table(self::TABLE_SCORE)
- ->where('ym', $ym)
- ->field('company_name')
- ->group('company_name')
- ->select();
- } catch (\Throwable $e) {
- return;
- }
- if (!is_array($rows) || $rows === []) {
- return;
- }
- $names = [];
- foreach ($rows as $r) {
- if (!is_array($r)) {
- continue;
- }
- $cn = trim((string)($r['company_name'] ?? ''));
- if ($cn !== '') {
- $names[] = $cn;
- }
- }
- self::ensureMonthlySupplierNames($ym, $names);
- }
- /**
- * 合格率百分比 → 质量得分(图三对照)
- * 100→20;95-99→18;90-94→16;85-89→14;70-84→12;<70→10
- */
- public static function mapPassRatePercentToQualityScore(float $passRatePercent): float
- {
- if ($passRatePercent >= 100) {
- return 20.0;
- }
- if ($passRatePercent >= 95) {
- return 18.0;
- }
- if ($passRatePercent >= 90) {
- return 16.0;
- }
- if ($passRatePercent >= 85) {
- return 14.0;
- }
- if ($passRatePercent >= 70) {
- return 12.0;
- }
- return 10.0;
- }
- /**
- * 按入库评分统计某供应商某月质量得分
- * 不合格率=(不合格订单数/总订单数)×100%;合格率=100%-不合格率;再对照得分表
- * 同一订单号多工序只计 1 单;任一工序不合格则整单不合格
- * 优先读入库评分表;表空时回退操作日志(入库评分)
- *
- * @return float|null 无入库评分订单时返回 null
- */
- public static function calcQualityScoreFromInbound(string $companyName, string $ym): ?float
- {
- $companyName = trim($companyName);
- $ym = self::formatScoreYm($ym);
- if ($companyName === '' || $ym === '') {
- return null;
- }
- $byOrder = self::loadInboundOrderResultsByCompanyYm($companyName, $ym);
- $total = count($byOrder);
- if ($total < 1) {
- return null;
- }
- $fail = 0;
- foreach ($byOrder as $res) {
- if ($res === '不合格') {
- $fail++;
- }
- }
- // 不合格率 = 不合格次数/总订单×100%;合格率 = 100% - 不合格率
- $passRate = (1 - ($fail / $total)) * 100;
- return self::mapPassRatePercentToQualityScore($passRate);
- }
- /**
- * 某供应商某月:订单号 => 合格|不合格
- *
- * @return array<string, string>
- */
- public static function loadInboundOrderResultsByCompanyYm(string $companyName, string $ym): array
- {
- $companyName = trim($companyName);
- $ym = self::formatScoreYm($ym);
- if ($companyName === '' || $ym === '') {
- return [];
- }
- $byOrder = [];
- // 1) 入库评分表
- try {
- $rows = Db::table('purchase_order_inbound_score')
- ->where('company_name', $companyName)
- ->where(function ($q) use ($ym) {
- $q->where('updatetime', 'like', $ym . '%')
- ->whereOr('createtime', 'like', $ym . '%');
- })
- ->field('ccydh,result')
- ->select();
- } catch (\Throwable $e) {
- $rows = [];
- Log::write('loadInboundOrderResults table: ' . $e->getMessage(), 'error');
- }
- if (is_array($rows)) {
- foreach ($rows as $r) {
- if (!is_array($r)) {
- continue;
- }
- $ccydh = trim((string)($r['ccydh'] ?? ''));
- $res = trim((string)($r['result'] ?? ''));
- if ($ccydh === '' || ($res !== '合格' && $res !== '不合格')) {
- continue;
- }
- if (!isset($byOrder[$ccydh]) || $res === '不合格') {
- $byOrder[$ccydh] = $res;
- }
- }
- }
- if ($byOrder !== []) {
- return $byOrder;
- }
- // 2) 回退:操作日志「入库评分」+ 采购单供应商
- try {
- $logs = Db::table('purchase_order_oper_log')
- ->where(function ($q) {
- $q->where('action', '入库评分')->whereOr('action', 'inbound_score');
- })
- ->where('createtime', 'like', $ym . '%')
- ->field('scydgy_id,content,createtime')
- ->order('id', 'asc')
- ->select();
- } catch (\Throwable $e) {
- Log::write('loadInboundOrderResults logs: ' . $e->getMessage(), 'error');
- return [];
- }
- if (!is_array($logs) || $logs === []) {
- return [];
- }
- $sidResult = [];
- foreach ($logs as $lg) {
- if (!is_array($lg)) {
- continue;
- }
- $sid = (int)($lg['scydgy_id'] ?? 0);
- if ($sid <= 0) {
- continue;
- }
- $content = trim((string)($lg['content'] ?? ''));
- if (!preg_match('/(合格|不合格)/u', $content, $m)) {
- continue;
- }
- $sidResult[$sid] = $m[1];
- }
- if ($sidResult === []) {
- return [];
- }
- try {
- $poRows = Db::table('purchase_order')
- ->where('scydgy_id', 'in', array_keys($sidResult))
- ->where('pick_company_name', $companyName)
- ->field('scydgy_id,CCYDH,pick_company_name')
- ->select();
- } catch (\Throwable $e) {
- return [];
- }
- if (!is_array($poRows)) {
- return [];
- }
- foreach ($poRows as $po) {
- if (!is_array($po)) {
- continue;
- }
- $sid = (int)($po['scydgy_id'] ?? 0);
- $ccydh = trim((string)($po['CCYDH'] ?? ''));
- $res = $sidResult[$sid] ?? '';
- if ($sid <= 0 || $ccydh === '' || ($res !== '合格' && $res !== '不合格')) {
- continue;
- }
- if (!isset($byOrder[$ccydh]) || $res === '不合格') {
- $byOrder[$ccydh] = $res;
- }
- }
- return $byOrder;
- }
- /**
- * 用入库合格/不合格重算并写入月度「质量得分」
- * 已手工保存(qp_manual=1)的供应商跳过,不再覆盖
- *
- * @param string|null $onlyCompany 仅同步该供应商;null=当月有入库评分的全部
- */
- public static function syncQualityScoreFromInbound(string $ym, ?string $onlyCompany = null): void
- {
- $ym = self::formatScoreYm($ym);
- if ($ym === '') {
- return;
- }
- self::ensureFinalScoreTable();
- // 先尽量从操作日志回填入库评分表(防止表数据丢失导致档案/计算为空)
- self::repairInboundScoreFromOperLogs($ym);
- $companies = [];
- $onlyCompany = $onlyCompany !== null ? trim($onlyCompany) : '';
- if ($onlyCompany !== '') {
- $companies = [$onlyCompany];
- } else {
- try {
- $rows = Db::table('purchase_order_inbound_score')
- ->where(function ($q) use ($ym) {
- $q->where('updatetime', 'like', $ym . '%')
- ->whereOr('createtime', 'like', $ym . '%');
- })
- ->field('company_name')
- ->group('company_name')
- ->select();
- } catch (\Throwable $e) {
- $rows = [];
- Log::write('syncQualityScoreFromInbound list: ' . $e->getMessage(), 'error');
- }
- if (is_array($rows)) {
- foreach ($rows as $r) {
- if (!is_array($r)) {
- continue;
- }
- $cn = trim((string)($r['company_name'] ?? ''));
- if ($cn !== '') {
- $companies[$cn] = true;
- }
- }
- }
- // 再并入操作日志涉及的供应商
- try {
- $logs = Db::table('purchase_order_oper_log')
- ->where(function ($q) {
- $q->where('action', '入库评分')->whereOr('action', 'inbound_score');
- })
- ->where('createtime', 'like', $ym . '%')
- ->field('scydgy_id')
- ->select();
- } catch (\Throwable $e) {
- $logs = [];
- }
- $sids = [];
- if (is_array($logs)) {
- foreach ($logs as $lg) {
- if (!is_array($lg)) {
- continue;
- }
- $sid = (int)($lg['scydgy_id'] ?? 0);
- if ($sid > 0) {
- $sids[$sid] = true;
- }
- }
- }
- if ($sids !== []) {
- try {
- $poRows = Db::table('purchase_order')
- ->where('scydgy_id', 'in', array_keys($sids))
- ->field('pick_company_name')
- ->group('pick_company_name')
- ->select();
- } catch (\Throwable $e) {
- $poRows = [];
- }
- if (is_array($poRows)) {
- foreach ($poRows as $po) {
- if (!is_array($po)) {
- continue;
- }
- $cn = trim((string)($po['pick_company_name'] ?? ''));
- if ($cn !== '') {
- $companies[$cn] = true;
- }
- }
- }
- }
- $companies = array_keys($companies);
- }
- if ($companies === []) {
- return;
- }
- self::ensureMonthlySupplierNames($ym, $companies);
- $now = date('Y-m-d H:i:s');
- foreach ($companies as $cn) {
- $cn = trim((string)$cn);
- if ($cn === '') {
- continue;
- }
- $score = self::calcQualityScoreFromInbound($cn, $ym);
- if ($score === null) {
- continue;
- }
- try {
- $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
- if (!is_array($exists) || $exists === []) {
- Db::table(self::TABLE_FINAL)->insert([
- 'ym' => $ym,
- 'company_name' => $cn,
- 'score' => 0,
- 'quality_score' => $score,
- 'price_score' => null,
- 'delivery_score' => null,
- 'value_added_score' => null,
- 'final_score' => null,
- 'score_grade' => '',
- 'qp_manual' => 0,
- 'createtime' => $now,
- 'updatetime' => $now,
- ]);
- continue;
- }
- // 已手工保存:不再按入库规则覆盖质量分
- if ((int)($exists['qp_manual'] ?? 0) === 1) {
- continue;
- }
- Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update([
- 'quality_score' => $score,
- 'updatetime' => $now,
- ]);
- } catch (\Throwable $e) {
- Log::write('syncQualityScoreFromInbound write: ' . $e->getMessage(), 'error');
- }
- }
- }
- /**
- * 操作日志有入库评分、评分表缺失时,回填 purchase_order_inbound_score
- */
- public static function repairInboundScoreFromOperLogs(string $ym): void
- {
- $ym = self::formatScoreYm($ym);
- if ($ym === '') {
- return;
- }
- try {
- $logs = Db::table('purchase_order_oper_log')
- ->where(function ($q) {
- $q->where('action', '入库评分')->whereOr('action', 'inbound_score');
- })
- ->where('createtime', 'like', $ym . '%')
- ->field('scydgy_id,content,createtime,admin_id,admin_name,purchase_order_id')
- ->order('id', 'asc')
- ->select();
- } catch (\Throwable $e) {
- return;
- }
- if (!is_array($logs) || $logs === []) {
- return;
- }
- $best = [];
- foreach ($logs as $lg) {
- if (!is_array($lg)) {
- continue;
- }
- $sid = (int)($lg['scydgy_id'] ?? 0);
- if ($sid <= 0) {
- continue;
- }
- $content = trim((string)($lg['content'] ?? ''));
- if (!preg_match('/(合格|不合格)/u', $content, $m)) {
- continue;
- }
- $best[$sid] = [
- 'result' => $m[1],
- 'createtime' => trim((string)($lg['createtime'] ?? '')),
- 'admin_id' => (int)($lg['admin_id'] ?? 0),
- 'admin_name' => trim((string)($lg['admin_name'] ?? '')),
- 'purchase_order_id' => (int)($lg['purchase_order_id'] ?? 0),
- ];
- }
- if ($best === []) {
- return;
- }
- try {
- $existRows = Db::table('purchase_order_inbound_score')
- ->where('scydgy_id', 'in', array_keys($best))
- ->field('scydgy_id')
- ->select();
- } catch (\Throwable $e) {
- return;
- }
- $existSids = [];
- if (is_array($existRows)) {
- foreach ($existRows as $er) {
- if (is_array($er)) {
- $existSids[(int)($er['scydgy_id'] ?? 0)] = true;
- }
- }
- }
- $needSids = [];
- foreach (array_keys($best) as $sid) {
- if (empty($existSids[$sid])) {
- $needSids[] = $sid;
- }
- }
- if ($needSids === []) {
- return;
- }
- try {
- $poRows = Db::table('purchase_order')
- ->where('scydgy_id', 'in', $needSids)
- ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_company_name')
- ->select();
- } catch (\Throwable $e) {
- return;
- }
- if (!is_array($poRows)) {
- return;
- }
- foreach ($poRows as $po) {
- if (!is_array($po)) {
- continue;
- }
- $sid = (int)($po['scydgy_id'] ?? 0);
- if ($sid <= 0 || empty($best[$sid])) {
- continue;
- }
- $b = $best[$sid];
- $now = $b['createtime'] !== '' ? $b['createtime'] : date('Y-m-d H:i:s');
- $data = [
- 'scydgy_id' => $sid,
- 'purchase_order_id' => (int)($po['id'] ?? 0) > 0 ? (int)$po['id'] : (int)$b['purchase_order_id'],
- 'ccydh' => trim((string)($po['CCYDH'] ?? '')),
- 'cyjmc' => trim((string)($po['CYJMC'] ?? '')),
- 'cgymc' => trim((string)($po['CGYMC'] ?? '')),
- 'company_name' => trim((string)($po['pick_company_name'] ?? '')),
- 'result' => $b['result'],
- 'admin_id' => (int)$b['admin_id'],
- 'admin_name' => (string)$b['admin_name'],
- 'createtime' => $now,
- 'updatetime' => $now,
- ];
- if ($data['company_name'] === '' || $data['ccydh'] === '') {
- continue;
- }
- try {
- Db::table('purchase_order_inbound_score')->insert($data);
- } catch (\Throwable $e) {
- // 唯一键冲突忽略
- if (stripos($e->getMessage(), 'Duplicate') === false) {
- Log::write('repairInboundScoreFromOperLogs: ' . $e->getMessage(), 'error');
- }
- }
- }
- }
- /**
- * 读取某月评审记录(仅读月度表;先按当月开标/询价供应商补齐名称)
- *
- * @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}>
- */
- public static function loadMonthlyMapByYm(string $ym): array
- {
- $ym = trim($ym);
- if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
- return [];
- }
- self::ensureFinalScoreTable();
- // 本月询价(开标后已写入订单评分表)的供应商:名称不存在则加空行,已存在不动
- self::ensureMonthlySupplierNamesFromOrderYm($ym);
- // 按入库合格/不合格重算质量得分(未人工改过的)
- self::syncQualityScoreFromInbound($ym);
- try {
- $rows = Db::table(self::TABLE_FINAL)
- ->where('ym', $ym)
- ->field('company_name,score,quality_score,price_score,delivery_score,value_added_score,final_score,score_grade,ym')
- ->select();
- } catch (\Throwable $e) {
- return [];
- }
- if (!is_array($rows)) {
- return [];
- }
- $out = [];
- foreach ($rows as $r) {
- if (!is_array($r)) {
- continue;
- }
- $cn = trim((string)($r['company_name'] ?? $r['Company_name'] ?? ''));
- if ($cn === '') {
- continue;
- }
- $quality = self::readOptionalScoreValue($r['quality_score'] ?? null);
- $price = self::readOptionalScoreValue($r['price_score'] ?? null);
- $delivery = self::readOptionalScoreValue($r['delivery_score'] ?? null);
- $valueAdded = self::readOptionalScoreValue($r['value_added_score'] ?? null);
- $final = self::readOptionalScoreValue($r['final_score'] ?? null);
- $out[$cn] = [
- 'score' => round((float)($r['score'] ?? 0), 2),
- 'quality_score' => $quality,
- 'price_score' => $price,
- 'delivery_score' => $delivery,
- 'value_added_score' => $valueAdded,
- 'final_score' => $final,
- 'final_saved' => $final !== null ? 1 : 0,
- 'score_grade' => self::normalizeScoreGrade($r['score_grade'] ?? ''),
- // 列表「日期」列按 ym(YYYY-MM)展示,不到日
- 'score_date' => self::formatScoreYm($r['ym'] ?? $ym),
- ];
- }
- return $out;
- }
- /**
- * 规范为 YYYY-MM(月度评审日期)
- *
- * @param mixed $raw
- */
- public static function formatScoreYm($raw): string
- {
- $raw = trim((string)$raw);
- if (preg_match('/^\d{4}-\d{2}$/', $raw)) {
- return $raw;
- }
- if (preg_match('/^(\d{4})\D+(\d{1,2})/', $raw, $m)) {
- return sprintf('%04d-%02d', (int)$m[1], (int)$m[2]);
- }
- return '';
- }
- /**
- * @param mixed $raw
- */
- public static function formatScoreDateYmd($raw): string
- {
- $raw = trim((string)$raw);
- if ($raw === '' || stripos($raw, '0000-00-00') === 0) {
- return '';
- }
- $ts = strtotime(str_replace('T', ' ', $raw));
- return ($ts !== false && $ts > 0) ? date('Y-m-d', $ts) : '';
- }
- /**
- * 解析可选分数:空=null;非法=false;合法=float
- *
- * @param mixed $raw
- * @return float|null|false
- */
- protected static function parseOptionalScore($raw)
- {
- if ($raw === null) {
- return null;
- }
- if (is_string($raw) && trim($raw) === '') {
- return null;
- }
- if (!is_numeric($raw)) {
- return false;
- }
- return round((float)$raw, 2);
- }
- /**
- * 批量保存某月评审分:商务技术分 / 价格分 / 交货分 / 最终得分 / 评分等级
- *
- * @param array<int, array{company_name?:string,quality_score?:mixed,price_score?:mixed,delivery_score?:mixed,final_score?:mixed,score_grade?:mixed}> $items
- * @return array{done:int,saved:int,cleared:int,error:string}
- */
- public static function saveFinalScoresForYm(string $ym, array $items): array
- {
- $ym = trim($ym);
- $result = ['done' => 0, 'saved' => 0, 'cleared' => 0, 'error' => ''];
- if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
- $result['error'] = '月份无效';
- return $result;
- }
- if ($items === []) {
- $result['error'] = '没有可保存的数据';
- return $result;
- }
- self::ensureFinalScoreTable();
- try {
- Db::query('SELECT 1 FROM `' . self::TABLE_FINAL . '` LIMIT 1');
- } catch (\Throwable $e) {
- $result['error'] = '月度汇总表未创建,请联系管理员';
- return $result;
- }
- $now = date('Y-m-d H:i:s');
- foreach ($items as $it) {
- if (!is_array($it)) {
- continue;
- }
- $cn = trim((string)($it['company_name'] ?? ''));
- if ($cn === '') {
- continue;
- }
- $grade = self::normalizeScoreGrade($it['score_grade'] ?? '');
- $quality = self::parseOptionalScore($it['quality_score'] ?? null);
- $price = self::parseOptionalScore($it['price_score'] ?? null);
- $delivery = self::parseOptionalScore($it['delivery_score'] ?? null);
- $valueAdded = self::parseOptionalScore($it['value_added_score'] ?? null);
- $final = self::parseOptionalScore($it['final_score'] ?? null);
- if ($quality === false || $price === false || $delivery === false || $valueAdded === false || $final === false) {
- continue;
- }
- // 未填最终得分时,按四项自动汇总
- if ($final === null) {
- $final = self::sumFinalScore($quality, $price, $delivery, $valueAdded);
- }
- // 未选等级时,按最终得分自动定级
- if ($grade === '' && $final !== null) {
- $grade = self::gradeFromFinalScore($final);
- }
- $hasQp = array_key_exists('quality_score', $it)
- || array_key_exists('price_score', $it)
- || array_key_exists('delivery_score', $it)
- || array_key_exists('value_added_score', $it);
- $hasAny = $final !== null || $grade !== ''
- || $quality !== null || $price !== null || $delivery !== null || $valueAdded !== null;
- // 全空行不写入,避免误把未改供应商标成手工锁定
- if (!$hasAny) {
- continue;
- }
- try {
- $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
- $upd = [
- 'final_score' => $final,
- 'score_grade' => $grade,
- 'updatetime' => $now,
- // 保存即视为手工确认,之后不再跑查询计算规则覆盖
- 'qp_manual' => 1,
- ];
- if ($hasQp) {
- // 空值存 NULL,列表显示空白而不是 0
- $upd['quality_score'] = $quality;
- $upd['price_score'] = $price;
- $upd['delivery_score'] = $delivery;
- $upd['value_added_score'] = $valueAdded;
- $upd['score'] = self::sumFinalScore($quality, $price, $delivery, $valueAdded) ?? 0;
- }
- if (is_array($exists) && $exists !== []) {
- Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update($upd);
- } else {
- Db::table(self::TABLE_FINAL)->insert([
- 'ym' => $ym,
- 'company_name' => $cn,
- 'score' => $hasQp ? (self::sumFinalScore($quality, $price, $delivery, $valueAdded) ?? 0) : 0,
- 'quality_score' => $hasQp ? $quality : null,
- 'price_score' => $hasQp ? $price : null,
- 'delivery_score' => $hasQp ? $delivery : null,
- 'value_added_score' => $hasQp ? $valueAdded : null,
- 'final_score' => $final,
- 'score_grade' => $grade,
- 'qp_manual' => 1,
- 'createtime' => $now,
- 'updatetime' => $now,
- ]);
- }
- if ($final === null && $grade === '' && !$hasQp) {
- $result['cleared']++;
- } else {
- $result['saved']++;
- }
- $result['done']++;
- } catch (\Throwable $e) {
- Log::write('supplier final score save: ' . $e->getMessage(), 'error');
- $result['error'] = $e->getMessage();
- }
- }
- return $result;
- }
- /** 历史表补质量/价格百分比字段 */
- protected static function ensureScoreWeightColumns(): void
- {
- try {
- $cols = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'quality_weight'");
- if (!is_array($cols) || $cols === []) {
- Db::execute(
- "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `quality_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比' AFTER `quality_score`"
- );
- }
- $cols2 = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'price_weight'");
- if (!is_array($cols2) || $cols2 === []) {
- Db::execute(
- "ALTER TABLE `" . self::TABLE_SCORE . "` ADD COLUMN `price_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比' AFTER `price_sum`"
- );
- }
- } catch (\Throwable $e) {
- Log::write('supplier score weight columns: ' . $e->getMessage(), 'error');
- }
- }
- /** 历史表补交货期权重/得分字段 */
- protected static function ensureLeadColumns(): void
- {
- $adds = [
- self::TABLE_RULE => [
- 'lead_weight' => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)' AFTER `price_weight`",
- ],
- self::TABLE_SCORE => [
- 'lead_days_sum' => "ADD COLUMN `lead_days_sum` int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)' AFTER `price_score`",
- 'lead_weight' => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比' AFTER `lead_days_sum`",
- 'lead_enabled' => "ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=当时纳入总分' AFTER `lead_weight`",
- 'lead_score' => "ADD COLUMN `lead_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '交货期评分值' AFTER `lead_enabled`",
- ],
- ];
- foreach ($adds as $table => $cols) {
- foreach ($cols as $col => $ddl) {
- try {
- $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
- if (is_array($info) && $info !== []) {
- continue;
- }
- Db::execute("ALTER TABLE `{$table}` {$ddl}");
- } catch (\Throwable $e) {
- Log::write("supplier score lead column {$table}.{$col}: " . $e->getMessage(), 'error');
- }
- }
- }
- }
- /** 将权重/质量分/单价合计等字段改为整数(去掉 .00) */
- protected static function ensureIntegerColumns(): void
- {
- $defs = [
- self::TABLE_RULE => [
- 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '上年度质量评分权重(%)'",
- 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '单价评分权重(%)'",
- 'lead_weight' => "int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)'",
- ],
- self::TABLE_SCORE => [
- 'quality_score' => "int NOT NULL DEFAULT 0 COMMENT '上年度质量评分'",
- 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比'",
- 'price_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比'",
- 'lead_days_sum' => "int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)'",
- 'lead_weight' => "int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比'",
- ],
- ];
- foreach ($defs as $table => $cols) {
- foreach ($cols as $col => $def) {
- try {
- $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
- if (!is_array($info) || $info === []) {
- continue;
- }
- $type = strtolower((string)($info[0]['Type'] ?? $info[0]['type'] ?? ''));
- if (preg_match('/^(tiny|small|medium|big)?int\b/', $type)) {
- continue;
- }
- Db::execute("ALTER TABLE `{$table}` MODIFY COLUMN `{$col}` {$def}");
- } catch (\Throwable $e) {
- Log::write("supplier score int column {$table}.{$col}: " . $e->getMessage(), 'error');
- }
- }
- }
- // 单价合计需保留小数(如 0.25),不可用 int
- try {
- $info = Db::query("SHOW COLUMNS FROM `" . self::TABLE_SCORE . "` LIKE 'price_sum'");
- if (is_array($info) && $info !== []) {
- $type = strtolower((string)($info[0]['Type'] ?? $info[0]['type'] ?? ''));
- if (preg_match('/^(tiny|small|medium|big)?int\b/', $type)) {
- Db::execute(
- "ALTER TABLE `" . self::TABLE_SCORE . "` MODIFY COLUMN `price_sum` decimal(12,4) NOT NULL DEFAULT 0.0000 COMMENT '本单工序单价合计'"
- );
- }
- }
- } catch (\Throwable $e) {
- Log::write('supplier score price_sum decimal: ' . $e->getMessage(), 'error');
- }
- }
- protected static function ensureDefaultRule(): void
- {
- self::ensureRuleNameColumn();
- self::ensureOrderTypeRules();
- }
- /**
- * 规则表去掉页面未用字段:lead_enabled、status
- * (交期是否纳入由 lead_weight>0 判断;删除用物理删除)
- */
- protected static function dropObsoleteRuleColumns(): void
- {
- static $done = false;
- if ($done) {
- return;
- }
- $done = true;
- foreach (['lead_enabled', 'status'] as $col) {
- try {
- $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'' . $col . '\'');
- if (is_array($cols) && $cols !== []) {
- Db::execute('ALTER TABLE `' . self::TABLE_RULE . '` DROP COLUMN `' . $col . '`');
- }
- } catch (\Throwable $e) {
- Log::write('supplier_score_rule drop ' . $col . ': ' . $e->getMessage(), 'error');
- }
- }
- }
- /**
- * 规则表补「规则名称/订单类型」字段(历史库可能缺此列)
- */
- protected static function ensureRuleNameColumn(): void
- {
- static $done = false;
- if ($done) {
- return;
- }
- try {
- $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'name\'');
- if (!is_array($cols) || $cols === []) {
- Db::execute(
- 'ALTER TABLE `' . self::TABLE_RULE . '` ADD COLUMN `name` varchar(100) NOT NULL DEFAULT \'\' COMMENT \'规则名称/订单类型\' AFTER `id`'
- );
- $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'name\'');
- }
- if (is_array($cols) && $cols !== []) {
- $done = true;
- }
- } catch (\Throwable $e) {
- Log::write('supplier_score_rule.name: ' . $e->getMessage(), 'error');
- }
- }
- /**
- * 写入/对齐「不同订单供应商评分权重方案」三条规则
- */
- protected static function ensureOrderTypeRules(): void
- {
- static $done = false;
- if ($done) {
- return;
- }
- try {
- if (\think\Cache::get('procuremen_supplier_order_type_rules_ok_v1')) {
- $done = true;
- return;
- }
- } catch (\Throwable $e) {
- }
- self::ensureRuleNameColumn();
- $schemes = [
- [
- 'name' => '普通订单(价格优先)',
- 'quality_weight' => 30,
- 'price_weight' => 50,
- 'lead_weight' => 20,
- ],
- [
- 'name' => '急单(交期优先)',
- 'quality_weight' => 30,
- 'price_weight' => 20,
- 'lead_weight' => 50,
- ],
- [
- 'name' => '重点产品(质量优先)',
- 'quality_weight' => 50,
- 'price_weight' => 20,
- 'lead_weight' => 30,
- ],
- ];
- $now = date('Y-m-d H:i:s');
- try {
- // 确认 name 列已存在再继续
- $cols = Db::query('SHOW COLUMNS FROM `' . self::TABLE_RULE . '` LIKE \'name\'');
- if (!is_array($cols) || $cols === []) {
- Log::write('supplier_score_rule.name still missing, skip seed', 'error');
- return;
- }
- foreach ($schemes as $s) {
- $exist = Db::table(self::TABLE_RULE)->where('name', $s['name'])->find();
- if (!is_array($exist)) {
- // 兼容旧数据:无 name 时按三档百分比匹配
- $exist = Db::table(self::TABLE_RULE)
- ->where('quality_weight', (int)$s['quality_weight'])
- ->where('price_weight', (int)$s['price_weight'])
- ->where('lead_weight', (int)$s['lead_weight'])
- ->where(function ($q) {
- $q->whereNull('name')->whereOr('name', '');
- })
- ->order('id', 'asc')
- ->find();
- }
- $payload = [
- 'name' => $s['name'],
- 'quality_weight' => (int)$s['quality_weight'],
- 'price_weight' => (int)$s['price_weight'],
- 'lead_weight' => (int)$s['lead_weight'],
- 'updatetime' => $now,
- ];
- if (is_array($exist)) {
- // 已存在:只补空名称,不覆盖人工改过的权重
- $existName = trim((string)($exist['name'] ?? ''));
- if ($existName === '') {
- Db::table(self::TABLE_RULE)->where('id', (int)$exist['id'])->update([
- 'name' => $s['name'],
- 'updatetime' => $now,
- ]);
- }
- } else {
- $payload['is_default'] = 0;
- $payload['createtime'] = $now;
- Db::table(self::TABLE_RULE)->insert($payload);
- }
- }
- // 非方案内的旧行直接删除,列表只保留三条
- $names = [];
- foreach ($schemes as $s) {
- $names[] = $s['name'];
- }
- $nameIn = "'" . implode("','", array_map('addslashes', $names)) . "'";
- Db::execute(
- 'DELETE FROM `' . self::TABLE_RULE . '` '
- . 'WHERE `name` NOT IN (' . $nameIn . ') OR `name` IS NULL OR `name`=\'\''
- );
- $hasDefault = Db::table(self::TABLE_RULE)->where('is_default', 1)->find();
- if (!is_array($hasDefault)) {
- $def = Db::table(self::TABLE_RULE)->where('name', '普通订单(价格优先)')->find();
- if (!is_array($def)) {
- $def = Db::table(self::TABLE_RULE)->order('id', 'asc')->find();
- }
- if (is_array($def)) {
- Db::table(self::TABLE_RULE)->where('id', (int)$def['id'])->update([
- 'is_default' => 1,
- 'updatetime' => $now,
- ]);
- }
- }
- $done = true;
- try {
- \think\Cache::set('procuremen_supplier_order_type_rules_ok_v1', 1, 86400);
- } catch (\Throwable $e) {
- }
- } catch (\Throwable $e) {
- Log::write('supplier score ensureOrderTypeRules: ' . $e->getMessage(), 'error');
- }
- }
- /**
- * 下拉用:正常状态的评分规则(订单类型)
- *
- * @return array<int, array{id:int,name:string,label:string,quality_weight:int,price_weight:int,lead_weight:int,is_default:int}>
- */
- public static function listRulesForSelect(): array
- {
- self::ensureSchema();
- $out = [];
- try {
- $rows = Db::table(self::TABLE_RULE)
- ->order('is_default', 'desc')
- ->order('id', 'asc')
- ->select();
- if (!is_array($rows)) {
- return $out;
- }
- foreach ($rows as $row) {
- if (!is_array($row)) {
- continue;
- }
- $name = trim((string)($row['name'] ?? ''));
- if ($name === '') {
- continue;
- }
- $qw = (int)($row['quality_weight'] ?? 0);
- $pw = (int)($row['price_weight'] ?? 0);
- $lw = (int)($row['lead_weight'] ?? 0);
- $out[] = [
- 'id' => (int)($row['id'] ?? 0),
- 'name' => $name,
- 'label' => $name . '|质量' . $qw . '%|价格' . $pw . '%|交期' . $lw . '%',
- 'quality_weight' => $qw,
- 'price_weight' => $pw,
- 'lead_weight' => $lw,
- 'is_default' => !empty($row['is_default']) ? 1 : 0,
- ];
- }
- } catch (\Throwable $e) {
- }
- return $out;
- }
- /**
- * 按订单号解析评分规则:优先下发时写入的权重快照,否则按 score_rule_id 读规则表,再否则默认
- *
- * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
- */
- public static function resolveRuleForCcydh(string $ccydh, ?array $poHint = null): array
- {
- $ccydh = trim($ccydh);
- if ($ccydh === '' && (!is_array($poHint) || $poHint === [])) {
- return self::getDefaultRule();
- }
- try {
- $po = is_array($poHint) && $poHint !== [] ? $poHint : null;
- if ($po === null && $ccydh !== '') {
- self::ensureSchema();
- $po = Db::table('purchase_order')->where('CCYDH', $ccydh)->order('id', 'desc')->find();
- }
- if (is_array($po)) {
- $ruleId = (int)($po['score_rule_id'] ?? 0);
- $qw = (int)($po['score_quality_weight'] ?? 0);
- $pw = (int)($po['score_price_weight'] ?? 0);
- $lw = (int)($po['score_lead_weight'] ?? 0);
- // 下发时已快照:开标/展示一律用快照,不受后续规则修改影响(无需 ensureSchema)
- if ($ruleId > 0 && ($qw + $pw + $lw) > 0) {
- $name = trim((string)($po['score_rule_name'] ?? ''));
- if ($name === '') {
- $live = self::getRuleById($ruleId);
- $name = (string)($live['name'] ?? '');
- }
- return [
- 'id' => $ruleId,
- 'name' => $name,
- 'quality_weight' => max(0, $qw),
- 'price_weight' => max(0, $pw),
- 'lead_weight' => max(0, $lw),
- 'lead_enabled' => $lw > 0 ? 1 : 0,
- ];
- }
- if ($ruleId > 0) {
- $rule = self::getRuleById($ruleId);
- if ((int)($rule['id'] ?? 0) > 0) {
- return $rule;
- }
- }
- }
- } catch (\Throwable $e) {
- }
- return self::getDefaultRule();
- }
- /**
- * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
- */
- public static function getRuleById(int $id): array
- {
- if ($id <= 0) {
- return self::getDefaultRule();
- }
- self::ensureSchema();
- try {
- $row = Db::table(self::TABLE_RULE)
- ->where('id', $id)
- ->find();
- if (is_array($row)) {
- $lw = (int)($row['lead_weight'] ?? 0);
- return [
- 'id' => (int)($row['id'] ?? 0),
- 'name' => trim((string)($row['name'] ?? '')),
- 'quality_weight' => (int)($row['quality_weight'] ?? 50),
- 'price_weight' => (int)($row['price_weight'] ?? 50),
- 'lead_weight' => $lw,
- 'lead_enabled' => $lw > 0 ? 1 : 0,
- ];
- }
- } catch (\Throwable $e) {
- }
- return self::getDefaultRule();
- }
- /**
- * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
- */
- public static function getDefaultRule(): array
- {
- self::ensureSchema();
- try {
- $row = Db::table(self::TABLE_RULE)
- ->where('is_default', 1)
- ->order('id', 'desc')
- ->find();
- if (!is_array($row)) {
- $row = Db::table(self::TABLE_RULE)->order('id', 'asc')->find();
- }
- if (is_array($row)) {
- $lw = (int)($row['lead_weight'] ?? 0);
- return [
- 'id' => (int)($row['id'] ?? 0),
- 'name' => trim((string)($row['name'] ?? '')),
- 'quality_weight' => (int)($row['quality_weight'] ?? 50),
- 'price_weight' => (int)($row['price_weight'] ?? 50),
- 'lead_weight' => $lw,
- 'lead_enabled' => $lw > 0 ? 1 : 0,
- ];
- }
- } catch (\Throwable $e) {
- }
- return [
- 'id' => 0,
- 'name' => '内置默认',
- 'quality_weight' => 50,
- 'price_weight' => 50,
- 'lead_weight' => 0,
- 'lead_enabled' => 0,
- ];
- }
- /**
- * 规范年月;空则取当前月
- */
- public static function normalizeYm(?string $ym): string
- {
- $ym = trim((string)$ym);
- if (preg_match('/^\d{4}-\d{2}$/', $ym)) {
- return $ym;
- }
- return date('Y-m');
- }
- /**
- * 上一自然月 YYYY-MM
- */
- public static function previousYm(string $ym): string
- {
- $ym = self::normalizeYm($ym);
- $ts = strtotime($ym . '-01');
- if ($ts === false) {
- $ts = time();
- }
- return date('Y-m', strtotime('-1 month', $ts));
- }
- /**
- * 从月度评审表取商务技术分:当月按上月起往前查,都没有则默认 50
- *
- * @param array<int, string> $companyNames
- * @return array<string, float> company_name => quality_score
- */
- public static function loadReviewQualityScoreMap(array $companyNames, ?string $asOfYm = null): array
- {
- $asOfYm = self::normalizeYm($asOfYm);
- $default = 50.0;
- $map = [];
- $names = [];
- foreach ($companyNames as $n) {
- $n = trim((string)$n);
- if ($n !== '') {
- $names[$n] = true;
- $map[$n] = $default;
- }
- }
- if ($names === []) {
- return $map;
- }
- self::ensureFinalScoreTable();
- try {
- // 当月按上个月起查:ym < asOfYm,且商务技术分已填写
- $rows = Db::table(self::TABLE_FINAL)
- ->where('company_name', 'in', array_keys($names))
- ->where('ym', '<', $asOfYm)
- ->whereNotNull('quality_score')
- ->field('company_name,ym,quality_score')
- ->order('ym', 'desc')
- ->select();
- } catch (\Throwable $e) {
- return $map;
- }
- if (!is_array($rows)) {
- return $map;
- }
- $seen = [];
- foreach ($rows as $r) {
- if (!is_array($r)) {
- continue;
- }
- $cn = trim((string)($r['company_name'] ?? ''));
- if ($cn === '' || isset($seen[$cn])) {
- continue;
- }
- $score = self::readOptionalScoreValue($r['quality_score'] ?? null);
- if ($score === null) {
- continue;
- }
- $seen[$cn] = true;
- $map[$cn] = round((float)$score, 2);
- }
- return $map;
- }
- /**
- * 按公司名批量取供应商 id(兼容旧调用保留评分字段结构)
- *
- * @param array<int, string> $companyNames
- * @return array<string, array{id:int,score:float,monthly_score:?float,quality_avg:float}>
- */
- public static function loadCustomerScoreMap(array $companyNames): array
- {
- $map = [];
- $names = [];
- foreach ($companyNames as $n) {
- $n = trim((string)$n);
- if ($n !== '') {
- $names[$n] = true;
- }
- }
- if ($names === []) {
- return $map;
- }
- try {
- $rows = Db::table('customer')
- ->where('company_name', 'in', array_keys($names))
- ->field('id,company_name')
- ->select();
- } catch (\Throwable $e) {
- return $map;
- }
- if (!is_array($rows)) {
- return $map;
- }
- foreach ($rows as $r) {
- if (!is_array($r)) {
- continue;
- }
- $cn = trim((string)($r['company_name'] ?? ''));
- if ($cn === '') {
- continue;
- }
- $map[$cn] = [
- 'id' => (int)($r['id'] ?? 0),
- 'score' => 0.0,
- 'monthly_score' => null,
- 'quality_avg' => 50.0,
- ];
- }
- return $map;
- }
- /**
- * 从报价组计算本单各供应商得分(不写库)
- *
- * @param array<int, array<string, mixed>> $quoteGroups loadAuditSupplierQuoteGroups 结构(name/lines[].amount)
- * @param string|null $asOfYm 订单所属月;商务/技术分从该月的上月起往前查评审表
- * @return array<string, array{
- * company_name:string,customer_id:int,quality_score:float,price_sum:float,
- * price_score:float,score:float,rank_no:int,score_text:string,rank_text:string
- * }>
- */
- public static function calculateForQuoteGroups(array $quoteGroups, ?array $rule = null, ?string $asOfYm = null): array
- {
- $rule = $rule ?: self::getDefaultRule();
- $qw = max(0, (float)($rule['quality_weight'] ?? 50));
- $pw = max(0, (float)($rule['price_weight'] ?? 50));
- $lw = max(0, (float)($rule['lead_weight'] ?? 0));
- // 填 0 的权重不参与计算
- $leadEnabled = $lw > 0 ? 1 : 0;
- $wSum = $qw + $pw + $lw;
- if ($wSum <= 0) {
- $qw = 50;
- $pw = 50;
- $lw = 0;
- $leadEnabled = 0;
- $wSum = 100;
- }
- $names = [];
- foreach ($quoteGroups as $g) {
- if (!is_array($g)) {
- continue;
- }
- $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
- if ($cn !== '') {
- $names[] = $cn;
- }
- }
- $custMap = self::loadCustomerScoreMap($names);
- $reviewMap = self::loadReviewQualityScoreMap($names, $asOfYm);
- $items = [];
- $priceSums = [];
- $leadSums = [];
- foreach ($quoteGroups as $g) {
- if (!is_array($g)) {
- continue;
- }
- $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
- if ($cn === '') {
- continue;
- }
- $priceSum = 0.0;
- $hasPrice = false;
- $leadSum = 0;
- $hasLead = false;
- // 确认页用 lines,审批页用 pick_lines
- $lineRows = $g['lines'] ?? $g['pick_lines'] ?? [];
- if (!is_array($lineRows)) {
- $lineRows = [];
- }
- foreach ($lineRows as $ln) {
- if (!is_array($ln)) {
- continue;
- }
- if (empty($ln['amount_quote_pending'])) {
- $am = self::resolveQuoteLineAmountRaw($ln);
- if ($am !== '' && $am !== '0' && $am !== '0.00' && is_numeric($am)) {
- $priceSum += (float)$am;
- $hasPrice = true;
- }
- }
- if (empty($ln['lead_days_quote_pending'])) {
- $ldRaw = $ln['lead_days'] ?? null;
- if ($ldRaw !== null && $ldRaw !== '' && is_numeric($ldRaw)) {
- $ld = (int)$ldRaw;
- if ($ld > 0) {
- $leadSum += $ld;
- $hasLead = true;
- }
- }
- }
- }
- // 商务/技术得分:月度评审「商务技术分」,当月按上月起往前,默认 50
- $quality = (float)($reviewMap[$cn] ?? 50);
- $psum = $hasPrice ? round($priceSum, 2) : 0;
- $lsum = $hasLead ? (int)$leadSum : 0;
- $items[$cn] = [
- 'company_name' => $cn,
- 'customer_id' => (int)($custMap[$cn]['id'] ?? 0),
- 'quality_score' => $quality,
- 'quality_score_text' => self::formatScore($quality),
- 'quality_weight' => (int)$qw,
- 'price_sum' => $psum,
- 'price_sum_text' => self::formatScore($psum),
- 'price_weight' => (int)$pw,
- 'has_price' => $hasPrice,
- 'price_score' => 0.0,
- 'price_score_text' => '0',
- 'lead_days_sum' => $lsum,
- 'lead_days_sum_text' => (string)$lsum,
- 'lead_weight' => (int)$lw,
- 'lead_enabled' => $leadEnabled,
- 'has_lead' => $hasLead,
- 'lead_score' => 0.0,
- 'lead_score_text' => '0',
- 'score' => 0.0,
- 'rank_no' => 0,
- 'score_text' => '',
- 'rank_text' => '',
- ];
- if ($hasPrice && $priceSum > 0) {
- $priceSums[$cn] = $priceSum;
- }
- if ($hasLead && $leadSum > 0) {
- $leadSums[$cn] = $leadSum;
- }
- }
- $minSum = null;
- foreach ($priceSums as $ps) {
- if ($minSum === null || $ps < $minSum) {
- $minSum = $ps;
- }
- }
- $minLead = null;
- foreach ($leadSums as $ls) {
- if ($minLead === null || $ls < $minLead) {
- $minLead = $ls;
- }
- }
- foreach ($items as $cn => &$it) {
- // 单价未填写:价格得分与总分均为 0
- if ($pw > 0 && (empty($it['has_price']) || (float)($it['price_sum'] ?? 0) <= 0)) {
- $it['price_score'] = 0.0;
- $it['price_score_text'] = '0';
- $it['lead_score'] = 0.0;
- $it['lead_score_text'] = self::formatScore(0);
- $it['score'] = 0.0;
- $it['score_text'] = '0';
- continue;
- }
- // 价格得分 = (最低单价合计 / 本供应商单价合计) × 价格权重% × 100
- if ($pw > 0 && !empty($it['has_price']) && $it['price_sum'] > 0 && $minSum !== null && $minSum > 0) {
- $ratio = round($minSum / (float)$it['price_sum'], 4);
- $it['price_score'] = round($ratio * ($pw / 100) * 100, 2);
- } else {
- $it['price_score'] = 0.0;
- }
- $it['price_score_text'] = self::formatScore($it['price_score']);
- if ($leadEnabled && !empty($it['has_lead']) && $it['lead_days_sum'] > 0 && $minLead !== null && $minLead > 0) {
- $leadRatio = round($minLead / (float)$it['lead_days_sum'], 4);
- $it['lead_score'] = round($leadRatio * 100, 2);
- } else {
- $it['lead_score'] = 0.0;
- }
- $it['lead_score_text'] = self::formatScore($it['lead_score']);
- // 总分 = 商务/技术得分×质量% + 价格得分(已含价格权重) [+ 交货得分×交货%]
- $total = 0.0;
- if ($qw > 0) {
- $total += $it['quality_score'] * ($qw / 100);
- }
- if ($pw > 0) {
- $total += (float)$it['price_score'];
- }
- if ($lw > 0) {
- $total += $it['lead_score'] * ($lw / 100);
- }
- $it['score'] = round($total, 2);
- $it['score_text'] = self::formatScore($it['score']);
- }
- unset($it);
- $sorted = $items;
- uasort($sorted, function ($a, $b) {
- $sa = (float)($a['score'] ?? 0);
- $sb = (float)($b['score'] ?? 0);
- if ($sa !== $sb) {
- return $sb <=> $sa;
- }
- return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
- });
- $rank = 0;
- foreach ($sorted as $cn => $row) {
- $rank++;
- $items[$cn]['rank_no'] = $rank;
- $items[$cn]['rank_text'] = (string)$rank;
- }
- return $items;
- }
- public static function formatScore($n): string
- {
- $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
- return $s === '' ? '0' : $s;
- }
- /**
- * 报价行单价原文(兼容确认页 amount、审批页 unit_price_text/amount_text)
- *
- * @param array<string, mixed> $ln
- */
- protected static function resolveQuoteLineAmountRaw(array $ln): string
- {
- foreach (['amount', 'unit_price_text', 'amount_text'] as $k) {
- if (!array_key_exists($k, $ln) || $ln[$k] === null) {
- continue;
- }
- $am = trim((string)$ln[$k]);
- if ($am === '' || $am === '未填写' || $am === '未开标验证' || $am === '开标验证后查看') {
- continue;
- }
- // 去掉千分位等
- $am = str_replace([',', ',', ' '], '', $am);
- if ($am !== '' && is_numeric($am)) {
- return $am;
- }
- }
- return '';
- }
- /**
- * 总分简要文案(仅总分数字;分项见独立字段)
- *
- * @param array<string, mixed> $hit calculate/loadSaved 单项
- */
- public static function formatScoreDetailText(array $hit): string
- {
- return self::formatScore($hit['score'] ?? 0);
- }
- /**
- * 分项得分展示文案(确认/详情表格用)
- *
- * @param array<string, mixed> $hit
- */
- public static function formatScorePartsHtml(array $hit): string
- {
- $qw = (float)($hit['quality_weight'] ?? 0);
- $pw = (float)($hit['price_weight'] ?? 0);
- $lw = (float)($hit['lead_weight'] ?? 0);
- $lines = [];
- if ($qw > 0) {
- $lines[] = '质量得分 ' . self::formatScore($hit['quality_score'] ?? 0);
- }
- if ($pw > 0) {
- $lines[] = '价格得分 ' . self::formatScore($hit['price_score'] ?? 0);
- }
- if ($lw > 0) {
- $lines[] = '交货得分 ' . self::formatScore($hit['lead_score'] ?? 0);
- }
- $lines[] = '总分 ' . self::formatScore($hit['score'] ?? 0);
- return implode("\n", $lines);
- }
- /**
- * 评分规则文案(用于确认页 / 详情展示)
- * 百分比取当前默认规则;权重大于 0 的项才写入公式
- *
- * @param array{quality_weight?:float|int|string,price_weight?:float|int|string,lead_weight?:float|int|string}|null $rule
- */
- public static function formatRuleFormulaText(?array $rule = null): string
- {
- $rule = $rule ?: self::getDefaultRule();
- $qw = (float)($rule['quality_weight'] ?? 50);
- $pw = (float)($rule['price_weight'] ?? 50);
- $lw = (float)($rule['lead_weight'] ?? 0);
- $parts = [];
- if ($qw > 0) {
- $parts[] = '(质量得分×' . self::formatWeightPercent($qw) . '%)';
- }
- if ($pw > 0) {
- $parts[] = '(价格得分)';
- }
- if ($lw > 0) {
- $parts[] = '(交货得分×' . self::formatWeightPercent($lw) . '%)';
- }
- $formula = $parts === []
- ? '(总分)=(质量得分×50%)+(价格得分)'
- : '(总分)=' . implode('+', $parts);
- $formula .= ';质量得分取月度评审「质量得分」(当月按上月起往前查,无则默认50)';
- if ($pw > 0) {
- $formula .= ';价格得分=(最低单价合计/本供应商单价合计)×'
- . self::formatWeightPercent($pw) . '%×100';
- }
- return $formula;
- }
- /**
- * 报价组是否展示交货期留痕列(已落库纳入,或尚无落库且当前默认规则交货分>0)
- *
- * @param array<int, array<string, mixed>> $quoteGroups
- */
- public static function detectShowLeadScore(array $quoteGroups = [], ?array $rule = null): bool
- {
- $hasSavedScore = false;
- foreach ($quoteGroups as $g) {
- if (!is_array($g)) {
- continue;
- }
- if (!empty($g['show_service_lead_score'])) {
- return true;
- }
- if (!empty($g['show_service_score'])) {
- $hasSavedScore = true;
- }
- }
- // 历史单已按旧规则落库且交货期权重为 0:不显示这两列
- if ($hasSavedScore) {
- return false;
- }
- $rule = $rule ?: self::getDefaultRule();
- return (float)($rule['lead_weight'] ?? 0) > 0;
- }
- /**
- * 评分规则提示:优先用传入规则;无则用默认规则
- *
- * @param array<int, array<string, mixed>> $quoteGroups 保留参数兼容调用方
- * @param array{quality_weight?:float|int|string,price_weight?:float|int|string,lead_weight?:float|int|string}|null $rule
- */
- public static function formatRuleFormulaTextForGroups(array $quoteGroups = [], ?array $rule = null): string
- {
- return self::formatRuleFormulaText($rule);
- }
- protected static function formatWeightPercent($n): string
- {
- $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
- return $s === '' ? '0' : $s;
- }
- /**
- * 开标验证通过后:计算并写入供应商服务评分表
- *
- * @param array<int, array<string, mixed>> $quoteGroups
- */
- public static function saveForOrder(string $ccydh, array $quoteGroups, ?string $ym = null): void
- {
- $ccydh = trim($ccydh);
- if ($ccydh === '') {
- return;
- }
- self::ensureSchema();
- $rule = self::resolveRuleForCcydh($ccydh);
- if ($ym === null || $ym === '') {
- $ym = date('Y-m');
- }
- $ym = self::normalizeYm($ym);
- $calc = self::calculateForQuoteGroups($quoteGroups, $rule, $ym);
- if ($calc === []) {
- return;
- }
- $now = date('Y-m-d H:i:s');
- try {
- Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->delete();
- $rows = [];
- foreach ($calc as $it) {
- $rows[] = [
- 'customer_id' => (int)($it['customer_id'] ?? 0),
- 'company_name' => (string)($it['company_name'] ?? ''),
- 'ym' => $ym,
- 'ccydh' => $ccydh,
- 'score' => (float)($it['score'] ?? 0),
- 'rank_no' => (int)($it['rank_no'] ?? 0),
- 'quality_score' => (int)round((float)($it['quality_score'] ?? 0)),
- 'quality_weight' => (int)($rule['quality_weight'] ?? 50),
- 'price_sum' => round((float)($it['price_sum'] ?? 0), 4),
- 'price_weight' => (int)($rule['price_weight'] ?? 50),
- 'price_score' => (float)($it['price_score'] ?? 0),
- 'lead_days_sum' => (int)round((float)($it['lead_days_sum'] ?? 0)),
- 'lead_weight' => (int)($rule['lead_weight'] ?? 0),
- 'lead_enabled' => ((int)($rule['lead_weight'] ?? 0) > 0) ? 1 : 0,
- 'lead_score' => (float)($it['lead_score'] ?? 0),
- 'rule_id' => (int)($rule['id'] ?? 0),
- 'createtime' => $now,
- 'updatetime' => $now,
- ];
- }
- if ($rows !== []) {
- Db::table(self::TABLE_SCORE)->insertAll($rows);
- }
- // 开标后:本月询价供应商名称写入月度评审表(不存在才加空行,已有记录不覆盖)
- $monthNames = [];
- foreach ($quoteGroups as $g) {
- if (!is_array($g)) {
- continue;
- }
- $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
- if ($cn !== '') {
- $monthNames[] = $cn;
- }
- }
- foreach ($rows as $row) {
- $cn = trim((string)($row['company_name'] ?? ''));
- if ($cn !== '') {
- $monthNames[] = $cn;
- }
- }
- self::ensureMonthlySupplierNames($ym, $monthNames);
- } catch (\Throwable $e) {
- Log::write('supplier service score save: ' . $e->getMessage(), 'error');
- }
- }
- /**
- * 读取某订单已落库评分;无则返回空
- *
- * @return array<string, array<string, mixed>>
- */
- public static function loadSavedByCcydh(string $ccydh): array
- {
- $ccydh = trim($ccydh);
- if ($ccydh === '') {
- return [];
- }
- try {
- $rows = Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->select();
- } catch (\Throwable $e) {
- try {
- self::ensureSchema();
- $rows = Db::table(self::TABLE_SCORE)->where('ccydh', $ccydh)->select();
- } catch (\Throwable $e2) {
- return [];
- }
- }
- if (!is_array($rows)) {
- return [];
- }
- $out = [];
- foreach ($rows as $r) {
- if (!is_array($r)) {
- continue;
- }
- $cn = trim((string)($r['company_name'] ?? ''));
- if ($cn === '') {
- continue;
- }
- $qualityScore = (int)round((float)($r['quality_score'] ?? 0));
- $priceSum = round((float)($r['price_sum'] ?? 0), 4);
- $qw = (int)($r['quality_weight'] ?? 50);
- $pw = (int)($r['price_weight'] ?? 50);
- $lw = (int)($r['lead_weight'] ?? 0);
- $leadEnabled = $lw > 0 ? 1 : 0;
- $leadSum = (int)round((float)($r['lead_days_sum'] ?? 0));
- $leadScore = (float)($r['lead_score'] ?? 0);
- $out[$cn] = [
- 'company_name' => $cn,
- 'customer_id' => (int)($r['customer_id'] ?? 0),
- 'quality_score' => $qualityScore,
- 'quality_score_text' => (string)$qualityScore,
- 'quality_weight' => $qw,
- 'price_sum' => $priceSum,
- 'price_sum_text' => self::formatScore($priceSum),
- 'price_weight' => $pw,
- 'price_score' => (float)($r['price_score'] ?? 0),
- 'price_score_text' => self::formatScore($r['price_score'] ?? 0),
- 'lead_days_sum' => $leadSum,
- 'lead_days_sum_text' => $leadEnabled ? (string)$leadSum : '',
- 'lead_weight' => $lw,
- 'lead_enabled' => $leadEnabled,
- 'lead_score' => $leadScore,
- 'lead_score_text' => $leadEnabled ? self::formatScore($leadScore) : '',
- 'score' => (float)($r['score'] ?? 0),
- 'rank_no' => (int)($r['rank_no'] ?? 0),
- 'score_text' => self::formatScore($r['score'] ?? 0),
- 'rank_text' => ((int)($r['rank_no'] ?? 0)) > 0 ? (string)(int)($r['rank_no'] ?? 0) : '',
- '_row_id' => (int)($r['id'] ?? 0),
- ];
- }
- // 按单价合计重算价格得分/总分(兼容历史把权重算进价格得分的数据)
- $minSum = null;
- foreach ($out as $it) {
- $ps = (float)($it['price_sum'] ?? 0);
- if ($ps > 0 && ($minSum === null || $ps < $minSum)) {
- $minSum = $ps;
- }
- }
- foreach ($out as $cn => &$it) {
- $qw = (int)($it['quality_weight'] ?? 50);
- $pw = (int)($it['price_weight'] ?? 50);
- $lw = (int)($it['lead_weight'] ?? 0);
- $priceSum = (float)($it['price_sum'] ?? 0);
- // 单价未填写:总分为 0
- if ($pw > 0 && $priceSum <= 0) {
- $it['price_score'] = 0.0;
- $it['price_score_text'] = '0';
- $it['score'] = 0.0;
- $it['score_text'] = '0';
- continue;
- }
- if ($pw > 0 && $priceSum > 0 && $minSum !== null && $minSum > 0) {
- $ratio = round($minSum / $priceSum, 4);
- $it['price_score'] = round($ratio * ($pw / 100) * 100, 2);
- } else {
- $it['price_score'] = 0.0;
- }
- $it['price_score_text'] = self::formatScore($it['price_score']);
- $total = 0.0;
- if ($qw > 0) {
- $total += (float)$it['quality_score'] * ($qw / 100);
- }
- if ($pw > 0) {
- $total += (float)$it['price_score'];
- }
- if ($lw > 0) {
- $total += (float)$it['lead_score'] * ($lw / 100);
- }
- $it['score'] = round($total, 2);
- $it['score_text'] = self::formatScore($it['score']);
- }
- unset($it);
- $sorted = $out;
- uasort($sorted, static function ($a, $b) {
- $sa = (float)($a['score'] ?? 0);
- $sb = (float)($b['score'] ?? 0);
- if ($sa !== $sb) {
- return $sb <=> $sa;
- }
- return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
- });
- $rank = 0;
- foreach ($sorted as $cn => $row) {
- $rank++;
- $oldRank = (int)($out[$cn]['rank_no'] ?? 0);
- $out[$cn]['rank_no'] = $rank;
- $out[$cn]['rank_text'] = (string)$rank;
- $rowId = (int)($out[$cn]['_row_id'] ?? 0);
- if ($rowId > 0) {
- $upd = [];
- if ($oldRank !== $rank) {
- $upd['rank_no'] = $rank;
- }
- foreach ($rows as $r) {
- if (!is_array($r) || (int)($r['id'] ?? 0) !== $rowId) {
- continue;
- }
- if (abs((float)($r['price_score'] ?? 0) - (float)$out[$cn]['price_score']) > 0.001) {
- $upd['price_score'] = $out[$cn]['price_score'];
- }
- if (abs((float)($r['score'] ?? 0) - (float)$out[$cn]['score']) > 0.001) {
- $upd['score'] = $out[$cn]['score'];
- }
- break;
- }
- if ($upd !== []) {
- try {
- Db::table(self::TABLE_SCORE)->where('id', $rowId)->update($upd);
- } catch (\Throwable $e) {
- }
- }
- }
- unset($out[$cn]['_row_id']);
- }
- return $out;
- }
- /**
- * 清空报价组上的评分留痕字段
- *
- * @param array<string, mixed> $g
- */
- protected static function clearScoreTrailOnGroup(array &$g, int $showFlag = 0): void
- {
- $g['service_rank'] = '';
- $g['service_score'] = '';
- $g['service_rank_text'] = '';
- $g['service_score_text'] = '';
- $g['service_score_parts_text'] = '';
- $g['service_quality_score'] = '';
- $g['service_quality_score_text'] = '';
- $g['service_price_sum'] = '';
- $g['service_price_sum_text'] = '';
- $g['service_price_score'] = '';
- $g['service_price_score_text'] = '';
- $g['service_lead_days_sum'] = '';
- $g['service_lead_days_sum_text'] = '';
- $g['service_lead_score'] = '';
- $g['service_lead_score_text'] = '';
- $g['service_lead_enabled'] = 0;
- $g['service_quality_weight'] = '';
- $g['service_price_weight'] = '';
- $g['service_lead_weight'] = '';
- $g['show_service_score'] = $showFlag;
- $g['show_service_lead_score'] = 0;
- }
- /**
- * 把排名/评分挂到报价组上(开标后可见)
- * 详情等读路径:优先读已落库评分,避免每次打开重算写库拖慢弹窗
- *
- * @param array<int, array<string, mixed>> $quoteGroups
- * @return array<int, array<string, mixed>>
- */
- public static function attachToQuoteGroups(array $quoteGroups, string $ccydh, bool $quoteVisible): array
- {
- if (!$quoteVisible || $quoteGroups === []) {
- foreach ($quoteGroups as &$g) {
- if (!is_array($g)) {
- continue;
- }
- self::clearScoreTrailOnGroup($g, 0);
- }
- unset($g);
- return $quoteGroups;
- }
- $saved = self::loadSavedByCcydh($ccydh);
- $needCalc = ($saved === []);
- if (!$needCalc) {
- $anyHit = false;
- foreach ($quoteGroups as $g) {
- if (!is_array($g)) {
- continue;
- }
- $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
- if ($cn !== '' && isset($saved[$cn])) {
- $anyHit = true;
- break;
- }
- }
- $needCalc = !$anyHit;
- }
- // 无落库记录时仅内存计算用于展示;落库由开标验证 saveForOrder 负责
- if ($needCalc) {
- $saved = self::calculateForQuoteGroups($quoteGroups, null, date('Y-m'));
- }
- foreach ($quoteGroups as &$g) {
- if (!is_array($g)) {
- continue;
- }
- $cn = trim((string)($g['name'] ?? $g['company_name'] ?? ''));
- $hit = ($cn !== '' && isset($saved[$cn]) && is_array($saved[$cn])) ? $saved[$cn] : null;
- if (is_array($hit)) {
- $g['service_rank'] = (int)($hit['rank_no'] ?? 0);
- $g['service_score'] = (float)($hit['score'] ?? 0);
- $g['service_rank_text'] = (string)($hit['rank_text'] ?? '');
- $g['service_score_text'] = self::formatScoreDetailText($hit);
- $g['service_score_parts_text'] = self::formatScorePartsHtml($hit);
- $qs = round((float)($hit['quality_score'] ?? 0), 2);
- $ps = (float)($hit['price_score'] ?? 0);
- $psum = round((float)($hit['price_sum'] ?? 0), 2);
- $leadEnabled = ((int)($hit['lead_weight'] ?? 0) > 0) ? 1 : 0;
- $ls = (float)($hit['lead_score'] ?? 0);
- $lsum = (int)round((float)($hit['lead_days_sum'] ?? 0));
- $g['service_quality_score'] = $qs;
- $g['service_quality_score_text'] = (string)($hit['quality_score_text'] ?? self::formatScore($qs));
- $g['service_price_sum'] = $psum;
- $g['service_price_sum_text'] = (string)($hit['price_sum_text'] ?? $psum);
- $g['service_price_score'] = $ps;
- $g['service_price_score_text'] = (string)($hit['price_score_text'] ?? self::formatScore($ps));
- $g['service_lead_enabled'] = $leadEnabled;
- $g['show_service_lead_score'] = $leadEnabled;
- $g['service_lead_days_sum'] = $lsum;
- $g['service_lead_days_sum_text'] = $leadEnabled ? (string)($hit['lead_days_sum_text'] ?? $lsum) : '';
- $g['service_lead_score'] = $ls;
- $g['service_lead_score_text'] = $leadEnabled ? (string)($hit['lead_score_text'] ?? self::formatScore($ls)) : '';
- $g['service_quality_weight'] = (int)($hit['quality_weight'] ?? 50);
- $g['service_price_weight'] = (int)($hit['price_weight'] ?? 50);
- $g['service_lead_weight'] = (int)($hit['lead_weight'] ?? 0);
- $g['show_service_score'] = 1;
- } else {
- self::clearScoreTrailOnGroup($g, 1);
- }
- }
- unset($g);
- // 开标后按排名升序(第1名在最前);无排名的放最后
- usort($quoteGroups, function ($a, $b) {
- $ra = is_array($a) ? (int)($a['service_rank'] ?? 0) : 0;
- $rb = is_array($b) ? (int)($b['service_rank'] ?? 0) : 0;
- $ha = $ra > 0;
- $hb = $rb > 0;
- if ($ha !== $hb) {
- return $ha ? -1 : 1;
- }
- if ($ha && $ra !== $rb) {
- return $ra <=> $rb;
- }
- $sa = is_array($a) ? (float)($a['service_score'] ?? 0) : 0.0;
- $sb = is_array($b) ? (float)($b['service_score'] ?? 0) : 0.0;
- if ($sa !== $sb) {
- return $sb <=> $sa;
- }
- $na = is_array($a) ? trim((string)($a['name'] ?? $a['company_name'] ?? '')) : '';
- $nb = is_array($b) ? trim((string)($b['name'] ?? $b['company_name'] ?? '')) : '';
- return strcmp($na, $nb);
- });
- return $quoteGroups;
- }
- }
|