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|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 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 $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 */ 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 */ 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 $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 */ 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 $companyNames * @return array 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 $companyNames * @return array */ 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> $quoteGroups loadAuditSupplierQuoteGroups 结构(name/lines[].amount) * @param string|null $asOfYm 订单所属月;商务/技术分从该月的上月起往前查评审表 * @return array */ 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 $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 $hit calculate/loadSaved 单项 */ public static function formatScoreDetailText(array $hit): string { return self::formatScore($hit['score'] ?? 0); } /** * 分项得分展示文案(确认/详情表格用) * * @param array $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> $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> $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> $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> */ 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 $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> $quoteGroups * @return array> */ 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; } }