| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683 |
- <?php
- namespace app\common\library;
- use think\Db;
- use think\Log;
- /**
- * 协助采购 — 供应商服务评分
- *
- * 表分工:
- * - supplier_service_score:订单明细(开标后按 ccydh×供应商写入,含本单总分 score)
- * - supplier_service_final_score:月度评审表(开标后写入本月询价供应商名称;分项与等级人工填写,已存在不覆盖)
- *
- * 总分 = 商务/技术得分×质量% + 价格得分 [+ 交货得分×交货%]
- * 价格得分 = (本单最低单价合计 / 本供应商单价合计) × 价格权重% × 100
- * (计算过程保留四位小数,得分保留两位)
- * 商务/技术得分 = 月度评审表「商务/技术得分」(当月按上月起往前查,都无则默认 50)
- * 权重取 supplier_score_rule 中 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;
- public static function ensureSchema(): void
- {
- if (self::$schemaReady === true) {
- return;
- }
- // 跨请求缓存:避免每次打开详情都 SHOW COLUMNS / ALTER 探测
- try {
- if (\think\Cache::get('procuremen_supplier_score_schema_ok_v1')) {
- self::$schemaReady = true;
- return;
- }
- } 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::$schemaReady = true;
- self::ensureDefaultRule();
- self::ensureScoreWeightColumns();
- self::ensureLeadColumns();
- self::ensureIntegerColumns();
- self::ensureFinalScoreTable();
- try {
- \think\Cache::set('procuremen_supplier_score_schema_ok_v1', 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::$schemaReady = true;
- self::ensureDefaultRule();
- self::ensureScoreWeightColumns();
- self::ensureLeadColumns();
- self::ensureIntegerColumns();
- self::ensureFinalScoreTable();
- try {
- \think\Cache::set('procuremen_supplier_score_schema_ok_v1', 1, 86400);
- } catch (\Throwable $e) {
- }
- }
- /** 月度记录表(商务技术分 / 价格分 / 最终得分,仅本页展示与导出) */
- public static function ensureFinalScoreTable(): void
- {
- $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 '交货分(人工,空=未填)',
- `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();
- }
- /** 历史月度表补商务技术分/价格分等字段 */
- 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`",
- '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' => '交货分(人工,空=未填)',
- '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 '';
- }
- /**
- * 按订单明细重算并写入某月评审记录(商务技术分/价格分;保留已填最终得分)
- *
- * @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;
- $avgQuality = $cnt > 0 ? round($a['quality_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 !== []) {
- $upd = [
- 'score' => $avgScore,
- 'updatetime' => $now,
- ];
- // 人工改过商务技术分/价格分/交货分后,同步不再覆盖
- if ((int)($exists['qp_manual'] ?? 0) !== 1) {
- $upd['quality_score'] = $avgQuality;
- $upd['price_score'] = $avgPrice;
- $upd['delivery_score'] = $avgDelivery;
- }
- 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' => $avgQuality,
- 'price_score' => $avgPrice,
- 'delivery_score' => $avgDelivery,
- '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,
- '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);
- }
- /**
- * 读取某月评审记录(仅读月度表;先按当月开标/询价供应商补齐名称)
- *
- * @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);
- try {
- $rows = Db::table(self::TABLE_FINAL)
- ->where('ym', $ym)
- ->field('company_name,score,quality_score,price_score,delivery_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);
- $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,
- '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);
- $final = self::parseOptionalScore($it['final_score'] ?? null);
- if ($quality === false || $price === false || $delivery === false || $final === false) {
- continue;
- }
- $hasQp = array_key_exists('quality_score', $it)
- || array_key_exists('price_score', $it)
- || array_key_exists('delivery_score', $it);
- try {
- $exists = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
- $upd = [
- 'final_score' => $final,
- 'score_grade' => $grade,
- 'updatetime' => $now,
- ];
- if ($hasQp) {
- // 空值存 NULL,列表显示空白而不是 0
- $upd['quality_score'] = $quality;
- $upd['price_score'] = $price;
- $upd['delivery_score'] = $delivery;
- $upd['score'] = round(($quality ?? 0) + ($price ?? 0) + ($delivery ?? 0), 2);
- $upd['qp_manual'] = 1;
- }
- 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 ? round(($quality ?? 0) + ($price ?? 0) + ($delivery ?? 0), 2) : 0,
- 'quality_score' => $hasQp ? $quality : null,
- 'price_score' => $hasQp ? $price : null,
- 'delivery_score' => $hasQp ? $delivery : null,
- 'final_score' => $final,
- 'score_grade' => $grade,
- 'qp_manual' => $hasQp ? 1 : 0,
- '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`",
- 'lead_enabled' => "ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=交货期纳入总分' AFTER `lead_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
- {
- try {
- $cnt = (int)Db::table(self::TABLE_RULE)->count();
- if ($cnt > 0) {
- $hasDefault = Db::table(self::TABLE_RULE)->where('is_default', 1)->find();
- if (!is_array($hasDefault)) {
- $first = Db::table(self::TABLE_RULE)->order('id', 'asc')->find();
- if (is_array($first)) {
- Db::table(self::TABLE_RULE)->where('id', (int)$first['id'])->update([
- 'is_default' => 1,
- 'updatetime' => date('Y-m-d H:i:s'),
- ]);
- }
- }
- return;
- }
- $now = date('Y-m-d H:i:s');
- Db::table(self::TABLE_RULE)->insert([
- 'name' => '默认规则(商务/技术50%+单价50%)',
- 'quality_weight' => 50,
- 'price_weight' => 50,
- 'is_default' => 1,
- 'status' => 'normal',
- 'createtime' => $now,
- 'updatetime' => $now,
- ]);
- } catch (\Throwable $e) {
- Log::write('supplier score ensureDefaultRule: ' . $e->getMessage(), 'error');
- }
- }
- /**
- * @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)
- ->where('status', 'normal')
- ->order('id', 'desc')
- ->find();
- if (!is_array($row)) {
- $row = Db::table(self::TABLE_RULE)->where('status', 'normal')->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,
- // 权重大于 0 即纳入;兼容旧 lead_enabled 字段
- '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 保留参数兼容调用方,不参与文案
- */
- public static function formatRuleFormulaTextForGroups(array $quoteGroups = []): string
- {
- return self::formatRuleFormulaText();
- }
- 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::getDefaultRule();
- 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;
- }
- }
|