m0_70156489 7 часов назад
Родитель
Сommit
38ae24e665

+ 569 - 129
application/common/library/ProcuremenSupplierScore.php

@@ -10,14 +10,17 @@ use think\Log;
  *
  * 表分工:
  * - supplier_service_score:订单明细(开标后按 ccydh×供应商写入,含本单总分 score)
- * - supplier_service_final_score:月度评审表(开标后写入本月询价供应商名称;分项与等级人工填写,已存在不覆盖)
+ * - supplier_service_final_score:月度评审表(四项自动统计,满分 105;qp_manual=1 手工锁定不覆盖)
  *
- * 总分 = 质量得分×质量% + 价格得分 [+ 交货得分×交货%]
- * 价格得分 = (本单最低单价合计 / 本供应商单价合计) × 价格权重% × 100
- * (计算过程保留四位小数,得分保留两位)
- * 质量得分 = 月度评审表「质量得分」(当月按上月起往前查,都无则默认 50)
- * 权重优先取 purchase_order 下发时快照(score_*_weight);无快照再按 score_rule_id 读规则表;再否则 is_default=1
- * 开标验证通过后写入订单明细评分,并补齐月度评审供应商名称
+ * 开标单笔总分 = 质量得分×质量% + 价格得分 [+ 交货得分×交货%]
+ * 开标价格得分 = (本单最低单价合计 / 本供应商单价合计) × 价格权重% × 100
+ *
+ * 月度评审(与开标单笔分离):
+ * 质量40 = 合格率对照(20) + 客户投诉(10) + 生产中断(10)
+ * 价格15 = 全部严格低于最高限价→15,否则(等于限价/限价空)→10
+ * 交货40 = 准时率对照(30) + 生产中断(10,出现一次得0)
+ * 增值10 = 未报价订单数≤3→10,>3→0(按订单号去重)
+ * 最终得分 = 四项相加;等级 A≥90 / B≥70 / C≥60 / D
  */
 class ProcuremenSupplierScore
 {
@@ -555,7 +558,7 @@ class ProcuremenSupplierScore
     }
 
     /**
-     * 合格率百分比 → 质量得分(图三对照
+     * 合格率百分比 → 质量「合格率」分项(满分 20
      * 100→20;95-99→18;90-94→16;85-89→14;70-84→12;<70→10
      */
     public static function mapPassRatePercentToQualityScore(float $passRatePercent): float
@@ -580,51 +583,81 @@ class ProcuremenSupplierScore
     }
 
     /**
-     * 按入库评分统计某供应商某月质量得分
-     * 不合格率=(不合格订单数/总订单数)×100%;合格率=100%-不合格率;再对照得分表
-     * 同一订单号多工序只计 1 单;任一工序不合格则整单不合格
-     * 优先读入库评分表;表空时回退操作日志(入库评分)
+     * 交货准时率百分比 → 交货「准时率」分项(满分 30)
+     * 100→30;95-99→20;90-94→15;85-89→10;70-84→5;<70→0
+     */
+    public static function mapOnTimeRatePercentToDeliveryScore(float $onTimeRatePercent): float
+    {
+        if ($onTimeRatePercent >= 100) {
+            return 30.0;
+        }
+        if ($onTimeRatePercent >= 95) {
+            return 20.0;
+        }
+        if ($onTimeRatePercent >= 90) {
+            return 15.0;
+        }
+        if ($onTimeRatePercent >= 85) {
+            return 10.0;
+        }
+        if ($onTimeRatePercent >= 70) {
+            return 5.0;
+        }
+
+        return 0.0;
+    }
+
+    /**
+     * 解析金额/限价;空或非数字返回 null
      *
-     * @return float|null 无入库评分订单时返回 null
+     * @param mixed $raw
      */
