m0_70156489 3 saat önce
ebeveyn
işleme
baba72e191
32 değiştirilmiş dosya ile 1875 ekleme ve 686 silme
  1. 59 32
      application/admin/controller/Customer.php
  2. 222 151
      application/admin/controller/Procuremen.php
  3. 77 20
      application/admin/controller/Procuremenexport.php
  4. 2 1
      application/admin/controller/Procuremenmenu.php
  5. 28 7
      application/admin/controller/Supplierscorerule.php
  6. 185 105
      application/admin/controller/Supplierservicescore.php
  7. 3 2
      application/admin/lang/zh-cn/customer.php
  8. 7 1
      application/admin/view/customer/add.html
  9. 7 1
      application/admin/view/customer/edit.html
  10. 16 2
      application/admin/view/procuremen/audit_append.html
  11. 3 3
      application/admin/view/procuremen/audit_issue.html
  12. 37 26
      application/admin/view/procuremen/details_fragment.html
  13. 18 5
      application/admin/view/procuremen/outward_detail.html
  14. 24 4
      application/admin/view/procuremen/review.html
  15. 4 1
      application/admin/view/procuremenexport/index.html
  16. 41 20
      application/admin/view/supplierscorerule/add.html
  17. 41 20
      application/admin/view/supplierscorerule/edit.html
  18. 2 2
      application/admin/view/supplierscorerule/index.html
  19. 30 2
      application/admin/view/supplierservicescore/index.html
  20. 696 76
      application/common/library/ProcuremenSupplierScore.php
  21. 3 0
      application/extra/customer_monthly_score.sql
  22. 1 1
      application/extra/site.php
  23. 13 4
      application/extra/supplier_score_install.sql
  24. 11 0
      application/extra/supplier_score_lead_columns.sql
  25. 18 0
      application/extra/supplier_service_final_score.sql
  26. 101 31
      application/index/controller/Index.php
  27. 15 45
      application/index/view/index/index.html
  28. 12 1
      public/assets/js/backend/customer.js
  29. 6 4
      public/assets/js/backend/procuremen.js
  30. 33 14
      public/assets/js/backend/procuremenexport.js
  31. 23 12
      public/assets/js/backend/supplierscorerule.js
  32. 137 93
      public/assets/js/backend/supplierservicescore.js

+ 59 - 32
application/admin/controller/Customer.php

@@ -29,6 +29,7 @@ class Customer extends Backend
     public function _initialize()
     {
         parent::_initialize();
+        $this->ensureMonthlyScoreColumn();
         $this->model = new CustomerModel;
         $this->view->assign('statusList', $this->model->getStatusList());
 
@@ -43,6 +44,36 @@ class Customer extends Backend
         }
     }
 
+    /** customer.monthly_score 月度评分 */
+    protected function ensureMonthlyScoreColumn(): void
+    {
+        try {
+            $cols = Db::query("SHOW COLUMNS FROM `customer` LIKE 'monthly_score'");
+            if (!is_array($cols) || $cols === []) {
+                Db::execute(
+                    "ALTER TABLE `customer` ADD COLUMN `monthly_score` int DEFAULT NULL COMMENT '月度评分' AFTER `score`"
+                );
+            }
+        } catch (\Throwable $e) {
+        }
+    }
+
+    /**
+     * @return int|null
+     */
+    protected function parseCustomerScoreInt($raw, string $label)
+    {
+        $raw = trim((string)$raw);
+        if ($raw === '') {
+            return null;
+        }
+        if (!preg_match('/^-?\d+$/', $raw)) {
+            $this->error($label . '须为整数');
+        }
+
+        return (int)$raw;
+    }
+
     public function companyTypeOptions()
     {
         try {
@@ -99,7 +130,7 @@ class Customer extends Backend
 
             foreach ($list as $row) {
                 $row->visible([
-                    'id', 'company_name', 'username', 'account', 'email', 'phone', 'score', 'company_type',
+                    'id', 'company_name', 'username', 'account', 'email', 'phone', 'score', 'monthly_score', 'company_type',
                     'createtime', 'updatetime', 'status',
                 ]);
             }
@@ -163,25 +194,23 @@ class Customer extends Backend
             $status = '1';
         }
 
-        $scoreRaw = trim((string)($params['score'] ?? ''));
-        $score = $scoreRaw === '' ? null : $scoreRaw;
-        if ($score !== null && !preg_match('/^-?\d+(\.\d+)?$/', (string)$score)) {
-            $this->error('上年度质量评分须为数字');
-        }
+        $score = $this->parseCustomerScoreInt($params['score'] ?? '', '综合评分');
+        $monthlyScore = $this->parseCustomerScoreInt($params['monthly_score'] ?? '', '月度评分');
 
         $now = date('Y-m-d H:i:s');
         $data = [
-            'company_name' => trim((string)($params['company_name'] ?? '')),
-            'username'     => trim((string)($params['username'] ?? '')),
-            'phone'        => $phone,
-            'account'      => $account,
-            'email'        => $email,
-            'password'     => md5(md5($pwd)),
-            'score'        => $score,
-            'company_type' => trim((string)($params['company_type'] ?? '')),
-            'status'       => $status,
-            'createtime'   => $now,
-            'updatetime'   => $now,
+            'company_name'  => trim((string)($params['company_name'] ?? '')),
+            'username'      => trim((string)($params['username'] ?? '')),
+            'phone'         => $phone,
+            'account'       => $account,
+            'email'         => $email,
+            'password'      => md5(md5($pwd)),
+            'score'         => $score,
+            'monthly_score' => $monthlyScore,
+            'company_type'  => trim((string)($params['company_type'] ?? '')),
+            'status'        => $status,
+            'createtime'    => $now,
+            'updatetime'    => $now,
         ];
 
         if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
@@ -260,23 +289,21 @@ class Customer extends Backend
             $status = '1';
         }
 
-        $scoreRaw = trim((string)($params['score'] ?? ''));
-        $score = $scoreRaw === '' ? null : $scoreRaw;
-        if ($score !== null && !preg_match('/^-?\d+(\.\d+)?$/', (string)$score)) {
-            $this->error('上年度质量评分须为数字');
-        }
+        $score = $this->parseCustomerScoreInt($params['score'] ?? '', '综合评分');
+        $monthlyScore = $this->parseCustomerScoreInt($params['monthly_score'] ?? '', '月度评分');
 
         $data = [
-            'id'           => $rowId,
-            'company_name' => trim((string)($params['company_name'] ?? '')),
-            'username'     => trim((string)($params['username'] ?? '')),
-            'phone'        => $phone,
-            'account'      => $account,
-            'email'        => $email,
-            'score'        => $score,
-            'company_type' => trim((string)($params['company_type'] ?? '')),
-            'status'       => $status,
-            'updatetime'   => date('Y-m-d H:i:s'),
+            'id'            => $rowId,
+            'company_name'  => trim((string)($params['company_name'] ?? '')),
+            'username'      => trim((string)($params['username'] ?? '')),
+            'phone'         => $phone,
+            'account'       => $account,
+            'email'         => $email,
+            'score'         => $score,
+            'monthly_score' => $monthlyScore,
+            'company_type'  => trim((string)($params['company_type'] ?? '')),
+            'status'        => $status,
+            'updatetime'    => date('Y-m-d H:i:s'),
         ];
 
         $pwd = trim((string)($params['password'] ?? ''));

+ 222 - 151
application/admin/controller/Procuremen.php

@@ -246,6 +246,7 @@ class Procuremen extends Backend
             'details'                 => $this->hasProcuremenPerm(['details']),
             'archiveabandon'          => $this->hasProcuremenPerm(['archiveabandon', 'archiveAbandon', 'auditabandon', 'auditAbandon']),
             'export_month_outward'    => $this->hasProcuremenPerm(['export_month_outward', 'exportMonthOutward']),
+            'export_month_quote'      => $this->hasProcuremenPerm(['export_month_quote', 'exportMonthQuote', 'export_month_outward', 'exportMonthOutward']),
             'review'                  => $this->hasProcuremenPerm(['review', 'picksubmit', 'pickreview']),
             'bidopenusers'            => $this->hasProcuremenPerm(['bidopenusers', 'bidopen', 'auditissue', 'auditsubmit']),
             'bidopensendcode'         => $this->hasProcuremenPerm(['bidopensendcode', 'bidopen', 'auditissue', 'auditsubmit']),
@@ -1734,7 +1735,15 @@ class Procuremen extends Backend
      */
     protected function extractScydgyRowId(array $row): int
     {
-        return (int)($row['ID'] ?? $row['id'] ?? $row['scydgy_id'] ?? 0);
+        // 优先 scydgy_id,避免明细行 ID(自增主键)被误当成工序主键;手工单为负数
+        if (array_key_exists('scydgy_id', $row) && $row['scydgy_id'] !== null && $row['scydgy_id'] !== '') {
+            return (int)$row['scydgy_id'];
+        }
+        if (array_key_exists('SCYDGY_ID', $row) && $row['SCYDGY_ID'] !== null && $row['SCYDGY_ID'] !== '') {
+            return (int)$row['SCYDGY_ID'];
+        }
+
+        return (int)($row['ID'] ?? $row['id'] ?? 0);
     }
 
     protected function isValidScydgyRowId(int $id): bool
@@ -1961,7 +1970,7 @@ class Procuremen extends Backend
     {
         $sysRq = $this->resolveBundleSysRq($bundle);
         if (!$this->isProcuremenQuoteDeadlineReached($sysRq)) {
-            $this->error('未到截止时间');
+            $this->error('未到招标截止日期');
         }
     }
 
