m0_70156489 1 өдөр өмнө
parent
commit
9e69980096

+ 361 - 128
application/admin/controller/Procuremen.php

@@ -26,7 +26,7 @@ class Procuremen extends Backend
     /**
      * 顶部快速搜索(多列 OR LIKE);字段须为真实列名且带别名 a/b,列不宜过多以免 SQL 异常
      */
-    protected $searchFields = 'a.ID,b.CCYDH,b.CYJMC,b.CCLBMMC,a.CGYMC,a.CDW,a.CDF,a.cGzzxMc,a.MBZ,b.cywyxm';
+    protected $searchFields = 'a.ID,b.CCYDH,b.CYJMC,b.CCLBMMC,a.CGYMC,a.CDW,b.czlyq,a.CDF,a.cGzzxMc,a.MBZ,b.cywyxm';
 
     /**
      * Procuremen模型对象
@@ -399,7 +399,7 @@ class Procuremen extends Backend
             $list = Db::table('scydgy')
                 ->alias('a')
                 ->join('mcyd b', 'b.ICYDID = a.ICYDID AND b.iStatus >= 10', 'inner')
-                ->field('a.ID, b.CCYDH, b.CYJMC, b.CCLBMMC, a.CDXMC, a.CGYBH, a.CGYMC, a.CDW, a.NGZL, b.CDF, a.cGzzxMc, a.MBZ, a.bwjg, a.iStatus, a.dStamp, b.dputrecord, b.cywyxm')
+                ->field('a.ID, b.CCYDH, b.CYJMC, b.CCLBMMC, a.CDXMC, a.CGYBH, a.CGYMC, a.CDW, a.NGZL, b.CDF, a.cGzzxMc, a.MBZ, b.czlyq, a.bwjg, a.iStatus, a.dStamp, b.dputrecord, b.cywyxm')
                 ->where([
                     'a.bwjg'    => 1,
                     'a.iEndBz'  => 0,
@@ -860,7 +860,8 @@ class Procuremen extends Backend
      */
     protected function loadPurchaseOrderRowsForListStage(string $stage, string $ym = '', bool $applyMonthRange = false): array
     {
-        $fields = 'id,scydgy_id,CCYDH,CYJMC,CCLBMMC,CDXMC,CGYBH,CGYMC,CDW,NGZL,CDF,cGzzxMc,MBZ,cywyxm,'
+        $this->ensurePurchaseOrderCzlyqColumn();
+        $fields = 'id,scydgy_id,CCYDH,CYJMC,CCLBMMC,CDXMC,CGYBH,CGYMC,CDW,NGZL,CDF,cGzzxMc,MBZ,cywyxm,czlyq,'
             . 'This_quantity,ceilingPrice,pick_time,createtime,dputrecord,dStamp,wflow_status,status,pick_company_name';
         try {
             $query = Db::table('purchase_order')->field($fields);
@@ -923,6 +924,170 @@ class Procuremen extends Backend
         $this->view->assign('procuremenBtnPickDelete', $this->procuremenCanPickDelete());
         $this->view->assign('procuremenBtnComplete', $this->procuremenCanComplete());
         $this->view->assign('procuremenBtnAuditAbandon', $this->hasProcuremenPerm(['auditabandon']));
+        $this->assignconfig('procuremenFilterOptions', $this->buildProcuremenFilterOptions($stage));
+    }
+
+    /**
+     * 通用搜索 / 表头筛选下拉选项(轻量:禁止首屏全表扫描)
+     *
+     * @return array{czlyq:array<string,string>,CCLBMMC:array<string,string>,cywyxm:array<string,string>,CGYMC:array<string,string>}
+     */
+    protected function buildProcuremenFilterOptions(string $stage): array
+    {
+        $buckets = [
+            'czlyq'   => [],
+            'CCLBMMC' => [],
+            'cywyxm'  => [],
+            'CGYMC'   => [],
+        ];
+        $maxPerField = 300;
+        try {
+            if ($stage === 'pick') {
+                // 首屏不读 Redis(整包解析会卡白屏);筛选项由列表 AJAX 的 filterOptions 带回
+                return $buckets;
+            }
+            if (in_array($stage, ['audit', 'confirm'], true)) {
+                $this->ensurePurchaseOrderCzlyqColumn();
+                foreach (array_keys($buckets) as $field) {
+                    try {
+                        $query = Db::table('purchase_order')->field($field);
+                        $this->applyPurchaseOrderNotDeletedWhere($query);
+                        if ($stage === 'audit') {
+                            $query->whereIn('wflow_status', ProcuremenStatus::wflowPendingConfirmValues());
+                        } else {
+                            $this->applyPurchaseOrderConfirmStageWhere($query);
+                        }
+                        $query->where($field, '<>', '')->whereNotNull($field);
+                        $vals = $query->distinct(true)->limit($maxPerField)->column($field);
+                        if (!is_array($vals)) {
+                            continue;
+                        }
+                        foreach ($vals as $v) {
+                            $v = trim((string)$v);
+                            if ($v !== '') {
+                                $buckets[$field][$v] = $v;
+                            }
+                        }
+                    } catch (\Throwable $e) {
+                        // 单字段失败不影响其它
+                    }
+                }
+            }
+        } catch (\Throwable $e) {
+            Log::write('协助列表筛选项构建失败:' . $e->getMessage(), 'error');
+        }
+        foreach ($buckets as &$set) {
+            if ($set !== []) {
+                ksort($set, SORT_STRING);
+            }
+        }
+        unset($set);
+
+        return $buckets;
+    }
+
+    /**
+     * filter JSON 是否含有效条件
+     *
+     * @param array<string, mixed> $filterArr
+     */
+    protected function procuremenFilterArrHasValue(array $filterArr): bool
+    {
+        foreach ($filterArr as $v) {
+            if (is_array($v)) {
+                if (array_filter($v, function ($x) {
+                    return $x !== '' && $x !== null;
+                })) {
+                    return true;
+                }
+            } elseif ($v !== '' && $v !== null) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * 是否按左侧月份缩窗:顶部搜索 / LIKE·RANGE 跨月;表头等值筛选仍按月
+     *
+     * @param array<string, mixed> $filterArr
+     * @param array<string, mixed> $opArr
+     */
+    protected function procuremenShouldApplyMonthRange(string $search, array $filterArr, array $opArr): bool
+    {
+        if ($search !== '') {
+            return false;
+        }
+        foreach ($filterArr as $fk => $fv) {
+            if (is_array($fv)) {
+                continue;
+            }
+            if ($fv === '' && $fv !== '0' && $fv !== 0) {
+                continue;
+            }
+            if ($fv === null) {
+                continue;
+            }
+            $sym = strtoupper(trim((string)($opArr[$fk] ?? '=')));
+            $sym = str_replace(['LIKE %...%', 'NOT LIKE %...%'], ['LIKE', 'NOT LIKE'], $sym);
+            if ($sym === '' || $sym === '=') {
+                continue;
+            }
+
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * 从已加载的列表池抽取筛选项(供 AJAX 列表响应带回,避免首屏重复扫库)
+     *
+     * @param array<int, mixed> $rows
+     * @return array{czlyq:array<string,string>,CCLBMMC:array<string,string>,cywyxm:array<string,string>,CGYMC:array<string,string>}
+     */
+    protected function extractProcuremenFilterOptionsFromRows(array $rows, int $maxPerField = 300): array
+    {
+        $buckets = [
+            'czlyq'   => [],
+            'CCLBMMC' => [],
+            'cywyxm'  => [],
+            'CGYMC'   => [],
+        ];
+        foreach ($rows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            foreach ($buckets as $field => &$set) {
+                if (count($set) >= $maxPerField) {
+                    continue;
+                }
+                $v = trim((string)($r[$field] ?? ''));
+                if ($v !== '') {
+                    $set[$v] = $v;
+                }
+            }
+            unset($set);
+            $full = true;
+            foreach ($buckets as $set) {
+                if (count($set) < $maxPerField) {
+                    $full = false;
+                    break;
+                }
+            }
+            if ($full) {
+                break;
+            }
+        }
+        foreach ($buckets as &$set) {
+            if ($set !== []) {
+                ksort($set, SORT_STRING);
+            }
+        }
+        unset($set);
+
+        return $buckets;
     }
 
     /** 协助下发(选工序+供应商,发短信邮件通知报价) */
@@ -997,32 +1162,17 @@ class Procuremen extends Backend
         $monthEnd = date('Y-m-t 23:59:59', strtotime($monthStart));
 
         $search = trim((string)$this->request->get('search', ''));
-        $hasActiveSearch = ($search !== '');
-        if (!$hasActiveSearch) {
-            $filterStr = $this->request->get('filter', '');
-            if ($filterStr !== '' && $filterStr !== '[]' && $filterStr !== '{}') {
-                $arr = json_decode($filterStr, true);
-                if (is_array($arr) && count($arr) > 0) {
-                    foreach ($arr as $v) {
-                        if (is_array($v)) {
-                            if (array_filter($v, function ($x) {
-                                return $x !== '' && $x !== null;
-                            })) {
-                                $hasActiveSearch = true;
-                                break;
-                            }
-                        } elseif ($v !== '' && $v !== null) {
-                            $hasActiveSearch = true;
-                            break;
-                        }
-                    }
-                }
-            }
-        }
-        $applyMonthRange = !$hasActiveSearch;
-
         $filterArr = (array)json_decode($this->request->get('filter', ''), true);
         $opArr = (array)json_decode($this->request->get('op', ''), true);
+        if (!is_array($filterArr)) {
+            $filterArr = [];
+        }
+        if (!is_array($opArr)) {
+            $opArr = [];
+        }
+        // 顶部快速搜索 / LIKE·RANGE 才跨月;表头等值下拉仍按当前月份,避免扫近一年整池
+        $applyMonthRange = $this->procuremenShouldApplyMonthRange($search, $filterArr, $opArr);
+        $hasActiveSearch = ($search !== '') || $this->procuremenFilterArrHasValue($filterArr);
 
         try {
             $listTimePrimary = in_array($wffTab, ['audit', 'confirm'], true) ? 'pick_time' : 'dputrecord';
@@ -1035,7 +1185,7 @@ class Procuremen extends Backend
                 if ($dbRows === []) {
                     try {
                         $dbRows = Db::table('purchase_order')
-                            ->field('id,scydgy_id,CCYDH,CYJMC,CCLBMMC,CDXMC,CGYBH,CGYMC,CDW,NGZL,CDF,cGzzxMc,MBZ,cywyxm,'
+                            ->field('id,scydgy_id,CCYDH,CYJMC,CCLBMMC,CDXMC,CGYBH,CGYMC,CDW,NGZL,CDF,cGzzxMc,MBZ,cywyxm,czlyq,'
                                 . 'This_quantity,ceilingPrice,pick_time,createtime,dputrecord,dStamp,wflow_status,status,pick_company_name')
                             ->whereRaw('(mod_rq IS NULL OR TRIM(CAST(mod_rq AS CHAR(32))) = \'\' OR TRIM(CAST(mod_rq AS CHAR(32))) LIKE \'0000-00-00%\')')
                             ->whereIn('wflow_status', ProcuremenStatus::wflowPendingApprovalValues())
@@ -1316,9 +1466,17 @@ class Procuremen extends Backend
             unset($rwClean);
 
             return json([
-                'total'    => count($filtered),
-                'rows'     => $rows,
-                'activeYm' => $ym,
+                'total'         => count($filtered),
+                'rows'          => $rows,
+                'activeYm'      => $ym,
+                // 仅无筛选的首页带回选项;从当月过滤后结果抽 distinct,避免扫近一年整池
+                'filterOptions' => (
+                    (int)$offset === 0
+                    && !$hasActiveSearch
+                    && (string)$this->request->get('need_filter_options', '1') !== '0'
+                )
+                    ? $this->extractProcuremenFilterOptionsFromRows(is_array($filtered) ? $filtered : [])
+                    : new \stdClass(),
             ]);
         } catch (\Throwable $e) {
             Log::write('协助列表加载异常:' . $e->getMessage(), 'error');
@@ -1552,6 +1710,7 @@ class Procuremen extends Backend
             'CGYBH'         => $row['CGYBH'] ?? null,
             'CGYMC'         => $row['CGYMC'] ?? null,
             'CDW'           => $row['CDW'] ?? null,
+            'czlyq'         => $row['czlyq'] ?? null,
             'NGZL'          => $row['NGZL'] ?? null,
             'CDF'           => $row['CDF'] ?? null,
             'cGzzxMc'       => $row['cGzzxMc'] ?? null,
@@ -3034,6 +3193,7 @@ class Procuremen extends Backend
             'CGYBH'          => $dbRow['CGYBH'] ?? '',
             'CGYMC'          => $dbRow['CGYMC'] ?? '',
             'CDW'            => $dbRow['CDW'] ?? '',
+            'czlyq'          => $dbRow['czlyq'] ?? '',
             'NGZL'           => $dbRow['NGZL'] ?? '',
             'CDF'            => $dbRow['CDF'] ?? '',
             'cGzzxMc'        => $dbRow['cGzzxMc'] ?? '',
@@ -3133,6 +3293,43 @@ class Procuremen extends Backend
             }
             $pool[] = $this->buildListRowFromPurchaseOrderDbRow($dbRow, $dStampMap);
         }
+        // 历史单据可能未落库 czlyq:按订单号从 ERP 补齐「类型」
+        $needCcydh = [];
+        foreach ($pool as $row) {
+            if (!is_array($row)) {
+                continue;
+            }
+            if (trim((string)($row['czlyq'] ?? '')) !== '') {
+                continue;
+            }
+            $ccydh = trim((string)($row['CCYDH'] ?? ''));
+            if ($ccydh !== '') {
+                $needCcydh[$ccydh] = true;
+            }
+        }
+        if ($needCcydh !== []) {
+            $czMap = [];
+            try {
+                $czMap = Db::table('mcyd')->where('CCYDH', 'in', array_keys($needCcydh))->column('czlyq', 'CCYDH');
+            } catch (\Throwable $e) {
+                $czMap = [];
+            }
+            if (is_array($czMap) && $czMap !== []) {
+                foreach ($pool as &$row) {
+                    if (!is_array($row)) {
+                        continue;
+                    }
+                    if (trim((string)($row['czlyq'] ?? '')) !== '') {
+                        continue;
+                    }
+                    $ccydh = trim((string)($row['CCYDH'] ?? ''));
+                    if ($ccydh !== '' && isset($czMap[$ccydh]) && trim((string)$czMap[$ccydh]) !== '') {
+                        $row['czlyq'] = trim((string)$czMap[$ccydh]);
+                    }
+                }
+                unset($row);
+            }
+        }
 
         return $pool;
     }
@@ -3292,11 +3489,44 @@ class Procuremen extends Backend
             }
         }
 
+        // 等值条件先行(表头下拉),减少后续日期解析量
+        $eqFilters = [];
+        $otherFilters = [];
+        foreach ($filterArr as $fk => $fv) {
+            if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', (string)$fk)) {
+                continue;
+            }
+            if (is_array($fv)) {
+                continue;
+            }
+            if ($fv === '' && $fv !== '0' && $fv !== 0) {
+                continue;
+            }
+            $sym = strtoupper(trim((string)($opArr[$fk] ?? '=')));
+            $sym = str_replace(['LIKE %...%', 'NOT LIKE %...%'], ['LIKE', 'NOT LIKE'], $sym);
+            $field = $stripAlias($fk);
+            if ($sym === '=' || $sym === '') {
+                $eqFilters[$field] = trim((string)$fv);
+            } else {
+                $otherFilters[$fk] = ['fv' => $fv, 'sym' => $sym, 'field' => $field];
+            }
+        }
+
+        $ymTarget = '';
+        if ($applyMonthRange && is_string($monthStart) && preg_match('/^(\d{4}-\d{2})/', $monthStart, $ymM)) {
+            $ymTarget = $ymM[1];
+        }
+
         $filtered = [];
         foreach ($pool as $r) {
             if (!is_array($r)) {
                 continue;
             }
+            foreach ($eqFilters as $field => $fv) {
+                if (trim((string)($r[$field] ?? '')) !== $fv) {
+                    continue 2;
+                }
+            }
             $isManual = !empty($r['_is_manual']) || $this->isManualScydgyRowId((int)($r['ID'] ?? $r['id'] ?? 0));
             $listTime = $this->procuremenRowListSortTime($r, $listTimePrimary);
             if (!$isManual) {
@@ -3309,11 +3539,18 @@ class Procuremen extends Backend
                     if ($mo < 1 || $mo > 12) {
                         continue;
                     }
-                    $ymRow = $m[1] . '-' . str_pad((string)$mo, 2, '0', STR_PAD_LEFT);
-                    $rowMonthStart = $ymRow . '-01 00:00:00';
-                    $rowMonthEnd = date('Y-m-t 23:59:59', strtotime($rowMonthStart));
-                    if (strcmp($rowMonthEnd, $monthStart) < 0 || strcmp($rowMonthStart, $monthEnd) > 0) {
-                        continue;
+                    if ($ymTarget !== '') {
+                        $ymRow = $m[1] . '-' . str_pad((string)$mo, 2, '0', STR_PAD_LEFT);
+                        if ($ymRow !== $ymTarget) {
+                            continue;
+                        }
+                    } else {
+                        $ymRow = $m[1] . '-' . str_pad((string)$mo, 2, '0', STR_PAD_LEFT);
+                        $rowMonthStart = $ymRow . '-01 00:00:00';
+                        $rowMonthEnd = date('Y-m-t 23:59:59', strtotime($rowMonthStart));
+                        if (strcmp($rowMonthEnd, $monthStart) < 0 || strcmp($rowMonthStart, $monthEnd) > 0) {
+                            continue;
+                        }
                     }
                 }
             } elseif ($applyMonthRange) {
@@ -3328,11 +3565,18 @@ class Procuremen extends Backend
                 if ($mo < 1 || $mo > 12) {
                     continue;
                 }
-                $ymRow = $m[1] . '-' . str_pad((string)$mo, 2, '0', STR_PAD_LEFT);
-                $rowMonthStart = $ymRow . '-01 00:00:00';
-                $rowMonthEnd = date('Y-m-t 23:59:59', strtotime($rowMonthStart));
-                if (strcmp($rowMonthEnd, $monthStart) < 0 || strcmp($rowMonthStart, $monthEnd) > 0) {
-                    continue;
+                if ($ymTarget !== '') {
+                    $ymRow = $m[1] . '-' . str_pad((string)$mo, 2, '0', STR_PAD_LEFT);
+                    if ($ymRow !== $ymTarget) {
+                        continue;
+                    }
+                } else {
+                    $ymRow = $m[1] . '-' . str_pad((string)$mo, 2, '0', STR_PAD_LEFT);
+                    $rowMonthStart = $ymRow . '-01 00:00:00';
+                    $rowMonthEnd = date('Y-m-t 23:59:59', strtotime($rowMonthStart));
+                    if (strcmp($rowMonthEnd, $monthStart) < 0 || strcmp($rowMonthStart, $monthEnd) > 0) {
+                        continue;
+                    }
                 }
             }
             if ($search !== '') {
@@ -3348,28 +3592,14 @@ class Procuremen extends Backend
                     continue;
                 }
             }
-            foreach ($filterArr as $fk => $fv) {
-                if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', (string)$fk)) {
-                    continue;
-                }
-                if (is_array($fv)) {
-                    continue;
-                }
-                if ($fv === '' && $fv !== '0' && $fv !== 0) {
-                    continue;
-                }
-                $sym = strtoupper(trim((string)($opArr[$fk] ?? '=')));
-                $sym = str_replace(['LIKE %...%', 'NOT LIKE %...%'], ['LIKE', 'NOT LIKE'], $sym);
-                $field = $stripAlias($fk);
+            foreach ($otherFilters as $item) {
+                $fv = $item['fv'];
+                $sym = $item['sym'];
+                $field = $item['field'];
                 $cell = array_key_exists($field, $r) ? $r[$field] : null;
                 $cellStr = trim((string)$cell);
 
                 switch ($sym) {
-                    case '=':
-                        if ((string)$cell !== (string)$fv) {
-                            continue 3;
-                        }
-                        break;
                     case '<>':
                         if ((string)$cell === (string)$fv) {
                             continue 3;
@@ -3386,78 +3616,45 @@ class Procuremen extends Backend
                             continue 3;
                         }
                         break;
-                    case '>':
-                    case '>=':
-                    case '<':
-                    case '<=':
-                        if (!is_numeric($cell) && $cellStr === '') {
-                            continue 3;
-                        }
-                        $cv = is_numeric($cell) ? (float)$cell : (float)$cellStr;
-                        $vv = (float)$fv;
-                        if ($sym === '>' && !($cv > $vv)) {
-                            continue 3;
+                    case 'BETWEEN':
+                    case 'RANGE':
+                        $parts = is_string($fv) ? explode(' - ', $fv) : [];
+                        if (count($parts) < 2) {
+                            $parts = is_string($fv) ? explode(',', $fv) : [];
                         }
-                        if ($sym === '>=' && !($cv >= $vv)) {
+                        $begin = isset($parts[0]) ? trim((string)$parts[0]) : '';
+                        $end = isset($parts[1]) ? trim((string)$parts[1]) : '';
+                        if ($begin !== '' && strcmp($cellStr, $begin) < 0) {
                             continue 3;
                         }
-                        if ($sym === '<' && !($cv < $vv)) {
+                        if ($end !== '' && strcmp($cellStr, $end) > 0) {
                             continue 3;
                         }
-                        if ($sym === '<=' && !($cv <= $vv)) {
+                        break;
+                    case '>':
+                        if (!(strcmp($cellStr, (string)$fv) > 0)) {
                             continue 3;
                         }
                         break;
-                    case 'RANGE':
-                    case 'NOT RANGE':
-                    case 'BETWEEN':
-                    case 'NOT BETWEEN':
-                    case 'BETWEEN TIME':
-                    case 'NOT BETWEEN TIME':
-                        $rawR = is_array($fv) ? implode(',', $fv) : (string)$fv;
-                        $rawR = str_replace(' - ', ',', $rawR);
-                        $arr = array_slice(array_map('trim', explode(',', $rawR)), 0, 2);
-                        if (count($arr) < 2) {
-                            break;
-                        }
-                        $lo = $arr[0];
-                        $hi = $arr[1];
-                        if ($lo === '' && $hi === '') {
-                            break;
-                        }
-                        $isNot = (strpos($sym, 'NOT') !== false);
-                        if ($lo === '') {
-                            $in = ($cellStr !== '' && $cellStr <= $hi);
-                            if ($isNot ? $in : !$in) {
-                                continue 3;
-                            }
-                            break;
-                        }
-                        if ($hi === '') {
-                            $in = ($cellStr !== '' && $cellStr >= $lo);
-                            if ($isNot ? $in : !$in) {
-                                continue 3;
-                            }
-                            break;
-                        }
-                        $in = ($cellStr >= $lo && $cellStr <= $hi);
-                        if ($isNot ? $in : !$in) {
+                    case '>=':
+                        if (!(strcmp($cellStr, (string)$fv) >= 0)) {
                             continue 3;
                         }
                         break;
-                    case 'NULL':
-                    case 'IS NULL':
-                        if ($cell !== null && $cell !== '') {
+                    case '<':
+                        if (!(strcmp($cellStr, (string)$fv) < 0)) {
                             continue 3;
                         }
                         break;
-                    case 'NOT NULL':
-                    case 'IS NOT NULL':
-                        if ($cell === null || $cell === '') {
+                    case '<=':
+                        if (!(strcmp($cellStr, (string)$fv) <= 0)) {
                             continue 3;
                         }
                         break;
                     default:
+                        if ((string)$cell !== (string)$fv) {
+                            continue 3;
+                        }
                         break;
                 }
             }
@@ -3497,6 +3694,7 @@ class Procuremen extends Backend
                 'CGYBH'         => $row['CGYBH'] ?? null,
                 'CGYMC'         => $row['CGYMC'] ?? null,
                 'CDW'           => $row['CDW'] ?? null,
+                'czlyq'         => $row['czlyq'] ?? null,
                 'NGZL'          => $row['NGZL'] ?? null,
                 'CDF'           => $row['CDF'] ?? null,
                 'cGzzxMc'       => $row['cGzzxMc'] ?? null,
@@ -3576,6 +3774,7 @@ class Procuremen extends Backend
                 'CGYBH'         => $row['CGYBH'] ?? null,
                 'CGYMC'         => $row['CGYMC'] ?? null,
                 'CDW'           => $row['CDW'] ?? null,
+                'czlyq'         => $row['czlyq'] ?? null,
                 'NGZL'          => $row['NGZL'] ?? null,
                 'CDF'           => $row['CDF'] ?? null,
                 'cGzzxMc'       => $row['cGzzxMc'] ?? null,
@@ -4607,9 +4806,6 @@ class Procuremen extends Backend
             }
         }
         $issueOperTimeRaw = $step2Done ? ($issueTime !== '' ? $issueTime : $poTime) : '';
-        if ($issueOperTimeRaw !== '' && $issueOperTimeRaw !== '—') {
-            $deadlineLines[] = '操作时间:' . $issueOperTimeRaw;
-        }
         $deadlineText = implode("\n", $deadlineLines);
 
         $prefixOperTime = static function (string $t): string {
@@ -4624,6 +4820,7 @@ class Procuremen extends Backend
             return '操作时间:' . $t;
         };
 
+        $step2Time = ($issueOperTimeRaw !== '' && $issueOperTimeRaw !== '—') ? $issueOperTimeRaw : '';
         $step4Time = $step4Done ? ($auditSelectTime !== '' ? $auditSelectTime : $pickedTime) : '';
         $step5Time = $step5Done ? ($confirmApproveTime !== '' ? $confirmApproveTime : $doneTime) : '';
         $step6Time = $step6Done ? $doneTime : '';
@@ -4632,13 +4829,13 @@ class Procuremen extends Backend
             [
                 'title'    => '未发',
                 'subtitle' => '',
-                'time'     => $issued ? '—' : ($hasMain ? $prefixOperTime($poTime) : ''),
+                'time'     => (!$issued && $hasMain) ? $prefixOperTime($poTime) : '',
                 'done'     => $step1Done,
             ],
             [
                 'title'    => '下发',
                 'subtitle' => $deadlineText,
-                'time'     => '',
+                'time'     => $prefixOperTime($step2Time),
                 'done'     => $step2Done,
             ],
             [
@@ -4700,6 +4897,7 @@ class Procuremen extends Backend
             'CCLBMMC'       => '承揽部门',
             'CGYMC'         => '工序名称',
             'CDW'           => '单位',
+            'czlyq'         => '类型',
             'NGZL'          => '工作量',
             'CDF'           => '单价',
             'cGzzxMc'      => '工作中心',
@@ -5548,6 +5746,7 @@ class Procuremen extends Backend
                 'CYJMC'            => trim((string)($r['CYJMC'] ?? '')),
                 'CGYMC'            => trim((string)($r['CGYMC'] ?? '')),
                 'CDW'              => trim((string)($r['CDW'] ?? '')),
+                'czlyq'            => trim((string)($r['czlyq'] ?? '')),
                 'NGZL'             => $r['NGZL'] ?? '',
                 'This_quantity'    => is_scalar($qty) ? trim((string)$qty) : '',
                 'ceilingPrice'     => is_scalar($price) ? trim((string)$price) : '',
@@ -5582,8 +5781,10 @@ class Procuremen extends Backend
         if ($amount === null) {
             return '';
         }
+        // 保留小数(最多 5 位),去掉末尾无意义的 0,避免 0.25 被显示成 0
+        $s = rtrim(rtrim(sprintf('%.5F', (float)$amount), '0'), '.');
 
-        return (string)(int)round($amount);
+        return $s === '' ? '0' : $s;
     }
 
     /**
@@ -5732,6 +5933,7 @@ class Procuremen extends Backend
                 $leadText = $this->formatProcuremenLeadDaysText($leadDays);
                 $line = [
                     'cgymc'           => $gymcBySid[$sid] ?? trim((string)($d['CGYMC'] ?? '')),
+                    'amount'          => $am,
                     'unit_price_text' => $amountFilled
                         ? ($amountNum !== null ? $this->formatProcuremenMoneyDisplay($amountNum) : $am)
                         : '未填写',
@@ -6109,7 +6311,7 @@ class Procuremen extends Backend
             . '</tr></thead><tbody>' . $rows . '</tbody></table>';
     }
 
-    protected function parseProcuremenSysRqInput(string $sysRq, bool $required = true): string
+    protected function parseProcuremenSysRqInput(string $sysRq, bool $required = true, bool $allowPast = false): string
     {
         $sysRq = trim($sysRq);
         if ($sysRq === '') {
@@ -6122,17 +6324,42 @@ class Procuremen extends Backend
         if ($ts === false || $ts <= 0) {
             $this->error('招标截止日期格式无效');
         }
-        $ymd = date('Y-m-d', $ts);
-        if ($ymd < date('Y-m-d')) {
-            $this->error('招标截止日期不能早于今天');
-        }
-        if ($ts <= time()) {
-            $this->error('招标截止时间不能早于当前时间');
+        // 下发询价时须晚于当前;确认供应商等场景截止已过后仍要解析写入,跳过「不能早于今天」
+        if (!$allowPast) {
+            $ymd = date('Y-m-d', $ts);
+            if ($ymd < date('Y-m-d')) {
+                $this->error('招标截止日期不能早于今天');
+            }
+            if ($ts <= time()) {
+                $this->error('招标截止时间不能早于当前时间');
+            }
         }
 
         return date('Y-m-d H:i:s', $ts);
     }
 
+    /**
+     * 确保 purchase_order.czlyq(类型)存在
+     */
+    protected function ensurePurchaseOrderCzlyqColumn(): void
+    {
+        static $done = false;
+        if ($done) {
+            return;
+        }
+        $done = true;
+        try {
+            $cols = Db::query("SHOW COLUMNS FROM `purchase_order` LIKE 'czlyq'");
+            if (empty($cols)) {
+                Db::execute(
+                    "ALTER TABLE `purchase_order` ADD COLUMN `czlyq` varchar(64) NOT NULL DEFAULT '' COMMENT '类型' AFTER `CDW`"
+                );
+            }
+        } catch (\Throwable $e) {
+            // ignore
+        }
+    }
+
     /**
      * 确保 purchase_order.delivery_deadline 存在
      */
@@ -6268,6 +6495,7 @@ class Procuremen extends Backend
             $exists = Db::table('purchase_order')->where('scydgy_id', $ids)->find();
         }
         $this->ensurePurchaseOrderDeliveryDeadlineColumn();
+        $this->ensurePurchaseOrderCzlyqColumn();
         $data = [
             'scydgy_id'     => $ids,
             'CCYDH'         => $row['CCYDH'] ?? null,
@@ -6277,6 +6505,7 @@ class Procuremen extends Backend
             'CGYBH'         => $row['CGYBH'] ?? null,
             'CGYMC'         => $row['CGYMC'] ?? null,
             'CDW'           => $row['CDW'] ?? null,
+            'czlyq'         => $row['czlyq'] ?? null,
             'NGZL'          => $row['NGZL'] ?? null,
             'CDF'           => $row['CDF'] ?? null,
             'cGzzxMc'       => $row['cGzzxMc'] ?? null,
@@ -7970,9 +8199,13 @@ class Procuremen extends Backend
 
         $sysRqDb = $this->formatProcuremenSysRqForInput($this->resolveBundleSysRq($bundle));
         if ($sysRqDb === '') {
-            $sysRqDb = $this->parseProcuremenSysRqInput(trim((string)$this->request->post('sys_rq', '')), true);
+            $sysRqDb = $this->parseProcuremenSysRqInput(trim((string)$this->request->post('sys_rq', '')), true, true);
         } else {
-            $sysRqDb = $this->parseProcuremenSysRqInput($sysRqDb, true);
+            // 确认供应商须已过招标截止,允许解析已过期的截止时间
+            $sysRqDb = $this->parseProcuremenSysRqInput($sysRqDb, true, true);
+        }
+        if (!$this->isProcuremenQuoteDeadlineReached($sysRqDb)) {
+            $this->error('未到招标截止日期,暂不可确认');
         }
         if (!$this->isProcuremenBidOpenVerifiedForBundle($bundle)) {
             $this->error('请先完成开标双重验证后再确认供应商');

+ 74 - 54
application/admin/controller/Supplierservicescore.php

@@ -8,10 +8,9 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Style\Alignment;
 use PhpOffice\PhpSpreadsheet\Style\Border;
 use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
-use think\Db;
 
 /**
- * 供应商服务评分表(按月汇总供应商总分
+ * 供应商评审表(月度记录:商务/技术得分 / 价格得分 / 交货得分 / 最终得分,不参与其它业务计算
  *
  * @icon fa fa-trophy
  */
@@ -51,16 +50,20 @@ class Supplierservicescore extends Backend
             $rows = [];
             foreach ($pageRows as $i => $item) {
                 $rows[] = [
-                    'id'                 => $seqBase + $i + 1,
-                    'seq_no'             => $seqBase + $i + 1,
-                    'company_name'       => (string)($item['company_name'] ?? ''),
-                    'score'              => (float)($item['score'] ?? 0),
-                    'score_text'         => (string)($item['score_text'] ?? ''),
-                    'final_score'        => $item['final_score'] ?? null,
-                    'final_score_text'   => (string)($item['final_score_text'] ?? ''),
-                    'final_score_saved'  => !empty($item['final_score_saved']) ? 1 : 0,
-                    'rank_no'            => (int)($item['rank_no'] ?? 0),
-                    'ym'                 => $ym,
+                    'id'                   => $seqBase + $i + 1,
+                    'seq_no'               => $seqBase + $i + 1,
+                    'company_name'         => (string)($item['company_name'] ?? ''),
+                    'quality_score'        => $item['quality_score'] ?? null,
+                    'quality_score_text'   => (string)($item['quality_score_text'] ?? ''),
+                    'price_score'          => $item['price_score'] ?? null,
+                    'price_score_text'     => (string)($item['price_score_text'] ?? ''),
+                    'delivery_score'       => $item['delivery_score'] ?? null,
+                    'delivery_score_text'  => (string)($item['delivery_score_text'] ?? ''),
+                    'final_score'          => $item['final_score'] ?? null,
+                    'final_score_text'     => (string)($item['final_score_text'] ?? ''),
+                    'final_score_saved'    => !empty($item['final_score_saved']) ? 1 : 0,
+                    'score_grade'          => (string)($item['score_grade'] ?? ''),
+                    'ym'                   => $ym,
                 ];
             }
 
@@ -71,8 +74,8 @@ class Supplierservicescore extends Backend
     }
 
     /**
-     * 批量保存当月最终得分
-     * POST: ym, rows=[{company_name, final_score}, ...] 或 rows_json
+     * 批量保存当月最终得分与评分等级
+     * POST: ym, rows=[{company_name, final_score, score_grade}, ...] 或 rows_json
      */
     public function savefinalbatch()
     {
@@ -104,15 +107,14 @@ class Supplierservicescore extends Backend
         $done = (int)($ret['done'] ?? 0);
         if ($done < 1) {
             $err = trim((string)($ret['error'] ?? ''));
-            $this->error($err !== '' ? $err : '保存失败,请检查填写的最终得分');
+            $this->error($err !== '' ? $err : '保存失败,请检查填写的分');
         }
         $this->success('操作成功');
     }
 
     /**
-     * 导出供应商评审表:按月份汇总供应商
-     * 列:序号、供应商名称、总分、排名
-     * 总分优先取最终得分,无则用系统总分;排名同此规则
+     * 导出供应商评审表
+     * 列:序号、供应商名称、商务/技术得分、价格得分、交货得分、最终得分、评分等级
      */
     public function export()
     {
@@ -151,7 +153,7 @@ class Supplierservicescore extends Backend
             'wrapText'   => true,
         ];
 
-        $sheet->mergeCells('A1:D1');
+        $sheet->mergeCells('A1:G1');
         $sheet->setCellValue('A1', $title);
         $sheet->getRowDimension(1)->setRowHeight(36);
         $sheet->getStyle('A1')->getFont()->setName($fontName)->setBold(true)->setSize(20);
@@ -159,16 +161,19 @@ class Supplierservicescore extends Backend
 
         $sheet->setCellValue('A2', '序号');
         $sheet->setCellValue('B2', '供应商名称');
-        $sheet->setCellValue('C2', '总分');
-        $sheet->setCellValue('D2', '排名');
+        $sheet->setCellValue('C2', '商务/技术得分');
+        $sheet->setCellValue('D2', '价格得分');
+        $sheet->setCellValue('E2', '交货得分');
+        $sheet->setCellValue('F2', '最终得分');
+        $sheet->setCellValue('G2', '评分等级');
         $sheet->getRowDimension(2)->setRowHeight(26);
-        $sheet->getStyle('A2:D2')->getFont()->setName($fontName)->setBold(true)->setSize(14);
-        $sheet->getStyle('A2:D2')->getAlignment()->applyFromArray($centerAlign);
+        $sheet->getStyle('A2:G2')->getFont()->setName($fontName)->setBold(true)->setSize(14);
+        $sheet->getStyle('A2:G2')->getAlignment()->applyFromArray($centerAlign);
 
         $rowNum = 3;
         if ($list === []) {
-            $sheet->mergeCells('A3:D3');
-            $sheet->setCellValue('A3', '(该月暂无供应商服务评分数据)');
+            $sheet->mergeCells('A3:G3');
+            $sheet->setCellValue('A3', '(该月暂无供应商评审记录)');
             $sheet->getRowDimension(3)->setRowHeight(26);
             $sheet->getStyle('A3')->getFont()->setName($fontName)->setSize(12);
             $sheet->getStyle('A3')->getAlignment()->applyFromArray($centerAlign);
@@ -176,11 +181,14 @@ class Supplierservicescore extends Backend
         } else {
             $i = 1;
             foreach ($list as $item) {
-                $scoreText = (string)($item['export_score_text'] ?? $item['score_text'] ?? '');
+                $finalText = (string)($item['final_score_text'] ?? '');
                 $sheet->setCellValue('A' . $rowNum, $i);
                 $sheet->setCellValue('B' . $rowNum, (string)($item['company_name'] ?? ''));
-                $sheet->setCellValue('C' . $rowNum, $scoreText);
-                $sheet->setCellValue('D' . $rowNum, (int)($item['rank_no'] ?? $i));
+                $sheet->setCellValue('C' . $rowNum, (string)($item['quality_score_text'] ?? ''));
+                $sheet->setCellValue('D' . $rowNum, (string)($item['price_score_text'] ?? ''));
+                $sheet->setCellValue('E' . $rowNum, (string)($item['delivery_score_text'] ?? ''));
+                $sheet->setCellValue('F' . $rowNum, $finalText);
+                $sheet->setCellValue('G' . $rowNum, (string)($item['score_grade'] ?? ''));
                 $sheet->getRowDimension($rowNum)->setRowHeight(24);
                 $rowNum++;
                 $i++;
@@ -188,13 +196,16 @@ class Supplierservicescore extends Backend
         }
 
         $lastRow = max(2, $rowNum - 1);
-        $sheet->getStyle('A1:D' . $lastRow)->applyFromArray($borderStyle);
-        $sheet->getStyle('A3:D' . $lastRow)->getFont()->setName($fontName)->setSize(12);
-        $sheet->getStyle('A3:D' . $lastRow)->getAlignment()->applyFromArray($centerAlign);
+        $sheet->getStyle('A1:G' . $lastRow)->applyFromArray($borderStyle);
+        $sheet->getStyle('A3:G' . $lastRow)->getFont()->setName($fontName)->setSize(12);
+        $sheet->getStyle('A3:G' . $lastRow)->getAlignment()->applyFromArray($centerAlign);
         $sheet->getColumnDimension('A')->setWidth(12);
         $sheet->getColumnDimension('B')->setWidth(48);
         $sheet->getColumnDimension('C')->setWidth(14);
-        $sheet->getColumnDimension('D')->setWidth(12);
+        $sheet->getColumnDimension('D')->setWidth(14);
+        $sheet->getColumnDimension('E')->setWidth(12);
+        $sheet->getColumnDimension('F')->setWidth(14);
+        $sheet->getColumnDimension('G')->setWidth(12);
         $sheet->getPageSetup()->setFitToPage(true)->setFitToWidth(1)->setFitToHeight(0);
 
         $filename = $title . '.xlsx';
@@ -209,8 +220,7 @@ class Supplierservicescore extends Backend
     }
 
     /**
-     * 按月汇总:读月度汇总表(订单评分后自动同步总分;最终得分人工可改)
-     * 排名:有最终得分按最终得分,否则按系统总分
+     * 月度评审记录列表(仅展示,最终得分可人工填写)
      *
      * @return array<int, array<string, mixed>>
      */
@@ -229,25 +239,42 @@ class Supplierservicescore extends Backend
             if ($keyword !== '' && mb_stripos($name, $keyword) === false) {
                 continue;
             }
-            $avg = (float)($item['score'] ?? 0);
+            $quality = array_key_exists('quality_score', $item) ? $item['quality_score'] : null;
+            $price = array_key_exists('price_score', $item) ? $item['price_score'] : null;
+            $delivery = array_key_exists('delivery_score', $item) ? $item['delivery_score'] : null;
+            if ($quality !== null) {
+                $quality = (float)$quality;
+            }
+            if ($price !== null) {
+                $price = (float)$price;
+            }
+            if ($delivery !== null) {
+                $delivery = (float)$delivery;
+            }
             $hasFinal = !empty($item['final_saved']);
             $final = $hasFinal ? (float)($item['final_score'] ?? 0) : null;
-            $rankScore = $hasFinal ? (float)$final : $avg;
+            // 列表排序:有最终得分按最终得分,否则按已填分项合计
+            $sortScore = $hasFinal
+                ? (float)$final
+                : (($quality ?? 0) + ($price ?? 0) + ($delivery ?? 0));
             $list[] = [
-                'company_name'       => $name,
-                'score'              => $avg,
-                'score_text'         => ProcuremenSupplierScore::formatScore($avg),
-                'final_score'        => $final,
-                'final_score_text'   => $hasFinal ? ProcuremenSupplierScore::formatScore($final) : '',
-                'final_score_saved'  => $hasFinal ? 1 : 0,
-                'rank_score'         => $rankScore,
-                'export_score_text'  => ProcuremenSupplierScore::formatScore($rankScore),
-                'rank_no'            => 0,
+                'company_name'         => $name,
+                'quality_score'        => $quality,
+                'quality_score_text'   => ProcuremenSupplierScore::formatOptionalScore($quality),
+                'price_score'          => $price,
+                'price_score_text'     => ProcuremenSupplierScore::formatOptionalScore($price),
+                'delivery_score'       => $delivery,
+                'delivery_score_text'  => ProcuremenSupplierScore::formatOptionalScore($delivery),
+                'final_score'          => $final,
+                'final_score_text'     => $hasFinal ? ProcuremenSupplierScore::formatOptionalScore($final) : '',
+                'final_score_saved'    => $hasFinal ? 1 : 0,
+                'score_grade'          => ProcuremenSupplierScore::normalizeScoreGrade($item['score_grade'] ?? ''),
+                'sort_score'           => $sortScore,
             ];
         }
         usort($list, function ($a, $b) {
-            $sa = (float)($a['rank_score'] ?? 0);
-            $sb = (float)($b['rank_score'] ?? 0);
+            $sa = (float)($a['sort_score'] ?? 0);
+            $sb = (float)($b['sort_score'] ?? 0);
             if ($sa !== $sb) {
                 return $sb <=> $sa;
             }
@@ -255,13 +282,6 @@ class Supplierservicescore extends Backend
             return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
         });
 
-        $rank = 0;
-        foreach ($list as &$item) {
-            $rank++;
-            $item['rank_no'] = $rank;
-        }
-        unset($item);
-
         return $list;
     }
 

+ 8 - 4
application/admin/view/procuremen/audit_issue.html

@@ -375,13 +375,13 @@
 <!--    </p>-->
     <p class="audit-notify-tip">
         <i class="fa fa-exclamation-triangle"></i>
-        <strong>重要提示:</strong>可先预选供应商,但点击「确认」前须完成开标验证。确认后将进入采购终审(本步<strong>不发送短信通知</strong>)。
+        <strong>重要提示:</strong>可先预选供应商。过招标截止时间后可点「开标」完成验证,再点「确认」。确认后将进入采购终审(本步<strong>不发送短信通知</strong>)。
         需再次提醒报价时,请在右侧<strong>操作</strong>列点击「发邮件」或「发短信」。
     </p>
     {if !empty($bidOpenVerified)}
     <p class="audit-score-rule-tip">
         <i class="fa fa-info-circle"></i>
-        <strong>评分规则:</strong>{$supplierScoreRuleText|default='(总分)=(商务/技术得分×50%)+(工序单价合计×50%)'|htmlentities}
+        <strong>评分规则:</strong>{$supplierScoreRuleText|default='(总分)=(商务/技术得分×50%)+(价格得分);价格得分=(最低单价合计/本供应商单价合计)×50%×100'|htmlentities}
     </p>
     {/if}
     <div class="audit-table-wrap audit-quote-table-wrap">
@@ -394,7 +394,9 @@
                 <th style="width:100px;">邮箱</th>
                 <th style="width:120px;">手机号</th>
                 <th class="text-center" style="width:56px;">排名</th>
-                <th class="text-center" style="min-width:220px;" title="{$supplierScoreRuleText|default=''|htmlentities}">总分</th>
+                <th class="text-center" style="width:88px;">商务/技术得分</th>
+                <th class="text-center" style="width:80px;" title="价格得分=(最低单价合计/本供应商单价合计)×价格权重%×100">价格得分</th>
+                <th class="text-center" style="width:72px;">总分</th>
                 <th style="width:130px;">工序名称</th>
                 <th class="text-center" style="width:130px;">单价</th>
                 <th class="text-center" style="width:130px;">预估工期</th>
@@ -420,7 +422,9 @@
                 <td{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;">{:htmlentities(mask_email(isset($co['email']) ? $co['email'] : ''))}</td>
                 <td{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;">{:htmlentities(mask_phone(isset($co['phone']) ? $co['phone'] : ''))}</td>
                 <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;">{if condition="!empty($bidOpenVerified)"}{$co.service_rank_text|default=''|htmlentities}{/if}</td>
-                <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;white-space:nowrap;">{if condition="!empty($bidOpenVerified)"}{$co.service_score_text|default=''|htmlentities}{/if}</td>
+                <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;">{if condition="!empty($bidOpenVerified)"}{$co.service_quality_score_text|default=''|htmlentities}{/if}</td>
+                <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;"{if condition="!empty($bidOpenVerified)"} title="单价合计 {$co.service_price_sum_text|default=''|htmlentities}"{/if}>{if condition="!empty($bidOpenVerified)"}{$co.service_price_score_text|default=''|htmlentities}{/if}</td>
+                <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;font-weight:600;">{if condition="!empty($bidOpenVerified)"}{$co.service_score_text|default=''|htmlentities}{/if}</td>
                 {/if}
                 <td>{$ln.cgymc|default=''|htmlentities}</td>
                 <td class="text-center{if condition="!empty($ln.amount_quote_pending)"} audit-quote-pending{elseif condition="$ln.amount_filled neq 1"} audit-quote-empty{/if}">{$ln.unit_price_text|default='未填写'|htmlentities}</td>

+ 49 - 22
application/admin/view/procuremen/details_fragment.html

@@ -25,7 +25,7 @@
         display: flex;
         flex-wrap: nowrap;
         justify-content: space-between;
-        align-items: flex-start;
+        align-items: stretch;
         padding: 8px 4px 4px;
         overflow-x: auto;
         -webkit-overflow-scrolling: touch;
@@ -36,6 +36,9 @@
         text-align: center;
         position: relative;
         padding: 0 4px;
+        display: flex;
+        flex-direction: column;
+        align-items: center;
     }
     .proc-step-item:not(:last-child)::after {
         content: '';
@@ -63,6 +66,7 @@
         background: #f5f5f5;
         color: #999;
         border: 2px solid #ddd;
+        flex: 0 0 auto;
     }
     .proc-step-item.is-done .proc-step-circle {
         background: #1890ff;
@@ -83,28 +87,34 @@
         color: #333;
         line-height: 1.35;
         margin-bottom: 2px;
+        flex: 0 0 auto;
     }
     .proc-step-sub {
         font-size: 11px;
         color: #888;
         line-height: 1.45;
-        min-height: 4.4em;
+        min-height: 3.2em;
+        flex: 1 1 auto;
         white-space: pre-line;
         word-break: break-word;
         text-align: left;
-        display: inline-block;
+        width: 100%;
         max-width: 100%;
-        vertical-align: top;
+        box-sizing: border-box;
     }
     .proc-step-time {
         font-size: 11px;
         color: #aaa;
-        margin-top: 4px;
+        margin-top: auto;
+        padding-top: 6px;
         white-space: pre-line;
-        line-height: 1.3;
+        line-height: 1.35;
         text-align: left;
-        display: inline-block;
+        width: 100%;
         max-width: 100%;
+        box-sizing: border-box;
+        min-height: 2.7em;
+        flex: 0 0 auto;
     }
     .procuremen-details-wrap .procuremen-order-info-block {
         border: 1px solid #ddd;
@@ -244,14 +254,14 @@
     .procuremen-details-wrap .detail-supplier-table {
         /* 固定表宽,避免 width:100% + table-layout:fixed 把名称列挤成竖排字 */
         table-layout: fixed;
-        width: 1480px;
-        min-width: 1480px;
+        width: 1620px;
+        min-width: 1620px;
         max-width: none;
         border-collapse: collapse;
     }
     .procuremen-details-wrap .detail-supplier-table.has-lead-score {
-        width: 1480px;
-        min-width: 1480px;
+        width: 1620px;
+        min-width: 1620px;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-supplier-name,
     .procuremen-details-wrap .detail-supplier-table td.col-supplier-name {
@@ -264,8 +274,13 @@
     }
     .procuremen-details-wrap .detail-supplier-table th.col-supplier-email,
     .procuremen-details-wrap .detail-supplier-table td.col-supplier-email {
-        width: 110px;
+        width: 86px;
+        min-width: 86px;
+        max-width: 86px;
         word-break: break-all;
+        white-space: normal;
+        line-height: 1.35;
+        font-size: 12px;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-supplier-user,
     .procuremen-details-wrap .detail-supplier-table td.col-supplier-user {
@@ -273,9 +288,12 @@
     }
     .procuremen-details-wrap .detail-supplier-table th.col-supplier-phone,
     .procuremen-details-wrap .detail-supplier-table td.col-supplier-phone {
-        width: 120px;
+        width: 98px;
+        min-width: 98px;
+        max-width: 98px;
         word-break: break-all;
         white-space: nowrap;
+        font-size: 12px;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-unit-price,
     .procuremen-details-wrap .detail-supplier-table td.col-unit-price {
@@ -499,7 +517,9 @@
                 <th class="col-supplier-email">邮箱</th>
                 <th class="col-supplier-phone">手机号</th>
                 <th class="text-center" style="width:56px;">排名</th>
-                <th class="text-center" style="min-width:220px;" title="{$supplierScoreRuleText|default=''|htmlentities}">总分</th>
+                <th class="text-center" style="width:88px;">商务/技术得分</th>
+                <th class="text-center" style="width:80px;" title="价格得分=(最低单价合计/本供应商单价合计)×价格权重%×100">价格得分</th>
+                <th class="text-center" style="width:72px;">总分</th>
                 <th style="width:100px;">工序名称</th>
                 <th class="text-center col-unit-price">单价</th>
                 <th class="text-center col-lead-days">预估工期</th>
@@ -522,7 +542,9 @@
                 <td class="col-supplier-email"{if condition="$sg.display_rowspan gt 1"} rowspan="{$sg.display_rowspan}"{/if} style="vertical-align:middle;">{:htmlentities(mask_email(isset($sg['email']) ? $sg['email'] : ''))}</td>
                 <td class="col-supplier-phone"{if condition="$sg.display_rowspan gt 1"} rowspan="{$sg.display_rowspan}"{/if} style="vertical-align:middle;">{:htmlentities(mask_phone(isset($sg['phone']) ? $sg['phone'] : ''))}</td>
                 <td class="text-center"{if condition="$sg.display_rowspan gt 1"} rowspan="{$sg.display_rowspan}"{/if} style="vertical-align:middle;">{$sg.service_rank_text|default=''|htmlentities}</td>
-                <td class="text-center"{if condition="$sg.display_rowspan gt 1"} rowspan="{$sg.display_rowspan}"{/if} style="vertical-align:middle;white-space:nowrap;">{$sg.service_score_text|default=''|htmlentities}</td>
+                <td class="text-center"{if condition="$sg.display_rowspan gt 1"} rowspan="{$sg.display_rowspan}"{/if} style="vertical-align:middle;">{$sg.service_quality_score_text|default=''|htmlentities}</td>
+                <td class="text-center"{if condition="$sg.display_rowspan gt 1"} rowspan="{$sg.display_rowspan}"{/if} style="vertical-align:middle;" title="单价合计 {$sg.service_price_sum_text|default=''|htmlentities}">{$sg.service_price_score_text|default=''|htmlentities}</td>
+                <td class="text-center"{if condition="$sg.display_rowspan gt 1"} rowspan="{$sg.display_rowspan}"{/if} style="vertical-align:middle;font-weight:600;">{$sg.service_score_text|default=''|htmlentities}</td>
                 {/if}
                 <td>{$ln.cgymc|default=''|htmlentities}</td>
                 <td class="text-center col-unit-price{if condition="!empty($ln.amount_quote_pending)"} detail-quote-pending{elseif condition="empty($ln.amount_filled) || $ln.amount_filled neq 1"} detail-quote-empty{/if}">{$ln.unit_price_text|default='未填写'|htmlentities}</td>
@@ -550,13 +572,14 @@
             </tr>
             {/if}
             {if !empty($sg.has_total)}
-            <tr class="active">
+            <tr class="active detail-supplier-total-row">
                 <td><strong>总计</strong></td>
                 <td></td>
                 <td></td>
                 <td></td>
-                <td class="text-center"><strong>{$sg.total_text|default=''|htmlentities}</strong></td>
-                <td colspan="2"></td>
+                <td class="text-center col-subtotal"><strong>{$sg.total_text|default=''|htmlentities}</strong></td>
+                <td></td>
+                <td></td>
             </tr>
             {/if}
             {/volist}
@@ -568,6 +591,10 @@
                 <td class="col-supplier-user">{$dr.username|default=''|htmlentities}</td>
                 <td class="col-supplier-email">{:htmlentities(mask_email(isset($dr['email']) ? $dr['email'] : ''))}</td>
                 <td class="col-supplier-phone">{:htmlentities(mask_phone(isset($dr['phone']) ? $dr['phone'] : ''))}</td>
+                <td class="text-center"></td>
+                <td class="text-center"></td>
+                <td class="text-center"></td>
+                <td class="text-center"></td>
                 <td></td>
                 <td class="text-center col-unit-price">
                     {if condition="$dr.amount !== '' && $dr.amount !== null && $dr.amount !== '0' && $dr.amount !== '0.00'"}
@@ -576,21 +603,21 @@
                     <span class="detail-quote-empty">未填写</span>
                     {/if}
                 </td>
-                <td class="text-center">
+                <td class="text-center col-lead-days">
                     {if condition="isset($dr.lead_days) && $dr.lead_days !== '' && $dr.lead_days !== null"}
                     {$dr.lead_days|htmlentities}天
                     {else /}
                     <span class="detail-quote-empty">未填写</span>
                     {/if}
                 </td>
-                <td class="text-center">
+                <td class="text-center col-delivery">
                     {if condition="$dr.delivery_ymd !== '' && $dr.delivery_ymd !== null"}
                     {$dr.delivery_ymd|htmlentities}
                     {else /}
                     <span class="detail-quote-empty">未填写</span>
                     {/if}
                 </td>
-                <td class="text-center"></td>
+                <td class="text-center col-subtotal"></td>
                 <td class="text-center">
                     {if isset($dr.is_void) && $dr.is_void}
                     <span class="label label-default">历史下发</span>
@@ -607,7 +634,7 @@
             {/volist}
             {else /}
             <tr>
-                <td colspan="11" class="text-center text-muted">暂无下发明细</td>
+                <td colspan="15" class="text-center text-muted">暂无下发明细</td>
             </tr>
             {/notempty}
             {/notempty}

+ 55 - 0
application/admin/view/procuremen/index.html

@@ -284,6 +284,61 @@
     .procuremen-toolbar-host .fixed-table-toolbar .toolbar .btn .fa {
         font-size: 14px !important;
     }
+    /* 表头标题文字后的小筛选图标 */
+    #procuremen-layout .bootstrap-table th .th-inner.procuremen-th-with-filter {
+        display: inline-flex !important;
+        flex-direction: row;
+        align-items: center;
+        justify-content: center;
+        gap: 4px;
+        white-space: nowrap !important;
+        line-height: 1.3 !important;
+    }
+    #procuremen-layout .bootstrap-table th .procuremen-th-label {
+        display: inline;
+        font-weight: 600;
+    }
+    #procuremen-layout .bootstrap-table th .procuremen-th-filter-dd {
+        display: inline-block;
+        vertical-align: middle;
+        position: relative;
+    }
+    #procuremen-layout .bootstrap-table th .procuremen-th-filter-btn {
+        color: #999;
+        padding: 0 2px;
+        font-size: 12px;
+        line-height: 1;
+        text-decoration: none !important;
+        cursor: pointer;
+    }
+    #procuremen-layout .bootstrap-table th .procuremen-th-filter-btn:hover,
+    #procuremen-layout .bootstrap-table th .procuremen-th-filter-btn.active {
+        color: #3c8dbc;
+    }
+    #procuremen-layout .bootstrap-table th .procuremen-th-filter-dd > .dropdown-menu {
+        min-width: 140px;
+        max-height: 260px;
+        overflow-y: auto;
+        font-size: 12px;
+        z-index: 1060;
+    }
+    body > .procuremen-th-filter-menu-float.dropdown-menu {
+        min-width: 140px;
+        max-height: 260px;
+        overflow-y: auto;
+        font-size: 12px;
+        display: block;
+    }
+    #procuremen-layout .bootstrap-table th .procuremen-th-filter-dd > .dropdown-menu > li > a,
+    body > .procuremen-th-filter-menu-float.dropdown-menu > li > a {
+        padding: 4px 12px;
+        white-space: nowrap;
+    }
+    #procuremen-layout .bootstrap-table th .procuremen-th-filter-dd > .dropdown-menu > li.active > a,
+    body > .procuremen-th-filter-menu-float.dropdown-menu > li.active > a {
+        background: #3c8dbc;
+        color: #fff;
+    }
     .procuremen-toolbar-host .fixed-table-toolbar > .pull-right,
     .procuremen-toolbar-host .fixed-table-toolbar > .columns.pull-right,
     .procuremen-toolbar-host .fixed-table-toolbar > .columns.columns-right,

+ 7 - 3
application/admin/view/procuremen/outward_detail.html

@@ -339,7 +339,7 @@
     {if !empty($bidOpenVerified)}
     <p class="audit-score-rule-tip">
         <i class="fa fa-info-circle"></i>
-        <strong>评分规则:</strong>{$supplierScoreRuleText|default='(总分)=(商务/技术得分×50%)+(工序单价合计×50%)'|htmlentities}
+        <strong>评分规则:</strong>{$supplierScoreRuleText|default='(总分)=(商务/技术得分×50%)+(价格得分);价格得分=(最低单价合计/本供应商单价合计)×50%×100'|htmlentities}
     </p>
     {/if}
     <p class="outward-confirm-block-title">供应商({$confirmPickCount|default=0} 家)</p>
@@ -353,7 +353,9 @@
                 <th style="min-width:160px;">邮箱</th>
                 <th style="width:120px;">手机号</th>
                 <th class="text-center" style="width:56px;">排名</th>
-                <th class="text-center" style="min-width:220px;" title="{$supplierScoreRuleText|default=''|htmlentities}">总分</th>
+                <th class="text-center" style="width:88px;">商务/技术得分</th>
+                <th class="text-center" style="width:80px;" title="价格得分=(最低单价合计/本供应商单价合计)×价格权重%×100">价格得分</th>
+                <th class="text-center" style="width:72px;">总分</th>
                 <th style="width:120px;">工序名称</th>
                 <th class="text-center" style="width:112px;">单价</th>
                 <th class="text-center" style="width:88px;">预估工期</th>
@@ -379,7 +381,9 @@
                 <td{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;">{:htmlentities(mask_email(isset($co['email']) ? $co['email'] : ''))}</td>
                 <td{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;">{:htmlentities(mask_phone(isset($co['phone']) ? $co['phone'] : ''))}</td>
                 <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;">{if condition="!empty($bidOpenVerified)"}{$co.service_rank_text|default=''|htmlentities}{/if}</td>
-                <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;white-space:nowrap;">{if condition="!empty($bidOpenVerified)"}{$co.service_score_text|default=''|htmlentities}{/if}</td>
+                <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;">{if condition="!empty($bidOpenVerified)"}{$co.service_quality_score_text|default=''|htmlentities}{/if}</td>
+                <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;"{if condition="!empty($bidOpenVerified)"} title="单价合计 {$co.service_price_sum_text|default=''|htmlentities}"{/if}>{if condition="!empty($bidOpenVerified)"}{$co.service_price_score_text|default=''|htmlentities}{/if}</td>
+                <td class="text-center"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if} style="vertical-align:middle;font-weight:600;">{if condition="!empty($bidOpenVerified)"}{$co.service_score_text|default=''|htmlentities}{/if}</td>
                 {/if}
                 <td class="outward-process-name">{$ql.cgymc|default=''|htmlentities}</td>
                 <td class="text-center{if condition="!empty($ql.amount_quote_pending)"} outward-quote-empty{elseif condition="$ql.amount_filled neq 1"} outward-quote-empty{/if}">{$ql.unit_price_text|default='未填写'|htmlentities}</td>

+ 40 - 8
application/admin/view/supplierservicescore/index.html

@@ -1,15 +1,15 @@
 <style>
-    /* 只收窄列表表格与分页,上方工具栏/搜索保持全宽 */
-    .supplierservicescore-page .bootstrap-table > .fixed-table-container {
-        max-width: 860px;
-    }
+    /* 表格铺满内容区,表头与单元格居中 */
+    .supplierservicescore-page .bootstrap-table > .fixed-table-container,
     .supplierservicescore-page .bootstrap-table > .fixed-table-pagination {
-        max-width: 860px;
+        max-width: none;
+        width: 100%;
     }
     .supplierservicescore-page .bootstrap-table .fixed-table-container .table,
-    .supplierservicescore-page .bootstrap-table .fixed-table-body .table {
+    .supplierservicescore-page .bootstrap-table .fixed-table-body .table,
+    .supplierservicescore-page .bootstrap-table .fixed-table-header .table {
         width: 100% !important;
-        max-width: 860px;
+        max-width: none;
         table-layout: fixed;
     }
     .supplierservicescore-page .bootstrap-table .table td,
@@ -18,10 +18,42 @@
         overflow: hidden;
         text-overflow: ellipsis;
         vertical-align: middle !important;
+        text-align: center !important;
     }
-    .supplierservicescore-page .bootstrap-table .table td .ssc-final-score-input {
+    .supplierservicescore-page .bootstrap-table .table td .ssc-final-score-input,
+    .supplierservicescore-page .bootstrap-table .table td .ssc-score-grade-select {
         overflow: visible;
     }
+    /* 分数输入、评分等级下拉在单元格内水平居中 */
+    .supplierservicescore-page .bootstrap-table .table td .ssc-quality-score-input,
+    .supplierservicescore-page .bootstrap-table .table td .ssc-price-score-input,
+    .supplierservicescore-page .bootstrap-table .table td .ssc-delivery-score-input,
+    .supplierservicescore-page .bootstrap-table .table td .ssc-final-score-input,
+    .supplierservicescore-page .bootstrap-table .table td .ssc-performance-score-input {
+        display: block !important;
+        width: 96px !important;
+        max-width: 96px;
+        margin: 0 auto !important;
+        float: none !important;
+        text-align: center;
+        height: 28px;
+        padding: 2px 6px;
+    }
+    .supplierservicescore-page .bootstrap-table .table td .ssc-score-grade-select {
+        display: block !important;
+        width: 96px !important;
+        max-width: 96px;
+        margin: 0 auto !important;
+        float: none !important;
+        height: 28px;
+        padding: 2px 6px;
+        text-align: center;
+        text-align-last: center;
+        -moz-text-align-last: center;
+    }
+    .supplierservicescore-page .bootstrap-table .table td .ssc-score-grade-select option {
+        text-align: center;
+    }
 </style>
 <div class="panel panel-default panel-intro supplierservicescore-page">
     {:build_heading()}

+ 2 - 2
application/api/controller/Procuremen.php

@@ -69,11 +69,11 @@ class Procuremen extends Controller
             $query = Db::table('scydgy')
                 ->alias('a')
                 ->join('mcyd b', 'b.ICYDID = a.ICYDID AND b.iStatus >= 10', 'inner')
-                ->field('a.ID, b.CCYDH, b.CYJMC, b.CCLBMMC, a.CDXMC, a.CGYBH, a.CGYMC, a.CDW, a.NGZL, b.CDF, a.cGzzxMc, a.MBZ, a.bwjg, a.iStatus, a.dStamp, b.dputrecord, b.cywyxm')
+                ->field('a.ID, b.CCYDH, b.CYJMC, b.CCLBMMC, a.CDXMC, a.CGYBH, a.CGYMC, a.CDW, a.NGZL, b.CDF, a.cGzzxMc, a.MBZ,b.czlyq, a.bwjg, a.iStatus, a.dStamp, b.dputrecord, b.cywyxm')
                 ->where([
                     'a.bwjg'    => 1,
                     'a.iEndBz'  => 0,
-                    'a.iType'   => 0,   
+                    'a.iType'   => 0,
                     'a.iStatus' => 10,
                 ])
                 ->where('a.dStamp', '>=', $startTime)

+ 503 - 164
application/common/library/ProcuremenSupplierScore.php

@@ -10,13 +10,13 @@ use think\Log;
  *
  * 表分工:
  * - supplier_service_score:订单明细(开标后按 ccydh×供应商写入,含本单总分 score)
- * - supplier_service_final_score:月度汇总(ym×供应商;score=当月订单平均总分;final_score=人工最终得分
+ * - supplier_service_final_score:月度评审表(开标后写入本月询价供应商名称;分项与等级人工填写,已存在不覆盖
  *
- * 总分 = 各分项×对应权重%(权重填 0 则该项不参与)
- * 商务/技术得分 =(综合评分 + 月度评分)/ 2;
- * 单价项按本单工序单价合计参与加权;权重按百分比计入总分。
- * 工期合计 = 同一供应商本单各工序预估工期之和;交货期评分值 = (本单最短合计 / 本供应商合计) × 100
- * 权重取 supplier_score_rule 中 is_default=1 的规则;开标验证通过后写入订单明细表,并同步月度总分
+ * 总分 = 商务/技术得分×质量% + 价格得分 [+ 交货得分×交货%]
+ * 价格得分 = (本单最低单价合计 / 本供应商单价合计) × 价格权重% × 100
+ * (计算过程保留四位小数,得分保留两位)
+ * 商务/技术得分 = 月度评审表「商务/技术得分」(当月按上月起往前查,都无则默认 50)
+ * 权重取 supplier_score_rule 中 is_default=1 的规则;开标验证通过后写入订单明细评分,并补齐月度评审供应商名称
  */
 class ProcuremenSupplierScore
 {
@@ -71,21 +71,26 @@ class ProcuremenSupplierScore
         self::ensureFinalScoreTable();
     }
 
-    /** 月度汇总表(总分 score + 人工最终得分 final_score) */
+    /** 月度记录表(商务技术分 / 价格分 / 最终得分,仅本页展示与导出) */
     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 '月度总分(当月订单平均)',
+  `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='供应商月度评分汇总'";
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商月度评审记录'";
         try {
             Db::execute($ddl);
         } catch (\Throwable $e) {
@@ -94,36 +99,63 @@ class ProcuremenSupplierScore
         self::ensureMonthlyScoreColumns();
     }
 
-    /** 历史月度表补总分字段,并将 final_score 改为可空 */
+    /** 历史月度表补商务技术分/价格分等字段 */
     protected static function ensureMonthlyScoreColumns(): void
     {
-        try {
-            $cols = Db::query("SHOW COLUMNS FROM `" . self::TABLE_FINAL . "` LIKE 'score'");
-            if (!is_array($cols) || $cols === []) {
-                Db::execute(
-                    "ALTER TABLE `" . self::TABLE_FINAL . "` ADD COLUMN `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '月度总分(当月订单平均)' AFTER `company_name`"
-                );
+        $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');
             }
-        } catch (\Throwable $e) {
-            Log::write('supplier monthly score column: ' . $e->getMessage(), 'error');
         }
-        try {
-            $info = Db::query("SHOW COLUMNS FROM `" . self::TABLE_FINAL . "` LIKE 'final_score'");
-            if (is_array($info) && $info !== []) {
-                $null = strtoupper((string)($info[0]['Null'] ?? ''));
-                if ($null !== 'YES') {
-                    Db::execute(
-                        "ALTER TABLE `" . self::TABLE_FINAL . "` MODIFY COLUMN `final_score` decimal(10,2) DEFAULT NULL COMMENT '最终得分(人工,空=未填)'"
-                    );
+        // 分数字段允许 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');
             }
-        } catch (\Throwable $e) {
-            Log::write('supplier monthly final_score 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=当月全部
      */
@@ -136,7 +168,9 @@ class ProcuremenSupplierScore
         self::ensureSchema();
         self::ensureFinalScoreTable();
         try {
-            $query = Db::table(self::TABLE_SCORE)->where('ym', $ym)->field('company_name,score');
+            $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) {
@@ -152,9 +186,30 @@ class ProcuremenSupplierScore
             }
             $rows = $query->select();
         } catch (\Throwable $e) {
-            Log::write('supplier monthly sync read: ' . $e->getMessage(), 'error');
+            // 无 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;
+                return;
+            }
         }
         if (!is_array($rows)) {
             return;
@@ -169,29 +224,54 @@ class ProcuremenSupplierScore
                 continue;
             }
             if (!isset($agg[$cn])) {
-                $agg[$cn] = ['sum' => 0.0, 'cnt' => 0];
+                $agg[$cn] = [
+                    'score_sum'     => 0.0,
+                    'quality_sum'   => 0.0,
+                    'price_sum'     => 0.0,
+                    'delivery_sum'  => 0.0,
+                    'cnt'           => 0,
+                ];
             }
-            $agg[$cn]['sum'] += (float)($r['score'] ?? 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) {
-            $avg = $a['cnt'] > 0 ? round($a['sum'] / $a['cnt'], 2) : 0.0;
+            $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 !== []) {
-                    Db::table(self::TABLE_FINAL)->where('id', (int)($exists['id'] ?? 0))->update([
-                        'score'      => $avg,
+                    $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'        => $avg,
-                        'final_score'  => null,
-                        'createtime'   => $now,
-                        'updatetime'   => $now,
+                        '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) {
@@ -240,9 +320,135 @@ class ProcuremenSupplierScore
     }
 
     /**
-     * 读取某月月度汇总(总分 + 最终得分)
+     * 读取库中可选分数: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,final_score:?float,final_saved:int}>
+     * @return array<string, array{score:float,quality_score:?float,price_score:?float,delivery_score:?float,final_score:?float,final_saved:int,score_grade:string}>
      */
     public static function loadMonthlyMapByYm(string $ym): array
     {
@@ -251,9 +457,13 @@ class ProcuremenSupplierScore
             return [];
         }
         self::ensureFinalScoreTable();
-        self::syncMonthlyScoresForYm($ym);
+        // 本月询价(开标后已写入订单评分表)的供应商:名称不存在则加空行,已存在不动
+        self::ensureMonthlySupplierNamesFromOrderYm($ym);
         try {
-            $rows = Db::table(self::TABLE_FINAL)->where('ym', $ym)->field('company_name,score,final_score')->select();
+            $rows = Db::table(self::TABLE_FINAL)
+                ->where('ym', $ym)
+                ->field('company_name,score,quality_score,price_score,delivery_score,final_score,score_grade')
+                ->select();
         } catch (\Throwable $e) {
             return [];
         }
@@ -269,11 +479,18 @@ class ProcuremenSupplierScore
             if ($cn === '') {
                 continue;
             }
-            $hasFinal = array_key_exists('final_score', $r) && $r['final_score'] !== null && $r['final_score'] !== '';
+            $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),
-                'final_score'  => $hasFinal ? round((float)$r['final_score'], 2) : null,
-                'final_saved'  => $hasFinal ? 1 : 0,
+                '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'] ?? ''),
             ];
         }
 
@@ -281,9 +498,30 @@ class ProcuremenSupplierScore
     }
 
     /**
-     * 批量保存某月最终得分(不覆盖月度总分 score)
+     * 解析可选分数:空=null;非法=false;合法=float
      *
-     * @param array<int, array{company_name?:string,final_score?:mixed}> $items
+     * @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
@@ -317,56 +555,54 @@ class ProcuremenSupplierScore
             if ($cn === '') {
                 continue;
             }
-            $raw = $it['final_score'] ?? '';
-            if ($raw === null || (is_string($raw) && trim($raw) === '')) {
-                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)->where('id', (int)($exists['id'] ?? 0))->update([
-                            'final_score' => null,
-                            'updatetime'  => $now,
-                        ]);
-                    }
-                    $result['cleared']++;
-                    $result['done']++;
-                } catch (\Throwable $e) {
-                    Log::write('supplier final score clear: ' . $e->getMessage(), 'error');
-                    $result['error'] = $e->getMessage();
-                }
-                continue;
-            }
-            if (!is_numeric($raw)) {
+            $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;
             }
-            $final = round((float)$raw, 2);
+            $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([
-                        'final_score' => $final,
-                        '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'          => $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 {
-                    // 无月度行时先按订单回填总分,再写入最终得分
-                    self::syncMonthlyScoresForYm($ym, [$cn]);
-                    $exists2 = Db::table(self::TABLE_FINAL)->where('ym', $ym)->where('company_name', $cn)->find();
-                    if (is_array($exists2) && $exists2 !== []) {
-                        Db::table(self::TABLE_FINAL)->where('id', (int)($exists2['id'] ?? 0))->update([
-                            'final_score' => $final,
-                            'updatetime'  => $now,
-                        ]);
-                    } else {
-                        Db::table(self::TABLE_FINAL)->insert([
-                            'ym'           => $ym,
-                            'company_name' => $cn,
-                            'score'        => 0,
-                            'final_score'  => $final,
-                            'createtime'   => $now,
-                            'updatetime'   => $now,
-                        ]);
-                    }
+                    $result['saved']++;
                 }
-                $result['saved']++;
                 $result['done']++;
             } catch (\Throwable $e) {
                 Log::write('supplier final score save: ' . $e->getMessage(), 'error');
@@ -440,7 +676,6 @@ class ProcuremenSupplierScore
             self::TABLE_SCORE => [
                 'quality_score'  => "int NOT NULL DEFAULT 0 COMMENT '上年度质量评分'",
                 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比'",
-                'price_sum'      => "int NOT NULL DEFAULT 0 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 '交货期百分比'",
@@ -463,6 +698,20 @@ class ProcuremenSupplierScore
                 }
             }
         }
+        // 单价合计需保留小数(如 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
@@ -540,7 +789,92 @@ class ProcuremenSupplierScore
     }
 
     /**
-     * 按公司名批量取供应商 id / 综合评分 / 月度评分
+     * 规范年月;空则取当前月
+     */
+    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}>
@@ -561,18 +895,10 @@ class ProcuremenSupplierScore
         try {
             $rows = Db::table('customer')
                 ->where('company_name', 'in', array_keys($names))
-                ->field('id,company_name,score,monthly_score')
+                ->field('id,company_name')
                 ->select();
         } catch (\Throwable $e) {
-            // 无 monthly_score 列时回退只取综合评分
-            try {
-                $rows = Db::table('customer')
-                    ->where('company_name', 'in', array_keys($names))
-                    ->field('id,company_name,score')
-                    ->select();
-            } catch (\Throwable $e2) {
-                return $map;
-            }
+            return $map;
         }
         if (!is_array($rows)) {
             return $map;
@@ -585,27 +911,11 @@ class ProcuremenSupplierScore
             if ($cn === '') {
                 continue;
             }
-            $compRaw = $r['score'] ?? null;
-            $monthRaw = $r['monthly_score'] ?? null;
-            $hasComp = !($compRaw === null || $compRaw === '');
-            $hasMonth = !($monthRaw === null || $monthRaw === '');
-            $comp = $hasComp ? (float)$compRaw : null;
-            $month = $hasMonth ? (float)$monthRaw : null;
-            if ($hasComp && $hasMonth) {
-                $avg = ((float)$comp + (float)$month) / 2;
-            } elseif ($hasComp) {
-                $avg = (float)$comp;
-            } elseif ($hasMonth) {
-                $avg = (float)$month;
-            } else {
-                $avg = 0.0;
-            }
             $map[$cn] = [
                 'id'            => (int)($r['id'] ?? 0),
-                'score'         => $hasComp ? (float)$comp : 0.0,
-                'monthly_score' => $hasMonth ? (float)$month : null,
-                // 商务/技术得分:综合分与月度分平均值
-                'quality_avg'   => round($avg, 2),
+                'score'         => 0.0,
+                'monthly_score' => null,
+                'quality_avg'   => 50.0,
             ];
         }
 
@@ -616,12 +926,13 @@ class ProcuremenSupplierScore
      * 从报价组计算本单各供应商得分(不写库)
      *
      * @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): 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));
@@ -649,6 +960,7 @@ class ProcuremenSupplierScore
             }
         }
         $custMap = self::loadCustomerScoreMap($names);