-    public static function calcQualityScoreFromInbound(string $companyName, string $ym): ?float
+    public static function parseMoneyValue($raw): ?float
     {
-        $companyName = trim($companyName);
-        $ym = self::formatScoreYm($ym);
-        if ($companyName === '' || $ym === '') {
+        if ($raw === null) {
             return null;
         }
-        $byOrder = self::loadInboundOrderResultsByCompanyYm($companyName, $ym);
-        $total = count($byOrder);
-        if ($total < 1) {
+        $s = trim((string)$raw);
+        if ($s === '') {
             return null;
         }
-        $fail = 0;
-        foreach ($byOrder as $res) {
-            if ($res === '不合格') {
-                $fail++;
-            }
+        $s = str_replace([',', ',', ' '], '', $s);
+        if ($s === '' || !is_numeric($s)) {
+            return null;
         }
-        // 不合格率 = 不合格次数/总订单×100%;合格率 = 100% - 不合格率
-        $passRate = (1 - ($fail / $total)) * 100;
 
-        return self::mapPassRatePercentToQualityScore($passRate);
+        return (float)$s;
     }
 
     /**
-     * 某供应商某月:订单号 => 合格|不合格
+     * 报价是否视为未填(空 / 0 / 0.00)
      *
-     * @return array<string, string>
+     * @param mixed $raw
      */
-    public static function loadInboundOrderResultsByCompanyYm(string $companyName, string $ym): array
+    public static function isBlankQuoteAmount($raw): bool
+    {
+        $v = self::parseMoneyValue($raw);
+        if ($v === null) {
+            return true;
+        }
+
+        return abs($v) < 0.0000001;
+    }
+
+    /**
+     * 某供应商某月入库评分订单聚合(按订单号去重)
+     *
+     * @return array<string, array{result:string,delivery_status:string,customer_complaint:int,order_interrupt:int}>
+     */
+    public static function loadInboundOrderAggByCompanyYm(string $companyName, string $ym): array
     {
         $companyName = trim($companyName);
         $ym = self::formatScoreYm($ym);
         if ($companyName === '' || $ym === '') {
             return [];
         }
+        /** @var array<string, array{result:string,delivery_status:string,customer_complaint:int,order_interrupt:int}> $byOrder */
         $byOrder = [];
-        // 1) 入库评分表
         try {
             $rows = Db::table('purchase_order_inbound_score')
                 ->where('company_name', $companyName)
@@ -632,11 +665,22 @@ class ProcuremenSupplierScore
                     $q->where('updatetime', 'like', $ym . '%')
                         ->whereOr('createtime', 'like', $ym . '%');
                 })
-                ->field('ccydh,result')
+                ->field('ccydh,result,delivery_status,customer_complaint,order_interrupt')
                 ->select();
         } catch (\Throwable $e) {
-            $rows = [];
-            Log::write('loadInboundOrderResults table: ' . $e->getMessage(), 'error');
+            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 $e2) {
+                $rows = [];
+                Log::write('loadInboundOrderAgg table: ' . $e2->getMessage(), 'error');
+            }
         }
         if (is_array($rows)) {
             foreach ($rows as $r) {
@@ -648,15 +692,75 @@ class ProcuremenSupplierScore
                 if ($ccydh === '' || ($res !== '合格' && $res !== '不合格')) {
                     continue;
                 }
-                if (!isset($byOrder[$ccydh]) || $res === '不合格') {
-                    $byOrder[$ccydh] = $res;
+                $ds = trim((string)($r['delivery_status'] ?? ''));
+                if ($ds !== '准时' && $ds !== '滞后') {
+                    $ds = '';
+                }
+                $complaint = (int)($r['customer_complaint'] ?? 0) === 1 ? 1 : 0;
+                $interrupt = (int)($r['order_interrupt'] ?? 0) === 1 ? 1 : 0;
+                if (!isset($byOrder[$ccydh])) {
+                    $byOrder[$ccydh] = [
+                        'result'             => $res,
+                        'delivery_status'    => $ds,
+                        'customer_complaint' => $complaint,
+                        'order_interrupt'    => $interrupt,
+                    ];
+                    continue;
+                }
+                if ($res === '不合格') {
+                    $byOrder[$ccydh]['result'] = '不合格';
+                }
+                if ($byOrder[$ccydh]['delivery_status'] === '' && $ds !== '') {
+                    $byOrder[$ccydh]['delivery_status'] = $ds;
+                }
+                if ($complaint === 1) {
+                    $byOrder[$ccydh]['customer_complaint'] = 1;
+                }
+                if ($interrupt === 1) {
+                    $byOrder[$ccydh]['order_interrupt'] = 1;
                 }
             }
         }
         if ($byOrder !== []) {
             return $byOrder;
         }
-        // 2) 回退:操作日志「入库评分」+ 采购单供应商
+        // 回退:操作日志仅能还原合格/不合格
+        $legacy = self::loadInboundOrderResultsFromOperLogs($companyName, $ym);
+        foreach ($legacy as $ccydh => $res) {
+            $byOrder[$ccydh] = [
+                'result'             => $res,
+                'delivery_status'    => '',
+                'customer_complaint' => 0,
+                'order_interrupt'    => 0,
+            ];
+        }
+
+        return $byOrder;
+    }
+
+    /**
+     * 某供应商某月:订单号 => 合格|不合格(兼容旧调用)
+     *
+     * @return array<string, string>
+     */
+    public static function loadInboundOrderResultsByCompanyYm(string $companyName, string $ym): array
+    {
+        $out = [];
+        foreach (self::loadInboundOrderAggByCompanyYm($companyName, $ym) as $ccydh => $agg) {
+            $out[$ccydh] = (string)($agg['result'] ?? '');
+        }
+
+        return $out;
+    }
+
+    /**
+     * 操作日志回退:订单号 => 合格|不合格
+     *
+     * @return array<string, string>
+     */
+    protected static function loadInboundOrderResultsFromOperLogs(string $companyName, string $ym): array
+    {
+        $byOrder = [];
         try {
             $logs = Db::table('purchase_order_oper_log')
                 ->where(function ($q) {
@@ -723,99 +827,413 @@ class ProcuremenSupplierScore
     }
 
     /**
-     * 用入库合格/不合格重算并写入月度「质量得分」
-     * 已手工保存(qp_manual=1)的供应商跳过,不再覆盖
+     * 质量得分(满分 40)= 合格率分(20) + 客户投诉(10) + 生产中断(10)
      *
-     * @param string|null $onlyCompany 仅同步该供应商;null=当月有入库评分的全部
+     * @return float|null 无入库评分订单时返回 null
      */
-    public static function syncQualityScoreFromInbound(string $ym, ?string $onlyCompany = null): void
+    public static function calcQualityScoreFromInbound(string $companyName, string $ym): ?float
     {
+        $companyName = trim($companyName);
         $ym = self::formatScoreYm($ym);
-        if ($ym === '') {
-            return;
+        if ($companyName === '' || $ym === '') {
+            return null;
         }
-        self::ensureFinalScoreTable();
-        // 先尽量从操作日志回填入库评分表(防止表数据丢失导致档案/计算为空)
-        self::repairInboundScoreFromOperLogs($ym);
-        $companies = [];
-        $onlyCompany = $onlyCompany !== null ? trim($onlyCompany) : '';
-        if ($onlyCompany !== '') {
-            $companies = [$onlyCompany];
+        $byOrder = self::loadInboundOrderAggByCompanyYm($companyName, $ym);
+        $total = count($byOrder);
+        if ($total < 1) {
+            return null;
+        }
+        $fail = 0;
+        $complaintAny = false;
+        $interruptCnt = 0;
+        foreach ($byOrder as $agg) {
+            if (($agg['result'] ?? '') === '不合格') {
+                $fail++;
+            }
+            if ((int)($agg['customer_complaint'] ?? 0) === 1) {
+                $complaintAny = true;
+            }
+            if ((int)($agg['order_interrupt'] ?? 0) === 1) {
+                $interruptCnt++;
+            }
+        }
+        $passRate = (1 - ($fail / $total)) * 100;
+        $passScore = self::mapPassRatePercentToQualityScore($passRate);
+        $complaintScore = $complaintAny ? 0.0 : 10.0;
+        if ($interruptCnt >= 3) {
+            $interruptScore = 0.0;
         } else {
+            $interruptScore = max(0.0, 10.0 - 2.0 * $interruptCnt);
+        }
+
+        return round($passScore + $complaintScore + $interruptScore, 2);
+    }
+
+    /**
+     * 交货得分(满分 40)= 准时率分(30) + 生产中断(10,出现一次得 0)
+     *
+     * @return float|null 无入库评分订单时返回 null
+     */
+    public static function calcDeliveryScoreFromInbound(string $companyName, string $ym): ?float
+    {
+        $companyName = trim($companyName);
+        $ym = self::formatScoreYm($ym);
+        if ($companyName === '' || $ym === '') {
+            return null;
+        }
+        $byOrder = self::loadInboundOrderAggByCompanyYm($companyName, $ym);
+        $total = count($byOrder);
+        if ($total < 1) {
+            return null;
+        }
+        $late = 0;
+        $interruptAny = false;
+        foreach ($byOrder as $agg) {
+            if (($agg['delivery_status'] ?? '') === '滞后') {
+                $late++;
+            }
+            if ((int)($agg['order_interrupt'] ?? 0) === 1) {
+                $interruptAny = true;
+            }
+        }
+        $onTimeRate = (1 - ($late / $total)) * 100;
+        $onTimeScore = self::mapOnTimeRatePercentToDeliveryScore($onTimeRate);
+        $interruptScore = $interruptAny ? 0.0 : 10.0;
+
+        return round($onTimeScore + $interruptScore, 2);
+    }
+
+    /**
+     * 价格得分(满分 15):全部严格低于最高限价→15;任一单等于限价或限价为空→10
+     *
+     * @return float|null 无比价订单时返回 null
+     */
+    public static function calcPriceScoreFromCeiling(string $companyName, string $ym): ?float
+    {
+        $companyName = trim($companyName);
+        $ym = self::formatScoreYm($ym);
+        if ($companyName === '' || $ym === '') {
+            return null;
+        }
+        $byOrder = self::loadInboundOrderAggByCompanyYm($companyName, $ym);
+        if ($byOrder === []) {
+            return null;
+        }
+        $ccydhList = array_keys($byOrder);
+        $compared = 0;
+        $allStrictlyBelow = true;
+        try {
+            $poRows = Db::table('purchase_order')
+                ->alias('po')
+                ->join('purchase_order_detail d', 'd.scydgy_id = po.scydgy_id')
+                ->where('po.CCYDH', 'in', $ccydhList)
+                ->where('d.company_name', $companyName)
+                ->whereRaw(
+                    '(po.mod_rq IS NULL OR TRIM(CAST(po.mod_rq AS CHAR(32))) = \'\' OR TRIM(CAST(po.mod_rq AS CHAR(32))) LIKE \'0000-00-00%\')'
+                )
+                ->field('po.CCYDH,po.ceilingPrice,d.amount')
+                ->select();
+        } catch (\Throwable $e) {
             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')
+                $poRows = Db::table('purchase_order')
+                    ->alias('po')
+                    ->join('purchase_order_detail d', 'd.scydgy_id = po.scydgy_id')
+                    ->where('po.CCYDH', 'in', $ccydhList)
+                    ->where('d.company_name', $companyName)
+                    ->field('po.CCYDH,po.ceilingPrice,d.amount')
                     ->select();
-            } catch (\Throwable $e) {
-                $rows = [];
-                Log::write('syncQualityScoreFromInbound list: ' . $e->getMessage(), 'error');
+            } catch (\Throwable $e2) {
+                Log::write('calcPriceScoreFromCeiling: ' . $e2->getMessage(), 'error');
+
+                return null;
             }
-            if (is_array($rows)) {
-                foreach ($rows as $r) {
-                    if (!is_array($r)) {
-                        continue;
-                    }
-                    $cn = trim((string)($r['company_name'] ?? ''));
-                    if ($cn !== '') {
-                        $companies[$cn] = true;
-                    }
+        }
+        if (!is_array($poRows) || $poRows === []) {
+            return null;
+        }
+        /** @var array<string, bool> $orderStrictBelow 订单是否全部严格低于限价 */
+        $orderStrictBelow = [];
+        foreach ($poRows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $ccydh = trim((string)($r['CCYDH'] ?? $r['ccydh'] ?? ''));
+            if ($ccydh === '' || !isset($byOrder[$ccydh])) {
+                continue;
+            }
+            if (self::isBlankQuoteAmount($r['amount'] ?? null)) {
+                continue;
+            }
+            $amount = self::parseMoneyValue($r['amount'] ?? null);
+            if ($amount === null) {
+                continue;
+            }
+            $compared++;
+            if (!isset($orderStrictBelow[$ccydh])) {
+                $orderStrictBelow[$ccydh] = true;
+            }
+            $ceiling = self::parseMoneyValue($r['ceilingPrice'] ?? $r['ceiling_price'] ?? null);
+            if ($ceiling === null) {
+                // 限价空 → 该单非严格低于
+                $orderStrictBelow[$ccydh] = false;
+                continue;
+            }
+            // 严格低于才算;等于或高于都不算
+            if ($amount >= $ceiling - 0.0000001) {
+                $orderStrictBelow[$ccydh] = false;
+            }
+        }
+        if ($compared < 1 || $orderStrictBelow === []) {
+            return null;
+        }
+        foreach ($orderStrictBelow as $ok) {
+            if (!$ok) {
+                $allStrictlyBelow = false;
+                break;
+            }
+        }
+
+        return $allStrictlyBelow ? 15.0 : 10.0;
+    }
+
+    /**
+     * 增值服务(满分 10):未报价订单数(按订单号去重)≤3 →10;>3 →0
+     *
+     * @return float|null 当月无询价记录时返回 null
+     */
+    public static function calcValueAddedScoreFromUnquoted(string $companyName, string $ym): ?float
+    {
+        $companyName = trim($companyName);
+        $ym = self::formatScoreYm($ym);
+        if ($companyName === '' || $ym === '') {
+            return null;
+        }
+        $stats = self::loadUnquotedOrderStatsByCompanyYm($companyName, $ym);
+        if ($stats === null) {
+            return null;
+        }
+        $unquoted = (int)($stats['unquoted'] ?? 0);
+
+        return $unquoted <= 3 ? 10.0 : 0.0;
+    }
+
+    /**
+     * 某供应商某月询价订单:总数 / 未报价数(均按 CCYDH 去重)
+     *
+     * @return array{total:int,unquoted:int}|null 无询价记录时 null
+     */
+    public static function loadUnquotedOrderStatsByCompanyYm(string $companyName, string $ym): ?array
+    {
+        $companyName = trim($companyName);
+        $ym = self::formatScoreYm($ym);
+        if ($companyName === '' || $ym === '') {
+            return null;
+        }
+        $ymEsc = str_replace(["\\", "'", '%', '_'], ["\\\\", "''", '\\%', '\\_'], $ym);
+        try {
+            $rows = Db::table('purchase_order_detail')
+                ->alias('d')
+                ->join('purchase_order po', 'po.scydgy_id = d.scydgy_id')
+                ->where('d.company_name', $companyName)
+                ->whereRaw(
+                    "("
+                    . "CASE "
+                    . "WHEN po.createtime IS NOT NULL AND CAST(po.createtime AS CHAR(32)) REGEXP '^[0-9]{10,}$' "
+                    . "THEN FROM_UNIXTIME(CAST(po.createtime AS UNSIGNED)) "
+                    . "WHEN po.createtime IS NOT NULL AND TRIM(CAST(po.createtime AS CHAR(32))) <> '' "
+                    . "AND TRIM(CAST(po.createtime AS CHAR(32))) NOT LIKE '0000-00-00%' "
+                    . "THEN TRIM(CAST(po.createtime AS CHAR(32))) "
+                    . "ELSE TRIM(CAST(IFNULL(po.dStamp, '') AS CHAR(32))) "
+                    . "END"
+                    . ") LIKE '{$ymEsc}-%'"
+                )
+                ->whereRaw(
+                    '(po.mod_rq IS NULL OR TRIM(CAST(po.mod_rq AS CHAR(32))) = \'\' OR TRIM(CAST(po.mod_rq AS CHAR(32))) LIKE \'0000-00-00%\')'
+                )
+                ->field('po.CCYDH,d.amount')
+                ->select();
+        } catch (\Throwable $e) {
+            Log::write('loadUnquotedOrderStats: ' . $e->getMessage(), 'error');
+
+            return null;
+        }
+        if (!is_array($rows) || $rows === []) {
+            return null;
+        }
+        /** @var array<string, bool> $quotedByOrder 订单是否至少有一条有效报价 */
+        $quotedByOrder = [];
+        foreach ($rows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $ccydh = trim((string)($r['CCYDH'] ?? $r['ccydh'] ?? ''));
+            if ($ccydh === '') {
+                continue;
+            }
+            if (!isset($quotedByOrder[$ccydh])) {
+                $quotedByOrder[$ccydh] = false;
+            }
+            if (!self::isBlankQuoteAmount($r['amount'] ?? null)) {
+                $quotedByOrder[$ccydh] = true;
+            }
+        }
+        if ($quotedByOrder === []) {
+            return null;
+        }
+        $total = count($quotedByOrder);
+        $unquoted = 0;
+        foreach ($quotedByOrder as $quoted) {
+            if (!$quoted) {
+                $unquoted++;
+            }
+        }
+
+        return ['total' => $total, 'unquoted' => $unquoted];
+    }
+
+    /**
+     * 收集某月需重算的供应商名称(入库评分 + 当月询价明细)
+     *
+     * @return string[]
+     */
+    public static function collectMonthlyReviewCompanyNames(string $ym, ?string $onlyCompany = null): array
+    {
+        $ym = self::formatScoreYm($ym);
+        if ($ym === '') {
+            return [];
+        }
+        $onlyCompany = $onlyCompany !== null ? trim($onlyCompany) : '';
+        if ($onlyCompany !== '') {
+            return [$onlyCompany];
+        }
+        $companies = [];
+        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('collectMonthlyReviewCompanyNames inbound: ' . $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 {
-                $logs = Db::table('purchase_order_oper_log')
-                    ->where(function ($q) {
-                        $q->where('action', '入库评分')->whereOr('action', 'inbound_score');
-                    })
-                    ->where('createtime', 'like', $ym . '%')
-                    ->field('scydgy_id')
+                $poRows = Db::table('purchase_order')
+                    ->where('scydgy_id', 'in', array_keys($sids))
+                    ->field('pick_company_name')
+                    ->group('pick_company_name')
                     ->select();
             } catch (\Throwable $e) {
-                $logs = [];
+                $poRows = [];
             }
-            $sids = [];
-            if (is_array($logs)) {
-                foreach ($logs as $lg) {
-                    if (!is_array($lg)) {
+            if (is_array($poRows)) {
+                foreach ($poRows as $po) {
+                    if (!is_array($po)) {
                         continue;
                     }
-                    $sid = (int)($lg['scydgy_id'] ?? 0);
-                    if ($sid > 0) {
-                        $sids[$sid] = true;
+                    $cn = trim((string)($po['pick_company_name'] ?? ''));
+                    if ($cn !== '') {
+                        $companies[$cn] = 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 = [];
+        }
+        // 并入当月有询价明细的供应商(增值服务未报价统计)
+        $ymEsc = str_replace(["\\", "'", '%', '_'], ["\\\\", "''", '\\%', '\\_'], $ym);
+        try {
+            $detailRows = Db::table('purchase_order_detail')
+                ->alias('d')
+                ->join('purchase_order po', 'po.scydgy_id = d.scydgy_id')
+                ->whereRaw(
+                    "("
+                    . "CASE "
+                    . "WHEN po.createtime IS NOT NULL AND CAST(po.createtime AS CHAR(32)) REGEXP '^[0-9]{10,}$' "
+                    . "THEN FROM_UNIXTIME(CAST(po.createtime AS UNSIGNED)) "
+                    . "WHEN po.createtime IS NOT NULL AND TRIM(CAST(po.createtime AS CHAR(32))) <> '' "
+                    . "AND TRIM(CAST(po.createtime AS CHAR(32))) NOT LIKE '0000-00-00%' "
+                    . "THEN TRIM(CAST(po.createtime AS CHAR(32))) "
+                    . "ELSE TRIM(CAST(IFNULL(po.dStamp, '') AS CHAR(32))) "
+                    . "END"
+                    . ") LIKE '{$ymEsc}-%'"
+                )
+                ->field('d.company_name')
+                ->group('d.company_name')
+                ->select();
+        } catch (\Throwable $e) {
+            $detailRows = [];
+        }
+        if (is_array($detailRows)) {
+            foreach ($detailRows as $dr) {
+                if (!is_array($dr)) {
+                    continue;
                 }
-                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;
-                        }
-                    }
+                $cn = trim((string)($dr['company_name'] ?? ''));
+                if ($cn !== '') {
+                    $companies[$cn] = true;
                 }
             }
-            $companies = array_keys($companies);
         }
+
+        return array_keys($companies);
+    }
+
+    /**
+     * 按新月度规则重算四项得分 + 最终分/等级(满分 105)
+     * 已手工保存(qp_manual=1)的供应商跳过
+     *
+     * @param string|null $onlyCompany 仅同步该供应商;null=当月相关全部
+     * @return array{updated:int,skipped:int}
+     */
+    public static function syncMonthlyReviewScoresFromRules(string $ym, ?string $onlyCompany = null): array
+    {
+        $ym = self::formatScoreYm($ym);
+        $result = ['updated' => 0, 'skipped' => 0];
+        if ($ym === '') {
+            return $result;
+        }
+        self::ensureFinalScoreTable();
+        self::repairInboundScoreFromOperLogs($ym);
+        $companies = self::collectMonthlyReviewCompanyNames($ym, $onlyCompany);
         if ($companies === []) {
-            return;
+            return $result;
         }
         self::ensureMonthlySupplierNames($ym, $companies);
         $now = date('Y-m-d H:i:s');
@@ -824,41 +1242,63 @@ class ProcuremenSupplierScore
             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 !== [] && (int)($exists['qp_manual'] ?? 0) === 1) {
+                    $result['skipped']++;
+                    continue;
+                }
+                $quality = self::calcQualityScoreFromInbound($cn, $ym);
+                $price = self::calcPriceScoreFromCeiling($cn, $ym);
+                $delivery = self::calcDeliveryScoreFromInbound($cn, $ym);
+                $valueAdded = self::calcValueAddedScoreFromUnquoted($cn, $ym);
+                $final = self::sumFinalScore($quality, $price, $delivery, $valueAdded);
+                $grade = $final !== null ? self::gradeFromFinalScore($final) : '';
+                $scoreCompat = $final !== null ? $final : 0.0;
                 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'       => '',
+                        'score'             => $scoreCompat,
+                        'quality_score'     => $quality,
+                        'price_score'       => $price,
+                        'delivery_score'    => $delivery,
+                        'value_added_score' => $valueAdded,
+                        'final_score'       => $final,
+                        'score_grade'       => $grade,
                         'qp_manual'         => 0,
                         'createtime'        => $now,
                         'updatetime'        => $now,
                     ]);
-                    continue;
-                }
-                // 已手工保存:不再按入库规则覆盖质量分
-                if ((int)($exists['qp_manual'] ?? 0) === 1) {
-                    continue;
+                } else {
+                    Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update([
+                        'score'             => $scoreCompat,
+                        'quality_score'     => $quality,
+                        'price_score'       => $price,
+                        'delivery_score'    => $delivery,
+                        'value_added_score' => $valueAdded,
+                        'final_score'       => $final,
+                        'score_grade'       => $grade,
+                        'updatetime'        => $now,
+                    ]);
                 }
-                Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update([
-                    'quality_score' => $score,
-                    'updatetime'    => $now,
-                ]);
+                $result['updated']++;
             } catch (\Throwable $e) {
-                Log::write('syncQualityScoreFromInbound write: ' . $e->getMessage(), 'error');
+                Log::write('syncMonthlyReviewScoresFromRules write: ' . $e->getMessage(), 'error');
             }
         }
+
+        return $result;
+    }
+
+    /**
+     * 兼容旧入口:改为按月度四项规则同步
+     *
+     * @param string|null $onlyCompany 仅同步该供应商;null=当月相关全部
+     */
+    public static function syncQualityScoreFromInbound(string $ym, ?string $onlyCompany = null): void
+    {
+        self::syncMonthlyReviewScoresFromRules($ym, $onlyCompany);
     }
 
     /**
@@ -996,8 +1436,8 @@ class ProcuremenSupplierScore
         self::ensureFinalScoreTable();
         // 本月询价(开标后已写入订单评分表)的供应商:名称不存在则加空行,已存在不动
         self::ensureMonthlySupplierNamesFromOrderYm($ym);
-        // 按入库合格/不合格重算质量得分(未人工改过的
-        self::syncQualityScoreFromInbound($ym);
+        // 按月度规则重算质量/价格/交货/增值(满分105;qp_manual=1 跳过
+        self::syncMonthlyReviewScoresFromRules($ym);
         try {
             $rows = Db::table(self::TABLE_FINAL)
                 ->where('ym', $ym)

+ 15 - 0
public/assets/js/backend/procuremen.js

@@ -2945,6 +2945,21 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         return s === '' ? '' : rfqEscHtml(s);
                     }
                 },
+                {
+                    field: 'has_quoted',
+                    title: '状态',
+                    operate: false,
+                    width: 80,
+                    align: 'center',
+                    halign: 'center',
+                    valign: 'middle',
+                    formatter: function (v) {
+                        if (parseInt(v, 10) === 1) {
+                            return '<span class="text-success">已报价</span>';
+                        }
+                        return '<span class="text-muted">未报价</span>';
+                    }
+                },
                 {
                     field: 'supplier_name',
                     title: '供应商',