@@ -2885,6 +2894,7 @@ class Procuremen extends Backend
             $leadText = $this->formatProcuremenLeadDaysText($leadDays);
             $pickLine = [
                 'cgymc'           => $gymc,
+                'amount'          => $amountFilled ? ($amountNum !== null ? (string)$amountNum : $am) : '',
                 'amount_text'     => $amountFilled ? ($amountNum !== null ? $this->formatProcuremenMoneyDisplay($amountNum) : $am) : '未填写',
                 'delivery_text'   => $deliveryFilled ? $deliveryShow : '未填写',
                 'amount_filled'   => $amountFilled,
@@ -3016,6 +3026,7 @@ class Procuremen extends Backend
         $sid = (int)($dbRow['scydgy_id'] ?? 0);
         $r = [
             'ID'             => $sid,
+            'scydgy_id'      => $sid,
             'CCYDH'          => $dbRow['CCYDH'] ?? '',
             'CYJMC'          => $dbRow['CYJMC'] ?? '',
             'CCLBMMC'        => $dbRow['CCLBMMC'] ?? '',
@@ -4873,7 +4884,9 @@ class Procuremen extends Backend
             || ProcuremenStatus::isWflowApproved($main['wflow_status'] ?? '');
         $this->view->assign('showSupplierBidResult', $showSupplierBidResult ? 1 : 0);
         $this->view->assign('operLogs', $operLogs);
-        $this->view->assign('supplierScoreRuleText', ProcuremenSupplierScore::formatRuleFormulaText());
+        $sg = $quoteView['supplier_groups'] ?? [];
+        $this->view->assign('supplierScoreRuleText', ProcuremenSupplierScore::formatRuleFormulaTextForGroups(is_array($sg) ? $sg : []));
+        $this->view->assign('showServiceLeadScore', ProcuremenSupplierScore::detectShowLeadScore(is_array($sg) ? $sg : []) ? 1 : 0);
 
         return [
             'ok'    => $main !== [] || $details !== [],
@@ -7553,7 +7566,8 @@ class Procuremen extends Backend
         }
         $this->view->assign('deliveryDeadline', $deliveryDeadlineForView);
         $this->view->assign('bidOpenVerified', $this->isProcuremenBidOpenVerifiedForBundle($bundle) ? 1 : 0);
-        $this->view->assign('supplierScoreRuleText', ProcuremenSupplierScore::formatRuleFormulaText());
+        $this->view->assign('supplierScoreRuleText', ProcuremenSupplierScore::formatRuleFormulaTextForGroups($quoteGroups));
+        $this->view->assign('showServiceLeadScore', ProcuremenSupplierScore::detectShowLeadScore($quoteGroups) ? 1 : 0);
         $quoteDeadlineReached = $this->isProcuremenQuoteDeadlineReached($this->resolveBundleSysRq($bundle)) ? 1 : 0;
         $this->view->assign('quoteDeadlineReached', $quoteDeadlineReached);
         $this->view->assign('canAppendSupplier', $this->canProcuremenAppendSupplierForBundle($bundle) ? 1 : 0);
@@ -8639,7 +8653,8 @@ class Procuremen extends Backend
         $this->view->assign('confirmPickCount', count($confirmPickGroups));
         $this->view->assign('pickedCompanyName', $pickedCompanyName);
         $this->view->assign('bidOpenVerified', ($isPurchaseConfirm && $this->isProcuremenBidOpenVerifiedForBundle($confirmBundle)) ? 1 : 0);
-        $this->view->assign('supplierScoreRuleText', ProcuremenSupplierScore::formatRuleFormulaText());
+        $this->view->assign('supplierScoreRuleText', ProcuremenSupplierScore::formatRuleFormulaTextForGroups($confirmPickGroups));
+        $this->view->assign('showServiceLeadScore', ProcuremenSupplierScore::detectShowLeadScore($confirmPickGroups) ? 1 : 0);
         $sysRqDisplay = '—';
         if ($isPurchaseConfirm) {
             $sysRqDisplay = $this->formatProcuremenSysRqForDisplay(
@@ -8789,6 +8804,200 @@ class Procuremen extends Backend
         if (!$this->hasProcuremenPerm(['export_month_outward', 'exportMonthOutward'])) {
             $this->error(__('You have no permission'));
         }
+        $bundle = $this->prepareMonthOutwardExportBundle();
+        $ym = (string)($bundle['ym'] ?? date('Y-m'));
+        $exportLines = is_array($bundle['exportLines'] ?? null) ? $bundle['exportLines'] : [];
+        $groups = is_array($bundle['groups'] ?? null) ? $bundle['groups'] : [];
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('外协加工明细');
+
+        $mon = (int)substr($ym, 5, 2);
+        $sheet->mergeCells('A1:H1');
+        $sheet->setCellValue('A1', $mon . '月份外协加工明细');
+        $sheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
+        $sheet->getStyle('A1')->getAlignment()
+            ->setHorizontal(Alignment::HORIZONTAL_CENTER)
+            ->setVertical(Alignment::VERTICAL_CENTER);
+
+        $headers = ['序号', '传票号', '传票名称', '外加工单位', '订法', '客户名称', '工序', '加工金额'];
+        $col = 'A';
+        foreach ($headers as $h) {
+            $sheet->setCellValue($col . '2', $h);
+            $col++;
+        }
+        $sheet->getStyle('A2:H2')->getFont()->setBold(true);
+        $sheet->getStyle('A2:H2')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+
+        $rowNum = 3;
+        $sumSubtotalCounts = 0;
+        $grandAmount = 0.0;
+        $globalSeq = 0;
+
+        if ($groups === []) {
+            $sheet->mergeCells('A3:H3');
+            $sheet->setCellValue('A3', '(当月暂无符合条件的协助明细)');
+            $sheet->getStyle('A3')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
+            $rowNum = 4;
+        } else {
+            foreach ($groups as $items) {
+                if ($items === []) {
+                    continue;
+                }
+                $groupLineCount = count($items);
+                $subAmount = 0.0;
+                foreach ($items as $line) {
+                    $globalSeq++;
+                    $dr = $line['detail'];
+                    $amt = $this->procuremenExportAmount($dr);
+                    $subAmount += $amt;
+
+                    $sheet->setCellValue('A' . $rowNum, $globalSeq);
+                    $sheet->setCellValue('B' . $rowNum, $line['CCYDH']);
+                    $sheet->setCellValue('C' . $rowNum, $line['CYJMC']);
+                    $sheet->setCellValue('D' . $rowNum, $line['company_name']);
+                    $sheet->setCellValue('E' . $rowNum, $line['CDF']);
+                    $sheet->setCellValue('F' . $rowNum, $line['cGzzxMc']);
+                    $sheet->setCellValue('G' . $rowNum, $line['gx']);
+                    $sheet->setCellValue('H' . $rowNum, $amt);
+                    $sheet->getStyle('H' . $rowNum)->getNumberFormat()->setFormatCode('"¥"#,##0.00');
+                    $rowNum++;
+                }
+
+                $sumSubtotalCounts += $groupLineCount;
+                $grandAmount += $subAmount;
+
+                $sheet->mergeCells('G' . $rowNum . ':H' . $rowNum);
+                $sheet->setCellValue('G' . $rowNum, '¥ ' . number_format($subAmount, 2, '.', ','));
+                $sheet->getStyle('G' . $rowNum)->getAlignment()
+                    ->setHorizontal(Alignment::HORIZONTAL_LEFT)
+                    ->setVertical(Alignment::VERTICAL_CENTER);
+                $rowNum++;
+            }
+        }
+
+        $sheet->setCellValue('A' . $rowNum, '总计');
+        $sheet->setCellValue('B' . $rowNum, $sumSubtotalCounts);
+        $sheet->mergeCells('G' . $rowNum . ':H' . $rowNum);
+        $sheet->setCellValue('G' . $rowNum, '¥ ' . number_format($grandAmount, 2, '.', ','));
+        $sheet->getStyle('A' . $rowNum . ':H' . $rowNum)->getFont()->setBold(true);
+        $sheet->getStyle('G' . $rowNum)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
+
+        $lastRow = $rowNum;
+        $sheet->getStyle('A1:H' . $lastRow)->applyFromArray([
+            'borders' => [
+                'allBorders' => [
+                    'borderStyle' => Border::BORDER_THIN,
+                    'color'       => ['rgb' => '000000'],
+                ],
+            ],
+        ]);
+        $sheet->getStyle('A3:H' . $lastRow)->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
+        if ($lastRow >= 3) {
+            $sheet->getStyle('C3:C' . $lastRow)->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+            $sheet->getStyle('D3:D' . $lastRow)->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+            $sheet->getStyle('F3:F' . $lastRow)->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+            $sheet->getStyle('G3:G' . $lastRow)->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
+        }
+        $this->applyMonthExportTableAlignment($sheet, 2, 3, $lastRow, 'H');
+        $sheet->getColumnDimension('A')->setWidth(7);
+        $sheet->getColumnDimension('B')->setWidth(16);
+        $sheet->getColumnDimension('C')->setWidth(52);
+        $sheet->getColumnDimension('D')->setWidth(32);
+        $sheet->getColumnDimension('E')->setWidth(12);
+        $sheet->getColumnDimension('F')->setWidth(44);
+        $sheet->getColumnDimension('G')->setWidth(26);
+        $sheet->getColumnDimension('H')->setWidth(13);
+        $sheet->getRowDimension(1)->setRowHeight(28);
+
+        $ymCompact = str_replace('-', '', $ym);
+        $fileBase = $mon . '月份外协加工明细_' . $ymCompact;
+        $filename = $fileBase . '.xlsx';
+
+        try {
+            $this->ensurePurchaseMonthExportLogTable();
+            list($adminId, $adminName) = $this->GetUseName();
+            Db::table('purchase_month_export_log')->insert([
+                'ym'           => $ym,
+                'admin_id'     => (int)$adminId,
+                'admin_name'   => mb_substr((string)$adminName, 0, 64, 'UTF-8'),
+                'row_count'    => count($exportLines),
+                'total_amount' => round($grandAmount, 2),
+                'createtime'   => time(),
+            ]);
+        } catch (\Throwable $e) {
+            Log::write('month export log: ' . $e->getMessage(), 'error');
+        }
+
+        if (ob_get_length()) {
+            ob_end_clean();
+        }
+        $asciiName = 'wxjgmx_' . $ymCompact . '.xlsx';
+        header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+        header('Content-Disposition: attachment;filename="' . $asciiName . '"; filename*=UTF-8\'\'' . rawurlencode($filename));
+        header('Cache-Control: max-age=0');
+        $writer = new Xlsx($spreadsheet);
+        $writer->save('php://output');
+        $spreadsheet->disconnectWorksheets();
+        unset($spreadsheet);
+        exit;
+    }
+
+    /**
+     * 按月份单独导出:供应商报价明细(含未中标),与加工明细分开文件
+     */
+    public function export_month_quote()
+    {
+        if (!$this->hasProcuremenPerm(['export_month_outward', 'exportMonthOutward', 'export_month_quote', 'exportMonthQuote'])) {
+            $this->error(__('You have no permission'));
+        }
+        $bundle = $this->prepareMonthOutwardExportBundle();
+        $ym = (string)($bundle['ym'] ?? date('Y-m'));
+        $detailRows = is_array($bundle['detailRows'] ?? null) ? $bundle['detailRows'] : [];
+        $mainBySid = is_array($bundle['mainBySid'] ?? null) ? $bundle['mainBySid'] : [];
+        $quoteLines = $this->buildMonthExportQuoteLines($detailRows, $mainBySid);
+
+        $spreadsheet = new Spreadsheet();
+        $sheet = $spreadsheet->getActiveSheet();
+        $sheet->setTitle('供应商报价明细');
+        $mon = (int)substr($ym, 5, 2);
+        $quoteLastRow = $this->appendMonthExportQuoteBlock($sheet, $quoteLines, $ym, 1);
+        $sheet->getColumnDimension('A')->setWidth(7);
+        $sheet->getColumnDimension('B')->setWidth(16);
+        $sheet->getColumnDimension('C')->setWidth(52);
+        $sheet->getColumnDimension('D')->setWidth(26);
+        $sheet->getColumnDimension('E')->setWidth(32);
+        $sheet->getColumnDimension('F')->setWidth(12);
+        $sheet->getColumnDimension('G')->setWidth(14);
+        $sheet->getColumnDimension('H')->setWidth(12);
+        if ($quoteLastRow < 1) {
+            $quoteLastRow = 3;
+        }
+
+        $ymCompact = str_replace('-', '', $ym);
+        $filename = $mon . '月供应商报价明细_' . $ymCompact . '.xlsx';
+        if (ob_get_length()) {
+            ob_end_clean();
+        }
+        $asciiName = 'gysbjmx_' . $ymCompact . '.xlsx';
+        header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
+        header('Content-Disposition: attachment;filename="' . $asciiName . '"; filename*=UTF-8\'\'' . rawurlencode($filename));
+        header('Cache-Control: max-age=0');
+        $writer = new Xlsx($spreadsheet);
+        $writer->save('php://output');
+        $spreadsheet->disconnectWorksheets();
+        unset($spreadsheet);
+        exit;
+    }
+
+    /**
+     * 月度导出公共数据:已完结订单按月份/搜索筛选后的主表与明细
+     *
+     * @return array{ym:string,mainBySid:array,detailRows:array,exportLines:array,groups:array}
+     */
+    protected function prepareMonthOutwardExportBundle(): array
+    {
         $this->request->filter(['strip_tags', 'trim']);
         $ym = trim((string)$this->request->get('ym', date('Y-m')));
         if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
@@ -8842,7 +9051,6 @@ class Procuremen extends Backend
             }
         }
 
-        // 有搜索时跨月份导出(与列表一致);无搜索时仍按查看月份
         $dbRows = array_values(array_filter($dbRows, function ($dbRow) use ($ym, $completeTsMap, $hasSearch) {
             if (!is_array($dbRow)) {
                 return false;
@@ -8857,15 +9065,11 @@ class Procuremen extends Backend
 
             return date('Y-m', $done['ts']) === $ym;
         }));
-
-        // 与列表页一致:按搜索关键字 / 通用搜索过滤
         $dbRows = $this->filterMonthOutwardExportDbRowsBySearch($dbRows);
 
         $pool = $this->procuremenPoolFromPurchaseOrderDbRows($dbRows);
-        $filtered = $pool;
-
         $mainBySid = [];
-        foreach ($filtered as $rw) {
+        foreach ($pool as $rw) {
             if (!is_array($rw)) {
                 continue;
             }
@@ -8979,146 +9183,13 @@ class Procuremen extends Backend
         }
         ksort($groups, SORT_STRING);
 
-        $spreadsheet = new Spreadsheet();
-        $sheet = $spreadsheet->getActiveSheet();
-        $sheet->setTitle('外协加工明细');
-
-        $mon = (int)substr($ym, 5, 2);
-        $sheet->mergeCells('A1:H1');
-        $sheet->setCellValue('A1', $mon . '月份外协加工明细');
-        $sheet->getStyle('A1')->getFont()->setBold(true)->setSize(14);
-        $sheet->getStyle('A1')->getAlignment()
-            ->setHorizontal(Alignment::HORIZONTAL_CENTER)
-            ->setVertical(Alignment::VERTICAL_CENTER);
-
-        $headers = ['序号', '传票号', '传票名称', '外加工单位', '订法', '客户名称', '工序', '加工金额'];
-        $col = 'A';
-        foreach ($headers as $h) {
-            $sheet->setCellValue($col . '2', $h);
-            $col++;
-        }
-        $sheet->getStyle('A2:H2')->getFont()->setBold(true);
-        $sheet->getStyle('A2:H2')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
-
-        $rowNum = 3;
-        $sumSubtotalCounts = 0;
-        $grandAmount = 0.0;
-        $globalSeq = 0;
-
-        if ($groups === []) {
-            $sheet->mergeCells('A3:H3');
-            $sheet->setCellValue('A3', '(当月暂无符合条件的协助明细)');
-            $sheet->getStyle('A3')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
-            $rowNum = 4;
-        } else {
-            foreach ($groups as $items) {
-                if ($items === []) {
-                    continue;
-                }
-                $groupLineCount = count($items);
-                $subAmount = 0.0;
-                foreach ($items as $line) {
-                    $globalSeq++;
-                    $dr = $line['detail'];
-                    $amt = $this->procuremenExportAmount($dr);
-                    $subAmount += $amt;
-
-                    $sheet->setCellValue('A' . $rowNum, $globalSeq);
-                    $sheet->setCellValue('B' . $rowNum, $line['CCYDH']);
-                    $sheet->setCellValue('C' . $rowNum, $line['CYJMC']);
-                    $sheet->setCellValue('D' . $rowNum, $line['company_name']);
-                    $sheet->setCellValue('E' . $rowNum, $line['CDF']);
-                    $sheet->setCellValue('F' . $rowNum, $line['cGzzxMc']);
-                    $sheet->setCellValue('G' . $rowNum, $line['gx']);
-                    $sheet->setCellValue('H' . $rowNum, $amt);
-                    $sheet->getStyle('H' . $rowNum)->getNumberFormat()->setFormatCode('"¥"#,##0.00');
-                    $rowNum++;
-                }
-
-                $sumSubtotalCounts += $groupLineCount;
-                $grandAmount += $subAmount;
-
-                $sheet->mergeCells('G' . $rowNum . ':H' . $rowNum);
-                $sheet->setCellValue('G' . $rowNum, '¥ ' . number_format($subAmount, 2, '.', ','));
-                $sheet->getStyle('G' . $rowNum)->getAlignment()
-                    ->setHorizontal(Alignment::HORIZONTAL_LEFT)
-                    ->setVertical(Alignment::VERTICAL_CENTER);
-                $rowNum++;
-            }
-        }
-
-        $sheet->setCellValue('A' . $rowNum, '总计');
-        $sheet->setCellValue('B' . $rowNum, $sumSubtotalCounts);
-        $sheet->mergeCells('G' . $rowNum . ':H' . $rowNum);
-        $sheet->setCellValue('G' . $rowNum, '¥ ' . number_format($grandAmount, 2, '.', ','));
-        $sheet->getStyle('A' . $rowNum . ':H' . $rowNum)->getFont()->setBold(true);
-        $sheet->getStyle('G' . $rowNum)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
-
-        $lastRow = $rowNum;
-        $sheet->getStyle('A1:H' . $lastRow)->applyFromArray([
-            'borders' => [
-                'allBorders' => [
-                    'borderStyle' => Border::BORDER_THIN,
-                    'color'       => ['rgb' => '000000'],
-                ],
-            ],
-        ]);
-        $sheet->getStyle('A3:H' . $lastRow)->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
-        if ($lastRow >= 3) {
-            $sheet->getStyle('C3:C' . $lastRow)->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
-            $sheet->getStyle('D3:D' . $lastRow)->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
-            $sheet->getStyle('F3:F' . $lastRow)->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
-            $sheet->getStyle('G3:G' . $lastRow)->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
-        }
-        $this->applyMonthExportTableAlignment($sheet, 2, 3, $lastRow, 'H');
-        $sheet->getColumnDimension('A')->setWidth(7);
-        $sheet->getColumnDimension('B')->setWidth(16);
-        $sheet->getColumnDimension('C')->setWidth(52);
-        $sheet->getColumnDimension('D')->setWidth(32);
-        $sheet->getColumnDimension('E')->setWidth(12);
-        $sheet->getColumnDimension('F')->setWidth(44);
-        $sheet->getColumnDimension('G')->setWidth(26);
-        $sheet->getColumnDimension('H')->setWidth(13);
-        $sheet->getRowDimension(1)->setRowHeight(28);
-
-        $quoteLines = $this->buildMonthExportQuoteLines($detailRows, $mainBySid);
-        $quoteStartRow = $lastRow + 3;
-        $quoteLastRow = $this->appendMonthExportQuoteBlock($sheet, $quoteLines, $ym, $quoteStartRow);
-        if ($quoteLastRow >= $quoteStartRow) {
-            $sheet->getColumnDimension('E')->setWidth(28);
-        }
-
-        $ymCompact = str_replace('-', '', $ym);
-        $fileBase = $mon . '月份外协加工明细_' . $ymCompact;
-        $filename = $fileBase . '.xlsx';
-
-        try {
-            $this->ensurePurchaseMonthExportLogTable();
-            list($adminId, $adminName) = $this->GetUseName();
-            Db::table('purchase_month_export_log')->insert([
-                'ym'           => $ym,
-                'admin_id'     => (int)$adminId,
-                'admin_name'   => mb_substr((string)$adminName, 0, 64, 'UTF-8'),
-                'row_count'    => count($exportLines),
-                'total_amount' => round($grandAmount, 2),
-                'createtime'   => time(),
-            ]);
-        } catch (\Throwable $e) {
-            Log::write('month export log: ' . $e->getMessage(), 'error');
-        }
-
-        if (ob_get_length()) {
-            ob_end_clean();
-        }
-        $asciiName = 'wxjgmx_' . $ymCompact . '.xlsx';
-        header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
-        header('Content-Disposition: attachment;filename="' . $asciiName . '"; filename*=UTF-8\'\'' . rawurlencode($filename));
-        header('Cache-Control: max-age=0');
-        $writer = new Xlsx($spreadsheet);
-        $writer->save('php://output');
-        $spreadsheet->disconnectWorksheets();
-        unset($spreadsheet);
-        exit;
+        return [
+            'ym'          => $ym,
+            'mainBySid'   => $mainBySid,
+            'detailRows'  => $detailRows,
+            'exportLines' => $exportLines,
+            'groups'      => $groups,
+        ];
     }
 
     /**

+ 77 - 20
application/admin/controller/Procuremenexport.php

@@ -21,11 +21,14 @@ class Procuremenexport extends Backend
     {
         parent::_initialize();
         $canExport = $this->hasProcuremenSiblingPerm(['export_month_outward', 'exportMonthOutward']);
+        $canExportQuote = $this->hasProcuremenSiblingPerm(['export_month_quote', 'exportMonthQuote', 'export_month_outward', 'exportMonthOutward']);
         $canDetails = $this->hasProcuremenSiblingPerm(['details']);
         $this->view->assign('canExportMonthOutward', $canExport);
+        $this->view->assign('canExportMonthQuote', $canExportQuote);
         $this->assignconfig('procuremenAuth', [
             'details'              => $canDetails,
             'export_month_outward' => $canExport,
+            'export_month_quote'   => $canExportQuote,
         ]);
     }
 
@@ -250,7 +253,7 @@ class Procuremenexport extends Backend
             $query = Db::table('purchase_order');
             $query->whereRaw(ProcuremenStatus::sqlPoCompleted('status'));
             $dbRows = $query
-                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_time,createtime,dStamp')
+                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_time,createtime,dStamp,pick_company_name')
                 ->order('id', 'desc')
                 ->select();
         } catch (\Throwable $e) {
@@ -272,7 +275,7 @@ class Procuremenexport extends Backend
             }
         }
         $completeTsMap = ProcuremenTime::loadOperLogTimestampMap(array_keys($sidList), ['purchase_confirm', 'mark_complete']);
-        $amountMap = $this->loadDetailAmountSumByScydgyIds(array_keys($sidList));
+        $pickedSupplierMap = $this->loadPickedSupplierNamesByScydgyIds(array_keys($sidList));
 
         $pool = [];
         foreach ($dbRows as $r) {
@@ -288,29 +291,34 @@ class Procuremenexport extends Backend
                 continue;
             }
             $sid = (int)($r['scydgy_id'] ?? 0);
+            $pickedName = trim((string)($pickedSupplierMap[$sid] ?? ''));
+            if ($pickedName === '') {
+                $pickedName = trim((string)($r['pick_company_name'] ?? ''));
+            }
             $pool[] = [
-                'id'              => (int)($r['id'] ?? 0),
-                'scydgy_id'       => $sid,
-                'CCYDH'           => trim((string)($r['CCYDH'] ?? '')),
-                'CYJMC'           => trim((string)($r['CYJMC'] ?? '')),
-                'CGYMC'           => trim((string)($r['CGYMC'] ?? '')),
-                'ym'              => $rowYm,
-                'createtime_text' => $done['text'],
-                'detail_amount'   => (float)($amountMap[$sid] ?? 0),
+                'id'                 => (int)($r['id'] ?? 0),
+                'scydgy_id'          => $sid,
+                'CCYDH'              => trim((string)($r['CCYDH'] ?? '')),
+                'CYJMC'              => trim((string)($r['CYJMC'] ?? '')),
+                'CGYMC'              => trim((string)($r['CGYMC'] ?? '')),
+                'ym'                 => $rowYm,
+                'createtime_text'    => $done['text'],
+                'picked_supplier'    => $pickedName,
             ];
         }
 
         $merged = $this->collapseExportRowsByOrder($pool);
         foreach ($merged as &$row) {
-            $amt = 0.0;
-            $cnt = 0;
+            $names = [];
             if (!empty($row['_merge_rows']) && is_array($row['_merge_rows'])) {
                 foreach ($row['_merge_rows'] as $mr) {
                     if (!is_array($mr)) {
                         continue;
                     }
-                    $amt += (float)($mr['detail_amount'] ?? 0);
-                    $cnt++;
+                    $n = trim((string)($mr['picked_supplier'] ?? ''));
+                    if ($n !== '' && !in_array($n, $names, true)) {
+                        $names[] = $n;
+                    }
                     if (trim((string)($row['ym'] ?? '')) === '') {
                         $ymv = trim((string)($mr['ym'] ?? ''));
                         if ($ymv !== '') {
@@ -319,19 +327,68 @@ class Procuremenexport extends Backend
                     }
                 }
             } else {
-                $amt = (float)($row['detail_amount'] ?? 0);
-                $cnt = 1;
+                $n = trim((string)($row['picked_supplier'] ?? ''));
+                if ($n !== '') {
+                    $names[] = $n;
+                }
             }
-            $row['row_count'] = $cnt;
-            $row['total_amount'] = round($amt, 2);
-            $row['total_amount_text'] = number_format($amt, 2, '.', ',');
-            unset($row['_merge_rows'], $row['detail_amount']);
+            $row['picked_supplier'] = implode('、', $names);
+            unset($row['_merge_rows']);
         }
         unset($row);
 
         return $merged;
     }
 
+    /**
+     * 中标/已选供应商名称(按工序 scydgy_id)
+     *
+     * @param int[] $scydgyIds
+     * @return array<int, string>
+     */
+    protected function loadPickedSupplierNamesByScydgyIds(array $scydgyIds): array
+    {
+        $out = [];
+        $scydgyIds = array_values(array_unique(array_filter(array_map('intval', $scydgyIds))));
+        if ($scydgyIds === []) {
+            return $out;
+        }
+        try {
+            $rows = Db::table('purchase_order_detail')
+                ->where('scydgy_id', 'in', $scydgyIds)
+                ->whereIn('status', ProcuremenStatus::podPickedValues())
+                ->field('scydgy_id,company_name')
+                ->select();
+        } catch (\Throwable $e) {
+            $rows = [];
+        }
+        if (!is_array($rows)) {
+            return $out;
+        }
+        $namesBySid = [];
+        foreach ($rows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $sid = (int)($r['scydgy_id'] ?? 0);
+            $cn = trim((string)($r['company_name'] ?? ''));
+            if ($sid <= 0 || $cn === '') {
+                continue;
+            }
+            if (!isset($namesBySid[$sid])) {
+                $namesBySid[$sid] = [];
+            }
+            if (!in_array($cn, $namesBySid[$sid], true)) {
+                $namesBySid[$sid][] = $cn;
+            }
+        }
+        foreach ($namesBySid as $sid => $names) {
+            $out[$sid] = implode('、', $names);
+        }
+
+        return $out;
+    }
+
     /**
      * 工序行金额合计:优先已选定明细 status=1,否则汇总有效明细上的加工金额
      *

+ 2 - 1
application/admin/controller/Procuremenmenu.php

@@ -127,7 +127,8 @@ class Procuremenmenu extends Backend
             }
             if ($m['name'] === 'procuremenexport/index') {
                 $exportChildren = [
-                    ['name' => 'procuremen/export_month_outward', 'title' => '导出'],
+                    ['name' => 'procuremen/export_month_outward', 'title' => '导出外协加工明细'],
+                    ['name' => 'procuremen/export_month_quote', 'title' => '导出报价明细'],
                     ['name' => 'procuremen/details', 'title' => '详情'],
                 ];
                 foreach ($exportChildren as $cr) {

+ 28 - 7
application/admin/controller/Supplierscorerule.php

@@ -37,7 +37,7 @@ class Supplierscorerule extends Backend
             $rows = [];
             foreach ($list as $row) {
                 $arr = $row->toArray();
-                $arr['is_default_text'] = !empty($arr['is_default']) ? '默认' : '';
+                $arr['is_default_text'] = !empty($arr['is_default']) ? '使用' : '未使用';
                 $rows[] = $arr;
             }
 
@@ -144,26 +144,47 @@ class Supplierscorerule extends Backend
     {
         $qw = trim((string)($params['quality_weight'] ?? ''));
         $pw = trim((string)($params['price_weight'] ?? ''));
+        $lw = trim((string)($params['lead_weight'] ?? '0'));
         if ($qw === '' || !preg_match('/^\d+$/', $qw)) {
-            $this->error('质量分%须为非负整数');
+            $this->error('商务/技术分%须为非负整数');
         }
         if ($pw === '' || !preg_match('/^\d+$/', $pw)) {
             $this->error('价格分%须为非负整数');
         }
-        if ((int)$qw + (int)$pw <= 0) {
-            $this->error('两项权重之和须大于 0');
+        if ($lw === '') {
+            $lw = '0';
+        }
+        if (!preg_match('/^\d+$/', $lw)) {
+            $this->error('交货分%须为非负整数');
+        }
+        $qwNum = (int)$qw;
+        $pwNum = (int)$pw;
+        $lwNum = (int)$lw;
+        if ($qwNum + $pwNum + $lwNum <= 0) {
+            $this->error('至少一项权重大于 0');
         }
         $status = trim((string)($params['status'] ?? 'normal'));
         if (!in_array($status, ['normal', 'hidden'], true)) {
             $status = 'normal';
         }
-        $qwNum = (int)$qw;
-        $pwNum = (int)$pw;
+        $parts = [];
+        if ($qwNum > 0) {
+            $parts[] = '商务/技术分' . $qwNum . '%';
+        }
+        if ($pwNum > 0) {
+            $parts[] = '价格分' . $pwNum . '%';
+        }
+        if ($lwNum > 0) {
+            $parts[] = '交货分' . $lwNum . '%';
+        }
 
         return [
-            'name'           => '质量分' . $qwNum . '%+价格分' . $pwNum . '%',
+            'name'           => $parts !== [] ? implode('+', $parts) : '规则',
             'quality_weight' => $qwNum,
             'price_weight'   => $pwNum,
+            'lead_weight'    => $lwNum,
+            // 兼容旧字段:权重大于 0 即视为纳入
+            'lead_enabled'   => $lwNum > 0 ? 1 : 0,
             'is_default'     => !empty($params['is_default']) ? 1 : 0,
             'status'         => $status,
         ];

+ 185 - 105
application/admin/controller/Supplierservicescore.php

@@ -11,7 +11,7 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
 use think\Db;
 
 /**
- * 供应商服务评分表(开标后按订单落库
+ * 供应商服务评分表(按月汇总供应商总分
  *
  * @icon fa fa-trophy
  */
@@ -20,10 +20,10 @@ class Supplierservicescore extends Backend
     /** @var \app\admin\model\Supplierservicescore */
     protected $model = null;
 
-    protected $searchFields = 'ccydh,company_name';
+    protected $searchFields = 'company_name';
 
-    /** 有列表权限即可导出(避免仅缺子权限导致按钮可见却下载失败) */
-    protected $noNeedRight = ['export'];
+    /** 有列表权限即可导出/批量保存最终得分 */
+    protected $noNeedRight = ['export', 'savefinalbatch'];
 
     public function _initialize()
     {
@@ -36,63 +36,89 @@ class Supplierservicescore extends Backend
     {
         $this->request->filter(['strip_tags', 'trim']);
         if ($this->request->isAjax()) {
-            list($where, $sort, $order, $offset, $limit) = $this->buildparams();
-            $sort = trim((string)$sort);
-            $order = strtolower(trim((string)$order)) === 'asc' ? 'asc' : 'desc';
-            // 同一订单内总分从高到低;默认按订单号分组
-            if ($sort === '' || $sort === 'ccydh' || $sort === 'id') {
-                $query = $this->model->where($where)->order('ccydh', $order)->order('score', 'desc');
-            } elseif ($sort === 'score' || $sort === 'score_text' || $sort === 'rank_no') {
-                $query = $this->model->where($where)->order('score', $order)->order('ccydh', 'desc');
-            } else {
-                $query = $this->model->where($where)->order($sort, $order)->order('ccydh', 'desc')->order('score', 'desc');
+            $ym = $this->resolveRequestYm();
+            $keyword = $this->resolveSearchKeyword();
+            $offset = max(0, (int)$this->request->get('offset', 0));
+            $limit = (int)$this->request->get('limit', 50);
+            if ($limit <= 0) {
+                $limit = 50;
             }
-            $list = $query->paginate($limit);
-            $ruleMap = [];
-            $defaultRule = ProcuremenSupplierScore::getDefaultRule();
+
+            $list = $this->loadMonthlySupplierScoreList($ym, $keyword);
+            $total = count($list);
+            $pageRows = array_slice($list, $offset, $limit);
+            $seqBase = $offset;
             $rows = [];
-            foreach ($list as $row) {
-                $arr = $row->toArray();
-                $arr['score_text'] = ProcuremenSupplierScore::formatScore($arr['score'] ?? 0);
-                $qw = $arr['quality_weight'] ?? null;
-                $pw = $arr['price_weight'] ?? null;
-                if ($qw === null || $qw === '' || $pw === null || $pw === '') {
-                    $rid = (int)($arr['rule_id'] ?? 0);
-                    if ($rid > 0 && !isset($ruleMap[$rid])) {
-                        try {
-                            $rr = Db::table('supplier_score_rule')->where('id', $rid)->find();
-                            $ruleMap[$rid] = is_array($rr) ? $rr : null;
-                        } catch (\Throwable $e) {
-                            $ruleMap[$rid] = null;
-                        }
-                    }
-                    $rule = ($rid > 0 && is_array($ruleMap[$rid] ?? null)) ? $ruleMap[$rid] : $defaultRule;
-                    if ($qw === null || $qw === '') {
-                        $qw = $rule['quality_weight'] ?? 50;
-                    }
-                    if ($pw === null || $pw === '') {
-                        $pw = $rule['price_weight'] ?? 50;
-                    }
-                }
-                $arr['quality_weight'] = $qw;
-                $arr['price_weight'] = $pw;
-                $arr['quality_weight_text'] = rtrim(rtrim(sprintf('%.2F', (float)$qw), '0'), '.') . '%';
-                $arr['price_weight_text'] = rtrim(rtrim(sprintf('%.2F', (float)$pw), '0'), '.') . '%';
-                $rows[] = $arr;
+            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,
+                ];
             }
 
-            return json(['total' => $list->total(), 'rows' => $rows]);
+            return json(['total' => $total, 'rows' => $rows]);
         }
 
         return $this->view->fetch();
     }
 
     /**
-     * 导出供应商评审表:按月份汇总供应商,总分从高到低
-     * 列:序号、供应商名称、总分(对应原模板「评审等级」位置)
+     * 批量保存当月最终得分
+     * POST: ym, rows=[{company_name, final_score}, ...] 或 rows_json
+     */
+    public function savefinalbatch()
+    {
+        if (!$this->auth->check('supplierservicescore/index') && !$this->auth->isSuperAdmin()) {
+            $this->error(__('You have no permission'));
+        }
+        ProcuremenSupplierScore::ensureFinalScoreTable();
+        $ym = trim((string)$this->request->post('ym', ''));
+        if ($ym === '') {
+            $ym = trim((string)$this->request->param('ym', ''));
+        }
+        if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
+            $this->error('请选择查询月份');
+        }
+        $rows = $this->request->post('rows/a', []);
+        if (!is_array($rows) || $rows === []) {
+            $rowsJson = trim((string)$this->request->post('rows_json', ''));
+            if ($rowsJson !== '') {
+                $decoded = json_decode($rowsJson, true);
+                if (is_array($decoded)) {
+                    $rows = $decoded;
+                }
+            }
+        }
+        if (!is_array($rows) || $rows === []) {
+            $this->error('没有可保存的数据');
+        }
+        $ret = ProcuremenSupplierScore::saveFinalScoresForYm($ym, $rows);
+        $done = (int)($ret['done'] ?? 0);
+        if ($done < 1) {
+            $err = trim((string)($ret['error'] ?? ''));
+            $this->error($err !== '' ? $err : '保存失败,请检查填写的最终得分');
+        }
+        $this->success('操作成功');
+    }
+
+    /**
+     * 导出供应商评审表:按月份汇总供应商
+     * 列:序号、供应商名称、总分、排名
+     * 总分优先取最终得分,无则用系统总分;排名同此规则
      */
     public function export()
     {
+        if (!$this->auth->check('supplierservicescore/index') && !$this->auth->isSuperAdmin()) {
+            $this->error(__('You have no permission'));
+        }
         $ym = trim((string)$this->request->param('ym', ''));
         if (!preg_match('/^(\d{4})-(\d{2})$/', $ym, $m)) {
             $this->error('请选择查询月份');
@@ -103,60 +129,13 @@ class Supplierservicescore extends Backend
             $this->error('查询月份无效');
         }
 
-        ProcuremenSupplierScore::ensureSchema();
-        try {
-            $rows = Db::table('supplier_service_score')
-                ->where('ym', $ym)
-                ->field('company_name, score')
-                ->select();
-        } catch (\Throwable $e) {
-            $rows = [];
-        }
-        if (!is_array($rows)) {
-            $rows = [];
-        }
-
-        // 同一供应商当月多条:取平均总分
-        $byCompany = [];
-        foreach ($rows as $r) {
-            if (!is_array($r)) {
-                continue;
-            }
-            $name = trim((string)($r['company_name'] ?? ''));
-            if ($name === '') {
-                continue;
-            }
-            if (!isset($byCompany[$name])) {
-                $byCompany[$name] = ['sum' => 0.0, 'cnt' => 0];
-            }
-            $byCompany[$name]['sum'] += (float)($r['score'] ?? 0);
-            $byCompany[$name]['cnt']++;
-        }
-        $list = [];
-        foreach ($byCompany as $name => $agg) {
-            $avg = $agg['cnt'] > 0 ? round($agg['sum'] / $agg['cnt'], 2) : 0.0;
-            $list[] = [
-                'company_name' => $name,
-                'score'        => $avg,
-                'score_text'   => ProcuremenSupplierScore::formatScore($avg),
-            ];
-        }
-        usort($list, function ($a, $b) {
-            $sa = (float)($a['score'] ?? 0);
-            $sb = (float)($b['score'] ?? 0);
-            if ($sa !== $sb) {
-                return $sb <=> $sa;
-            }
-
-            return strcmp((string)($a['company_name'] ?? ''), (string)($b['company_name'] ?? ''));
-        });
+        $list = $this->loadMonthlySupplierScoreList($ym, '');
 
         $title = $year . '年' . $month . '月外协加工供应商评审表';
         $spreadsheet = new Spreadsheet();
         $sheet = $spreadsheet->getActiveSheet();
         $sheet->setTitle('供应商评审表');
 
-        // 样式对齐原纸质/Excel 评审表:大标题、居中、黑框、行高加高
         $fontName = '宋体';
         $borderStyle = [
             'borders' => [
@@ -172,7 +151,7 @@ class Supplierservicescore extends Backend
             'wrapText'   => true,
         ];
 
-        $sheet->mergeCells('A1:C1');
+        $sheet->mergeCells('A1:D1');
         $sheet->setCellValue('A1', $title);
         $sheet->getRowDimension(1)->setRowHeight(36);
         $sheet->getStyle('A1')->getFont()->setName($fontName)->setBold(true)->setSize(20);
@@ -181,13 +160,14 @@ class Supplierservicescore extends Backend
         $sheet->setCellValue('A2', '序号');
         $sheet->setCellValue('B2', '供应商名称');
         $sheet->setCellValue('C2', '总分');
+        $sheet->setCellValue('D2', '排名');
         $sheet->getRowDimension(2)->setRowHeight(26);
-        $sheet->getStyle('A2:C2')->getFont()->setName($fontName)->setBold(true)->setSize(14);
-        $sheet->getStyle('A2:C2')->getAlignment()->applyFromArray($centerAlign);
+        $sheet->getStyle('A2:D2')->getFont()->setName($fontName)->setBold(true)->setSize(14);
+        $sheet->getStyle('A2:D2')->getAlignment()->applyFromArray($centerAlign);
 
         $rowNum = 3;
         if ($list === []) {
-            $sheet->mergeCells('A3:C3');
+            $sheet->mergeCells('A3:D3');
             $sheet->setCellValue('A3', '(该月暂无供应商服务评分数据)');
             $sheet->getRowDimension(3)->setRowHeight(26);
             $sheet->getStyle('A3')->getFont()->setName($fontName)->setSize(12);
@@ -196,9 +176,11 @@ class Supplierservicescore extends Backend
         } else {
             $i = 1;
             foreach ($list as $item) {
+                $scoreText = (string)($item['export_score_text'] ?? $item['score_text'] ?? '');
                 $sheet->setCellValue('A' . $rowNum, $i);
                 $sheet->setCellValue('B' . $rowNum, (string)($item['company_name'] ?? ''));
-                $sheet->setCellValue('C' . $rowNum, (string)($item['score_text'] ?? ''));
+                $sheet->setCellValue('C' . $rowNum, $scoreText);
+                $sheet->setCellValue('D' . $rowNum, (int)($item['rank_no'] ?? $i));
                 $sheet->getRowDimension($rowNum)->setRowHeight(24);
                 $rowNum++;
                 $i++;
@@ -206,12 +188,13 @@ class Supplierservicescore extends Backend
         }
 
         $lastRow = max(2, $rowNum - 1);
-        $sheet->getStyle('A1:C' . $lastRow)->applyFromArray($borderStyle);
-        $sheet->getStyle('A3:C' . $lastRow)->getFont()->setName($fontName)->setSize(12);
-        $sheet->getStyle('A3:C' . $lastRow)->getAlignment()->applyFromArray($centerAlign);
+        $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->getColumnDimension('A')->setWidth(12);
         $sheet->getColumnDimension('B')->setWidth(48);
-        $sheet->getColumnDimension('C')->setWidth(16);
+        $sheet->getColumnDimension('C')->setWidth(14);
+        $sheet->getColumnDimension('D')->setWidth(12);
         $sheet->getPageSetup()->setFitToPage(true)->setFitToWidth(1)->setFitToHeight(0);
 
         $filename = $title . '.xlsx';
@@ -224,4 +207,101 @@ class Supplierservicescore extends Backend
         unset($spreadsheet);
         exit;
     }
+
+    /**
+     * 按月汇总:读月度汇总表(订单评分后自动同步总分;最终得分人工可改)
+     * 排名:有最终得分按最终得分,否则按系统总分
+     *
+     * @return array<int, array<string, mixed>>
+     */
+    protected function loadMonthlySupplierScoreList(string $ym, string $keyword = ''): array
+    {
+        $ym = trim($ym);
+        if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
+            return [];
+        }
+        ProcuremenSupplierScore::ensureSchema();
+        $monthlyMap = ProcuremenSupplierScore::loadMonthlyMapByYm($ym);
+
+        $keyword = trim($keyword);
+        $list = [];
+        foreach ($monthlyMap as $name => $item) {
+            if ($keyword !== '' && mb_stripos($name, $keyword) === false) {
+                continue;
+            }
+            $avg = (float)($item['score'] ?? 0);
+            $hasFinal = !empty($item['final_saved']);
+            $final = $hasFinal ? (float)($item['final_score'] ?? 0) : null;
+            $rankScore = $hasFinal ? (float)$final : $avg;
+            $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,
+            ];
+        }
+        usort($list, function ($a, $b) {
+            $sa = (float)($a['rank_score'] ?? 0);
+            $sb = (float)($b['rank_score'] ?? 0);
+            if ($sa !== $sb) {
+                return $sb <=> $sa;
+            }
+
+            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;
+    }
+
+    protected function resolveRequestYm(): string
+    {
+        $ym = trim((string)$this->request->get('ym', ''));
+        if (preg_match('/^\d{4}-\d{2}$/', $ym)) {
+            return $ym;
+        }
+        $filterRaw = $this->request->get('filter', '');
+        if (is_string($filterRaw) && $filterRaw !== '') {
+            $filter = json_decode($filterRaw, true);
+            if (is_array($filter)) {
+                $fy = trim((string)($filter['ym'] ?? ''));
+                if (preg_match('/^\d{4}-\d{2}$/', $fy)) {
+                    return $fy;
+                }
+            }
+        }
+        if (preg_match('/^(\d{4})\D+(\d{1,2})/', $ym, $m)) {
+            return sprintf('%04d-%02d', (int)$m[1], (int)$m[2]);
+        }
+
+        return date('Y-m');
+    }
+
+    protected function resolveSearchKeyword(): string
+    {
+        $keyword = trim((string)$this->request->get('search', ''));
+        if ($keyword !== '') {
+            return $keyword;
+        }
+        $filterRaw = $this->request->get('filter', '');
+        if (is_string($filterRaw) && $filterRaw !== '') {
+            $filter = json_decode($filterRaw, true);
+            if (is_array($filter)) {
+                return trim((string)($filter['company_name'] ?? ''));
+            }
+        }
+
+        return '';
+    }
 }

+ 3 - 2
application/admin/lang/zh-cn/customer.php

@@ -8,8 +8,9 @@ return [
     'Password'     => '密码',
     'Email'        => '邮箱',
     'Phone'        => '手机号',
-    'Score'        => '上年度质量评分',
-    'Company_type' => '业务分类',
+    'Score'         => '综合评分',
+    'Monthly_score' => '月度评分',
+    'Company_type'  => '业务分类',
     'Createtime'   => '创建时间',
     'Updatetime'   => '更新时间',
     'Status'       => '状态',

+ 7 - 1
application/admin/view/customer/add.html

@@ -39,7 +39,13 @@
     <div class="form-group">
         <label class="control-label col-xs-12 col-sm-2">{:__('Score')}:</label>
         <div class="col-xs-12 col-sm-8">
-            <input id="c-score" class="form-control" name="row[score]" type="number" step="1" placeholder="选填">
+            <input id="c-score" class="form-control" name="row[score]" type="number" step="1" placeholder="选填整数">
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('Monthly_score')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            <input id="c-monthly_score" class="form-control" name="row[monthly_score]" type="number" step="1" placeholder="选填整数">
         </div>
     </div>
     <div class="form-group company-type-form-group">

+ 7 - 1
application/admin/view/customer/edit.html

@@ -39,7 +39,13 @@
     <div class="form-group">
         <label class="control-label col-xs-12 col-sm-2">{:__('Score')}:</label>
         <div class="col-xs-12 col-sm-8">
-            <input id="c-score" class="form-control" name="row[score]" type="number" step="1" value="{$row.score|htmlentities}" placeholder="选填">
+            <input id="c-score" class="form-control" name="row[score]" type="number" step="1" value="{$row.score|htmlentities}" placeholder="选填整数">
+        </div>
+    </div>
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('Monthly_score')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            <input id="c-monthly_score" class="form-control" name="row[monthly_score]" type="number" step="1" value="{$row.monthly_score|default=''|htmlentities}" placeholder="选填整数">
         </div>
     </div>
     <div class="form-group company-type-form-group">

+ 16 - 2
application/admin/view/procuremen/audit_append.html

@@ -32,18 +32,32 @@
         border-bottom: 1px solid #e5e5e5;
     }
     .audit-append-form .review-merge-table-wrap {
-        max-height: 120px;
+        max-height: min(42vh, 360px);
         overflow: auto;
         border: 1px solid #e5e5e5;
     }
     .audit-append-form .review-merge-table {
         margin: 0;
         font-size: 12px;
+        width: 100%;
+    }
+    .audit-append-form .review-merge-table > thead > tr > th {
+        position: sticky;
+        top: 0;
+        z-index: 2;
+        white-space: nowrap;
+        background: #f5f5f5;
+        border: 1px solid #ddd !important;
+        padding: 3px 6px;
+        line-height: 1.35;
     }
-    .audit-append-form .review-merge-table > thead > tr > th,
     .audit-append-form .review-merge-table > tbody > tr > td {
         padding: 3px 6px;
         line-height: 1.35;
+        word-break: break-word;
+        white-space: normal;
+        vertical-align: middle;
+        border: 1px solid #ddd !important;
     }
     .audit-append-form .review-deadline-row {
         display: flex;

+ 3 - 3
application/admin/view/procuremen/audit_issue.html

@@ -381,7 +381,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%)'|htmlentities}
     </p>
     {/if}
     <div class="audit-table-wrap audit-quote-table-wrap">
@@ -394,7 +394,7 @@
                 <th style="width:100px;">邮箱</th>
                 <th style="width:120px;">手机号</th>
                 <th class="text-center" style="width:56px;">排名</th>
-                <th class="text-center" style="width:72px;" title="{$supplierScoreRuleText|default=''|htmlentities}">评分</th>
+                <th class="text-center" style="min-width:220px;" title="{$supplierScoreRuleText|default=''|htmlentities}">总分</th>
                 <th style="width:130px;">工序名称</th>
                 <th class="text-center" style="width:130px;">单价</th>
                 <th class="text-center" style="width:130px;">预估工期</th>
@@ -420,7 +420,7 @@
                 <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;">{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;white-space:nowrap;">{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>

+ 37 - 26
application/admin/view/procuremen/details_fragment.html

@@ -195,8 +195,9 @@
         border: 1px solid #e8e8e8;
         border-radius: 4px;
         background: #fafafa;
-        max-height: 280px;
-        overflow-y: auto;
+        /* 记录多时全部展开,由详情弹窗整体滚动 */
+        max-height: none;
+        overflow: visible;
     }
     .procuremen-details-wrap .procuremen-oper-log li {
         padding: 8px 12px;
@@ -241,20 +242,29 @@
         white-space: nowrap;
     }
     .procuremen-details-wrap .detail-supplier-table {
+        /* 固定表宽,避免 width:100% + table-layout:fixed 把名称列挤成竖排字 */
         table-layout: fixed;
-        width: 100%;
-        min-width: 1320px;
+        width: 1480px;
+        min-width: 1480px;
+        max-width: none;
         border-collapse: collapse;
     }
+    .procuremen-details-wrap .detail-supplier-table.has-lead-score {
+        width: 1480px;
+        min-width: 1480px;
+    }
     .procuremen-details-wrap .detail-supplier-table th.col-supplier-name,
     .procuremen-details-wrap .detail-supplier-table td.col-supplier-name {
-        width: 22%;
-        min-width: 250px;
-        word-break: break-word;
+        width: 180px;
+        min-width: 180px;
+        word-break: normal;
+        overflow-wrap: break-word;
+        white-space: normal;
+        line-height: 1.4;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-supplier-email,
     .procuremen-details-wrap .detail-supplier-table td.col-supplier-email {
-        width: 100px;
+        width: 110px;
         word-break: break-all;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-supplier-user,
@@ -265,30 +275,31 @@
     .procuremen-details-wrap .detail-supplier-table td.col-supplier-phone {
         width: 120px;
         word-break: break-all;
+        white-space: nowrap;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-unit-price,
     .procuremen-details-wrap .detail-supplier-table td.col-unit-price {
-        width: 130px;
+        width: 90px;
         white-space: nowrap;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-lead-days,
     .procuremen-details-wrap .detail-supplier-table td.col-lead-days {
-        width: 130px;
+        width: 90px;
         white-space: nowrap;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-delivery,
     .procuremen-details-wrap .detail-supplier-table td.col-delivery {
-        width: 130px;
+        width: 110px;
         white-space: nowrap;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-subtotal,
     .procuremen-details-wrap .detail-supplier-table td.col-subtotal {
-        width: 130px;
+        width: 90px;
         white-space: nowrap;
     }
     .procuremen-details-wrap .detail-supplier-table th.col-op-time,
     .procuremen-details-wrap .detail-supplier-table td.col-op-time {
-        width: 160px;
+        width: 150px;
         white-space: nowrap;
     }
     .procuremen-details-wrap .detail-supplier-table > thead > tr > th,
@@ -480,22 +491,22 @@
     </p>
     {/notempty}
     <div class="details-supplier-table-wrap table-responsive">
-        <table class="table table-bordered table-condensed detail-mini detail-supplier-table">
+        <table class="table table-bordered table-condensed detail-mini detail-supplier-table{if !empty($showServiceLeadScore)} has-lead-score{/if}">
             <thead>
             <tr>
                 <th class="col-supplier-name">供应商名称</th>
-                <th class="col-supplier-user" style="width:72px;">姓名</th>
-                <th class="col-supplier-email" style="width:100px;">邮箱</th>
-                <th class="col-supplier-phone" style="width:120px;">手机号</th>
+                <th class="col-supplier-user">姓名</th>
+                <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="width:72px;" title="{$supplierScoreRuleText|default=''|htmlentities}">评分</th>
-                <th style="width:120px;">工序名称</th>
-                <th class="text-center col-unit-price" style="width:130px;">单价</th>
-                <th class="text-center col-lead-days" style="width:130px;">预估工期</th>
-                <th class="text-center col-delivery" style="width:130px;">交货日期</th>
-                <th class="text-center col-subtotal" style="width:130px;">小计</th>
-                <th class="text-center" style="width:88px;">状态</th>
-                <th class="col-op-time" style="width:160px;">操作时间</th>
+                <th class="text-center" style="min-width:220px;" title="{$supplierScoreRuleText|default=''|htmlentities}">总分</th>
+                <th style="width:100px;">工序名称</th>
+                <th class="text-center col-unit-price">单价</th>
+                <th class="text-center col-lead-days">预估工期</th>
+                <th class="text-center col-delivery">交货日期</th>
+                <th class="text-center col-subtotal">小计</th>
+                <th class="text-center" style="width:72px;">状态</th>
+                <th class="col-op-time">操作时间</th>
             </tr>
             </thead>
             <tbody>
@@ -511,7 +522,7 @@
                 <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;">{$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;white-space:nowrap;">{$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>

+ 18 - 5
application/admin/view/procuremen/outward_detail.html

@@ -227,6 +227,19 @@
         border: 1px solid #faebcc;
         border-radius: 3px;
     }
+    .outward-detail-wrap .audit-score-rule-tip {
+        margin: 0 0 8px;
+        padding: 6px 10px;
+        font-size: 12px;
+        line-height: 1.5;
+        color: #31708f;
+        background: #f0f7fb;
+        border: 1px solid #d9edf7;
+        border-radius: 3px;
+    }
+    .outward-detail-wrap .audit-score-rule-tip strong {
+        color: #245269;
+    }
     .btn.procuremen-btn-slate {
         color: #fff !important;
         background-color: #4b5573 !important;
@@ -324,9 +337,9 @@
         审批通过:将向中标供应商发送「已通过」短信、向未中标供应商发送「未通过」短信。
     </p>
     {if !empty($bidOpenVerified)}
-    <p class="audit-score-rule-tip" style="margin:0 0 8px;color:#666;">
+    <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%)'|htmlentities}
     </p>
     {/if}
     <p class="outward-confirm-block-title">供应商({$confirmPickCount|default=0} 家)</p>
@@ -340,7 +353,7 @@
                 <th style="min-width:160px;">邮箱</th>
                 <th style="width:120px;">手机号</th>
                 <th class="text-center" style="width:56px;">排名</th>
-                <th class="text-center" style="width:72px;" title="{$supplierScoreRuleText|default=''|htmlentities}">总分</th>
+                <th class="text-center" style="min-width:220px;" title="{$supplierScoreRuleText|default=''|htmlentities}">总分</th>
                 <th style="width:120px;">工序名称</th>
                 <th class="text-center" style="width:112px;">单价</th>
                 <th class="text-center" style="width:88px;">预估工期</th>
@@ -366,7 +379,7 @@
                 <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;">{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;white-space:nowrap;">{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>
@@ -395,7 +408,7 @@
             {/volist}
             {empty name="confirmPickGroups"}
             <tr>
-                <td colspan="13" class="text-center text-muted">暂无供应商报价</td>
+                <td colspan="20" class="text-center text-muted">暂无供应商报价</td>
             </tr>
             {/empty}
             </tbody>

+ 24 - 4
application/admin/view/procuremen/review.html

@@ -547,24 +547,44 @@
         font-size: 13px;
         color: #000;
     }
+    /* 选多道工序时加大可视高度,仍可滚动,避免把下方供应商区顶没 */
     .review-merge-table-wrap {
-        max-height: 120px;
+        max-height: min(42vh, 360px);
         overflow: auto;
         border: 1px solid #e5e5e5;
     }
     .review-merge-table {
         margin: 0;
         font-size: 12px;
+        width: 100%;
+        table-layout: auto;
     }
-    .review-merge-table th {
+    .review-merge-table > thead > tr > th {
+        position: sticky;
+        top: 0;
+        z-index: 2;
         white-space: nowrap;
         background: #f5f5f5;
+        border: 1px solid #ddd !important;
+    }
+    .review-merge-table > tbody > tr > td {
+        word-break: break-word;
+        white-space: normal;
+        vertical-align: middle;
+        border: 1px solid #ddd !important;
     }
     .review-merge-table th.text-center,
     .review-merge-table td.text-center {
         text-align: center;
         vertical-align: middle;
     }
+    .review-merge-table .review-merge-col-name {
+        min-width: 180px;
+        max-width: 320px;
+    }
+    .review-merge-table .review-merge-col-remark {
+        min-width: 100px;
+    }
 </style>
 <form id="review-form" class="review-dialog-form" role="form" data-toggle="validator" method="post" action="" data-pick-mode="{$pickMode|default=0}">
     {:token()}
@@ -579,13 +599,13 @@
                         <thead>
                         <tr>
                             <th class="text-center" style="width:100px;">订单号</th>
-                            <th style="min-width:160px;">印件名称</th>
+                            <th class="review-merge-col-name">印件名称</th>
                             <th>工序名称</th>
                             <th class="text-center" style="width:56px;">单位</th>
                             <th class="text-center" style="width:72px;">本次数量</th>
                             <th class="text-center" style="width:72px;">最高限价</th>
                             <th class="text-center" style="width:80px;">订法</th>
-                            <th style="min-width:120px;">备注</th>
+                            <th class="review-merge-col-remark">备注</th>
                         </tr>
                         </thead>
                         <tbody id="review-merge-tbody"></tbody>

+ 4 - 1
application/admin/view/procuremenexport/index.html

@@ -6,7 +6,10 @@
             <div id="toolbar" class="toolbar">
                 <a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i> 刷新</a>
                 {if $canExportMonthOutward}
-                <a href="javascript:;" class="btn btn-success" id="btn-export-month-outward" title="导出当前列表筛选结果(含搜索)的协助明细 Excel"><i class="fa fa-download"></i> 导出 Excel</a>
+                <a href="javascript:;" class="btn btn-success" id="btn-export-month-outward" title="导出外协加工明细 Excel(不含报价明细)"><i class="fa fa-download"></i> 导出外协加工明细</a>
+                {/if}
+                {if $canExportMonthQuote}
+                <a href="javascript:;" class="btn btn-info" id="btn-export-month-quote" title="导出供应商报价明细 Excel(含未中标)"><i class="fa fa-download"></i> 导出报价明细</a>
                 {/if}
                 <span class="procuremen-export-ym-wrap" style="display:inline-block;margin-left:12px;vertical-align:middle;">
                     <label style="margin:0 6px 0 0;font-weight:normal;">查看月份</label>

+ 41 - 20
application/admin/view/supplierscorerule/add.html

@@ -1,38 +1,59 @@
-<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
+<style>
+.supplier-score-rule-form.form-horizontal .form-group { margin-left: 0; margin-right: 0; }
+.supplier-score-rule-form.form-horizontal .control-label {
+    float: left;
+    width: 130px;
+    padding-top: 7px;
+    padding-right: 10px;
+    margin-bottom: 0;
+    text-align: right;
+    white-space: nowrap;
+}
+.supplier-score-rule-form.form-horizontal .form-group > .ssr-field {
+    margin-left: 140px;
+}
+.supplier-score-rule-form.form-horizontal .ssr-field .form-control {
+    width: 120px;
+    max-width: 100%;
+}
+</style>
+<form id="add-form" class="form-horizontal supplier-score-rule-form" role="form" data-toggle="validator" method="POST" action="">
+    <input type="hidden" name="row[status]" value="normal">
     <div class="form-group">
-        <label class="control-label col-xs-12 col-sm-2"><span class="text-danger">*</span> 质量分%:</label>
-        <div class="col-xs-12 col-sm-8">
+        <label class="control-label"><span class="text-danger">*</span> 商务/技术分%:</label>
+        <div class="ssr-field">
             <input class="form-control" name="row[quality_weight]" type="number" step="1" min="0" value="" data-rule="required;integer" placeholder="请输入整数,如 50">
         </div>
     </div>
     <div class="form-group">
-        <label class="control-label col-xs-12 col-sm-2"><span class="text-danger">*</span> 价格分%:</label>
-        <div class="col-xs-12 col-sm-8">
+        <label class="control-label"><span class="text-danger">*</span> 价格分%:</label>
+        <div class="ssr-field">
             <input class="form-control" name="row[price_weight]" type="number" step="1" min="0" value="" data-rule="required;integer" placeholder="请输入整数,如 50">
-            <span class="help-block">总分 = 质量分×质量分% + 价格分×价格分%(两项按比例折算,填整数即可,50 表示 50%)</span>
         </div>
     </div>
     <div class="form-group">
-        <label class="control-label col-xs-12 col-sm-2">设为默认:</label>
-        <div class="col-xs-12 col-sm-8">
-            <input type="hidden" name="row[is_default]" value="0">
-            <label class="checkbox-inline"><input type="checkbox" name="row[is_default]" value="1"> 开标计算使用此规则</label>
+        <label class="control-label">交货分%:</label>
+        <div class="ssr-field">
+            <input class="form-control" name="row[lead_weight]" type="number" step="1" min="0" value="0" data-rule="integer" placeholder="0 表示不参与计算">
+            <span class="help-block">填 0 或不填则不参与计算总分</span>
         </div>
     </div>
     <div class="form-group">
-        <label class="control-label col-xs-12 col-sm-2">状态:</label>
-        <div class="col-xs-12 col-sm-8">
-            <select class="form-control" name="row[status]">
-                <option value="normal" selected>正常</option>
-                <option value="hidden">隐藏</option>
-            </select>
+        <label class="control-label">设为默认:</label>
+        <div class="ssr-field">
+            <input type="hidden" name="row[is_default]" value="0">
+            <label class="checkbox-inline"><input type="checkbox" name="row[is_default]" value="1"> 开标计算使用此规则</label>
         </div>
     </div>
     <div class="form-group layer-footer">
-        <label class="control-label col-xs-12 col-sm-2"></label>
-        <div class="col-xs-12 col-sm-8">
-            <button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
-            <button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
+        <div style="float:right;">
+            <button type="submit" style="margin-right:20px" class="btn btn-primary btn-embossed disabled">
+                <i class="fa fa-check"></i> 确认
+            </button>
+            <button type="button" style="margin-right:20px" class="btn btn-default btn-embossed layer-close">
+                <i class="fa fa-times"></i> 关闭
+            </button>
         </div>
+        <div style="clear:both;"></div>
     </div>
 </form>

+ 41 - 20
application/admin/view/supplierscorerule/edit.html

@@ -1,38 +1,59 @@
-<form id="edit-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
+<style>
+.supplier-score-rule-form.form-horizontal .form-group { margin-left: 0; margin-right: 0; }
+.supplier-score-rule-form.form-horizontal .control-label {
+    float: left;
+    width: 130px;
+    padding-top: 7px;
+    padding-right: 10px;
+    margin-bottom: 0;
+    text-align: right;
+    white-space: nowrap;
+}
+.supplier-score-rule-form.form-horizontal .form-group > .ssr-field {
+    margin-left: 140px;
+}
+.supplier-score-rule-form.form-horizontal .ssr-field .form-control {
+    width: 120px;
+    max-width: 100%;
+}
+</style>
+<form id="edit-form" class="form-horizontal supplier-score-rule-form" role="form" data-toggle="validator" method="POST" action="">
+    <input type="hidden" name="row[status]" value="{$row.status|default='normal'}">
     <div class="form-group">
-        <label class="control-label col-xs-12 col-sm-2"><span class="text-danger">*</span> 质量分%:</label>
-        <div class="col-xs-12 col-sm-8">
+        <label class="control-label"><span class="text-danger">*</span> 商务/技术分%:</label>
+        <div class="ssr-field">
             <input class="form-control" name="row[quality_weight]" type="number" step="1" min="0" value="{:intval($row['quality_weight'])}" data-rule="required;integer">
         </div>
     </div>
     <div class="form-group">
-        <label class="control-label col-xs-12 col-sm-2"><span class="text-danger">*</span> 价格分%:</label>
-        <div class="col-xs-12 col-sm-8">
+        <label class="control-label"><span class="text-danger">*</span> 价格分%:</label>
+        <div class="ssr-field">
             <input class="form-control" name="row[price_weight]" type="number" step="1" min="0" value="{:intval($row['price_weight'])}" data-rule="required;integer">
-            <span class="help-block">总分 = 质量分×质量分% + 价格分×价格分%(两项按比例折算,填整数即可,50 表示 50%)</span>
         </div>
     </div>
     <div class="form-group">
-        <label class="control-label col-xs-12 col-sm-2">设为默认:</label>
-        <div class="col-xs-12 col-sm-8">
-            <input type="hidden" name="row[is_default]" value="0">
-            <label class="checkbox-inline"><input type="checkbox" name="row[is_default]" value="1"{if $row.is_default} checked{/if}> 开标计算使用此规则</label>
+        <label class="control-label">交货分%:</label>
+        <div class="ssr-field">
+            <input class="form-control" name="row[lead_weight]" type="number" step="1" min="0" value="{:intval(isset($row['lead_weight']) ? $row['lead_weight'] : 0)}" data-rule="integer">
+            <span class="help-block">填 0 或不填则不参与计算总分</span>
         </div>
     </div>
     <div class="form-group">
-        <label class="control-label col-xs-12 col-sm-2">状态:</label>
-        <div class="col-xs-12 col-sm-8">
-            <select class="form-control" name="row[status]">
-                <option value="normal"{if $row.status=='normal'} selected{/if}>正常</option>
-                <option value="hidden"{if $row.status=='hidden'} selected{/if}>隐藏</option>
-            </select>
+        <label class="control-label">设为默认:</label>
+        <div class="ssr-field">
+            <input type="hidden" name="row[is_default]" value="0">
+            <label class="checkbox-inline"><input type="checkbox" name="row[is_default]" value="1"{if $row.is_default} checked{/if}> 开标计算使用此规则</label>
         </div>
     </div>
     <div class="form-group layer-footer">
-        <label class="control-label col-xs-12 col-sm-2"></label>
-        <div class="col-xs-12 col-sm-8">
-            <button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
-            <button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
+        <div style="float:right;">
+            <button type="submit" style="margin-right:20px" class="btn btn-primary btn-embossed disabled">
+                <i class="fa fa-check"></i> 确认
+            </button>
+            <button type="button" style="margin-right:20px" class="btn btn-default btn-embossed layer-close">
+                <i class="fa fa-times"></i> 关闭
+            </button>
         </div>
+        <div style="clear:both;"></div>
     </div>
 </form>

+ 2 - 2
application/admin/view/supplierscorerule/index.html

@@ -4,8 +4,8 @@
         <div class="widget-body no-padding">
             <div id="toolbar" class="toolbar">
                 <a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i></a>
-                <a href="javascript:;" class="btn btn-success btn-add {:$auth->check('supplierscorerule/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
-                <a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('supplierscorerule/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
+                <a href="javascript:;" class="btn btn-success btn-add {:$auth->check('supplierscorerule/add')?'':'hide'}" title="{:__('Add')}" data-area='["460px","360px"]'><i class="fa fa-plus"></i> {:__('Add')}</a>
+                <a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('supplierscorerule/edit')?'':'hide'}" title="{:__('Edit')}" data-area='["460px","360px"]'><i class="fa fa-pencil"></i> {:__('Edit')}</a>
                 <a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('supplierscorerule/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
             </div>
             <table id="table" class="table table-striped table-bordered table-hover table-nowrap"

+ 30 - 2
application/admin/view/supplierservicescore/index.html

@@ -1,4 +1,29 @@
-<div class="panel panel-default panel-intro">
+<style>
+    /* 只收窄列表表格与分页,上方工具栏/搜索保持全宽 */
+    .supplierservicescore-page .bootstrap-table > .fixed-table-container {
+        max-width: 860px;
+    }
+    .supplierservicescore-page .bootstrap-table > .fixed-table-pagination {
+        max-width: 860px;
+    }
+    .supplierservicescore-page .bootstrap-table .fixed-table-container .table,
+    .supplierservicescore-page .bootstrap-table .fixed-table-body .table {
+        width: 100% !important;
+        max-width: 860px;
+        table-layout: fixed;
+    }
+    .supplierservicescore-page .bootstrap-table .table td,
+    .supplierservicescore-page .bootstrap-table .table th {
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        vertical-align: middle !important;
+    }
+    .supplierservicescore-page .bootstrap-table .table td .ssc-final-score-input {
+        overflow: visible;
+    }
+</style>
+<div class="panel panel-default panel-intro supplierservicescore-page">
     {:build_heading()}
     <div class="panel-body">
         <div class="widget-body no-padding">
@@ -8,11 +33,14 @@
                     <a href="javascript:;" class="btn btn-success btn-export-review" id="btn-export-review" title="按月份导出供应商评审表">
                         <i class="fa fa-download"></i> 导出供应商评审表
                     </a>
+                    <a href="javascript:;" class="btn btn-info" id="btn-save-final-score" title="保存当前页最终得分">
+                        <i class="fa fa-save"></i> 保存
+                    </a>
                     <label style="margin:0 6px 0 10px;font-weight:normal;" for="export-review-ym">查询月份</label>
                     <input type="month" id="export-review-ym" class="form-control input-sm" style="display:inline-block;width:150px;height:30px;vertical-align:middle;" title="选择月份后自动查询该月评分"/>
                 </span>
             </div>
-            <table id="table" class="table table-striped table-bordered table-hover table-nowrap" width="100%"></table>
+            <table id="table" class="table table-striped table-bordered table-hover table-nowrap"></table>
         </div>
     </div>
 </div>

+ 696 - 76
application/common/library/ProcuremenSupplierScore.php

@@ -8,14 +8,23 @@ use think\Log;
 /**
  * 协助采购 — 供应商服务评分
  *
- * 总分 = 上年度质量评分×质量权重% + 单价评分值×单价权重%
- * 单价合计 = 同一供应商本单各工序单价之和;单价评分值 = (本单最低合计 / 本供应商合计) × 100
- * 权重取 supplier_score_rule 中 is_default=1 的规则;开标验证通过后写入 supplier_service_score
+ * 表分工:
+ * - supplier_service_score:订单明细(开标后按 ccydh×供应商写入,含本单总分 score)
+ * - supplier_service_final_score:月度汇总(ym×供应商;score=当月订单平均总分;final_score=人工最终得分)
+ *
+ * 总分 = 各分项×对应权重%(权重填 0 则该项不参与)
+ * 商务/技术得分 =(综合评分 + 月度评分)/ 2;
+ * 单价项按本单工序单价合计参与加权;权重按百分比计入总分。
+ * 工期合计 = 同一供应商本单各工序预估工期之和;交货期评分值 = (本单最短合计 / 本供应商合计) × 100
+ * 权重取 supplier_score_rule 中 is_default=1 的规则;开标验证通过后写入订单明细表,并同步月度总分
  */
 class ProcuremenSupplierScore
 {
     public const TABLE_RULE = 'supplier_score_rule';
+    /** 订单明细评分表 */
     public const TABLE_SCORE = 'supplier_service_score';
+    /** 月度汇总表(总分 + 最终得分) */
+    public const TABLE_FINAL = 'supplier_service_final_score';
 
     /** @var bool|null */
     protected static $schemaReady = null;
@@ -31,7 +40,9 @@ class ProcuremenSupplierScore
             self::$schemaReady = true;
             self::ensureDefaultRule();
             self::ensureScoreWeightColumns();
+            self::ensureLeadColumns();
             self::ensureIntegerColumns();
+            self::ensureFinalScoreTable();
 
             return;
         } catch (\Throwable $e) {
@@ -55,7 +66,315 @@ class ProcuremenSupplierScore
         self::$schemaReady = true;
         self::ensureDefaultRule();
         self::ensureScoreWeightColumns();
+        self::ensureLeadColumns();
         self::ensureIntegerColumns();
+        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 '月度总分(当月订单平均)',
+  `final_score` decimal(10,2) DEFAULT NULL COMMENT '最终得分(人工,空=未填)',
+  `createtime` datetime DEFAULT NULL,
+  `updatetime` datetime DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_ym_company` (`ym`,`company_name`),
+  KEY `idx_ym` (`ym`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商月度评分汇总'";
+        try {
+            Db::execute($ddl);
+        } catch (\Throwable $e) {
+            Log::write('supplier monthly score table: ' . $e->getMessage(), 'error');
+        }
+        self::ensureMonthlyScoreColumns();
+    }
+
+    /** 历史月度表补总分字段,并将 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`"
+                );
+            }
+        } 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 '最终得分(人工,空=未填)'"
+                    );
+                }
+            }
+        } catch (\Throwable $e) {
+            Log::write('supplier monthly final_score null: ' . $e->getMessage(), 'error');
+        }
+    }
+
+    /**
+     * 按订单明细重算并写入某月月度总分(保留已填最终得分)
+     *
+     * @param array<int, string>|null $onlyCompanies 仅同步这些供应商;null=当月全部
+     */
+    public static function syncMonthlyScoresForYm(string $ym, ?array $onlyCompanies = null): void
+    {
+        $ym = trim($ym);
+        if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
+            return;
+        }
+        self::ensureSchema();
+        self::ensureFinalScoreTable();
+        try {
+            $query = Db::table(self::TABLE_SCORE)->where('ym', $ym)->field('company_name,score');
+            if (is_array($onlyCompanies) && $onlyCompanies !== []) {
+                $names = [];
+                foreach ($onlyCompanies as $n) {
+                    $n = trim((string)$n);
+                    if ($n !== '') {
+                        $names[$n] = true;
+                    }
+                }
+                if ($names === []) {
+                    return;
+                }
+                $query->where('company_name', 'in', array_keys($names));
+            }
+            $rows = $query->select();
+        } catch (\Throwable $e) {
+            Log::write('supplier monthly sync read: ' . $e->getMessage(), 'error');
+
+            return;
+        }
+        if (!is_array($rows)) {
+            return;
+        }
+        $agg = [];
+        foreach ($rows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $cn = trim((string)($r['company_name'] ?? ''));
+            if ($cn === '') {
+                continue;
+            }
+            if (!isset($agg[$cn])) {
+                $agg[$cn] = ['sum' => 0.0, 'cnt' => 0];
+            }
+            $agg[$cn]['sum'] += (float)($r['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;
+            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,
+                        'updatetime' => $now,
+                    ]);
+                } else {
+                    Db::table(self::TABLE_FINAL)->insert([
+                        'ym'           => $ym,
+                        'company_name' => $cn,
+                        'score'        => $avg,
+                        'final_score'  => null,
+                        'createtime'   => $now,
+                        'updatetime'   => $now,
+                    ]);
+                }
+            } catch (\Throwable $e) {
+                Log::write('supplier monthly sync write: ' . $e->getMessage(), 'error');
+            }
+        }
+    }
+
+    /**
+     * 读取某月各供应商最终得分(仅已人工填写的)
+     *
+     * @return array<string, float> company_name => final_score
+     */
+    public static function loadFinalScoreMapByYm(string $ym): array
+    {
+        $ym = trim($ym);
+        if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
+            return [];
+        }
+        self::ensureFinalScoreTable();
+        try {
+            $rows = Db::table(self::TABLE_FINAL)
+                ->where('ym', $ym)
+                ->whereNotNull('final_score')
+                ->field('company_name,final_score')
+                ->select();
+        } catch (\Throwable $e) {
+            return [];
+        }
+        if (!is_array($rows)) {
+            return [];
+        }
+        $out = [];
+        foreach ($rows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $cn = trim((string)($r['company_name'] ?? ''));
+            if ($cn === '') {
+                continue;
+            }
+            $out[$cn] = round((float)($r['final_score'] ?? 0), 2);
+        }
+
+        return $out;
+    }
+
+    /**
+     * 读取某月月度汇总(总分 + 最终得分)
+     *
+     * @return array<string, array{score:float,final_score:?float,final_saved:int}>
+     */
+    public static function loadMonthlyMapByYm(string $ym): array
+    {
+        $ym = trim($ym);
+        if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
+            return [];
+        }
+        self::ensureFinalScoreTable();
+        self::syncMonthlyScoresForYm($ym);
+        try {
+            $rows = Db::table(self::TABLE_FINAL)->where('ym', $ym)->field('company_name,score,final_score')->select();
+        } catch (\Throwable $e) {
+            return [];
+        }
+        if (!is_array($rows)) {
+            return [];
+        }
+        $out = [];
+        foreach ($rows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            $cn = trim((string)($r['company_name'] ?? ''));
+            if ($cn === '') {
+                continue;
+            }
+            $hasFinal = array_key_exists('final_score', $r) && $r['final_score'] !== null && $r['final_score'] !== '';
+            $out[$cn] = [
+                'score'        => round((float)($r['score'] ?? 0), 2),
+                'final_score'  => $hasFinal ? round((float)$r['final_score'], 2) : null,
+                'final_saved'  => $hasFinal ? 1 : 0,
+            ];
+        }
+
+        return $out;
+    }
+
+    /**
+     * 批量保存某月最终得分(不覆盖月度总分 score)
+     *
+     * @param array<int, array{company_name?:string,final_score?:mixed}> $items
+     * @return array{done:int,saved:int,cleared:int,error:string}
+     */
+    public static function saveFinalScoresForYm(string $ym, array $items): array
+    {
+        $ym = trim($ym);
+        $result = ['done' => 0, 'saved' => 0, 'cleared' => 0, 'error' => ''];
+        if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
+            $result['error'] = '月份无效';
+
+            return $result;
+        }
+        if ($items === []) {
+            $result['error'] = '没有可保存的数据';
+
+            return $result;
+        }
+        self::ensureFinalScoreTable();
+        try {
+            Db::query('SELECT 1 FROM `' . self::TABLE_FINAL . '` LIMIT 1');
+        } catch (\Throwable $e) {
+            $result['error'] = '月度汇总表未创建,请联系管理员';
+
+            return $result;
+        }
+        $now = date('Y-m-d H:i:s');
+        foreach ($items as $it) {
+            if (!is_array($it)) {
+                continue;
+            }
+            $cn = trim((string)($it['company_name'] ?? ''));
+            if ($cn === '') {
+                continue;
+            }
+            $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)) {
+                continue;
+            }
+            $final = round((float)$raw, 2);
+            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' => $final,
+                        'updatetime'  => $now,
+                    ]);
+                } 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['done']++;
+            } catch (\Throwable $e) {
+                Log::write('supplier final score save: ' . $e->getMessage(), 'error');
+                $result['error'] = $e->getMessage();
+            }
+        }
+
+        return $result;
     }
 
     /** 历史表补质量/价格百分比字段 */