+        $reviewMap = self::loadReviewQualityScoreMap($names, $asOfYm);
 
         $items = [];
         $priceSums = [];
@@ -692,7 +1004,8 @@ class ProcuremenSupplierScore
                     }
                 }
             }
-            $quality = (float)($custMap[$cn]['quality_avg'] ?? 0);
+            // 商务/技术得分:月度评审「商务技术分」,当月按上月起往前,默认 50
+            $quality = (float)($reviewMap[$cn] ?? 50);
             $psum = $hasPrice ? round($priceSum, 2) : 0;
             $lsum = $hasLead ? (int)$leadSum : 0;
             $items[$cn] = [
@@ -740,28 +1053,30 @@ class ProcuremenSupplierScore
             }
         }
         foreach ($items as $cn => &$it) {
-            // 仍保留相对价格分(供参考),总分按「工序单价合计 × 权重%」计入
-            if (!empty($it['has_price']) && $it['price_sum'] > 0 && $minSum !== null && $minSum > 0) {
-                $it['price_score'] = round(($minSum / $it['price_sum']) * 100, 2);
+            // 价格得分 = (最低单价合计 / 本供应商单价合计) × 价格权重% × 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) {
-                $it['lead_score'] = round(($minLead / $it['lead_days_sum']) * 100, 2);
+                $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_sum'] * ($pw / 100);
+                $total += (float)$it['price_score'];
             }
             if ($lw > 0) {
                 $total += $it['lead_score'] * ($lw / 100);
@@ -824,32 +1139,38 @@ class ProcuremenSupplierScore
     }
 
     /**
-     * 总分留痕文案:59 = (100×50%)+(18×50%)
-     * 权重大于 0 的项才写入;数值取商务/技术得分、工序单价合计(及交货期分)
+     * 总分简要文案(仅总分数字;分项见独立字段)
      *
      * @param array<string, mixed> $hit calculate/loadSaved 单项
      */
     public static function formatScoreDetailText(array $hit): string
     {
-        $scoreText = self::formatScore($hit['score'] ?? 0);
+        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);
-        $parts = [];
+        $lines = [];
         if ($qw > 0) {
-            $parts[] = '(' . self::formatScore($hit['quality_score'] ?? 0) . '×' . self::formatWeightPercent($qw) . '%)';
+            $lines[] = '商务/技术 ' . self::formatScore($hit['quality_score'] ?? 0);
         }
         if ($pw > 0) {
-            $parts[] = '(' . self::formatScore($hit['price_sum'] ?? 0) . '×' . self::formatWeightPercent($pw) . '%)';
+            $lines[] = '价格得分 ' . self::formatScore($hit['price_score'] ?? 0);
         }
         if ($lw > 0) {
-            $parts[] = '(' . self::formatScore($hit['lead_score'] ?? 0) . '×' . self::formatWeightPercent($lw) . '%)';
-        }
-        if ($parts === []) {
-            return $scoreText;
+            $lines[] = '交货得分 ' . self::formatScore($hit['lead_score'] ?? 0);
         }
+        $lines[] = '总分 ' . self::formatScore($hit['score'] ?? 0);
 
-        return $scoreText . ' = ' . implode('+', $parts);
+        return implode("\n", $lines);
     }
 
     /**
@@ -869,16 +1190,21 @@ class ProcuremenSupplierScore
             $parts[] = '(商务/技术得分×' . self::formatWeightPercent($qw) . '%)';
         }
         if ($pw > 0) {
-            $parts[] = '(工序单价合计×' . self::formatWeightPercent($pw) . '%)';
+            $parts[] = '(价格得分)';
         }
         if ($lw > 0) {
             $parts[] = '(交货得分×' . self::formatWeightPercent($lw) . '%)';
         }
-        if ($parts === []) {
-            return '(总分)=(商务/技术得分×50%)+(工序单价合计×50%)';
+        $formula = $parts === []
+            ? '(总分)=(商务/技术得分×50%)+(价格得分)'
+            : '(总分)=' . implode('+', $parts);
+        $formula .= ';商务/技术得分取月度评审「商务/技术得分」(当月按上月起往前查,无则默认50)';
+        if ($pw > 0) {
+            $formula .= ';价格得分=(最低单价合计/本供应商单价合计)×'
+                . self::formatWeightPercent($pw) . '%×100';
         }
 
-        return '(总分)=' . implode('+', $parts);
+        return $formula;
     }
 
     /**
@@ -939,13 +1265,14 @@ class ProcuremenSupplierScore
         }
         self::ensureSchema();
         $rule = self::getDefaultRule();
-        $calc = self::calculateForQuoteGroups($quoteGroups, $rule);
-        if ($calc === []) {
-            return;
-        }
         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();
@@ -960,7 +1287,7 @@ class ProcuremenSupplierScore
                     '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'      => (int)round((float)($it['price_sum'] ?? 0)),
+                    '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)),
@@ -975,15 +1302,24 @@ class ProcuremenSupplierScore
             if ($rows !== []) {
                 Db::table(self::TABLE_SCORE)->insertAll($rows);
             }
-            // 订单明细变更后,同步刷新当月月度总分
-            $companies = [];
+            // 开标后:本月询价供应商名称写入月度评审表(不存在才加空行,已有记录不覆盖)
+            $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 !== '') {
-                    $companies[] = $cn;
+                    $monthNames[] = $cn;
                 }
             }
-            self::syncMonthlyScoresForYm($ym, $companies !== [] ? $companies : null);
+            self::ensureMonthlySupplierNames($ym, $monthNames);
         } catch (\Throwable $e) {
             Log::write('supplier service score save: ' . $e->getMessage(), 'error');
         }
@@ -1021,7 +1357,7 @@ class ProcuremenSupplierScore
             $score = (float)($r['score'] ?? 0);
             $rank = (int)($r['rank_no'] ?? 0);
             $qualityScore = (int)round((float)($r['quality_score'] ?? 0));
-            $priceSum = (int)round((float)($r['price_sum'] ?? 0));
+            $priceSum = round((float)($r['price_sum'] ?? 0), 4);
             $priceScore = (float)($r['price_score'] ?? 0);
             $qw = (int)($r['quality_weight'] ?? 50);
             $pw = (int)($r['price_weight'] ?? 50);
@@ -1036,7 +1372,7 @@ class ProcuremenSupplierScore
                 'quality_score_text'   => (string)$qualityScore,
                 'quality_weight'       => $qw,
                 'price_sum'            => $priceSum,
-                'price_sum_text'       => (string)$priceSum,
+                'price_sum_text'       => self::formatScore($priceSum),
                 'price_weight'         => $pw,
                 'price_score'          => $priceScore,
                 'price_score_text'     => self::formatScore($priceScore),
@@ -1067,6 +1403,7 @@ class ProcuremenSupplierScore
         $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'] = '';
@@ -1106,9 +1443,10 @@ class ProcuremenSupplierScore
         }
 
         // 每次按最新规则重算并落库,避免历史错误总分残留
-        $saved = self::calculateForQuoteGroups($quoteGroups);
+        $ym = date('Y-m');
+        $saved = self::calculateForQuoteGroups($quoteGroups, null, $ym);
         try {
-            self::saveForOrder($ccydh, $quoteGroups);
+            self::saveForOrder($ccydh, $quoteGroups, $ym);
         } catch (\Throwable $e) {
         }
         foreach ($quoteGroups as &$g) {
@@ -1122,6 +1460,7 @@ class ProcuremenSupplierScore
                 $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);
@@ -1129,7 +1468,7 @@ class ProcuremenSupplierScore
                 $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'] ?? $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;

+ 1 - 1
application/extra/site.php

@@ -5,7 +5,7 @@ return array (
   'brand_logo' => 'https://a-7in6-com.oss-cn-hangzhou.aliyuncs.com/xinhua/img/logo1.png',
   'beian' => '',
   'cdnurl' => '',
-  'version' => '1.10.53',
+  'version' => '1.10.77',
   'timezone' => 'Asia/Shanghai',
   'forbiddenip' => '',
   'languages' => 

+ 1 - 1
application/extra/supplier_score_install.sql

@@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS `supplier_service_score` (
   `rank_no` int unsigned NOT NULL DEFAULT 0 COMMENT '本单排名',
   `quality_score` int NOT NULL DEFAULT 0 COMMENT '上年度质量评分',
   `quality_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比',
-  `price_sum` int NOT NULL DEFAULT 0 COMMENT '本单工序单价合计',
+  `price_sum` decimal(12,4) NOT NULL DEFAULT 0.0000 COMMENT '本单工序单价合计',
   `price_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比',
   `price_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '单价评分值',
   `lead_days_sum` int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)',

+ 2 - 2
application/extra/supplier_score_int_columns.sql

@@ -4,9 +4,9 @@ ALTER TABLE `supplier_score_rule`
   MODIFY COLUMN `quality_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '上年度质量评分权重(%)',
   MODIFY COLUMN `price_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '单价评分权重(%)';
 
--- 服务评分表(总分/单价评分值仍可保留小数)
+-- 服务评分表(总分/单价合计/单价评分值保留小数)
 ALTER TABLE `supplier_service_score`
   MODIFY COLUMN `quality_score` int NOT NULL DEFAULT 0 COMMENT '上年度质量评分',
   MODIFY COLUMN `quality_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比',
-  MODIFY COLUMN `price_sum` int NOT NULL DEFAULT 0 COMMENT '本单工序单价合计',
+  MODIFY COLUMN `price_sum` decimal(12,4) NOT NULL DEFAULT 0.0000 COMMENT '本单工序单价合计',
   MODIFY COLUMN `price_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '价格百分比';

+ 15 - 7
application/extra/supplier_service_final_score.sql

@@ -1,18 +1,26 @@
--- 供应商月度评分汇总(评审表展示:总分自动同步,最终得分人工填写
--- 订单明细在 supplier_service_score(按 ccydh×供应商)
+-- 供应商月度评审记录(仅本表展示/导出,不参与其它业务
+-- 商务技术分、价格分、交货分:默认来自当月订单平均,可人工修改;最终得分/评分等级:人工填写
 CREATE TABLE IF NOT EXISTS `supplier_service_final_score` (
   `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 '月度总分(当月订单平均)',
+  `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '月度总分(兼容旧字段)',
+  `quality_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '商务技术分(当月订单平均)',
+  `price_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '价格分(当月订单平均)',
+  `delivery_score` decimal(10,2) NOT NULL DEFAULT 0.00 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='供应商月度评分汇总';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商月度评审记录';
 
--- 若表已存在缺列,可执行:
--- ALTER TABLE `supplier_service_final_score` ADD COLUMN `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '月度总分(当月订单平均)' AFTER `company_name`;
--- ALTER TABLE `supplier_service_final_score` MODIFY COLUMN `final_score` decimal(10,2) DEFAULT NULL COMMENT '最终得分(人工,空=未填)';
+-- 若表已存在,可执行:
+-- ALTER TABLE `supplier_service_final_score` ADD COLUMN `quality_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '商务技术分(当月订单平均)' AFTER `score`;
+-- ALTER TABLE `supplier_service_final_score` ADD COLUMN `price_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '价格分(当月订单平均)' AFTER `quality_score`;
+-- ALTER TABLE `supplier_service_final_score` ADD COLUMN `delivery_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '交货分' AFTER `price_score`;
+-- ALTER TABLE `supplier_service_final_score` ADD COLUMN `score_grade` char(1) NOT NULL DEFAULT '' COMMENT '评分等级 A/B/C/D' AFTER `final_score`;
+-- ALTER TABLE `supplier_service_final_score` ADD COLUMN `qp_manual` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=商务技术分/价格分/交货分已人工修改' AFTER `score_grade`;

+ 45 - 0
application/index/controller/Index.php

@@ -1923,6 +1923,12 @@ class Index extends Frontend
         $rows = array_values(array_filter($rows, function ($r) use ($tab) {
             return is_array($r) && trim((string)($r['mproc_list_tab'] ?? '')) === $tab;
         }));
+        // 「已完成」仅保留近三个月(按招标截止日,缺省则交货截止/创建时间)
+        if ($tab === 'done') {
+            $rows = array_values(array_filter($rows, function ($r) {
+                return is_array($r) && $this->mprocIsWithinRecentMonths($r, 3);
+            }));
+        }
         // 按招标截止日期排序:未提交/已提交截止近的在前;已完成最近截止的在前
         $rows = $this->mprocSortRowsByBidDeadline($rows, $tab === 'done' ? 'desc' : 'asc');
 
@@ -1982,6 +1988,45 @@ class Index extends Frontend
         return ($ts !== false && $ts > 0) ? (int)$ts : 0;
     }
 
+    /**
+     * 是否属于近 N 个月(优先招标截止,其次交货截止,再次创建/更新时间)
+     *
+     * @param array<string, mixed> $row
+     */
+    protected function mprocIsWithinRecentMonths(array $row, int $months = 3): bool
+    {
+        $months = max(1, $months);
+        $since = strtotime(date('Y-m-d 00:00:00', strtotime('-' . $months . ' months')));
+        if ($since === false) {
+            return true;
+        }
+        $candidates = [
+            $row['mproc_bid_deadline'] ?? '',
+            $row['sys_rq'] ?? '',
+            $row['SYS_RQ'] ?? '',
+            $row['mproc_bid_deadline_display'] ?? '',
+            $row['mproc_delivery_deadline'] ?? '',
+            $row['delivery_deadline'] ?? '',
+            $row['mproc_delivery_deadline_display'] ?? '',
+            $row['createtime'] ?? '',
+            $row['updatetime'] ?? '',
+        ];
+        foreach ($candidates as $raw) {
+            $raw = trim((string)$raw);
+            if ($raw === '' || stripos($raw, '0000-00-00') === 0) {
+                continue;
+            }
+            $ts = strtotime(str_replace('T', ' ', $raw));
+            if ($ts === false || $ts <= 0) {
+                continue;
+            }
+
+            return $ts >= $since;
+        }
+
+        return false;
+    }
+
     /**
      * 同一供应商 + 同一工序只保留一条明细(优先 id 更大的最新记录)
      *

+ 169 - 96
application/index/view/index/index.html

@@ -90,7 +90,7 @@
         .mproc-order-main .list { padding: 10px 10px 12px; }
         .card { background: #fff; border-radius: 10px; padding: 12px 12px 10px; margin-bottom: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); }
         .order-group-card { padding-bottom: 4px; }
-        .order-group-head { margin-bottom: 2px; position: relative; }
+        .order-group-head { margin-bottom: 8px; padding-bottom: 8px; position: relative; border-bottom: 1px dashed #e5e5e5; }
         .order-group-head .title {
             margin: 0 0 6px;
             padding-right: 64px;
@@ -145,6 +145,15 @@
             color: #888;
             white-space: nowrap;
         }
+        /* 数量/限价/单价同一行 */
+        .order-line .kv-row.order-line-metrics {
+            flex-wrap: nowrap;
+            gap: 6px 8px;
+        }
+        .order-line .kv-row.order-line-metrics .kv > span:first-child {
+            flex: 0 0 auto;
+            width: auto;
+        }
         .order-line .kv .kv-miss {
             margin-right: 0;
         }