@@ -79,6 +398,36 @@ class ProcuremenSupplierScore
         }
     }
 
+    /** 历史表补交货期权重/得分字段 */
+    protected static function ensureLeadColumns(): void
+    {
+        $adds = [
+            self::TABLE_RULE => [
+                'lead_weight'  => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)' AFTER `price_weight`",
+                'lead_enabled' => "ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=交货期纳入总分' AFTER `lead_weight`",
+            ],
+            self::TABLE_SCORE => [
+                'lead_days_sum' => "ADD COLUMN `lead_days_sum` int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)' AFTER `price_score`",
+                'lead_weight'   => "ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比' AFTER `lead_days_sum`",
+                'lead_enabled'  => "ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=当时纳入总分' AFTER `lead_weight`",
+                'lead_score'    => "ADD COLUMN `lead_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '交货期评分值' AFTER `lead_enabled`",
+            ],
+        ];
+        foreach ($adds as $table => $cols) {
+            foreach ($cols as $col => $ddl) {
+                try {
+                    $info = Db::query("SHOW COLUMNS FROM `{$table}` LIKE '{$col}'");
+                    if (is_array($info) && $info !== []) {
+                        continue;
+                    }
+                    Db::execute("ALTER TABLE `{$table}` {$ddl}");
+                } catch (\Throwable $e) {
+                    Log::write("supplier score lead column {$table}.{$col}: " . $e->getMessage(), 'error');
+                }
+            }
+        }
+    }
+
     /** 将权重/质量分/单价合计等字段改为整数(去掉 .00) */
     protected static function ensureIntegerColumns(): void
     {
@@ -86,12 +435,15 @@ class ProcuremenSupplierScore
             self::TABLE_RULE => [
                 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '上年度质量评分权重(%)'",
                 'price_weight'   => "int unsigned NOT NULL DEFAULT 50 COMMENT '单价评分权重(%)'",
+                'lead_weight'    => "int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)'",
             ],
             self::TABLE_SCORE => [
                 'quality_score'  => "int NOT NULL DEFAULT 0 COMMENT '上年度质量评分'",
                 'quality_weight' => "int unsigned NOT NULL DEFAULT 50 COMMENT '质量百分比'",
                 'price_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 '交货期百分比'",
             ],
         ];
         foreach ($defs as $table => $cols) {
@@ -133,7 +485,7 @@ class ProcuremenSupplierScore
             }
             $now = date('Y-m-d H:i:s');
             Db::table(self::TABLE_RULE)->insert([
-                'name'           => '默认规则(质量50%+单价50%)',
+                'name'           => '默认规则(商务/技术50%+单价50%)',
                 'quality_weight' => 50,
                 'price_weight'   => 50,
                 'is_default'     => 1,
@@ -147,7 +499,7 @@ class ProcuremenSupplierScore
     }
 
     /**
-     * @return array{id:int,name:string,quality_weight:int,price_weight:int}
+     * @return array{id:int,name:string,quality_weight:int,price_weight:int,lead_weight:int,lead_enabled:int}
      */
     public static function getDefaultRule(): array
     {
@@ -162,11 +514,16 @@ class ProcuremenSupplierScore
                 $row = Db::table(self::TABLE_RULE)->where('status', 'normal')->order('id', 'asc')->find();
             }
             if (is_array($row)) {
+                $lw = (int)($row['lead_weight'] ?? 0);
+
                 return [
                     'id'             => (int)($row['id'] ?? 0),
                     'name'           => trim((string)($row['name'] ?? '')),
                     'quality_weight' => (int)($row['quality_weight'] ?? 50),
                     'price_weight'   => (int)($row['price_weight'] ?? 50),
+                    'lead_weight'    => $lw,
+                    // 权重大于 0 即纳入;兼容旧 lead_enabled 字段
+                    'lead_enabled'   => $lw > 0 ? 1 : 0,
                 ];
             }
         } catch (\Throwable $e) {
@@ -177,14 +534,16 @@ class ProcuremenSupplierScore
             'name'           => '内置默认',
             'quality_weight' => 50,
             'price_weight'   => 50,
+            'lead_weight'    => 0,
+            'lead_enabled'   => 0,
         ];
     }
 
     /**
-     * 按公司名批量取供应商 id / 上年度质量评分
+     * 按公司名批量取供应商 id / 综合评分 / 月度评分
      *
      * @param array<int, string> $companyNames
-     * @return array<string, array{id:int,score:float}>
+     * @return array<string, array{id:int,score:float,monthly_score:?float,quality_avg:float}>
      */
     public static function loadCustomerScoreMap(array $companyNames): array
     {
@@ -202,10 +561,18 @@ class ProcuremenSupplierScore
         try {
             $rows = Db::table('customer')
                 ->where('company_name', 'in', array_keys($names))
-                ->field('id,company_name,score')
+                ->field('id,company_name,score,monthly_score')
                 ->select();
         } catch (\Throwable $e) {
-            return $map;
+            // 无 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;
+            }
         }
         if (!is_array($rows)) {
             return $map;
@@ -218,11 +585,27 @@ class ProcuremenSupplierScore
             if ($cn === '') {
                 continue;
             }
-            $raw = $r['score'] ?? null;
-            $score = ($raw === null || $raw === '') ? 0 : (int)round((float)$raw);
+            $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' => $score,
+                'id'            => (int)($r['id'] ?? 0),
+                'score'         => $hasComp ? (float)$comp : 0.0,
+                'monthly_score' => $hasMonth ? (float)$month : null,
+                // 商务/技术得分:综合分与月度分平均值
+                'quality_avg'   => round($avg, 2),
             ];
         }
 
@@ -241,12 +624,17 @@ class ProcuremenSupplierScore
     public static function calculateForQuoteGroups(array $quoteGroups, ?array $rule = null): array
     {
         $rule = $rule ?: self::getDefaultRule();
-        $qw = (float)($rule['quality_weight'] ?? 50);
-        $pw = (float)($rule['price_weight'] ?? 50);
-        $wSum = $qw + $pw;
+        $qw = max(0, (float)($rule['quality_weight'] ?? 50));
+        $pw = max(0, (float)($rule['price_weight'] ?? 50));
+        $lw = max(0, (float)($rule['lead_weight'] ?? 0));
+        // 填 0 的权重不参与计算
+        $leadEnabled = $lw > 0 ? 1 : 0;
+        $wSum = $qw + $pw + $lw;
         if ($wSum <= 0) {
             $qw = 50;
             $pw = 50;
+            $lw = 0;
+            $leadEnabled = 0;
             $wSum = 100;
         }
 
@@ -264,6 +652,7 @@ class ProcuremenSupplierScore
 
         $items = [];
         $priceSums = [];
+        $leadSums = [];
         foreach ($quoteGroups as $g) {
             if (!is_array($g)) {
                 continue;
@@ -274,39 +663,68 @@ class ProcuremenSupplierScore
             }
             $priceSum = 0.0;
             $hasPrice = false;
-            foreach (($g['lines'] ?? []) as $ln) {
+            $leadSum = 0;
+            $hasLead = false;
+            // 确认页用 lines,审批页用 pick_lines
+            $lineRows = $g['lines'] ?? $g['pick_lines'] ?? [];
+            if (!is_array($lineRows)) {
+                $lineRows = [];
+            }
+            foreach ($lineRows as $ln) {
                 if (!is_array($ln)) {
                     continue;
                 }
-                if (!empty($ln['amount_quote_pending'])) {
-                    continue;
-                }
-                $am = trim((string)($ln['amount'] ?? ''));
-                if ($am === '' || $am === '0' || $am === '0.00') {
-                    continue;
+                if (empty($ln['amount_quote_pending'])) {
+                    $am = self::resolveQuoteLineAmountRaw($ln);
+                    if ($am !== '' && $am !== '0' && $am !== '0.00' && is_numeric($am)) {
+                        $priceSum += (float)$am;
+                        $hasPrice = true;
+                    }
                 }
-                if (!is_numeric($am)) {
-                    continue;
+                if (empty($ln['lead_days_quote_pending'])) {
+                    $ldRaw = $ln['lead_days'] ?? null;
+                    if ($ldRaw !== null && $ldRaw !== '' && is_numeric($ldRaw)) {
+                        $ld = (int)$ldRaw;
+                        if ($ld > 0) {
+                            $leadSum += $ld;
+                            $hasLead = true;
+                        }
+                    }
                 }
-                $priceSum += (float)$am;
-                $hasPrice = true;
             }
-            $quality = (int)round((float)($custMap[$cn]['score'] ?? 0));
+            $quality = (float)($custMap[$cn]['quality_avg'] ?? 0);
+            $psum = $hasPrice ? round($priceSum, 2) : 0;
+            $lsum = $hasLead ? (int)$leadSum : 0;
             $items[$cn] = [
-                'company_name'   => $cn,
-                'customer_id'    => (int)($custMap[$cn]['id'] ?? 0),
-                'quality_score'  => $quality,
-                'price_sum'      => $hasPrice ? (int)round($priceSum) : 0,
-                'has_price'      => $hasPrice,
-                'price_score'    => 0.0,
-                'score'          => 0.0,
-                'rank_no'        => 0,
-                'score_text'     => '',
-                'rank_text'      => '',
+                'company_name'         => $cn,
+                'customer_id'          => (int)($custMap[$cn]['id'] ?? 0),
+                'quality_score'        => $quality,
+                'quality_score_text'   => self::formatScore($quality),
+                'quality_weight'       => (int)$qw,
+                'price_sum'            => $psum,
+                'price_sum_text'       => self::formatScore($psum),
+                'price_weight'         => (int)$pw,
+                'has_price'            => $hasPrice,
+                'price_score'          => 0.0,
+                'price_score_text'     => '0',
+                'lead_days_sum'        => $lsum,
+                'lead_days_sum_text'   => (string)$lsum,
+                'lead_weight'          => (int)$lw,
+                'lead_enabled'         => $leadEnabled,
+                'has_lead'             => $hasLead,
+                'lead_score'           => 0.0,
+                'lead_score_text'      => '0',
+                'score'                => 0.0,
+                'rank_no'              => 0,
+                'score_text'           => '',
+                'rank_text'            => '',
             ];
             if ($hasPrice && $priceSum > 0) {
                 $priceSums[$cn] = $priceSum;
             }
+            if ($hasLead && $leadSum > 0) {
+                $leadSums[$cn] = $leadSum;
+            }
         }
 
         $minSum = null;
@@ -315,16 +733,40 @@ class ProcuremenSupplierScore
                 $minSum = $ps;
             }
         }
+        $minLead = null;
+        foreach ($leadSums as $ls) {
+            if ($minLead === null || $ls < $minLead) {
+                $minLead = $ls;
+            }
+        }
         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);
             } else {
                 $it['price_score'] = 0.0;
             }