@@ -253,6 +262,21 @@
         .edit-field-row .edit-field:last-child {
             flex: 1 1 auto;
         }
+        .edit-order-shared {
+            margin: 0 0 4px;
+            padding: 0;
+            background: transparent;
+            border: none;
+            border-radius: 0;
+        }
+        .edit-order-shared .edit-line-grid {
+            gap: 10px;
+        }
+        .order-group-lead-row {
+            margin-top: 6px;
+            padding-top: 4px;
+            border-top: none;
+        }
         .edit-field-row .edit-field:first-child label,
         .edit-field label {
             flex: 0 0 58px;
@@ -326,13 +350,12 @@
         .edit-remark-wrap {
             display: flex;
             align-items: flex-start;
-            gap: 10px;
-            margin-top: 4px;
-            padding: 12px 0 0;
+            gap: 6px;
+            margin: 10px 0 0;
+            padding: 0;
             background: transparent;
             border: none;
             border-radius: 0;
-            border-top: 1px solid #e8eef3;
         }
         .edit-remark-wrap label {
             flex: 0 0 58px;
@@ -342,11 +365,13 @@
             font-weight: 500;
             color: #555;
             line-height: 1.3;
+            white-space: nowrap;
         }
         .edit-remark-wrap textarea {
             flex: 1 1 auto;
             width: auto;
             min-width: 0;
+            height: auto;
             min-height: 72px;
             box-sizing: border-box;
             padding: 10px 12px;
@@ -361,10 +386,10 @@
         }
         .edit-remark-wrap textarea:focus { outline: none; border-color: #3c8dbc; }
         .order-group-remark {
-            margin-top: 2px;
-            font-size: 12px;
+            margin: 6px 0 0;
+            font-size: 13px;
             line-height: 1.45;
-            color: #555;
+            color: #333;
             word-break: break-all;
         }
         .order-group-remark span { color: #888; margin-right: 4px; }
@@ -685,9 +710,6 @@
                 <div class="kv"><span>招标截止日期</span>{notempty name="r.mproc_bid_deadline_display"}{$r.mproc_bid_deadline_display|htmlentities}{else /}—{/notempty}</div>
                 <div class="kv"><span>交货截止日期</span>{notempty name="r.mproc_delivery_deadline_display"}{$r.mproc_delivery_deadline_display|htmlentities}{else /}—{/notempty}</div>
             </div>
-            {notempty name="r.mproc_remark"}
-            <div class="kv order-group-remark"><span>备注</span>{$r.mproc_remark|htmlentities}</div>
-            {/notempty}
         </div>
         <div class="order-line js-line"
              data-id="{$r.eid|default=0}"
@@ -701,18 +723,19 @@
              data-this-quantity="{$r.mproc_this_quantity_display|default=''|htmlentities}"
              data-can-edit="{$r.mproc_can_edit|default=0}">
             <p class="order-line-title"><span>{$r.CGYMC|default=''|htmlentities}</span></p>
-            <div class="kv-row">
+            <div class="kv-row order-line-metrics">
                 <div class="kv"><span>本次数量</span>{$r.mproc_this_quantity_display|default=''|htmlentities}</div>
                 <div class="kv"><span>最高限价</span>{$r.ceilingPrice|default=''|htmlentities}</div>
-            </div>
-            <div class="kv-row">
                 <div class="kv"><span>单价</span>{if $r.amount_missing}<span class="kv-miss">未填写</span>{else /}{$r.amount_display|htmlentities}{/if}</div>
-                <div class="kv"><span>工期</span>{if $r.lead_days_missing}<span class="kv-miss">未填写</span>{else /}{$r.lead_days_display|htmlentities}天{/if}</div>
-            </div>
-            <div class="kv-row">
-                <div class="kv"><span>交货日期</span>{if $r.delivery_missing}<span class="kv-miss">未填写</span>{else /}{$r.delivery_display|htmlentities}{/if}</div>
             </div>
         </div>
+        {notempty name="r.mproc_remark"}
+        <div class="kv order-group-remark"><span>备注</span>{$r.mproc_remark|htmlentities}</div>
+        {/notempty}
+        <div class="kv-row order-group-lead-row">
+            <div class="kv"><span>工期</span>{if $r.lead_days_missing}<span class="kv-miss">未填写</span>{else /}{$r.lead_days_display|htmlentities}天{/if}</div>
+            <div class="kv"><span>交货日期</span>{if $r.delivery_missing}<span class="kv-miss">未填写</span>{else /}{$r.delivery_display|htmlentities}{/if}</div>
+        </div>
         {eq name="r.mproc_can_edit" value="1"}
         <div class="order-group-foot">
             <button type="button" class="btn-edit js-open-edit" data-stop="1">填写单价/工期</button>
@@ -732,9 +755,6 @@
                 <div class="kv"><span>招标截止日期</span>{notempty name="g.mproc_bid_deadline_display"}{$g.mproc_bid_deadline_display|htmlentities}{else /}—{/notempty}</div>
                 <div class="kv"><span>交货截止日期</span>{notempty name="g.mproc_delivery_deadline_display"}{$g.mproc_delivery_deadline_display|htmlentities}{else /}—{/notempty}</div>
             </div>
-            {notempty name="g.remark"}
-            <div class="kv order-group-remark"><span>备注</span>{$g.remark|htmlentities}</div>
-            {/notempty}
         </div>
         {volist name="g.lines" id="r"}
         <div class="order-line js-line"
@@ -749,19 +769,34 @@
              data-this-quantity="{$r.mproc_this_quantity_display|default=''|htmlentities}"
              data-can-edit="{$r.mproc_can_edit|default=0}">
             <p class="order-line-title"><span>{$r.CGYMC|default=''|htmlentities}</span></p>
-            <div class="kv-row">
+            <div class="kv-row order-line-metrics">
                 <div class="kv"><span>本次数量</span>{$r.mproc_this_quantity_display|default=''|htmlentities}</div>
                 <div class="kv"><span>最高限价</span>{$r.ceilingPrice|default=''|htmlentities}</div>
-            </div>
-            <div class="kv-row">
                 <div class="kv"><span>单价</span>{if $r.amount_missing}<span class="kv-miss">未填写</span>{else /}{$r.amount_display|htmlentities}{/if}</div>
-                <div class="kv"><span>工期</span>{if $r.lead_days_missing}<span class="kv-miss">未填写</span>{else /}{$r.lead_days_display|htmlentities}天{/if}</div>
-            </div>
-            <div class="kv-row">
-                <div class="kv"><span>交货日期</span>{if $r.delivery_missing}<span class="kv-miss">未填写</span>{else /}{$r.delivery_display|htmlentities}{/if}</div>
             </div>
         </div>
         {/volist}
+        {notempty name="g.remark"}
+        <div class="kv order-group-remark"><span>备注</span>{$g.remark|htmlentities}</div>
+        {/notempty}
+        {notempty name="g.lines"}
+        <?php
+            $__leadMiss = 1; $__leadDisp = ''; $__delMiss = 1; $__delDisp = '';
+            foreach ((array)$g['lines'] as $__lr) {
+                if ($__leadMiss && empty($__lr['lead_days_missing']) && (string)($__lr['lead_days_display'] ?? '') !== '') {
+                    $__leadMiss = 0; $__leadDisp = (string)$__lr['lead_days_display'];
+                }
+                if ($__delMiss && empty($__lr['delivery_missing']) && (string)($__lr['delivery_display'] ?? '') !== '') {
+                    $__delMiss = 0; $__delDisp = (string)$__lr['delivery_display'];
+                }
+                if (!$__leadMiss && !$__delMiss) break;
+            }
+        ?>
+        <div class="kv-row order-group-lead-row">
+            <div class="kv"><span>工期</span>{if condition="$__leadMiss"}<span class="kv-miss">未填写</span>{else /}{$__leadDisp|htmlentities}天{/if}</div>
+            <div class="kv"><span>交货日期</span>{if condition="$__delMiss"}<span class="kv-miss">未填写</span>{else /}{$__delDisp|htmlentities}{/if}</div>
+        </div>
+        {/notempty}
         {eq name="g.can_edit" value="1"}
         <div class="order-group-foot">
             <button type="button" class="btn-edit js-open-edit" data-stop="1">填写单价/工期</button>
@@ -818,10 +853,6 @@
     <div class="modal-sheet" id="edit-sheet">
         <p class="modal-head" id="edit-sheet-title">填写单价/工期</p>
         <div id="edit-lines-box"></div>
-        <div class="edit-remark-wrap">
-            <label for="edit-remark">备注</label>
-            <textarea id="edit-remark" class="js-edit-remark" rows="2" maxlength="500" autocomplete="off"></textarea>
-        </div>
         <div class="modal-actions">
             <button type="button" class="btn-cancel" id="edit-cancel">取消</button>
             <button type="button" class="btn-ok" id="edit-save">保存</button>
@@ -1393,20 +1424,41 @@
         var gymc = pick(r, ['CGYMC', 'cgymc']);
         return '<div class="order-line js-line" data-id="' + eid + '" data-ccydh="' + mprocEscAttr(ccydh) + '" data-amount="' + mprocEscAttr(amtRaw) + '" data-delivery="' + mprocEscAttr(delRaw) + '" data-lead-days="' + mprocEscAttr(leadRaw) + '" data-title="' + mprocEscAttr(tit) + '" data-cgymc="' + mprocEscAttr(gymc) + '" data-ceiling-price="' + mprocEscAttr(ceilP) + '" data-this-quantity="' + mprocEscAttr(thisQty) + '" data-can-edit="' + canEdit + '">'
             + '<p class="order-line-title"><span>' + mprocEsc(gymc) + '</span></p>'
-            + '<div class="kv-row">'
+            + '<div class="kv-row order-line-metrics">'
             + '<div class="kv"><span>本次数量</span>' + mprocEsc(thisQty) + '</div>'
             + '<div class="kv"><span>最高限价</span>' + mprocEsc(ceilP) + '</div>'
-            + '</div>'
-            + '<div class="kv-row">'
             + '<div class="kv"><span>单价</span>' + mprocAmountLineHtml(r) + '</div>'
-            + '<div class="kv"><span>工期</span>' + mprocLeadDaysLineHtml(r) + '</div>'
-            + '</div>'
-            + '<div class="kv-row">'
-            + '<div class="kv"><span>交货日期</span>' + mprocDeliveryLineHtml(r) + '</div>'
             + '</div>'
             + '</div>';
     }
 
+    function mprocPickGroupLeadDeliveryHtml(lines) {
+        var leadHtml = '<span class="kv-miss">未填写</span>';
+        var delHtml = '<span class="kv-miss">未填写</span>';
+        var gotLead = false;
+        var gotDel = false;
+        (lines || []).forEach(function (r) {
+            if (!gotLead && parseInt(r.lead_days_missing, 10) !== 1) {
+                var ld = mprocLeadDaysLineHtml(r);
+                if (ld && ld.indexOf('kv-miss') < 0) {
+                    leadHtml = ld;
+                    gotLead = true;
+                }
+            }
+            if (!gotDel && parseInt(r.delivery_missing, 10) !== 1) {
+                var dd = mprocDeliveryLineHtml(r);
+                if (dd && dd.indexOf('kv-miss') < 0) {
+                    delHtml = dd;
+                    gotDel = true;
+                }
+            }
+        });
+        return '<div class="kv-row order-group-lead-row">'
+            + '<div class="kv"><span>工期</span>' + leadHtml + '</div>'
+            + '<div class="kv"><span>交货日期</span>' + delHtml + '</div>'
+            + '</div>';
+    }
+
     function renderOrderGroupHtml(g, focusEid, focusTab) {
         var tit = pick(g, ['CYJMC', 'cyjmc']);
         var ccydh = pick(g, ['CCYDH', 'ccydh']);
@@ -1481,9 +1533,10 @@
             + '<div class="order-group-head">'
             + '<p class="title">' + (ccydh ? ('<span class="mproc-ord-no">' + mprocEsc(ccydh) + '</span>') : '') + mprocEsc(tit) + badgeHtml + '</p>'
             + deadlineHtml
-            + remarkHtml
             + '</div>'
             + linesHtml
+            + remarkHtml
+            + mprocPickGroupLeadDeliveryHtml(lines)
             + (canEdit
                 ? '<div class="order-group-foot"><button type="button" class="btn-edit js-open-edit" data-stop="1">填写单价/工期</button></div>'
                 : '')
@@ -2191,8 +2244,6 @@
 
     function buildEditLineBlockHtml(line, index, total) {
         var amt = mprocSanitizeAmountValue(line.amount || '');
-        var del = deliveryToDateVal(line.delivery || '');
-        var lead = mprocResolveLeadDaysValue(line);
         var qtyText = String(line.qty == null ? '' : line.qty).trim();
         var ceilText = String(line.ceiling == null ? '' : line.ceiling).trim();
         var qtyShow = qtyText !== '' ? qtyText : '—';
@@ -2213,6 +2264,33 @@
             + '<p class="modal-ceil-hint js-edit-ceil-hint" style="display:none;"></p>'
             + '</div>'
             + '</div>'
+            + '</div>'
+            + '</div>';
+    }
+
+    function buildEditRemarkHtml(remark) {
+        return '<div class="edit-remark-wrap" id="edit-remark-wrap">'
+            + '<label for="edit-remark">备注</label>'
+            + '<textarea id="edit-remark" class="js-edit-remark" rows="2" maxlength="500" autocomplete="off">'
+            + mprocEsc(remark || '')
+            + '</textarea>'
+            + '</div>';
+    }
+
+    function buildEditOrderSharedHtml(lines) {
+        var seed = lines && lines.length ? lines[0] : {};
+        (lines || []).forEach(function (line) {
+            if ((!seed.lead_days || String(seed.lead_days).trim() === '') && line.lead_days) {
+                seed = Object.assign({}, seed, { lead_days: line.lead_days });
+            }
+            if ((!seed.delivery || String(seed.delivery).trim() === '') && line.delivery) {
+                seed = Object.assign({}, seed, { delivery: line.delivery });
+            }
+        });
+        var del = deliveryToDateVal(seed.delivery || '');
+        var lead = mprocResolveLeadDaysValue(seed);
+        // 工期、交期同一行;备注在下方单独一行
+        return '<div class="edit-order-shared" id="edit-order-shared">'
             + '<div class="edit-field-row">'
             + '<div class="edit-field">'
             + '<label>工期(天)</label>'
@@ -2230,7 +2308,6 @@
             + '</div>'
             + '</div>'
             + '</div>'
-            + '</div>'
             + '</div>';
     }
 
@@ -2301,9 +2378,12 @@
             titleEl.textContent = head || '填写单价/工期';
         }
         if (editLinesBox) {
+            var groupRemark = String(groupEl.getAttribute('data-remark') || '').replace(/&quot;/g, '"');
+            // 工序单价 → 工期/交期(同一行)→ 备注在最下
             editLinesBox.innerHTML = lines.map(function (line, idx) {
                 return buildEditLineBlockHtml(line, idx, lines.length);
-            }).join('');
+            }).join('') + buildEditOrderSharedHtml(lines) + buildEditRemarkHtml(groupRemark);
+            editRemarkInp = document.getElementById('edit-remark');
             editLinesBox.querySelectorAll('.js-edit-amount').forEach(function (inp) {
                 mprocBindAmountInput(inp);
                 var block = inp.closest('.edit-line-block');
@@ -2315,14 +2395,11 @@
                 });
                 mprocRefreshLineCeilHint(block);
             });
-            editLinesBox.querySelectorAll('.js-date-shell').forEach(bindDateShell);
-            editLinesBox.querySelectorAll('.edit-line-block').forEach(function (block) {
-                mprocBindLeadDeliverySync(block);
-            });
-        }
-        if (editRemarkInp) {
-            var groupRemark = String(groupEl.getAttribute('data-remark') || '').replace(/&quot;/g, '"');
-            editRemarkInp.value = groupRemark;
+            var sharedBox = editLinesBox.querySelector('#edit-order-shared');
+            if (sharedBox) {
+                editLinesBox.querySelectorAll('#edit-order-shared .js-date-shell').forEach(bindDateShell);
+                mprocBindLeadDeliverySync(sharedBox);
+            }
         }
         mask.classList.add('show');
         mask.setAttribute('aria-hidden', 'false');
@@ -2338,9 +2415,7 @@
         if (editLinesBox) {
             editLinesBox.innerHTML = '';
         }
-        if (editRemarkInp) {
-            editRemarkInp.value = '';
-        }
+        editRemarkInp = null;
     }
 
     function postSave() {
@@ -2356,6 +2431,22 @@
         }
         var items = [];
         var blocks = editLinesBox.querySelectorAll('.edit-line-block');
+        var sharedBox = editLinesBox.querySelector('#edit-order-shared');
+        var leadInp = sharedBox ? sharedBox.querySelector('.js-edit-lead-days') : null;
+        var delInp = sharedBox ? sharedBox.querySelector('.js-edit-delivery') : null;
+        if (delInp) {
+            mprocClampDeliveryInput(delInp);
+            var shellShared = sharedBox ? sharedBox.querySelector('.js-date-shell') : null;
+            if (shellShared) {
+                syncDateShellUi(shellShared);
+            }
+        }
+        var sharedDelivery = delInp ? String(delInp.value || '').trim() : '';
+        var sharedLead = leadInp ? String(leadInp.value || '').trim().replace(/[^\d]/g, '') : '';
+        if (leadInp) {
+            leadInp.value = sharedLead;
+        }
+        var anyAmount = false;
         for (var i = 0; i < blocks.length; i++) {
             var block = blocks[i];
             var id = parseInt(block.getAttribute('data-id'), 10) || 0;
@@ -2363,42 +2454,14 @@
                 continue;
             }
             var amtInp = block.querySelector('.js-edit-amount');