-            $it['score'] = round(
-                $it['quality_score'] * ($qw / $wSum) + $it['price_score'] * ($pw / $wSum),
-                2
-            );
+            $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);
+            } 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);
+            }
+            if ($lw > 0) {
+                $total += $it['lead_score'] * ($lw / 100);
+            }
+            $it['score'] = round($total, 2);
             $it['score_text'] = self::formatScore($it['score']);
         }
         unset($it);
@@ -356,18 +798,125 @@ class ProcuremenSupplierScore
         return $s === '' ? '0' : $s;
     }
 
+    /**
+     * 报价行单价原文(兼容确认页 amount、审批页 unit_price_text/amount_text)
+     *
+     * @param array<string, mixed> $ln
+     */
+    protected static function resolveQuoteLineAmountRaw(array $ln): string
+    {
+        foreach (['amount', 'unit_price_text', 'amount_text'] as $k) {
+            if (!array_key_exists($k, $ln) || $ln[$k] === null) {
+                continue;
+            }
+            $am = trim((string)$ln[$k]);
+            if ($am === '' || $am === '未填写' || $am === '未开标验证' || $am === '开标验证后查看') {
+                continue;
+            }
+            // 去掉千分位等
+            $am = str_replace([',', ',', ' '], '', $am);
+            if ($am !== '' && is_numeric($am)) {
+                return $am;
+            }
+        }
+
+        return '';
+    }
+
+    /**
+     * 总分留痕文案: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);
+        $qw = (float)($hit['quality_weight'] ?? 0);
+        $pw = (float)($hit['price_weight'] ?? 0);
+        $lw = (float)($hit['lead_weight'] ?? 0);
+        $parts = [];
+        if ($qw > 0) {
+            $parts[] = '(' . self::formatScore($hit['quality_score'] ?? 0) . '×' . self::formatWeightPercent($qw) . '%)';
+        }
+        if ($pw > 0) {
+            $parts[] = '(' . self::formatScore($hit['price_sum'] ?? 0) . '×' . self::formatWeightPercent($pw) . '%)';
+        }
+        if ($lw > 0) {
+            $parts[] = '(' . self::formatScore($hit['lead_score'] ?? 0) . '×' . self::formatWeightPercent($lw) . '%)';
+        }
+        if ($parts === []) {
+            return $scoreText;
+        }
+
+        return $scoreText . ' = ' . implode('+', $parts);
+    }
+
     /**
      * 评分规则文案(用于确认页 / 详情展示)
+     * 百分比取当前默认规则;权重大于 0 的项才写入公式
      *
-     * @param array{quality_weight?:float|int|string,price_weight?:float|int|string}|null $rule
+     * @param array{quality_weight?:float|int|string,price_weight?:float|int|string,lead_weight?:float|int|string}|null $rule
      */
     public static function formatRuleFormulaText(?array $rule = null): string
     {
         $rule = $rule ?: self::getDefaultRule();
-        $qw = self::formatWeightPercent($rule['quality_weight'] ?? 50);
-        $pw = self::formatWeightPercent($rule['price_weight'] ?? 50);
+        $qw = (float)($rule['quality_weight'] ?? 50);
+        $pw = (float)($rule['price_weight'] ?? 50);
+        $lw = (float)($rule['lead_weight'] ?? 0);
+        $parts = [];
+        if ($qw > 0) {
+            $parts[] = '(商务/技术得分×' . self::formatWeightPercent($qw) . '%)';
+        }
+        if ($pw > 0) {
+            $parts[] = '(工序单价合计×' . self::formatWeightPercent($pw) . '%)';
+        }
+        if ($lw > 0) {
+            $parts[] = '(交货得分×' . self::formatWeightPercent($lw) . '%)';
+        }
+        if ($parts === []) {
+            return '(总分)=(商务/技术得分×50%)+(工序单价合计×50%)';
+        }
 
-        return '(总分)=(质量分×' . $qw . '%)+(价格分×' . $pw . '%)';
+        return '(总分)=' . implode('+', $parts);
+    }
+
+    /**
+     * 报价组是否展示交货期留痕列(已落库纳入,或尚无落库且当前默认规则交货分>0)
+     *
+     * @param array<int, array<string, mixed>> $quoteGroups
+     */
+    public static function detectShowLeadScore(array $quoteGroups = [], ?array $rule = null): bool
+    {
+        $hasSavedScore = false;
+        foreach ($quoteGroups as $g) {
+            if (!is_array($g)) {
+                continue;
+            }
+            if (!empty($g['show_service_lead_score'])) {
+                return true;
+            }
+            if (!empty($g['show_service_score'])) {
+                $hasSavedScore = true;
+            }
+        }
+        // 历史单已按旧规则落库且交货期权重为 0:不显示这两列
+        if ($hasSavedScore) {
+            return false;
+        }
+        $rule = $rule ?: self::getDefaultRule();
+
+        return (float)($rule['lead_weight'] ?? 0) > 0;
+    }
+
+    /**
+     * 评分规则提示:始终按当前默认规则比例显示(切换默认后即时变化)
+     *
+     * @param array<int, array<string, mixed>> $quoteGroups 保留参数兼容调用方,不参与文案
+     */
+    public static function formatRuleFormulaTextForGroups(array $quoteGroups = []): string
+    {
+        return self::formatRuleFormulaText();
     }
 
     protected static function formatWeightPercent($n): string
@@ -414,6 +963,10 @@ class ProcuremenSupplierScore
                     'price_sum'      => (int)round((float)($it['price_sum'] ?? 0)),
                     'price_weight'   => (int)($rule['price_weight'] ?? 50),
                     'price_score'    => (float)($it['price_score'] ?? 0),
+                    'lead_days_sum'  => (int)round((float)($it['lead_days_sum'] ?? 0)),
+                    'lead_weight'    => (int)($rule['lead_weight'] ?? 0),
+                    'lead_enabled'   => ((int)($rule['lead_weight'] ?? 0) > 0) ? 1 : 0,
+                    'lead_score'     => (float)($it['lead_score'] ?? 0),
                     'rule_id'        => (int)($rule['id'] ?? 0),
                     'createtime'     => $now,
                     'updatetime'     => $now,
@@ -422,6 +975,15 @@ class ProcuremenSupplierScore
             if ($rows !== []) {
                 Db::table(self::TABLE_SCORE)->insertAll($rows);
             }
+            // 订单明细变更后,同步刷新当月月度总分
+            $companies = [];
+            foreach ($rows as $row) {
+                $cn = trim((string)($row['company_name'] ?? ''));
+                if ($cn !== '') {
+                    $companies[] = $cn;
+                }
+            }
+            self::syncMonthlyScoresForYm($ym, $companies !== [] ? $companies : null);
         } catch (\Throwable $e) {
             Log::write('supplier service score save: ' . $e->getMessage(), 'error');
         }
@@ -458,16 +1020,36 @@ 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));
+            $priceScore = (float)($r['price_score'] ?? 0);
+            $qw = (int)($r['quality_weight'] ?? 50);
+            $pw = (int)($r['price_weight'] ?? 50);
+            $lw = (int)($r['lead_weight'] ?? 0);
+            $leadEnabled = $lw > 0 ? 1 : 0;
+            $leadSum = (int)round((float)($r['lead_days_sum'] ?? 0));
+            $leadScore = (float)($r['lead_score'] ?? 0);
             $out[$cn] = [
-                'company_name'  => $cn,
-                'customer_id'   => (int)($r['customer_id'] ?? 0),
-                'quality_score' => (float)($r['quality_score'] ?? 0),
-                'price_sum'     => (float)($r['price_sum'] ?? 0),
-                'price_score'   => (float)($r['price_score'] ?? 0),
-                'score'         => $score,
-                'rank_no'       => $rank,
-                'score_text'    => self::formatScore($score),
-                'rank_text'     => $rank > 0 ? (string)$rank : '',
+                'company_name'         => $cn,
+                'customer_id'          => (int)($r['customer_id'] ?? 0),
+                'quality_score'        => $qualityScore,
+                'quality_score_text'   => (string)$qualityScore,
+                'quality_weight'       => $qw,
+                'price_sum'            => $priceSum,
+                'price_sum_text'       => (string)$priceSum,
+                'price_weight'         => $pw,
+                'price_score'          => $priceScore,
+                'price_score_text'     => self::formatScore($priceScore),
+                'lead_days_sum'        => $leadSum,
+                'lead_days_sum_text'   => $leadEnabled ? (string)$leadSum : '',
+                'lead_weight'          => $lw,
+                'lead_enabled'         => $leadEnabled,
+                'lead_score'           => $leadScore,
+                'lead_score_text'      => $leadEnabled ? self::formatScore($leadScore) : '',
+                'score'                => $score,
+                'rank_no'              => $rank,
+                'score_text'           => self::formatScore($score),
+                'rank_text'            => $rank > 0 ? (string)$rank : '',
             ];
         }
 