-            var delInp = block.querySelector('.js-edit-delivery');
-            var leadInp = block.querySelector('.js-edit-lead-days');
             var amt = mprocNormalizeAmountForCheck(amtInp ? amtInp.value : '');
             if (amtInp) {
                 amtInp.value = amt;
             }
-            if (delInp) {
-                mprocClampDeliveryInput(delInp);
-                var shell = block.querySelector('.js-date-shell');
-                if (shell) {
-                    syncDateShellUi(shell);
-                }
-            }
-            var delivery = delInp ? String(delInp.value || '').trim() : '';
-            var lead = leadInp ? String(leadInp.value || '').trim().replace(/[^\d]/g, '') : '';
-            if (leadInp) {
-                leadInp.value = lead;
-            }
-            var hasAmt = amt !== '';
-            var hasLead = lead !== '';
-            var hasDel = delivery !== '';
-            var filledCount = (hasAmt ? 1 : 0) + (hasLead ? 1 : 0) + (hasDel ? 1 : 0);
-            if (filledCount === 0) {
+            if (amt === '') {
                 continue;
             }
-            if (filledCount < 3) {
-                mprocShowToast('请填写单价、工期、交期');
-                if (!hasAmt && amtInp) {
-                    amtInp.focus();
-                } else if (!hasLead && leadInp) {
-                    leadInp.focus();
-                } else if (delInp) {
-                    delInp.focus();
-                }
-                return null;
-            }
+            anyAmount = true;
             var amtErr = mprocValidateAmountPositive(amt);
             if (amtErr) {
                 mprocRefreshLineCeilHint(block);
@@ -2418,22 +2481,31 @@
                 mprocShowToast(ceilErr);
                 return null;
             }
-            if (!/^\d+$/.test(lead)) {
-                mprocShowToast('工期须为非负整数(天)');
-                if (leadInp) {
-                    leadInp.focus();
-                }
-                return null;
-            }
             items.push({
                 id: id,
                 amount: amt,
-                delivery: delivery,
-                lead_days: lead
+                delivery: sharedDelivery,
+                lead_days: sharedLead
             });
         }
         if (!items.length) {
-            mprocShowToast('请填写单价、工期、交期');
+            mprocShowToast('请至少填写一道工序的单价');
+            return null;
+        }
+        if (sharedLead === '' || sharedDelivery === '') {
+            mprocShowToast('请填写整单工期与交期');
+            if (sharedLead === '' && leadInp) {
+                leadInp.focus();
+            } else if (delInp) {
+                delInp.focus();
+            }
+            return null;
+        }
+        if (!/^\d+$/.test(sharedLead)) {
+            mprocShowToast('工期须为非负整数(天)');
+            if (leadInp) {
+                leadInp.focus();
+            }
             return null;
         }
         // 提交前再判一次,防止填表过程中刚好截止
@@ -2444,7 +2516,8 @@
             }
             return null;
         }
-        var remark = editRemarkInp ? String(editRemarkInp.value || '').trim() : '';
+        var remarkEl = editLinesBox.querySelector('#edit-remark') || editRemarkInp;
+        var remark = remarkEl ? String(remarkEl.value || '').trim() : '';
         var body = 'items=' + encodeURIComponent(JSON.stringify(items))
             + '&remark=' + encodeURIComponent(remark);
         return fetch(saveUrl, {

+ 2 - 2
public/assets/js/backend/customer.js

@@ -65,13 +65,13 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         }},
                         {field: 'email', title: __('Email'), operate: 'LIKE', table: table, class: 'autocontent', formatter: Table.api.formatter.content},
                         {field: 'phone', title: __('Phone'), operate: 'LIKE', table: table, class: 'autocontent', formatter: Table.api.formatter.content},
-                        {field: 'score', title: __('Score'), operate: 'BETWEEN', sortable: true, formatter: function (value) {
+                        {field: 'score', title: __('Score'), operate: 'BETWEEN', sortable: true, visible: false, formatter: function (value) {
                             if (value === null || value === undefined || value === '') {
                                 return '';
                             }
                             return String(parseInt(value, 10));
                         }},
-                        {field: 'monthly_score', title: __('Monthly_score'), operate: 'BETWEEN', sortable: true, formatter: function (value) {
+                        {field: 'monthly_score', title: __('Monthly_score'), operate: 'BETWEEN', sortable: true, visible: false, formatter: function (value) {
                             if (value === null || value === undefined || value === '') {
                                 return '';
                             }

+ 253 - 22
public/assets/js/backend/procuremen.js

@@ -420,13 +420,24 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
 
         var syncFromHidden = function (val) {
             val = procuremenNormalizeSysRqValue(val);
-            var parts = val.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}):(\d{2})/);
-            if (!parts) {
+            var parts = val.match(/^(\d{4}-\d{2}-\d{2})(?:\s+|T)(\d{1,2}):(\d{1,2})/);
+            if (parts) {
+                $date.val(parts[1]);
+                $hour.val(procuremenPad2(parseInt(parts[2], 10)));
+                $minute.val(procuremenPad2(parseInt(parts[3], 10)));
                 return;
             }
-            $date.val(parts[1]);
-            $hour.val(parts[2]);
-            $minute.val(parts[3]);
+            // 仅日期时也回填,避免招标截止日期显示空白
+            var dayOnly = val.match(/^(\d{4}-\d{2}-\d{2})/);
+            if (dayOnly) {
+                $date.val(dayOnly[1]);
+                if (!$hour.val()) {
+                    $hour.val('00');
+                }
+                if (!$minute.val()) {
+                    $minute.val('00');
+                }
+            }
         };
 
         syncFromHidden($hidden.val());
@@ -1031,20 +1042,196 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 }
                 return n + '家';
             }
+            var filterOpts = (typeof Config !== 'undefined' && Config.procuremenFilterOptions)
+                ? Config.procuremenFilterOptions
+                : {};
+            if (!filterOpts || typeof filterOpts !== 'object') {
+                filterOpts = {};
+            }
+            ['czlyq', 'CCLBMMC', 'cywyxm', 'CGYMC'].forEach(function (f) {
+                if (!filterOpts[f] || typeof filterOpts[f] !== 'object') {
+                    filterOpts[f] = {};
+                }
+            });
+            function procuremenMergeFilterOptions(incoming) {
+                if (!incoming || typeof incoming !== 'object') {
+                    return;
+                }
+                var got = false;
+                ['czlyq', 'CCLBMMC', 'cywyxm', 'CGYMC'].forEach(function (f) {
+                    var src = incoming[f];
+                    if (!src || typeof src !== 'object') {
+                        return;
+                    }
+                    if (!filterOpts[f] || typeof filterOpts[f] !== 'object') {
+                        filterOpts[f] = {};
+                    }
+                    if (Object.keys(src).length) {
+                        got = true;
+                    }
+                    $.extend(filterOpts[f], src);
+                });
+                if (typeof Config !== 'undefined') {
+                    Config.procuremenFilterOptions = filterOpts;
+                }
+                if (got) {
+                    Controller._filterOptionsReady = true;
+                }
+            }
+            function procuremenSearchList(field) {
+                var list = filterOpts && filterOpts[field] ? filterOpts[field] : {};
+                return list && typeof list === 'object' ? list : {};
+            }
+            // 表头标题文字后小筛选:承揽部门 / 工序名称 / 类型
+            Controller.colFilters = {CCLBMMC: '', CGYMC: '', czlyq: ''};
+            var procuremenHeaderFilterFields = [
+                {field: 'CCLBMMC', label: '承揽部门'},
+                {field: 'CGYMC', label: '工序名称'},
+                {field: 'czlyq', label: '类型'}
+            ];
+            function procuremenEscOption(s) {
+                return String(s == null ? '' : s)
+                    .replace(/&/g, '&amp;')
+                    .replace(/</g, '&lt;')
+                    .replace(/>/g, '&gt;')
+                    .replace(/"/g, '&quot;');
+            }
+            function procuremenBuildHeaderFilterMenu(field) {
+                var cur = Controller.colFilters[field] || '';
+                var list = procuremenSearchList(field);
+                var html = '<li' + (!cur ? ' class="active"' : '') + '>'
+                    + '<a href="javascript:;" class="procuremen-th-filter-item" data-field="'
+                    + procuremenEscOption(field) + '" data-value="">全部</a></li>';
+                $.each(list, function (k, v) {
+                    var val = k != null && String(k) !== '' ? String(k) : String(v);
+                    var lab = v != null && String(v) !== '' ? String(v) : val;
+                    if (!val) {
+                        return;
+                    }
+                    html += '<li' + (cur === val ? ' class="active"' : '') + '>'
+                        + '<a href="javascript:;" class="procuremen-th-filter-item" data-field="'
+                        + procuremenEscOption(field) + '" data-value="' + procuremenEscOption(val) + '">'
+                        + procuremenEscOption(lab) + '</a></li>';
+                });
+                return html;
+            }
+            function procuremenCloseHeaderFilterMenus() {
+                $('.procuremen-th-filter-dd.open').each(function () {
+                    var $dd = $(this);
+                    var $menu = $dd.data('procuremenMenu');
+                    $dd.removeClass('open');
+                    if ($menu && $menu.length) {
+                        $menu.hide().css({position: '', top: '', left: '', zIndex: ''}).appendTo($dd);
+                        $dd.removeData('procuremenMenu');
+                    }
+                });
+                $('body > .procuremen-th-filter-menu-float').remove();
+            }
+            function procuremenSyncHeaderFilterActive() {
+                procuremenHeaderFilterFields.forEach(function (item) {
+                    var cur = Controller.colFilters[item.field] || '';
+                    $('.procuremen-th-filter-btn[data-field="' + item.field + '"]').toggleClass('active', !!cur);
+                });
+            }
+            function procuremenInjectHeaderFilters() {
+                var $scope = $('#procuremen-layout .bootstrap-table');
+                if (!$scope.length) {
+                    $scope = table.closest('.bootstrap-table');
+                }
+                if (!$scope.length) {
+                    return;
+                }
+                procuremenHeaderFilterFields.forEach(function (item) {
+                    $scope.find('th[data-field="' + item.field + '"]').each(function () {
+                        var $th = $(this);
+                        var $inner = $th.find('> .th-inner').first();
+                        if (!$inner.length) {
+                            return;
+                        }
+                        $inner.addClass('procuremen-th-with-filter');
+                        var $dd = $inner.find('> .procuremen-th-filter-dd');
+                        if ($dd.length) {
+                            return;
+                        }
+                        var titleText = $.trim($inner.text()) || item.label;
+                        $inner.empty()
+                            .append($('<span class="procuremen-th-label"/>').text(titleText));
+                        $dd = $('<span class="dropdown procuremen-th-filter-dd"/>')
+                            .append(
+                                $('<a href="javascript:;" class="procuremen-th-filter-btn"/>')
+                                    .attr('title', '筛选' + item.label)
+                                    .attr('data-field', item.field)
+                                    .html('<i class="fa fa-filter"></i>')
+                            )
+                            .append($('<ul class="dropdown-menu dropdown-menu-left procuremen-th-filter-menu"/>'));
+                        $inner.append($dd);
+                    });
+                });
+                procuremenSyncHeaderFilterActive();
+            }
+            $(document).off('click.procuremenColFilter', '.procuremen-th-filter-item')
+                .on('click.procuremenColFilter', '.procuremen-th-filter-item', function (e) {
+                    e.preventDefault();
+                    e.stopPropagation();
+                    var field = String($(this).data('field') || '');
+                    var val = $(this).attr('data-value');
+                    if (val == null) {
+                        val = '';
+                    }
+                    val = String(val);
+                    if (!field || !Controller.colFilters.hasOwnProperty(field)) {
+                        return;
+                    }
+                    Controller.colFilters[field] = val;
+                    procuremenSyncHeaderFilterActive();
+                    procuremenCloseHeaderFilterMenus();
+                    try {
+                        $('#table').bootstrapTable('refresh', {pageNumber: 1});
+                    } catch (ignore) {
+                    }
+                })
+                .off('click.procuremenColFilterBtn', '.procuremen-th-filter-btn')
+                .on('click.procuremenColFilterBtn', '.procuremen-th-filter-btn', function (e) {
+                    e.preventDefault();
+                    e.stopPropagation();
+                    var $btn = $(this);
+                    var field = String($btn.data('field') || '');
+                    var $dd = $btn.closest('.procuremen-th-filter-dd');
+                    var willOpen = !$dd.hasClass('open');
+                    procuremenCloseHeaderFilterMenus();
+                    if (!willOpen || !field) {
+                        return;
+                    }
+                    var $menu = $dd.find('> .dropdown-menu').first();
+                    if (!$menu.length) {
+                        return;
+                    }
+                    // 仅在打开时生成选项,避免每次表头刷新重建大 DOM
+                    $menu.html(procuremenBuildHeaderFilterMenu(field));
+                    $dd.addClass('open');
+                    var rect = $btn[0].getBoundingClientRect();
+                    $menu.addClass('procuremen-th-filter-menu-float').appendTo(document.body).css({
+                        position: 'fixed',
+                        top: (rect.bottom + 2) + 'px',
+                        left: Math.max(8, rect.left) + 'px',
+                        display: 'block',
+                        zIndex: 2000
+                    }).show();
+                    $dd.data('procuremenMenu', $menu);
+                })
+                .off('mousedown.procuremenColFilterBtn', '.procuremen-th-filter-dd, .procuremen-th-filter-menu-float')
+                .on('mousedown.procuremenColFilterBtn', '.procuremen-th-filter-dd, .procuremen-th-filter-menu-float', function (e) {
+                    e.stopPropagation();
+                })
+                .off('click.procuremenColFilterClose')
+                .on('click.procuremenColFilterClose', function () {
+                    procuremenCloseHeaderFilterMenus();
+                });
+            table.on('post-header.bs.table', function () {
+                procuremenInjectHeaderFilters();
+            });
             var indexTableColumns = [
                 {checkbox: true, field: 'state', visible: indexShowall, align: 'center', width: 42, class: 'procuremen-col-checkbox'},
-                // {field: 'ID', title: __('ID'), operate: 'LIKE', table: 'a', width: 100, align: 'center',
-                //     visible: indexShowall,
-                //     formatter: function (v) {
-                //         return v != null && v !== '' && String(v) !== '0' ? String(v) : '';
-                //     }
-                // },
-                // {field: 'purchase_order_id',title: 'ID',operate: false,table: 'a',width: 88,align: 'center',
-                //     visible: indexShowIssued,
-                //     formatter: function (v) {
-                //         return v != null && v !== '' && String(v) !== '0' ? String(v) : '';
-                //     }
-                // },
                 {field: 'CCYDH', title: __('订单号'), operate: 'LIKE', table: 'b', width: 128, align: 'center', sortable: true, class: 'procuremen-th-sort-tight'},
                 {field: 'CYJMC', title: __('印件名称'), operate: 'LIKE', table: 'b', width: 270, align: 'left', class: 'procuremen-cell-wrap',
                     formatter: function (v) {
@@ -1054,13 +1241,18 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         return '<span title="' + procuremenEscAttr(v) + '">' + procuremenEscHtml(v) + '</span>';
                     }
                 },
-                {field: 'CCLBMMC', title: '承揽部门', operate: 'LIKE', table: 'b', width: 140, align: 'center', class: 'procuremen-cell-wrap'},
-                {field: 'CGYMC', title: __('工序名称'), operate: 'LIKE', table: 'a', width: 140, align: 'left', class: 'procuremen-cell-wrap',
+                {field: 'CCLBMMC', title: '承揽部门', operate: '=', searchList: procuremenSearchList('CCLBMMC'), table: 'b', width: 140, align: 'center', class: 'procuremen-cell-wrap'},
+                {field: 'CGYMC', title: __('工序名称'), operate: '=', searchList: procuremenSearchList('CGYMC'), table: 'a', width: 140, align: 'left', class: 'procuremen-cell-wrap',
                     formatter: function (v) {
                         return v != null && v !== '' ? String(v) : '';
                     }
                 },
                 {field: 'CDW', title: __('单位'), operate: 'LIKE', table: 'a', width: 88, align: 'center'},
+                {field: 'czlyq', title: '类型', operate: '=', searchList: procuremenSearchList('czlyq'), table: 'b', width: 100, align: 'center',
+                    formatter: function (v) {
+                        return v != null && v !== '' ? String(v) : '';
+                    }
+                },
                 {field: 'NGZL', title: __('工作量'), operate: 'LIKE', table: 'a', width: 88, align: 'center',
                     // 确认/审批列表不展示工作量,仅初选保留
                     visible: indexInitWffTab === 'pick',
@@ -1159,7 +1351,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 {field: 'CDF', title: __('订法'), operate: 'LIKE', table: 'a', width: 100, align: 'center', class: 'procuremen-cell-wrap'},
                 {field: 'cGzzxMc', title: __('外厂单位'), operate: 'LIKE', table: 'a', width: 220, align: 'center', class: 'procuremen-cell-wrap'},
                 {field: 'MBZ', title: __('备注'), operate: 'LIKE', table: 'a', width: 150, align: 'center', class: 'procuremen-cell-wrap'},
-                {field: 'cywyxm', title: __('业务员'), operate: 'LIKE', table: 'b', width: 80, align: 'center'},
+                {field: 'cywyxm', title: __('业务员'), operate: '=', searchList: procuremenSearchList('cywyxm'), table: 'b', width: 80, align: 'center'},
                 {field: 'dStamp', title: __('操作日期'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, table: 'a', width: 176, align: 'center', sortable: true, class: 'procuremen-th-sort-tight'},
                 {field: 'dputrecord', title: __('提交日期'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, table: 'b', width: 176, align: 'center', sortable: true, class: 'procuremen-th-sort-tight'},
                 {field: 'operate',title: '操作',width: 168,align: 'center',fixed: 'right', operate: false,
@@ -1171,7 +1363,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         if (!row || pk == null || pk === '' || String(pk) === '0') {
                             return '';
                         }
-                        var areaDetails = ' data-area=\'["92%","88%"]\'';
+                        var areaDetails = ' data-area=\'["100%","100%"]\'';
                         var areaAudit = ' data-area=\'["98%","92%"]\'';
                         var areaConfirmOutward = ' data-area=\'["98%","92%"]\'';
                         var parts = [];
@@ -1436,6 +1628,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 search: true,
                 smartDisplay: false,
                 responseHandler: function (res) {
+                    if (res && res.filterOptions) {
+                        procuremenMergeFilterOptions(res.filterOptions);
+                    }
                     if (res && res.activeYm && (Controller.wffTab === 'audit' || Controller.wffTab === 'confirm')) {
                         procuremenApplyActiveYm(res.activeYm);
                     }
@@ -1447,6 +1642,34 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 queryParams: function (params) {
                     params.ym = Controller.currYm;
                     params.wff_tab = Controller.wffTab;
+                    params.need_filter_options = Controller._filterOptionsReady ? '0' : '1';
+                    var filter = {};
+                    var op = {};
+                    try {
+                        filter = JSON.parse(params.filter || '{}') || {};
+                        op = JSON.parse(params.op || '{}') || {};
+                    } catch (e) {
+                        filter = {};
+                        op = {};
+                    }
+                    if (!Controller.colFilters) {
+                        Controller.colFilters = {CCLBMMC: '', CGYMC: '', czlyq: ''};
+                    }
+                    ['CCLBMMC', 'CGYMC', 'czlyq'].forEach(function (k) {
+                        var v = Controller.colFilters[k];
+                        delete filter[k];
+                        delete op[k];
+                        delete filter['a.' + k];
+                        delete op['a.' + k];
+                        delete filter['b.' + k];
+                        delete op['b.' + k];
+                        if (v != null && String(v) !== '') {
+                            filter[k] = String(v);
+                            op[k] = '=';
+                        }
+                    });
+                    params.filter = JSON.stringify(filter);
+                    params.op = JSON.stringify(op);
                     return params;
                 },
                 columns: [indexTableColumns]
@@ -1457,8 +1680,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
             // 本页不需要「跨页选择模式」提示
             table.closest('.bootstrap-table').find('.btn-selected-tips').remove();
             $('#procuremen-toolbar-host .btn-selected-tips, #toolbar .btn-selected-tips').remove();
+            procuremenInjectHeaderFilters();
             table.on('post-body.bs.table post-header.bs.table', function () {
                 $('#procuremen-toolbar-host .btn-selected-tips, .bootstrap-table .btn-selected-tips, #toolbar .btn-selected-tips').remove();
+                procuremenInjectHeaderFilters();
             });
 
             table.off('mouseenter.procuremenSupplierLines mouseleave.procuremenSupplierLines', '.procuremen-supplier-lines-cell')
@@ -2107,6 +2332,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         if (buttond && buttond.layerArea && buttond.layerArea.length) {
                             layerOptsd.area = buttond.layerArea;
                         }
+                        // 详情弹窗全屏
+                        layerOptsd.area = ['100%', '100%'];
                         var winNd = $ad.data('window') || 'self';
                         var winD = window[winNd] || window;
                         if (!winD.Backend || !winD.Backend.api) {
@@ -2467,7 +2694,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     return;
                 }
                 var url = Fast.api.fixurl('procuremen/details?ids=' + encodeURIComponent(String(sid)));
-                Backend.api.open(url, '详情', {area: ['92%', '88%']});
+                Backend.api.open(url, '详情', {area: ['100%', '100%']});
             });
         },
 
@@ -4596,6 +4823,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     Toastr.warning('请选择一家供应商');
                     return;
                 }
+                if (!auditIssueQuoteDeadlineReached()) {
+                    Toastr.warning('未到招标截止日期,暂不可确认');
+                    return;
+                }
                 if (!auditIssueBidOpenVerified()) {
                     Toastr.warning('请先完成开标验证后再确认供应商');
                     return;

+ 1 - 1
public/assets/js/backend/procuremenarchive.js

@@ -170,7 +170,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     return;
                 }
                 var url = Fast.api.fixurl('procuremen/details?ids=' + encodeURIComponent(String(sid)));
-                Backend.api.open(url, '详情', {area: ['92%', '88%']});
+                Backend.api.open(url, '详情', {area: ['100%', '100%']});
             });
 
             $(document).off('click.procuremenArchiveAbandon', '#btn-archive-abandon').on('click.procuremenArchiveAbandon', '#btn-archive-abandon', function (e) {

+ 1 - 1
public/assets/js/backend/procuremenexport.js

@@ -187,7 +187,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     return;
                 }
                 var url = Fast.api.fixurl('procuremen/details?ids=' + encodeURIComponent(String(sid)));
-                Backend.api.open(url, '详情', {area: ['92%', '88%']});
+                Backend.api.open(url, '详情', {area: ['100%', '100%']});
             });
         },
         api: {

+ 134 - 29
public/assets/js/backend/supplierservicescore.js

@@ -40,18 +40,77 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     .replace(/</g, '&lt;')
                     .replace(/>/g, '&gt;');
             }
+            function scoreInputHtml(company, cssClass, val, title) {
+                var show = (val == null || val === '') ? '' : String(val);
+                return '<input type="text" inputmode="decimal" class="form-control input-sm ' + cssClass + '"'
+                    + ' data-company="' + escAttr(company) + '"'
+                    + ' value="' + escAttr(show) + '"'
+                    + ' title="' + escAttr(title || '') + '"/>';
+            }
+            function scoreCellVal(row, textKey, valueKey) {
+                var text = row[textKey];
+                if (text != null && String(text).trim() !== '') {
+                    return String(text).trim();
+                }
+                var v = row[valueKey];
+                if (v == null || v === '') {
+                    return '';
+                }
+                return String(v);
+            }
             function collectFinalRows() {
                 var rows = [];
+                var byCompany = {};
+                function ensure(company) {
+                    if (!byCompany[company]) {
+                        byCompany[company] = {
+                            company_name: company,
+                            quality_score: '',
+                            price_score: '',
+                            delivery_score: '',
+                            final_score: '',
+                            score_grade: ''
+                        };
+                    }
+                    return byCompany[company];
+                }
+                $('#table').find('input.ssc-quality-score-input').each(function () {
+                    var company = String($(this).data('company') || '').trim();
+                    if (!company) {
+                        return;
+                    }
+                    ensure(company).quality_score = String($(this).val() || '').trim();
+                });
+                $('#table').find('input.ssc-price-score-input').each(function () {
+                    var company = String($(this).data('company') || '').trim();
+                    if (!company) {
+                        return;
+                    }
+                    ensure(company).price_score = String($(this).val() || '').trim();
+                });
+                $('#table').find('input.ssc-delivery-score-input').each(function () {
+                    var company = String($(this).data('company') || '').trim();
+                    if (!company) {
+                        return;
+                    }
+                    ensure(company).delivery_score = String($(this).val() || '').trim();
+                });
                 $('#table').find('input.ssc-final-score-input').each(function () {
-                    var $inp = $(this);
-                    var company = String($inp.data('company') || '').trim();
+                    var company = String($(this).data('company') || '').trim();
                     if (!company) {
                         return;
                     }
-                    rows.push({
-                        company_name: company,
-                        final_score: String($inp.val() || '').trim()
-                    });
+                    ensure(company).final_score = String($(this).val() || '').trim();
+                });
+                $('#table').find('select.ssc-score-grade-select').each(function () {
+                    var company = String($(this).data('company') || '').trim();
+                    if (!company) {
+                        return;
+                    }
+                    ensure(company).score_grade = String($(this).val() || '').trim().toUpperCase();
+                });
+                $.each(byCompany, function (_, row) {
+                    rows.push(row);
                 });
                 return rows;
             }
@@ -65,7 +124,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
             table.bootstrapTable({
                 url: $.fn.bootstrapTable.defaults.extend.index_url,
                 pk: 'id',
-                sortName: 'rank_no',
+                sortName: 'seq_no',
                 sortOrder: 'asc',
                 search: true,
                 commonSearch: false,
@@ -81,6 +140,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         sortable: false,
                         width: 60,
                         align: 'center',
+                        halign: 'center',
                         formatter: function (value, row, index) {
                             if (value != null && value !== '') {
                                 return value;
@@ -95,18 +155,47 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         field: 'company_name',
                         title: '供应商',
                         operate: 'LIKE',
-                        align: 'left',
-                        width: 280
+                        align: 'center',
+                        halign: 'center',
+                        width: 320
+                    },
+                    {
+                        field: 'quality_score',
+                        title: '商务/技术得分',
+                        operate: false,
+                        sortable: false,
+                        align: 'center',
+                        halign: 'center',
+                        width: 120,
+                        formatter: function (value, row) {
+                            var company = row.company_name || '';
+                            return scoreInputHtml(company, 'ssc-quality-score-input', scoreCellVal(row, 'quality_score_text', 'quality_score'), '商务/技术得分(可改)');
+                        }
+                    },
+                    {
+                        field: 'price_score',
+                        title: '价格得分',
+                        operate: false,
+                        sortable: false,
+                        align: 'center',
+                        halign: 'center',
+                        width: 120,
+                        formatter: function (value, row) {
+                            var company = row.company_name || '';
+                            return scoreInputHtml(company, 'ssc-price-score-input', scoreCellVal(row, 'price_score_text', 'price_score'), '价格得分(可改)');
+                        }
                     },
                     {
-                        field: 'score',
-                        title: '总分',
+                        field: 'delivery_score',
+                        title: '交货得分',
                         operate: false,
                         sortable: false,
                         align: 'center',
-                        width: 90,
+                        halign: 'center',
+                        width: 120,
                         formatter: function (value, row) {
-                            return row.score_text != null && row.score_text !== '' ? row.score_text : value;
+                            var company = row.company_name || '';
+                            return scoreInputHtml(company, 'ssc-delivery-score-input', scoreCellVal(row, 'delivery_score_text', 'delivery_score'), '交货得分(可改)');
                         }
                     },
                     {
@@ -115,32 +204,48 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         operate: false,
                         sortable: false,
                         align: 'center',
-                        width: 120,
+                        halign: 'center',
+                        width: 130,
                         formatter: function (value, row) {
                             var company = row.company_name || '';
-                            var scoreText = row.score_text != null ? String(row.score_text) : '';
                             var val = '';
-                            // 仅已保存的最终得分回填;未保存保持空,避免清空后又被总分填回
-                            if (parseInt(row.final_score_saved, 10) === 1 && row.final_score != null && row.final_score !== '') {
-                                val = row.final_score_text != null && row.final_score_text !== ''
-                                    ? row.final_score_text
-                                    : row.final_score;
+                            if (parseInt(row.final_score_saved, 10) === 1) {
+                                val = scoreCellVal(row, 'final_score_text', 'final_score');
                             }
-                            return '<input type="text" inputmode="decimal" class="form-control input-sm ssc-final-score-input"'
-                                + ' data-company="' + escAttr(company) + '"'
-                                + ' data-score="' + escAttr(scoreText) + '"'
-                                + ' value="' + escAttr(val) + '"'
-                                + ' style="width:96px;margin:0 auto;text-align:center;height:28px;padding:2px 6px;"'
-                                + ' title="仅作人工记录"/>';
+                            return scoreInputHtml(company, 'ssc-final-score-input', val, '最终得分(可改)');
                         }
                     },
                     {
-                        field: 'rank_no',
-                        title: '排名',
+                        field: 'score_grade',
+                        title: '评分等级',
                         operate: false,
                         sortable: false,
                         align: 'center',
-                        width: 70
+                        halign: 'center',
+                        width: 120,
+                        formatter: function (value, row) {
+                            var company = row.company_name || '';
+                            var cur = String(row.score_grade || '').trim().toUpperCase();
+                            if (['A', 'B', 'C', 'D'].indexOf(cur) < 0) {
+                                cur = '';
+                            }
+                            var opts = [
+                                {v: '', t: '请选择'},
+                                {v: 'A', t: 'A'},
+                                {v: 'B', t: 'B'},
+                                {v: 'C', t: 'C'},
+                                {v: 'D', t: 'D'}
+                            ];
+                            var html = '<select class="form-control input-sm ssc-score-grade-select"'
+                                + ' data-company="' + escAttr(company) + '"'
+                                + ' title="评分等级">';
+                            opts.forEach(function (o) {
+                                html += '<option value="' + o.v + '"' + (cur === o.v ? ' selected' : '') + '>'
+                                    + o.t + '</option>';
+                            });
+                            html += '</select>';
+                            return html;
+                        }
                     }
                 ]],
                 queryParams: function (params) {