@@ -475,7 +1057,36 @@ class ProcuremenSupplierScore
     }
 
     /**
-     * 把排名/评分挂到报价组上(开标后可见)
+     * 清空报价组上的评分留痕字段
+     *
+     * @param array<string, mixed> $g
+     */
+    protected static function clearScoreTrailOnGroup(array &$g, int $showFlag = 0): void
+    {
+        $g['service_rank'] = '';
+        $g['service_score'] = '';
+        $g['service_rank_text'] = '';
+        $g['service_score_text'] = '';
+        $g['service_quality_score'] = '';
+        $g['service_quality_score_text'] = '';
+        $g['service_price_sum'] = '';
+        $g['service_price_sum_text'] = '';
+        $g['service_price_score'] = '';
+        $g['service_price_score_text'] = '';
+        $g['service_lead_days_sum'] = '';
+        $g['service_lead_days_sum_text'] = '';
+        $g['service_lead_score'] = '';
+        $g['service_lead_score_text'] = '';
+        $g['service_lead_enabled'] = 0;
+        $g['service_quality_weight'] = '';
+        $g['service_price_weight'] = '';
+        $g['service_lead_weight'] = '';
+        $g['show_service_score'] = $showFlag;
+        $g['show_service_lead_score'] = 0;
+    }
+
+    /**
+     * 把排名/评分/计算留痕挂到报价组上(开标后可见)
      *
      * @param array<int, array<string, mixed>> $quoteGroups
      * @return array<int, array<string, mixed>>
@@ -487,26 +1098,18 @@ class ProcuremenSupplierScore
                 if (!is_array($g)) {
                     continue;
                 }
-                $g['service_rank'] = '';
-                $g['service_score'] = '';
-                $g['service_rank_text'] = '';
-                $g['service_score_text'] = '';
-                $g['show_service_score'] = 0;
+                self::clearScoreTrailOnGroup($g, 0);
             }
             unset($g);
 
             return $quoteGroups;
         }
 
-        $saved = self::loadSavedByCcydh($ccydh);
-        if ($saved === []) {
-            $saved = self::calculateForQuoteGroups($quoteGroups);
-            // 历史已开标但尚未落库:补写一次
-            try {
-                self::saveForOrder($ccydh, $quoteGroups);
-                $saved = self::loadSavedByCcydh($ccydh) ?: $saved;
-            } catch (\Throwable $e) {
-            }
+        // 每次按最新规则重算并落库,避免历史错误总分残留
+        $saved = self::calculateForQuoteGroups($quoteGroups);
+        try {
+            self::saveForOrder($ccydh, $quoteGroups);
+        } catch (\Throwable $e) {
         }
         foreach ($quoteGroups as &$g) {
             if (!is_array($g)) {
@@ -518,14 +1121,31 @@ class ProcuremenSupplierScore
                 $g['service_rank'] = (int)($hit['rank_no'] ?? 0);
                 $g['service_score'] = (float)($hit['score'] ?? 0);
                 $g['service_rank_text'] = (string)($hit['rank_text'] ?? '');
-                $g['service_score_text'] = (string)($hit['score_text'] ?? '');
+                $g['service_score_text'] = self::formatScoreDetailText($hit);
+                $qs = round((float)($hit['quality_score'] ?? 0), 2);
+                $ps = (float)($hit['price_score'] ?? 0);
+                $psum = round((float)($hit['price_sum'] ?? 0), 2);
+                $leadEnabled = ((int)($hit['lead_weight'] ?? 0) > 0) ? 1 : 0;
+                $ls = (float)($hit['lead_score'] ?? 0);
+                $lsum = (int)round((float)($hit['lead_days_sum'] ?? 0));
+                $g['service_quality_score'] = $qs;
+                $g['service_quality_score_text'] = (string)($hit['quality_score_text'] ?? $qs);
+                $g['service_price_sum'] = $psum;
+                $g['service_price_sum_text'] = (string)($hit['price_sum_text'] ?? $psum);
+                $g['service_price_score'] = $ps;
+                $g['service_price_score_text'] = (string)($hit['price_score_text'] ?? self::formatScore($ps));
+                $g['service_lead_enabled'] = $leadEnabled;
+                $g['show_service_lead_score'] = $leadEnabled;
+                $g['service_lead_days_sum'] = $lsum;
+                $g['service_lead_days_sum_text'] = $leadEnabled ? (string)($hit['lead_days_sum_text'] ?? $lsum) : '';
+                $g['service_lead_score'] = $ls;
+                $g['service_lead_score_text'] = $leadEnabled ? (string)($hit['lead_score_text'] ?? self::formatScore($ls)) : '';
+                $g['service_quality_weight'] = (int)($hit['quality_weight'] ?? 50);
+                $g['service_price_weight'] = (int)($hit['price_weight'] ?? 50);
+                $g['service_lead_weight'] = (int)($hit['lead_weight'] ?? 0);
                 $g['show_service_score'] = 1;
             } else {
-                $g['service_rank'] = '';
-                $g['service_score'] = '';
-                $g['service_rank_text'] = '';
-                $g['service_score_text'] = '';
-                $g['show_service_score'] = 1;
+                self::clearScoreTrailOnGroup($g, 1);
             }
         }
         unset($g);

+ 3 - 0
application/extra/customer_monthly_score.sql

@@ -0,0 +1,3 @@
+-- 供应商(customer)增加月度评分
+ALTER TABLE `customer`
+  ADD COLUMN `monthly_score` int DEFAULT NULL COMMENT '月度评分' AFTER `score`;

+ 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.31',
+  'version' => '1.10.53',
   'timezone' => 'Asia/Shanghai',
   'forbiddenip' => '',
   'languages' => 

+ 13 - 4
application/extra/supplier_score_install.sql

@@ -4,6 +4,8 @@ CREATE TABLE IF NOT EXISTS `supplier_score_rule` (
   `name` varchar(64) NOT NULL DEFAULT '' COMMENT '规则名称',
   `quality_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '上年度质量评分权重(%)',
   `price_weight` int unsigned NOT NULL DEFAULT 50 COMMENT '单价评分权重(%)',
+  `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)',
+  `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=交货期纳入总分',
   `is_default` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=默认计算规则',
   `status` varchar(16) NOT NULL DEFAULT 'normal' COMMENT 'normal/hidden',
   `createtime` datetime DEFAULT NULL,
@@ -12,25 +14,32 @@ CREATE TABLE IF NOT EXISTS `supplier_score_rule` (
   KEY `idx_default` (`is_default`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商服务评分权重规则';
 
--- 供应商服务评分表(开标验证通过后按订单写入)
+-- 供应商服务评分表(开标验证通过后按订单写入的明细
 CREATE TABLE IF NOT EXISTS `supplier_service_score` (
   `id` int unsigned NOT NULL AUTO_INCREMENT,
   `customer_id` int unsigned NOT NULL DEFAULT 0 COMMENT '供应商ID(customer.id)',
   `company_name` varchar(128) NOT NULL DEFAULT '' COMMENT '供应商名称',
   `ym` char(7) NOT NULL DEFAULT '' COMMENT '对应年月 YYYY-MM',
   `ccydh` varchar(64) NOT NULL DEFAULT '' COMMENT '订单号',
-  `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '总分',
+  `score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '本单总分',
   `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_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 '本单工序工期合计(天)',
+  `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比',
+  `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=当时纳入总分',
+  `lead_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '交货期评分值',
   `rule_id` int unsigned NOT NULL DEFAULT 0 COMMENT '所用规则ID',
   `createtime` datetime DEFAULT NULL,
   `updatetime` datetime DEFAULT NULL,
   PRIMARY KEY (`id`),
   UNIQUE KEY `uk_ccydh_company` (`ccydh`,`company_name`),
   KEY `idx_customer_ym` (`customer_id`,`ym`),
-  KEY `idx_ccydh` (`ccydh`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商服务评分表';
+  KEY `idx_ccydh` (`ccydh`),
+  KEY `idx_ym` (`ym`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商服务评分订单明细';
+
+-- 月度汇总见 supplier_service_final_score.sql(score=月度总分,final_score=最终得分)

+ 11 - 0
application/extra/supplier_score_lead_columns.sql

@@ -0,0 +1,11 @@
+-- 评分规则:交货期权重 + 是否纳入总分
+ALTER TABLE `supplier_score_rule`
+  ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期得分权重(%)' AFTER `price_weight`,
+  ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=交货期纳入总分' AFTER `lead_weight`;
+
+-- 服务评分落库留痕
+ALTER TABLE `supplier_service_score`
+  ADD COLUMN `lead_days_sum` int NOT NULL DEFAULT 0 COMMENT '本单工序工期合计(天)' AFTER `price_score`,
+  ADD COLUMN `lead_weight` int unsigned NOT NULL DEFAULT 0 COMMENT '交货期百分比' AFTER `lead_days_sum`,
+  ADD COLUMN `lead_enabled` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '1=当时纳入总分' AFTER `lead_weight`,
+  ADD COLUMN `lead_score` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '交货期评分值' AFTER `lead_enabled`;

+ 18 - 0
application/extra/supplier_service_final_score.sql

@@ -0,0 +1,18 @@
+-- 供应商月度评分汇总(评审表展示:总分自动同步,最终得分人工填写)
+-- 订单明细在 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 '月度总分(当月订单平均)',
+  `final_score` decimal(10,2) DEFAULT NULL COMMENT '最终得分(人工,空=未填)',
+  `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='供应商月度评分汇总';
+
+-- 若表已存在缺列,可执行:
+-- 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 '最终得分(人工,空=未填)';

+ 101 - 31
application/index/controller/Index.php

@@ -647,9 +647,9 @@ class Index extends Frontend
     }
 
     /**
-     * 列表:非管理员按 company_name 与登录时解析的单位名一致;管理员不加条件
+     * 列表:非管理员按 company_name 与登录单位一致,或 phone 与登录手机号一致(兼容手工下发单位名细微差异)
      *
-     * @return array 可直接 $query->where($arr),空数组表示不加条件
+     * @return array|callable 可直接 $query->where(...);空数组表示不加条件
      */
     protected function mprocListWhereForLoginUser(array $user)
     {
@@ -657,20 +657,36 @@ class Index extends Frontend
             return [];
         }
         $cCol = $this->mprocResolveProcuremenColumn(['company_name']);
-        if ($cCol === null || $cCol === '') {
+        $pCol = $this->mprocResolveProcuremenColumn(['phone']);
+        if (($cCol === null || $cCol === '') && ($pCol === null || $pCol === '')) {
             return ['id' => 0];
         }
         $cn = trim((string)($user['company_name'] ?? ''));
-        if ($cn === '') {
-            $phone = trim((string)($user['phone'] ?? ''));
-            if ($phone !== '') {
-                $cn = $this->mprocResolveCompanyForLoginPhone($phone);
-            }
+        $phone = trim((string)($user['phone'] ?? ''));
+        if ($cn === '' && $phone !== '') {
+            $cn = $this->mprocResolveCompanyForLoginPhone($phone);
         }
-        if ($cn === '') {
+        if ($cn === '' && $phone === '') {
             return ['id' => 0];
         }
-        return [$cCol => $cn];
+
+        return function ($q) use ($cCol, $pCol, $cn, $phone) {
+            $first = true;
+            if ($cCol !== null && $cCol !== '' && $cn !== '') {
+                $q->where($cCol, $cn);
+                $first = false;
+            }
+            if ($pCol !== null && $pCol !== '' && $phone !== '') {
+                if ($first) {
+                    $q->where($pCol, $phone);
+                } else {
+                    $q->whereOr($pCol, $phone);
+                }
+            }
+            if ($first) {
+                $q->where('id', 0);
+            }
+        };
     }
 
     /**
@@ -1614,6 +1630,10 @@ class Index extends Frontend
         if (in_array($effectiveSn, ['已完成', '未通过', '已废弃'], true)) {
             return 'done';
         }
+        // 已开标或已过招标截止 →「已完成」(含已报价),角标「已截止」或中标结果
+        if ($this->mprocIsBidOpenVerifiedForPoOrRow($po, $row)) {
+            return 'done';
+        }
         if ($this->mprocIsQuoteDeadlineReachedForPo($po)) {
             return 'done';
         }
@@ -1721,7 +1741,7 @@ class Index extends Frontend
                     $hasWin = true;
                 } elseif ($pr === '未中标' || $dl === '未中标') {
                     $hasLose = true;
-                } elseif ($dl === '已截止') {
+                } elseif ($dl === '已截止' || (int)($ln['mproc_deadline_reached'] ?? 0) === 1) {
                     $hasExpired = true;
                 }
             }
@@ -1851,10 +1871,14 @@ class Index extends Frontend
             $row['mproc_list_tab'] = $listTab;
             $row['mproc_deadline_reached'] = $this->mprocIsQuoteDeadlineReachedForPo($poRow) ? 1 : 0;
             $row['mproc_pick_result'] = $this->mprocResolvePickResultText($row, $poRow);
-            if ($row['mproc_pick_result'] === '' && $listTab === 'done' && $row['mproc_deadline_reached']) {
-                $row['mproc_done_label'] = '已截止';
-            } elseif ($row['mproc_pick_result'] !== '') {
+            $row['mproc_bid_open_verified'] = $this->mprocIsBidOpenVerifiedForPoOrRow($poRow, $row) ? 1 : 0;
+            if ($row['mproc_pick_result'] !== '') {
                 $row['mproc_done_label'] = $row['mproc_pick_result'];
+            } elseif ($listTab === 'done' && (
+                (int)$row['mproc_deadline_reached'] === 1
+                || (int)$row['mproc_bid_open_verified'] === 1
+            )) {
+                $row['mproc_done_label'] = '已截止';
             } else {
                 $row['mproc_done_label'] = '';
             }
@@ -1865,7 +1889,6 @@ class Index extends Frontend
                 $row['status_name'] = trim((string)$row['status_name']);
             }
             $row['mproc_can_edit'] = $this->mprocCanEditRow($user, $row, $poRow) ? 1 : 0;
-            $row['mproc_bid_open_verified'] = $this->mprocIsBidOpenVerifiedForPoOrRow($poRow, $row) ? 1 : 0;
 
             $am = $row['amount'] ?? null;
             if ($am === null || $am === '' || (is_string($am) && trim($am) === '')) {
@@ -1900,6 +1923,8 @@ 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;
         }));
+        // 按招标截止日期排序:未提交/已提交截止近的在前;已完成最近截止的在前
+        $rows = $this->mprocSortRowsByBidDeadline($rows, $tab === 'done' ? 'desc' : 'asc');
 
         $groups = $this->mprocGroupRowsByOrder($rows);
 
@@ -1910,6 +1935,53 @@ class Index extends Frontend
         ];
     }
 
+    /**
+     * 按招标截止时间排序(无截止时间的排最后)
+     *
+     * @param array<int, array<string, mixed>> $rows
+     * @param string                           $dir  asc|desc
+     * @return array<int, array<string, mixed>>
+     */
+    protected function mprocSortRowsByBidDeadline(array $rows, string $dir = 'asc'): array
+    {
+        $dir = strtolower($dir) === 'desc' ? 'desc' : 'asc';
+        usort($rows, function ($a, $b) use ($dir) {
+            $ta = $this->mprocBidDeadlineSortTs(is_array($a) ? $a : []);
+            $tb = $this->mprocBidDeadlineSortTs(is_array($b) ? $b : []);
+            $ha = $ta > 0;
+            $hb = $tb > 0;
+            if ($ha !== $hb) {
+                return $ha ? -1 : 1;
+            }
+            if ($ha && $ta !== $tb) {
+                return $dir === 'desc' ? ($tb <=> $ta) : ($ta <=> $tb);
+            }
+            $ida = (int)($a['eid'] ?? $a['id'] ?? 0);
+            $idb = (int)($b['eid'] ?? $b['id'] ?? 0);
+
+            return $idb <=> $ida;
+        });
+
+        return array_values($rows);
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    protected function mprocBidDeadlineSortTs(array $row): int
+    {
+        $raw = trim((string)($row['mproc_bid_deadline'] ?? $row['sys_rq'] ?? $row['SYS_RQ'] ?? ''));
+        if ($raw === '') {
+            $raw = trim((string)($row['mproc_bid_deadline_display'] ?? ''));
+        }
+        if ($raw === '') {
+            return 0;
+        }
+        $ts = strtotime(str_replace('T', ' ', $raw));
+
+        return ($ts !== false && $ts > 0) ? (int)$ts : 0;
+    }
+
     /**
      * 同一供应商 + 同一工序只保留一条明细(优先 id 更大的最新记录)
      *
@@ -2087,15 +2159,18 @@ class Index extends Frontend
         $row['mproc_list_tab'] = $listTab;
         $row['mproc_deadline_reached'] = $this->mprocIsQuoteDeadlineReachedForPo($poRow) ? 1 : 0;
         $row['mproc_pick_result'] = $this->mprocResolvePickResultText($row, $poRow);
-        if ($row['mproc_pick_result'] === '' && $listTab === 'done' && $row['mproc_deadline_reached']) {
-            $row['mproc_done_label'] = '已截止';
-        } elseif ($row['mproc_pick_result'] !== '') {
+        $row['mproc_bid_open_verified'] = $this->mprocIsBidOpenVerifiedForPoOrRow(is_array($poRow) ? $poRow : null, $row) ? 1 : 0;
+        if ($row['mproc_pick_result'] !== '') {
             $row['mproc_done_label'] = $row['mproc_pick_result'];
+        } elseif ($listTab === 'done' && (
+            (int)$row['mproc_deadline_reached'] === 1
+            || (int)$row['mproc_bid_open_verified'] === 1
+        )) {
+            $row['mproc_done_label'] = '已截止';
         } else {
             $row['mproc_done_label'] = '';
         }
         $row['mproc_can_edit'] = $this->mprocCanEditRow($user, $row, $poRow) ? 1 : 0;
-        $row['mproc_bid_open_verified'] = $this->mprocIsBidOpenVerifiedForPoOrRow(is_array($poRow) ? $poRow : null, $row) ? 1 : 0;
         $am = $row['amount'] ?? null;
         $row['amount_display'] = ($am === null || $am === '' || (is_string($am) && trim($am) === '')) ? '' : (is_scalar($am) ? (string)$am : '');
         $dv = isset($row['delivery']) ? trim((string)$row['delivery']) : '';
@@ -2166,6 +2241,9 @@ class Index extends Frontend
             return $sn;
         }
         if (!is_array($po)) {
+            if ($sn === '未提交' && $this->mprocDetailQuoteSubmitted($row)) {
+                return '已提交';
+            }
             if ($sn !== '') {
                 return $sn;
             }
@@ -2174,6 +2252,10 @@ class Index extends Frontend
         }
         $poStatus = $po['status'] ?? $po['STATUS'] ?? '';
         if (!ProcuremenStatus::isPoCompleted($poStatus)) {
+            // 库中仍写「未提交」但已填单价/交期:按已提交展示(手工单常见)
+            if ($sn === '未提交' && $this->mprocDetailQuoteSubmitted($row)) {
+                return '已提交';
+            }
             if ($sn !== '') {
                 return $sn;
             }
@@ -2983,18 +3065,6 @@ class Index extends Frontend
         }
         $data['lead_days'] = $leadDays;
 
-        if (!empty($data['delivery'])) {
-            $deliveryYmd = substr((string)$data['delivery'], 0, 10);
-            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $deliveryYmd) && $deliveryYmd < date('Y-m-d')) {
-                throw new \InvalidArgumentException($label . '交货日期不能早于今天');
-            }
-            $orderDeliveryDeadline = $this->mprocResolveDeliveryDeadlineYmdForDetailRow($row);
-            if ($orderDeliveryDeadline !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $deliveryYmd)
-                && $deliveryYmd > $orderDeliveryDeadline) {
-                throw new \InvalidArgumentException($label . '交货日期不能超过交货截止日期 ' . $orderDeliveryDeadline);
-            }
-        }
-
         $dcCol = $this->mprocResolveProcuremenColumn(['delivery_createtime', 'deliverycreatetime']);
         if ($dcCol !== null && array_key_exists('delivery', $data) && $data['delivery'] !== null && $data['delivery'] !== '') {
             $data[$dcCol] = date('Y-m-d H:i:s');

+ 15 - 45
application/index/view/index/index.html

@@ -1368,6 +1368,15 @@
         if (hasExpired) {
             return mprocPickBadgeHtml('已截止', '');
         }
+        var deadlineHit = false;
+        (lines || []).forEach(function (r) {
+            if (parseInt(r.mproc_deadline_reached, 10) === 1) {
+                deadlineHit = true;
+            }
+        });
+        if (deadlineHit) {
+            return mprocPickBadgeHtml('已截止', '');
+        }
         return '';
     }
 
@@ -1906,32 +1915,11 @@
         if (!delInp) {
             return false;
         }
-        var today = mprocTodayYmd();
-        var max = mprocResolveEditDeliveryDeadlineMax();
-        var attrMax = String(delInp.getAttribute('data-max-deadline') || delInp.getAttribute('max') || '').trim();
-        if (!max && attrMax && /^\d{4}-\d{2}-\d{2}/.test(attrMax)) {
-            max = attrMax.slice(0, 10);
-        }
-        delInp.setAttribute('min', today);
-        if (max) {
-            delInp.setAttribute('max', max);
-            delInp.setAttribute('data-max-deadline', max);
-        } else {
-            delInp.removeAttribute('max');
-            delInp.removeAttribute('data-max-deadline');
-        }
-        var v = String(delInp.value || '').trim();
-        var changed = false;
-        if (v && v < today) {
-            delInp.value = today;
-            changed = true;
-            v = today;
-        }
-        if (max && v && v > max) {
-            delInp.value = max;
-            changed = true;
-        }
-        return changed;
+        // 前端不限制交期区间(不早于今天 / 不超过交货截止)
+        delInp.removeAttribute('min');
+        delInp.removeAttribute('max');
+        delInp.removeAttribute('data-max-deadline');
+        return false;
     }
 
     function mprocBindLeadDeliverySync(block) {
@@ -2209,8 +2197,6 @@
         var ceilText = String(line.ceiling == null ? '' : line.ceiling).trim();
         var qtyShow = qtyText !== '' ? qtyText : '—';
         var ceilShow = ceilText !== '' ? ceilText : '—';
-        var maxDl = mprocResolveEditDeliveryDeadlineMax();
-        var maxAttr = maxDl ? (' max="' + mprocEscAttr(maxDl) + '" data-max-deadline="' + mprocEscAttr(maxDl) + '"') : '';
         return '<div class="edit-line-block" data-id="' + mprocEscAttr(String(line.id)) + '" data-ceiling="' + mprocEscAttr(ceilText) + '">'
             + '<div class="edit-line-head-row">'
             + '<p class="edit-line-head">' + mprocEsc(line.gymc || '工序') + '</p>'
@@ -2239,7 +2225,7 @@
             + '<div class="edit-field-ctrl">'
             + '<div class="date-field-shell js-date-shell" role="button" tabindex="0" title="点击选择交货日期">'
             + '<span class="date-placeholder">选择日期</span>'
-            + '<input type="date" class="inp-date js-edit-delivery" lang="zh-CN" autocomplete="off" min="' + mprocEscAttr(mprocTodayYmd()) + '"' + maxAttr + ' value="' + mprocEscAttr(del) + '">'
+            + '<input type="date" class="inp-date js-edit-delivery" lang="zh-CN" autocomplete="off" value="' + mprocEscAttr(del) + '">'
             + '</div>'
             + '</div>'
             + '</div>'
@@ -2370,8 +2356,6 @@
         }
         var items = [];
         var blocks = editLinesBox.querySelectorAll('.edit-line-block');
-        var today = mprocTodayYmd();
-        var maxDl = mprocResolveEditDeliveryDeadlineMax();
         for (var i = 0; i < blocks.length; i++) {
             var block = blocks[i];
             var id = parseInt(block.getAttribute('data-id'), 10) || 0;
@@ -2441,20 +2425,6 @@
                 }
                 return null;
             }
-            if (delivery < today) {
-                mprocShowToast('交货日期不能早于今天');
-                if (delInp) {
-                    delInp.focus();
-                }
-                return null;
-            }
-            if (maxDl && delivery > maxDl) {
-                mprocShowToast('交货日期不能超过交货截止日期');
-                if (delInp) {
-                    delInp.focus();
-                }
-                return null;
-            }
             items.push({
                 id: id,
                 amount: amt,

+ 12 - 1
public/assets/js/backend/customer.js

@@ -65,7 +65,18 @@ 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},
+                        {field: 'score', title: __('Score'), operate: 'BETWEEN', sortable: true, 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) {
+                            if (value === null || value === undefined || value === '') {
+                                return '';
+                            }
+                            return String(parseInt(value, 10));
+                        }},
                         {field: 'company_type', title: __('Company_type'), operate: 'LIKE', table: table, class: 'autocontent', formatter: Table.api.formatter.content},
                         {
                             field: 'status',

+ 6 - 4
public/assets/js/backend/procuremen.js

@@ -1062,6 +1062,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 },
                 {field: 'CDW', title: __('单位'), operate: 'LIKE', table: 'a', width: 88, align: 'center'},
                 {field: 'NGZL', title: __('工作量'), operate: 'LIKE', table: 'a', width: 88, align: 'center',
+                    // 确认/审批列表不展示工作量,仅初选保留
+                    visible: indexInitWffTab === 'pick',
                     formatter: function (v) {
                         if (v == null || v === '') {
                             return '';
@@ -3223,12 +3225,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     if (mergeSameOrder && idx === 0) {
                         $tr.append(
                             $('<td class="text-center"/>').attr('rowspan', list.length).text(ccydh),
-                            $('<td/>').attr('rowspan', list.length).text(cyjmc)
+                            $('<td class="review-merge-col-name"/>').attr('rowspan', list.length).text(cyjmc)
                         );
                     } else if (!mergeSameOrder) {
                         $tr.append(
                             $('<td class="text-center"/>').text(ccydh),
-                            $('<td/>').text(cyjmc)
+                            $('<td class="review-merge-col-name"/>').text(cyjmc)
                         );
                     }
                     $tr.append(
@@ -3237,7 +3239,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         $('<td class="text-center"/>').text(qty),
                         $('<td class="text-center"/>').text(price),
                         $('<td class="text-center"/>').text(r.CDF || ''),
-                        $('<td/>').text(r.MBZ != null && r.MBZ !== '' ? String(r.MBZ) : '')
+                        $('<td class="review-merge-col-remark"/>').text(r.MBZ != null && r.MBZ !== '' ? String(r.MBZ) : '')
                     );
                     $tb.append($tr);
                 });
@@ -4248,7 +4250,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     return;
                 }
                 if (!auditIssueQuoteDeadlineReached()) {
-                    Toastr.warning('未到截止时间');
+                    Toastr.warning('未到招标截止日期');
                     return;
                 }
                 var users = [];

+ 33 - 14
public/assets/js/backend/procuremenexport.js

@@ -67,13 +67,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         {field: 'CCYDH', title: '订单号', operate: 'LIKE'},
                         {field: 'CYJMC', title: '印件名称', class: 'autocontent', operate: 'LIKE', formatter: Table.api.formatter.content},
                         {field: 'CGYMC', title: '工序名称', class: 'autocontent', operate: 'LIKE', formatter: Table.api.formatter.content},
+                        {
+                            field: 'picked_supplier',
+                            title: '已选供应商',
+                            class: 'autocontent',
+                            operate: 'LIKE',
+                            formatter: Table.api.formatter.content
+                        },
                         {field: 'createtime_text', title: '完结时间', width: 165, operate: 'LIKE'},
-                        {field: 'row_count', title: '工序数', width: 72, align: 'center', operate: false},
-                        {field: 'total_amount', title: '金额合计', operate: false, formatter: function (value, row) {
-                            return row.total_amount_text != null && row.total_amount_text !== ''
-                                ? row.total_amount_text
-                                : (value != null ? String(value) : '');
-                        }},
                         {
                             field: 'operate',
                             title: '操作',
@@ -105,15 +106,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 refreshTable();
             });
 
-            $(document).off('click.procuremenExportMonth', '#btn-export-month-outward').on('click.procuremenExportMonth', '#btn-export-month-outward', function () {
-                if (!exportCan('export_month_outward')) {
-                    Toastr.error('无导出权限');
-                    return;
-                }
+            function buildMonthExportUrl(action) {
                 var ym = ($('#export-preview-ym').val() || '').trim();
                 if (!/^\d{4}-\d{2}$/.test(ym)) {
                     Toastr.warning('请选择有效月份');
-                    return;
+                    return '';
                 }
                 var opts = table.bootstrapTable('getOptions') || {};
                 var search = $.trim(opts.searchText || '');
@@ -148,7 +145,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     }
                 } catch (eFilter) {
                 }
-                var url = Fast.api.fixurl('procuremen/export_month_outward?ym=' + encodeURIComponent(ym));
+                var url = Fast.api.fixurl('procuremen/' + action + '?ym=' + encodeURIComponent(ym));
                 if (search) {
                     url += '&search=' + encodeURIComponent(search);
                 }
@@ -158,7 +155,29 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 if (op) {
                     url += '&op=' + encodeURIComponent(op);
                 }
-                window.open(url, '_blank');
+                return url;
+            }
+
+            $(document).off('click.procuremenExportMonth', '#btn-export-month-outward').on('click.procuremenExportMonth', '#btn-export-month-outward', function () {
+                if (!exportCan('export_month_outward')) {
+                    Toastr.error('无导出权限');
+                    return;
+                }
+                var url = buildMonthExportUrl('export_month_outward');
+                if (url) {
+                    window.open(url, '_blank');
+                }
+            });
+
+            $(document).off('click.procuremenExportQuote', '#btn-export-month-quote').on('click.procuremenExportQuote', '#btn-export-month-quote', function () {
+                if (!exportCan('export_month_quote') && !exportCan('export_month_outward')) {
+                    Toastr.error('无导出权限');
+                    return;
+                }
+                var url = buildMonthExportUrl('export_month_quote');
+                if (url) {
+                    window.open(url, '_blank');
+                }
             });
 
             $(document).off('click.procuremenExportDetails', '.btn-export-details').on('click.procuremenExportDetails', '.btn-export-details', function (e) {

+ 23 - 12
public/assets/js/backend/supplierscorerule.js

@@ -12,6 +12,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 }
             });
             var table = $('#table');
+            // 行内编辑弹窗尺寸
+            Table.button.edit.extend = 'data-toggle="tooltip" data-container="body" data-area=\'["460px","360px"]\'';
             table.bootstrapTable({
                 url: $.fn.bootstrapTable.defaults.extend.index_url,
                 pk: 'id',
@@ -20,7 +22,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 columns: [[
                     {checkbox: true},
                     {field: 'id', title: 'ID', width: 60, sortable: true},
-                    {field: 'quality_weight', title: '质量分%', operate: 'BETWEEN', sortable: true, formatter: function (value) {
+                    {field: 'quality_weight', title: '商务/技术分%', operate: 'BETWEEN', sortable: true, formatter: function (value) {
                         if (value === null || value === undefined || value === '') {
                             return '';
                         }
@@ -32,22 +34,22 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         }
                         return String(parseInt(value, 10));
                     }},
+                    {field: 'lead_weight', title: '交货分%', operate: 'BETWEEN', sortable: true, formatter: function (value) {
+                        if (value === null || value === undefined || value === '') {
+                            return '0';
+                        }
+                        return String(parseInt(value, 10));
+                    }},
                     {
                         field: 'is_default',
-                        title: '默认',
-                        searchList: {1: '默认', 0: '否'},
+                        title: '状态',
+                        searchList: {1: '使用', 0: '未使用'},
                         formatter: function (v) {
                             return parseInt(v, 10) === 1
-                                ? '<span class="label label-success">默认</span>'
-                                : '';
+                                ? '<span class="label label-success">使用</span>'
+                                : '<span class="label label-default">未使用</span>';
                         }
                     },
-                    {
-                        field: 'status',
-                        title: '状态',
-                        searchList: {normal: '正常', hidden: '隐藏'},
-                        formatter: Table.api.formatter.status
-                    },
                     {
                         field: 'operate',
                         title: __('Operate'),
@@ -83,7 +85,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
         },
         api: {
             bindevent: function () {
-                Form.api.bindevent($('form[role=form]'));
+                var $form = $('form[role=form]');
+                Form.api.bindevent($form);
+                // 弹层 footer 克隆后点击会回传到 iframe 内按钮;确保关闭可用
+                $form.on('click', '.layer-close', function () {
+                    if (window.name) {
+                        var index = parent.Layer.getFrameIndex(window.name);
+                        parent.Layer.close(index);
+                    }
+                    return false;
+                });
             }
         }
     };

+ 137 - 93
public/assets/js/backend/supplierservicescore.js

@@ -20,7 +20,6 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 if (/^\d{4}-\d{2}$/.test(v)) {
                     return v;
                 }
-                // 兼容界面显示成 2026年07月
                 var m = v.match(/^(\d{4})\D+(\d{1,2})/);
                 if (m) {
                     return m[1] + '-' + pad2(parseInt(m[2], 10));
@@ -32,15 +31,30 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 if (v) {
                     return v;
                 }
-                var $filterYm = $('input[name="ym"], .commonsearch-table input[name="ym"]').first();
-                if ($filterYm.length) {
-                    v = normalizeYm($filterYm.val());
-                    if (v) {
-                        return v;
-                    }
-                }
                 return currentYm();
             }
+            function escAttr(s) {
+                return String(s == null ? '' : s)
+                    .replace(/&/g, '&amp;')
+                    .replace(/"/g, '&quot;')
+                    .replace(/</g, '&lt;')
+                    .replace(/>/g, '&gt;');
+            }
+            function collectFinalRows() {
+                var rows = [];
+                $('#table').find('input.ssc-final-score-input').each(function () {
+                    var $inp = $(this);
+                    var company = String($inp.data('company') || '').trim();
+                    if (!company) {
+                        return;
+                    }
+                    rows.push({
+                        company_name: company,
+                        final_score: String($inp.val() || '').trim()
+                    });
+                });
+                return rows;
+            }
 
             var $ymInput = $('#export-review-ym');
             if ($ymInput.length && !normalizeYm($ymInput.val())) {
@@ -51,101 +65,101 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
             table.bootstrapTable({
                 url: $.fn.bootstrapTable.defaults.extend.index_url,
                 pk: 'id',
-                sortName: 'ccydh',
-                sortOrder: 'desc',
-                queryParams: function (params) {
-                    var filter = {};
-                    var op = {};
-                    try {
-                        filter = typeof params.filter === 'string' ? (JSON.parse(params.filter || '{}') || {}) : (params.filter || {});
-                        op = typeof params.op === 'string' ? (JSON.parse(params.op || '{}') || {}) : (params.op || {});
-                    } catch (e) {
-                        filter = {};
-                        op = {};
-                    }
-                    var keyword = String(params.search || '').trim();
-                    // 有关键词时跨月查(订单号/供应商名),避免被「查询月份」卡住查不到
-                    if (!keyword) {
-                        var ym = resolveYm();
-                        if (ym) {
-                            filter.ym = ym;
-                            op.ym = '=';
-                        }
-                    } else if (filter.ym) {
-                        delete filter.ym;
-                        if (op.ym) {
-                            delete op.ym;
-                        }
-                    }
-                    params.filter = JSON.stringify(filter);
-                    params.op = JSON.stringify(op);
-                    return params;
-                },
+                sortName: 'rank_no',
+                sortOrder: 'asc',
+                search: true,
+                commonSearch: false,
+                searchFormVisible: false,
+                searchPlaceholder: '搜索供应商',
+                pageSize: 100,
+                pageList: [50, 100, 200, 500],
                 columns: [[
-                    {field: 'ccydh', title: '订单号', operate: 'LIKE', sortable: true},
-                    {field: 'company_name', title: '供应商名称', operate: 'LIKE'},
-                    {field: 'rank_no', title: '排名', sortable: true, width: 70},
                     {
-                        field: 'score',
-                        title: '总分',
-                        operate: 'BETWEEN',
-                        sortable: true,
-                        formatter: function (value, row) {
-                            return row.score_text != null && row.score_text !== '' ? row.score_text : value;
-                        }
-                    },
-                    {
-                        field: 'quality_score',
-                        title: '上年度质量评分',
-                        operate: 'BETWEEN',
-                        formatter: function (value) {
-                            if (value === null || value === undefined || value === '') {
-                                return '';
+                        field: 'seq_no',
+                        title: '序号',
+                        operate: false,
+                        sortable: false,
+                        width: 60,
+                        align: 'center',
+                        formatter: function (value, row, index) {
+                            if (value != null && value !== '') {
+                                return value;
                             }
-                            return String(parseInt(value, 10));
+                            var opts = table.bootstrapTable('getOptions') || {};
+                            var pageNumber = parseInt(opts.pageNumber, 10) || 1;
+                            var pageSize = parseInt(opts.pageSize, 10) || 10;
+                            return (pageNumber - 1) * pageSize + index + 1;
                         }
                     },
                     {
-                        field: 'quality_weight',
-                        title: '质量分%',
-                        operate: 'BETWEEN',
-                        sortable: true,
-                        formatter: function (value) {
-                            if (value === null || value === undefined || value === '') {
-                                return '';
-                            }
-                            return String(parseInt(value, 10));
-                        }
+                        field: 'company_name',
+                        title: '供应商',
+                        operate: 'LIKE',
+                        align: 'left',
+                        width: 280
                     },
                     {
-                        field: 'price_sum',
-                        title: '单价合计',
-                        operate: 'BETWEEN',
-                        formatter: function (value) {
-                            if (value === null || value === undefined || value === '') {
-                                return '';
-                            }
-                            return String(parseInt(value, 10));
+                        field: 'score',
+                        title: '总分',
+                        operate: false,
+                        sortable: false,
+                        align: 'center',
+                        width: 90,
+                        formatter: function (value, row) {
+                            return row.score_text != null && row.score_text !== '' ? row.score_text : value;
                         }
                     },
                     {
-                        field: 'price_weight',
-                        title: '价格分%',
-                        operate: 'BETWEEN',
-                        sortable: true,
-                        formatter: function (value) {
-                            if (value === null || value === undefined || value === '') {
-                                return '';
+                        field: 'final_score',
+                        title: '最终得分',
+                        operate: false,
+                        sortable: false,
+                        align: 'center',
+                        width: 120,
+                        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;
                             }
-                            return String(parseInt(value, 10));
+                            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="仅作人工记录"/>';
                         }
                     },
-                    {field: 'ym', title: '年月', operate: false, width: 90}
-                ]]
+                    {
+                        field: 'rank_no',
+                        title: '排名',
+                        operate: false,
+                        sortable: false,
+                        align: 'center',
+                        width: 70
+                    }
+                ]],
+                queryParams: function (params) {
+                    var ym = resolveYm();
+                    params.ym = ym;
+                    var filter = {ym: ym};
+                    var op = {ym: '='};
+                    var keyword = String(params.search || '').trim();
+                    if (keyword) {
+                        filter.company_name = keyword;
+                        op.company_name = 'LIKE';
+                    }
+                    params.filter = JSON.stringify(filter);
+                    params.op = JSON.stringify(op);
+                    return params;
+                }
             });
             Table.api.bindevent(table);
 
-            // 选择月份后立即按该月查询列表
             $(document).off('change.supplierScoreYm', '#export-review-ym').on('change.supplierScoreYm', '#export-review-ym', function () {
                 var ym = normalizeYm($(this).val());
                 if (ym && $(this).val() !== ym) {
@@ -159,18 +173,48 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 e.stopPropagation();
                 var ym = resolveYm();
                 if (!/^\d{4}-\d{2}$/.test(ym)) {
-                    if (typeof Toastr !== 'undefined') {
-                        Toastr.error('请选择查询月份');
-                    } else {
-                        Layer.msg('请选择查询月份');
-                    }
+                    Toastr.error('请选择查询月份');
                     return false;
                 }
                 if ($ymInput.length) {
                     $ymInput.val(ym);
                 }
-                var url = Fast.api.fixurl('supplierservicescore/export') + '?ym=' + encodeURIComponent(ym);
-                window.location.href = url;
+                window.location.href = Fast.api.fixurl('supplierservicescore/export') + '?ym=' + encodeURIComponent(ym);
+                return false;
+            });
+
+            $(document).off('click.supplierScoreSaveFinal', '#btn-save-final-score').on('click.supplierScoreSaveFinal', '#btn-save-final-score', function (e) {
+                e.preventDefault();
+                e.stopPropagation();
+                var ym = resolveYm();
+                if (!/^\d{4}-\d{2}$/.test(ym)) {
+                    Toastr.error('请选择查询月份');
+                    return false;
+                }
+                var rows = collectFinalRows();
+                if (!rows.length) {
+                    Toastr.warning('当前没有可保存的数据');
+                    return false;
+                }
+                var $btn = $(this);
+                if ($btn.data('loading')) {
+                    return false;
+                }
+                $btn.data('loading', 1).prop('disabled', true);
+                Fast.api.ajax({
+                    url: 'supplierservicescore/savefinalbatch',
+                    type: 'POST',
+                    data: {
+                        ym: ym,
+                        rows_json: JSON.stringify(rows)
+                    }
+                }, function () {
+                    $btn.data('loading', 0).prop('disabled', false);
+                    table.bootstrapTable('refresh');
+                    return true;
+                }, function () {
+                    $btn.data('loading', 0).prop('disabled', false);
+                });
                 return false;
             });
         }