m0_70156489 1 week ago
parent
commit
e4babb9d4c

+ 476 - 85
application/admin/controller/Procuremen.php

@@ -48,7 +48,7 @@ class Procuremen extends Backend
     protected $pickHiddenScydgySetCache = null;
 
     /** 邮件发送日志表结构已就绪缓存键 */
-    protected const EMAIL_SEND_LOG_SCHEMA_CACHE_KEY = 'purchase_email_send_log_schema_v1';
+    protected const EMAIL_SEND_LOG_SCHEMA_CACHE_KEY = 'purchase_email_send_log_schema_v2';
 
     /**
      * 采购子接口均走菜单规则鉴权;下列方法在方法内用 hasProcuremenPerm 做别名校验。
@@ -94,6 +94,7 @@ class Procuremen extends Backend
             '1' => ProcuremenStatus::WFLOW_PENDING_CONFIRM,
             '2' => ProcuremenStatus::WFLOW_PENDING_APPROVAL,
             '3' => ProcuremenStatus::WFLOW_APPROVED,
+            '4' => ProcuremenStatus::WFLOW_REJECTED,
         ];
         try {
             foreach ($map as $num => $text) {
@@ -1352,8 +1353,9 @@ class Procuremen extends Backend
     protected function loadPurchaseOrderRowsForListStage(string $stage, string $ym = '', bool $applyMonthRange = false): array
     {
         $this->ensurePurchaseOrderCzlyqColumn();
+        $this->ensurePurchaseOrderManualPickColumns();
         $fields = 'id,scydgy_id,CCYDH,CYJMC,CCLBMMC,CDXMC,CGYBH,CGYMC,CDW,NGZL,CDF,cGzzxMc,MBZ,cywyxm,czlyq,'
-            . 'This_quantity,ceilingPrice,pick_time,createtime,dputrecord,dStamp,wflow_status,status,pick_company_name,order_batch_no';
+            . 'This_quantity,ceilingPrice,pick_time,createtime,dputrecord,dStamp,wflow_status,status,pick_company_name,manual_pick,order_batch_no';
         try {
             $this->ensurePurchaseOrderOrderBatchNoColumn();
             $query = Db::table('purchase_order')->field($fields);
@@ -1815,9 +1817,58 @@ class Procuremen extends Backend
                     $sortField = 'ID';
                 }
             }
+            // 列表「下发时间」列字段为 issue_time,与 pick_time 同义
+            if ($sortField === 'issue_time') {
+                $sortField = 'pick_time';
+            }
             $ord = strtoupper((string)$order) === 'ASC' ? 1 : -1;
             $nFiltered = count($filtered);
-            if ($nFiltered > 1) {
+            if (in_array($wffTab, ['audit', 'confirm'], true) && $nFiltered > 1) {
+                // 确认/审批:状态分组优先;同状态内固定按下发时间倒序(最新在上,不跟前端 ASC/DESC 翻转)
+                // 先补齐下发时间,避免 pick_time 空时回退到提交日导致顺序错乱
+                $this->hydrateProcuremenListIssueSortTimes($filtered);
+                usort($filtered, function ($a, $b) use ($wffTab) {
+                    if (!is_array($a)) {
+                        return 1;
+                    }
+                    if (!is_array($b)) {
+                        return -1;
+                    }
+                    $statusKeyA = $a['order_status_text'] ?? $a['wflow_status'] ?? '';
+                    if (trim((string)$statusKeyA) === '') {
+                        $statusKeyA = $a['wflow_status'] ?? '';
+                    }
+                    $statusKeyB = $b['order_status_text'] ?? $b['wflow_status'] ?? '';
+                    if (trim((string)$statusKeyB) === '') {
+                        $statusKeyB = $b['wflow_status'] ?? '';
+                    }
+                    $wa = ProcuremenStatus::orderStatusSortWeight($wffTab, $statusKeyA);
+                    $wb = ProcuremenStatus::orderStatusSortWeight($wffTab, $statusKeyB);
+                    if ($wa !== $wb) {
+                        return $wa <=> $wb;
+                    }
+                    $ta = trim((string)($a['_issue_sort_time'] ?? ''));
+                    if ($ta === '') {
+                        $ta = $this->procuremenRowListSortTime($a, 'pick_time');
+                    }
+                    $tb = trim((string)($b['_issue_sort_time'] ?? ''));
+                    if ($tb === '') {
+                        $tb = $this->procuremenRowListSortTime($b, 'pick_time');
+                    }
+                    if ($ta !== $tb) {
+                        if ($ta === '') {
+                            return 1;
+                        }
+                        if ($tb === '') {
+                            return -1;
+                        }
+                        // 倒序:新的在前
+                        return strcmp($tb, $ta);
+                    }
+
+                    return ((int)($b['ID'] ?? 0) <=> (int)($a['ID'] ?? 0));
+                });
+            } elseif ($nFiltered > 1) {
                 if (in_array($sortField, ['pick_time', 'dputrecord', 'dStamp', 'createtime'], true)) {
                     usort($filtered, function ($a, $b) use ($sortField, $ord) {
                         $ta = $this->procuremenRowListSortTime($a, $sortField);
@@ -1871,22 +1922,6 @@ class Procuremen extends Backend
                     });
                 }
             }
-            // 确认/审批:订单状态优先(同状态内保留上面时间等排序)
-            if (in_array($wffTab, ['audit', 'confirm'], true) && count($filtered) > 1) {
-                $statusWeights = [];
-                foreach ($filtered as $i => $row) {
-                    if (!is_array($row)) {
-                        $statusWeights[$i] = 99;
-                        continue;
-                    }
-                    $statusKey = $row['order_status_text'] ?? $row['wflow_status'] ?? '';
-                    if (trim((string)$statusKey) === '') {
-                        $statusKey = $row['wflow_status'] ?? '';
-                    }
-                    $statusWeights[$i] = ProcuremenStatus::orderStatusSortWeight($wffTab, $statusKey);
-                }
-                array_multisort($statusWeights, SORT_ASC, SORT_NUMERIC, $filtered);
-            }
 
             $offset = max(0, (int)$offset);
             $limit = max(1, (int)$limit);
@@ -2373,6 +2408,111 @@ class Procuremen extends Backend
         return $pool;
     }
 
+    /**
+     * 确认/审批列表排序:补齐下发时间(pick_time + 下发操作日志)
+     *
+     * @param array<int, array<string, mixed>> $rows
+     */
+    protected function hydrateProcuremenListIssueSortTimes(array &$rows): void
+    {
+        if ($rows === []) {
+            return;
+        }
+        $sidList = [];
+        foreach ($rows as $rw) {
+            if (!is_array($rw)) {
+                continue;
+            }
+            if (!empty($rw['_order_merge_rows']) && is_array($rw['_order_merge_rows'])) {
+                foreach ($rw['_order_merge_rows'] as $mr) {
+                    if (!is_array($mr)) {
+                        continue;
+                    }
+                    $mid = (int)($mr['ID'] ?? 0);
+                    if ($this->isValidScydgyRowId($mid)) {
+                        $sidList[$mid] = true;
+                    }
+                }
+            }
+            $id = (int)($rw['ID'] ?? 0);
+            if ($this->isValidScydgyRowId($id)) {
+                $sidList[$id] = true;
+            }
+        }
+        $issueBySid = [];
+        $sids = array_keys($sidList);
+        if ($sids !== []) {
+            $issueActs = ProcuremenOperLog::expandActionQueryValues('issue_submit');
+            if ($issueActs !== []) {
+                try {
+                    $logs = Db::table('purchase_order_oper_log')
+                        ->where(ProcuremenOperLog::COL_SCYDGY_ID, 'in', $sids)
+                        ->where(ProcuremenOperLog::COL_ACTION, 'in', $issueActs)
+                        ->field(ProcuremenOperLog::COL_SCYDGY_ID . ',' . ProcuremenOperLog::COL_TIME)
+                        ->order('id', 'asc')
+                        ->select();
+                } catch (\Throwable $e) {
+                    $logs = [];
+                }
+                if (is_array($logs)) {
+                    foreach ($logs as $lg) {
+                        if (!is_array($lg)) {
+                            continue;
+                        }
+                        $lg = ProcuremenOperLog::normalizeRow($lg);
+                        $sid = (int)($lg[ProcuremenOperLog::COL_SCYDGY_ID] ?? 0);
+                        if ($sid === 0) {
+                            continue;
+                        }
+                        $tm = $this->formatProcuremenDetailTime($lg[ProcuremenOperLog::COL_TIME] ?? null);
+                        if ($tm === '') {
+                            continue;
+                        }
+                        if (!isset($issueBySid[$sid]) || strcmp($tm, $issueBySid[$sid]) > 0) {
+                            $issueBySid[$sid] = $tm;
+                        }
+                    }
+                }
+            }
+        }
+        foreach ($rows as &$rw) {
+            if (!is_array($rw)) {
+                continue;
+            }
+            $mergeSids = [];
+            if (!empty($rw['_order_merge_rows']) && is_array($rw['_order_merge_rows'])) {
+                foreach ($rw['_order_merge_rows'] as $mr) {
+                    if (!is_array($mr)) {
+                        continue;
+                    }
+                    $mid = (int)($mr['ID'] ?? 0);
+                    if ($this->isValidScydgyRowId($mid)) {
+                        $mergeSids[] = $mid;
+                    }
+                }
+            }
+            $rid = (int)($rw['ID'] ?? 0);
+            if ($this->isValidScydgyRowId($rid) && !in_array($rid, $mergeSids, true)) {
+                $mergeSids[] = $rid;
+            }
+            $best = $this->procuremenRowListSortTime($rw, 'pick_time');
+            foreach ($mergeSids as $sid) {
+                if (isset($issueBySid[$sid]) && ($best === '' || strcmp($issueBySid[$sid], $best) > 0)) {
+                    $best = $issueBySid[$sid];
+                }
+            }
+            $rw['_issue_sort_time'] = $best;
+            if ($best !== '') {
+                $pickOut = trim((string)($rw['pick_time'] ?? ''));
+                if ($pickOut === '' || stripos($pickOut, '0000-00-00') === 0) {
+                    $rw['pick_time'] = $best;
+                }
+                $rw['issue_time'] = $best;
+            }
+        }
+        unset($rw);
+    }
+
     /**
      * 列表排序用时间:下发时间 pick_time 优先,其次 createtime / dputrecord / dStamp
      */
@@ -3441,25 +3581,42 @@ class Procuremen extends Backend
             }
         }
 
-        if ($batchNo !== '') {
-            // 有正式批次号:开标只看本批;指定只看本行/同批合并行,不按订单号全局查
-            $verified = isset($bidOpenBatchSet[$batchNo]);
-        } else {
-            // 旧数据无批次号:兼容按订单号开标/指定
-            $verified = false;
-            $ccydhTokens = $this->expandProcuremenCcydhTokens($rw['CCYDH'] ?? '');
-            if (!empty($rw['_order_merge_rows']) && is_array($rw['_order_merge_rows'])) {
-                foreach ($rw['_order_merge_rows'] as $mr) {
-                    if (!is_array($mr)) {
+        // 收集本行/合并行订单号,供开标记录与自选集合兜底(自选=免开标,等同已开标可继续确认)
+        $ccydhTokens = $this->expandProcuremenCcydhTokens($rw['CCYDH'] ?? '');
+        if (!empty($rw['_order_merge_rows']) && is_array($rw['_order_merge_rows'])) {
+            foreach ($rw['_order_merge_rows'] as $mr) {
+                if (!is_array($mr)) {
+                    continue;
+                }
+                if ($batchNo !== '') {
+                    $mrBatch = $this->resolveProcuremenOrderBatchNo($mr);
+                    if ($mrBatch !== '' && $mrBatch !== $batchNo) {
                         continue;
                     }
-                    foreach ($this->expandProcuremenCcydhTokens($mr['CCYDH'] ?? '') as $one) {
-                        if (!in_array($one, $ccydhTokens, true)) {
-                            $ccydhTokens[] = $one;
-                        }
+                }
+                foreach ($this->expandProcuremenCcydhTokens($mr['CCYDH'] ?? '') as $one) {
+                    if (!in_array($one, $ccydhTokens, true)) {
+                        $ccydhTokens[] = $one;
+                    }
+                }
+            }
+        }
+
+        if ($batchNo !== '') {
+            // 有正式批次号:开标只看本批
+            $verified = isset($bidOpenBatchSet[$batchNo]);
+            // 自选兜底:本行字段漏带时,仍可用有效主单上的自选标记(旧批已软删,不会串读)
+            if (!$manual) {
+                foreach ($ccydhTokens as $c) {
+                    if (isset($manualPickSet[$c])) {
+                        $manual = true;
+                        break;
                     }
                 }
             }
+        } else {
+            // 旧数据无批次号:兼容按订单号开标/指定
+            $verified = false;
             foreach ($ccydhTokens as $c) {
                 if (isset($bidOpenSet[$c])) {
                     $verified = true;
@@ -3541,7 +3698,7 @@ class Procuremen extends Backend
     }
 
     /**
-     * 按订单状态筛选(待确认 / 待审核 / 已审核)
+     * 按订单状态筛选(待确认 / 已驳回 / 待审核 / 已审核)
      *
      * @param array<int, array<string, mixed>> $pool
      * @return array<int, array<string, mixed>>
@@ -3549,7 +3706,7 @@ class Procuremen extends Backend
     protected function filterProcuremenPoolByOrderStatus(array $pool, string $status): array
     {
         $status = trim($status);
-        if ($status === '' || !in_array($status, ['待确认', '待审核', '已审核'], true)) {
+        if ($status === '' || !in_array($status, ['待确认', '已驳回', '待审核', '已审核'], true)) {
             return $pool;
         }
         $out = [];
@@ -4816,6 +4973,8 @@ class Procuremen extends Backend
             $r['pick_company_name'] = $pickNm;
             $r['picked_supplier_name'] = $pickNm;
         }
+        // 列表开标状态依赖此字段:有批次号时只认本行/合并行,必须从库带出
+        $r['manual_pick'] = ((int)($dbRow['manual_pick'] ?? 0) === 1) ? 1 : 0;
         $r['wflow_status'] = trim((string)($dbRow['wflow_status'] ?? ''));
         $r['order_status_text'] = ProcuremenStatus::orderStatusLabelForList($r['wflow_status']);
         if (array_key_exists('status', $dbRow)) {
@@ -6019,6 +6178,9 @@ class Procuremen extends Backend
         if (isset($pos[0]) && is_array($pos[0])) {
             $poIdLog = (int)($pos[0]['id'] ?? 0);
         }
+        $mergeRowsLog = is_array($bundle['merge_rows'] ?? null) ? $bundle['merge_rows'] : [];
+        $emailLogCcydh = $this->collectCcydhListForEmailLog($mergeRowsLog, $pos);
+        $emailLogBatchNo = $this->resolveBundleOrderBatchNo($bundle);
         $sendSmsSafe = function ($phone, $content) {
             $phone = trim((string)$phone);
             if ($phone === '') {
@@ -6030,7 +6192,7 @@ class Procuremen extends Backend
                 Log::write('采购确认短信失败 phone=' . $phone . ' ' . $e->getMessage(), 'error');
             }
         };
-        $sendConfirmEmailSafe = function (array $dr, string $emailScene) use ($confirmNotifyVars, $poIdLog) {
+        $sendConfirmEmailSafe = function (array $dr, string $emailScene) use ($confirmNotifyVars, $poIdLog, $emailLogCcydh, $emailLogBatchNo) {
             $cname = trim((string)($dr['company_name'] ?? ''));
             $ph = trim((string)($dr['phone'] ?? ''));
             $toEmail = trim((string)($dr['email'] ?? ''));
@@ -6063,16 +6225,18 @@ class Procuremen extends Backend
                 $mailSubject = $this->resolveProcuremenEmailSubject($emailScene);
                 $mailConfig = $this->loadMailerConfig();
                 $this->issueSendSupplierEmail([
-                    'company_name'              => $cname,
-                    'email'                     => $toEmail,
-                    'mail_plain'                => $mailPlain,
-                    'mail_body'                 => $mailBody,
-                    'notify_vars'               => $vars,
-                    'sms_content'               => '',
-                    'detail_links'              => $platformUrl !== '' ? [['url' => $platformUrl, 'cgymc' => '']] : [],
-                    'email_log_scene'           => (strpos((string)$emailScene, 'confirm_ok') !== false) ? 'confirm_ok' : 'confirm_fail',
-                    'email_log_scydgy_id'       => (int)($dr['scydgy_id'] ?? 0),
+                    'company_name'                => $cname,
+                    'email'                       => $toEmail,
+                    'mail_plain'                  => $mailPlain,
+                    'mail_body'                   => $mailBody,
+                    'notify_vars'                 => $vars,
+                    'sms_content'                 => '',
+                    'detail_links'                => $platformUrl !== '' ? [['url' => $platformUrl, 'cgymc' => '']] : [],
+                    'email_log_scene'             => (strpos((string)$emailScene, 'confirm_ok') !== false) ? 'confirm_ok' : 'confirm_fail',
+                    'email_log_scydgy_id'         => (int)($dr['scydgy_id'] ?? 0),
                     'email_log_purchase_order_id' => $poIdLog,
+                    'email_log_ccydh'             => $emailLogCcydh !== '' ? $emailLogCcydh : trim((string)($vars['ccydh'] ?? '')),
+                    'email_log_order_batch_no'    => $emailLogBatchNo,
                 ], $mailConfig, $mailSubject);
             } catch (\Throwable $e) {
                 Log::write('采购确认邮件失败 company=' . $cname . ' email=' . $toEmail . ' ' . $e->getMessage(), 'error');
@@ -7050,6 +7214,7 @@ class Procuremen extends Backend
             try {
                 $poBn = Db::table('purchase_order')
                     ->where('scydgy_id', 'in', array_keys($detailSidList))
+                    ->whereRaw("TRIM(IFNULL(`order_batch_no`,'')) <> ''")
                     ->order('id', 'desc')
                     ->find();
                 if (is_array($poBn)) {
@@ -7752,16 +7917,20 @@ class Procuremen extends Backend
             }
             // 已选供应商:取合并行中首个非空 pick_company_name
             $pickedName = '';
+            $manualPick = ((int)($head['manual_pick'] ?? 0) === 1) ? 1 : 0;
             foreach ($rows as $r) {
                 if (!is_array($r)) {
                     continue;
                 }
+                if ((int)($r['manual_pick'] ?? 0) === 1) {
+                    $manualPick = 1;
+                }
                 $pn = trim((string)($r['pick_company_name'] ?? $r['picked_supplier_name'] ?? ''));
-                if ($pn !== '') {
+                if ($pn !== '' && $pickedName === '') {
                     $pickedName = $pn;
-                    break;
                 }
             }
+            $merged['manual_pick'] = $manualPick;
             if ($pickedName !== '') {
                 $merged['pick_company_name'] = $pickedName;
                 $merged['picked_supplier_name'] = $pickedName;
@@ -9151,7 +9320,9 @@ class Procuremen extends Backend
     {
         $map = $this->buildBundleOrderDeliveryDeadlineMap($bundle);
         if ($map !== []) {
-            return (string)reset($map);
+            $firstDeadline = reset($map);
+
+            return (string)$firstDeadline;
         }
 
         return '';
@@ -9638,6 +9809,9 @@ class Procuremen extends Backend
             $poIdLog = (int)($pos[0]['id'] ?? 0);
         }
         $notifyBundle['email_log_purchase_order_id'] = $poIdLog;
+        // 日志单独记订单号/批次号:多订单下发时模版变量 ccydh 会刻意留空,不能沿用到日志
+        $notifyBundle['email_log_ccydh'] = $this->collectCcydhListForEmailLog($mergeRows, $pos);
+        $notifyBundle['email_log_order_batch_no'] = $this->resolveBundleOrderBatchNo($bundle);
 
         if ($sendEmail) {
             $mailTplRow = $this->loadNotifyTemplateRow($emailTplScene);
@@ -9771,7 +9945,7 @@ class Procuremen extends Backend
             Log::write('procuremen auditResend partial fail: ' . implode(';', $notifyErrors), 'error');
             $ok = max(0, count($targets) - count($notifyErrors));
             $this->success(
-                '已向 ' . $ok . ' 家发送成功;个别未送达请核对手机号/邮箱后重试',
+                '已向 ' . $ok . ' 家发送成功;',
                 '',
                 [
                     'notify_failed'      => 1,
@@ -9917,39 +10091,38 @@ class Procuremen extends Backend
         $fromEmail = trim((string)($mailConfig['addr'] ?? ''));
         $sendOk = true;
         $failReason = '';
+        $logCcydh = trim((string)($bundle['email_log_ccydh'] ?? ''));
+        if ($logCcydh === '') {
+            $logCcydh = trim((string)(($bundle['notify_vars']['ccydh'] ?? '') ?: ($bundle['ccydh'] ?? '')));
+        }
+        $logBatchNo = trim((string)($bundle['email_log_order_batch_no'] ?? ($bundle['order_batch_no'] ?? '')));
+        $logCommon = [
+            'from_email'        => $fromEmail,
+            'to_email'          => $toEmail,
+            'send_time'         => date('Y-m-d H:i:s'),
+            'scene'             => (string)($bundle['email_log_scene'] ?? '初选下发'),
+            'order_batch_no'    => $logBatchNo,
+            'ccydh'             => $logCcydh,
+            'company_name'      => $companyName,
+            'mail_subject'      => $mailSubject,
+            'scydgy_id'         => (int)($bundle['email_log_scydgy_id'] ?? 0),
+            'purchase_order_id' => (int)($bundle['email_log_purchase_order_id'] ?? 0),
+        ];
         try {
             $mail->send();
         } catch (\Throwable $e) {
             $sendOk = false;
             $failReason = $e->getMessage();
-            $this->persistEmailSendLog([
-                'from_email'          => $fromEmail,
-                'to_email'            => $toEmail,
-                'send_time'           => date('Y-m-d H:i:s'),
-                'scene'               => (string)($bundle['email_log_scene'] ?? '初选下发'),
-                'ccydh'               => trim((string)(($bundle['notify_vars']['ccydh'] ?? '') ?: ($bundle['ccydh'] ?? ''))),
-                'company_name'        => $companyName,
-                'mail_subject'        => $mailSubject,
-                'status'              => 0,
-                'fail_reason'         => $failReason,
-                'scydgy_id'           => (int)($bundle['email_log_scydgy_id'] ?? 0),
-                'purchase_order_id'   => (int)($bundle['email_log_purchase_order_id'] ?? 0),
-            ]);
+            $this->persistEmailSendLog(array_merge($logCommon, [
+                'status'      => 0,
+                'fail_reason' => $failReason,
+            ]));
             throw $e;
         }
-        $this->persistEmailSendLog([
-            'from_email'          => $fromEmail,
-            'to_email'            => $toEmail,
-            'send_time'           => date('Y-m-d H:i:s'),
-            'scene'               => (string)($bundle['email_log_scene'] ?? '初选下发'),
-            'ccydh'               => trim((string)(($bundle['notify_vars']['ccydh'] ?? '') ?: ($bundle['ccydh'] ?? ''))),
-            'company_name'        => $companyName,
-            'mail_subject'        => $mailSubject,
-            'status'              => 1,
-            'fail_reason'         => '',
-            'scydgy_id'           => (int)($bundle['email_log_scydgy_id'] ?? 0),
-            'purchase_order_id'   => (int)($bundle['email_log_purchase_order_id'] ?? 0),
-        ]);
+        $this->persistEmailSendLog(array_merge($logCommon, [
+            'status'      => 1,
+            'fail_reason' => '',
+        ]));
     }
 
     /**
@@ -10086,6 +10259,45 @@ class Procuremen extends Backend
         ];
     }
 
+    /**
+     * 邮件发送日志用的订单号列表(多单逗号分隔;与模版变量是否留空无关)
+     *
+     * @param array<int, array<string, mixed>> $mergeRows
+     * @param array<int, array<string, mixed>> $pos
+     */
+    protected function collectCcydhListForEmailLog(array $mergeRows, array $pos = []): string
+    {
+        $set = [];
+        foreach ($mergeRows as $r) {
+            if (!is_array($r)) {
+                continue;
+            }
+            foreach (['CCYDH', 'ccydh'] as $k) {
+                $dh = trim((string)($r[$k] ?? ''));
+                if ($dh !== '') {
+                    $set[$dh] = true;
+                    break;
+                }
+            }
+        }
+        if ($set === []) {
+            foreach ($pos as $po) {
+                if (!is_array($po)) {
+                    continue;
+                }
+                foreach (['CCYDH', 'ccydh'] as $k) {
+                    $dh = trim((string)($po[$k] ?? ''));
+                    if ($dh !== '') {
+                        $set[$dh] = true;
+                        break;
+                    }
+                }
+            }
+        }
+
+        return implode(',', array_keys($set));
+    }
+
     /**
      * 调试:app_debug 或 notify_dry_run 时把短信/邮件内容写入 runtime/log
      *
@@ -10102,7 +10314,7 @@ class Procuremen extends Backend
     protected function ensurePurchaseEmailSendLogTableOnce(): void
     {
         try {
-            Db::query('SELECT 1 FROM `purchase_email_send_log` LIMIT 1');
+            Db::query('SELECT `id`,`order_batch_no`,`ccydh` FROM `purchase_email_send_log` LIMIT 1');
             Cache::set(self::EMAIL_SEND_LOG_SCHEMA_CACHE_KEY, 1);
             return;
         } catch (\Throwable $e) {
@@ -10123,6 +10335,34 @@ class Procuremen extends Backend
                 }
             }
         }
+        try {
+            $cols = Db::query("SHOW COLUMNS FROM `purchase_email_send_log` LIKE 'order_batch_no'");
+            if (!is_array($cols) || $cols === []) {
+                Db::execute(
+                    "ALTER TABLE `purchase_email_send_log` ADD COLUMN `order_batch_no` varchar(32) NOT NULL DEFAULT '' COMMENT '下发批次号' AFTER `scene`"
+                );
+            }
+        } catch (\Throwable $ignore) {
+        }
+        try {
+            $cols = Db::query("SHOW COLUMNS FROM `purchase_email_send_log` LIKE 'ccydh'");
+            if (is_array($cols) && $cols !== []) {
+                $type = strtolower((string)($cols[0]['Type'] ?? $cols[0]['type'] ?? ''));
+                if (preg_match('/varchar\((\d+)\)/', $type, $m) && (int)$m[1] < 500) {
+                    Db::execute(
+                        "ALTER TABLE `purchase_email_send_log` MODIFY COLUMN `ccydh` varchar(500) NOT NULL DEFAULT '' COMMENT '订单号(多单逗号分隔)'"
+                    );
+                }
+            }
+        } catch (\Throwable $ignore) {
+        }
+        try {
+            $idx = Db::query("SHOW INDEX FROM `purchase_email_send_log` WHERE Key_name = 'idx_order_batch_no'");
+            if (!is_array($idx) || $idx === []) {
+                Db::execute('ALTER TABLE `purchase_email_send_log` ADD INDEX `idx_order_batch_no` (`order_batch_no`)');
+            }
+        } catch (\Throwable $ignore) {
+        }
         Cache::set(self::EMAIL_SEND_LOG_SCHEMA_CACHE_KEY, 1);
     }
 
@@ -10150,7 +10390,8 @@ class Procuremen extends Backend
             'to_email'          => $cut($row['to_email'] ?? '', 128),
             'send_time'         => $row['send_time'] ?? date('Y-m-d H:i:s'),
             'scene'             => $cut($this->normalizeEmailLogSceneForStore((string)($row['scene'] ?? '')), 64),
-            'ccydh'             => $cut($row['ccydh'] ?? '', 64),
+            'order_batch_no'    => $cut($row['order_batch_no'] ?? '', 32),
+            'ccydh'             => $cut($row['ccydh'] ?? '', 500),
             'company_name'      => $cut($row['company_name'] ?? '', 128),
             'mail_subject'      => $cut($row['mail_subject'] ?? '', 255),
             'status'            => !empty($row['status']) ? 1 : 0,
@@ -10164,6 +10405,17 @@ class Procuremen extends Backend
         try {
             Db::table('purchase_email_send_log')->insert($data);
         } catch (\Throwable $e) {
+            // 兼容尚未自动补列的旧库:去掉批次号再试一次
+            if (stripos($e->getMessage(), 'order_batch_no') !== false) {
+                try {
+                    unset($data['order_batch_no']);
+                    Db::table('purchase_email_send_log')->insert($data);
+                    return;
+                } catch (\Throwable $e2) {
+                    Log::write('persistEmailSendLog: ' . $e2->getMessage(), 'error');
+                    return;
+                }
+            }
             Log::write('persistEmailSendLog: ' . $e->getMessage(), 'error');
         }
     }
@@ -10195,7 +10447,7 @@ class Procuremen extends Backend
     public function emailLog()
     {
         $this->relationSearch = false;
-        $this->searchFields = 'from_email,to_email,ccydh,company_name,admin_name,mail_subject';
+        $this->searchFields = 'from_email,to_email,ccydh,order_batch_no,company_name,admin_name,mail_subject';
         $this->request->filter(['strip_tags', 'trim']);
         $bakModel = $this->model;
         $this->model = new \app\admin\model\Purchaseemailsendlog;
@@ -10212,6 +10464,7 @@ class Procuremen extends Backend
                 ->paginate($limit);
 
             $rows = [];
+            $needEnrichPoIds = [];
             foreach ($list->items() as $item) {
                 $row = is_array($item) ? $item : $item->toArray();
                 $scene = trim((string)($row['scene'] ?? ''));
@@ -10219,8 +10472,35 @@ class Procuremen extends Backend
                 $row['status_text'] = !empty($row['status']) ? '成功' : '失败';
                 $row['to_email_masked'] = mask_email((string)($row['to_email'] ?? ''));
                 $row['from_email_masked'] = mask_email((string)($row['from_email'] ?? ''));
+                $row['order_batch_no'] = trim((string)($row['order_batch_no'] ?? ''));
+                $row['ccydh'] = trim((string)($row['ccydh'] ?? ''));
+                $poId = (int)($row['purchase_order_id'] ?? 0);
+                if ($poId > 0 && ($row['ccydh'] === '' || $row['order_batch_no'] === '')) {
+                    $needEnrichPoIds[$poId] = true;
+                }
                 $rows[] = $row;
             }
+            if ($needEnrichPoIds !== []) {
+                $poMap = $this->loadEmailLogEnrichPoMap(array_keys($needEnrichPoIds));
+                foreach ($rows as &$row) {
+                    $poId = (int)($row['purchase_order_id'] ?? 0);
+                    if ($poId <= 0 || !isset($poMap[$poId])) {
+                        continue;
+                    }
+                    $info = $poMap[$poId];
+                    if ($row['order_batch_no'] === '' && $info['order_batch_no'] !== '') {
+                        $row['order_batch_no'] = $info['order_batch_no'];
+                    }
+                    if ($row['ccydh'] === '') {
+                        if ($info['order_batch_no'] !== '' && isset($info['batch_ccydhs']) && $info['batch_ccydhs'] !== '') {
+                            $row['ccydh'] = $info['batch_ccydhs'];
+                        } elseif ($info['ccydh'] !== '') {
+                            $row['ccydh'] = $info['ccydh'];
+                        }
+                    }
+                }
+                unset($row);
+            }
 
             $this->model = $bakModel;
 
@@ -10232,6 +10512,80 @@ class Procuremen extends Backend
         return $this->view->fetch('procuremen/emaillog');
     }
 
+    /**
+     * 历史邮件日志缺订单号/批次号时,按 purchase_order_id 回填展示
+     *
+     * @param array<int, int> $poIds
+     * @return array<int, array{ccydh:string,order_batch_no:string,batch_ccydhs:string}>
+     */
+    protected function loadEmailLogEnrichPoMap(array $poIds): array
+    {
+        $out = [];
+        $poIds = array_values(array_unique(array_filter(array_map('intval', $poIds))));
+        if ($poIds === []) {
+            return $out;
+        }
+        try {
+            $pos = Db::table('purchase_order')->where('id', 'in', $poIds)->field('id,CCYDH,order_batch_no')->select();
+        } catch (\Throwable $e) {
+            try {
+                $pos = Db::table('purchase_order')->where('id', 'in', $poIds)->field('id,CCYDH')->select();
+            } catch (\Throwable $e2) {
+                return $out;
+            }
+        }
+        $batchSet = [];
+        foreach ($pos as $po) {
+            if (!is_array($po)) {
+                continue;
+            }
+            $id = (int)($po['id'] ?? 0);
+            if ($id <= 0) {
+                continue;
+            }
+            $bn = $this->resolveProcuremenOrderBatchNo($po);
+            $out[$id] = [
+                'ccydh'          => trim((string)($po['CCYDH'] ?? '')),
+                'order_batch_no' => $bn,
+                'batch_ccydhs'   => '',
+            ];
+            if ($bn !== '') {
+                $batchSet[$bn] = true;
+            }
+        }
+        if ($batchSet === []) {
+            return $out;
+        }
+        try {
+            $batchRows = Db::table('purchase_order')
+                ->where('order_batch_no', 'in', array_keys($batchSet))
+                ->field('CCYDH,order_batch_no')
+                ->select();
+        } catch (\Throwable $e) {
+            return $out;
+        }
+        $byBatch = [];
+        foreach ($batchRows as $br) {
+            if (!is_array($br)) {
+                continue;
+            }
+            $bn = trim((string)($br['order_batch_no'] ?? ''));
+            $dh = trim((string)($br['CCYDH'] ?? ''));
+            if ($bn === '' || $dh === '') {
+                continue;
+            }
+            $byBatch[$bn][$dh] = true;
+        }
+        foreach ($out as $id => $info) {
+            $bn = $info['order_batch_no'];
+            if ($bn !== '' && isset($byBatch[$bn])) {
+                $out[$id]['batch_ccydhs'] = implode(',', array_keys($byBatch[$bn]));
+            }
+        }
+
+        return $out;
+    }
+
     /**
      * 协助下发弹窗(选多家供应商并通知)
      */
@@ -11527,6 +11881,8 @@ class Procuremen extends Backend
                     'email_log_scene'             => 'rfq_salesman',
                     'email_log_scydgy_id'         => $sid,
                     'email_log_purchase_order_id' => (int)($po['id'] ?? 0),
+                    'email_log_ccydh'             => $ccydh,
+                    'email_log_order_batch_no'    => $this->resolveProcuremenOrderBatchNo(is_array($po) ? $po : []),
                     'notify_vars'                 => $notifyVars,
                 ];
                 $this->issueSendSupplierEmail($bundle, $mailConfig, $mailSubject);
@@ -11991,6 +12347,8 @@ class Procuremen extends Backend
                     'email_log_scene'             => 'delivery_notify',
                     'email_log_scydgy_id'         => $firstSid,
                     'email_log_purchase_order_id' => $firstPoId,
+                    'email_log_ccydh'             => $ccydh,
+                    'email_log_order_batch_no'    => $this->resolveProcuremenOrderBatchNo(is_array($po) ? $po : []),
                     'notify_vars'                 => $notifyVars,
                 ], $mailConfig, $mailSubject);
                 $okNames[] = $toName . '(' . $toEmail . ')';
@@ -13233,7 +13591,7 @@ class Procuremen extends Backend
             'notify_failed'  => 0,
         ];
         $successMsg = '下发成功' . $skipHint . ',已向 ' . $okCount
-            . ' 家供应商发起通知。若个别未收到短信或邮件,请到「协助审批下发」使用重发功能补发。';
+            . ' 家供应商发起通知。';
 
         if ($this->isProcuremenNotifyDryRun()) {
             $this->notifyDryRunPreview = [];
@@ -13356,13 +13714,45 @@ class Procuremen extends Backend
         $bidOpenVerified = $this->isProcuremenBidOpenVerifiedForBundle($bundle) ? 1 : 0;
         $manualPickInfo = $this->resolveProcuremenManualPickInfo($bundle);
         $manualPicked = $this->isProcuremenManualPickedForBundle($bundle) ? 1 : 0;
-        $quoteVisible = ($bidOpenVerified || $manualPicked) ? 1 : 0;
+        $isRejected = 0;
+        foreach ($pos as $poRow) {
+            if (!is_array($poRow)) {
+                continue;
+            }
+            if (ProcuremenStatus::isWflowRejected($poRow['wflow_status'] ?? '')) {
+                $isRejected = 1;
+                break;
+            }
+        }
+        $quoteVisible = ($bidOpenVerified || $manualPicked || $isRejected) ? 1 : 0;
         if ($manualPicked && $pickedName === '' && $manualPickInfo['company'] !== '') {
             $pickedName = $manualPickInfo['company'];
-            $this->view->assign('pickedCompanyName', $pickedName);
         }
+        // 驳回后:仅一家供应商时自动选定,便于直接确认;多家则留空供用户选择
+        $quoteGroupCount = 0;
+        $onlyQuoteCompany = '';
+        foreach ($quoteGroups as $g) {
+            if (!is_array($g)) {
+                continue;
+            }
+            $nm = trim((string)($g['name'] ?? ''));
+            if ($nm === '') {
+                continue;
+            }
+            $quoteGroupCount++;
+            if ($onlyQuoteCompany === '') {
+                $onlyQuoteCompany = $nm;
+            }
+        }
+        if ($isRejected) {
+            // 驳回需重新选定:清空锁定用的已选名;仅一家时再自动勾选
+            $pickedName = ($quoteGroupCount === 1) ? $onlyQuoteCompany : '';
+        }
+        $this->view->assign('pickedCompanyName', $pickedName);
         $this->view->assign('bidOpenVerified', $bidOpenVerified);
         $this->view->assign('manualPicked', $manualPicked);
+        $this->view->assign('isRejected', $isRejected);
+        $this->view->assign('quoteGroupCount', $quoteGroupCount);
         $this->view->assign('manualPickReason', $manualPickInfo['reason']);
         $this->view->assign('quoteVisible', $quoteVisible);
         $orderScoreRule = ProcuremenSupplierScore::resolveRuleForCcydh((string)($bundle['ccydh'] ?? ''));
@@ -13613,7 +14003,7 @@ class Procuremen extends Backend
         }
 
         $this->respondSuccessThenContinue(
-            '已补加 ' . count($toAdd) . ' 家供应商并发起通知。若个别未收到,请使用「重发短信 / 重发邮件」补发。',
+            '已补加 ' . count($toAdd) . ' 家供应商并发起通知。',
             ['append_ok' => 1, 'notify_pending' => 1, 'notify_failed' => 0]
         );
 
@@ -14110,7 +14500,8 @@ class Procuremen extends Backend
         // 同订单其它旧批次(有明确批次号、未完结)软删除,避免列表/弹窗仍读到旧批「已开标/已指定」
         $keepBatch = '';
         if ($batchSet !== []) {
-            $keepBatch = (string)reset(array_keys($batchSet));
+            $batchKeys = array_keys($batchSet);
+            $keepBatch = (string)reset($batchKeys);
         }
         $this->softDeleteOtherBatchPurchaseOrders(array_keys($ccydhSet), array_keys($sids), $keepBatch);
         $this->voidPurchaseOrderDetailsForScydgyIds(array_keys($sids));
@@ -14352,7 +14743,7 @@ class Procuremen extends Backend
         }
 
         $this->pickHiddenScydgySetCache = null;
-        $this->success('已驳回并退回「协助审批下发」');
+        $this->success('已驳回并退回「确认供应商」');
     }
 
     /**
@@ -14370,7 +14761,7 @@ class Procuremen extends Backend
         }
 
         Db::table('purchase_order')->where('scydgy_id', $sid)->update([
-            'wflow_status'      => ProcuremenStatus::WFLOW_PENDING_CONFIRM,
+            'wflow_status'      => ProcuremenStatus::WFLOW_REJECTED,
             'status'            => ProcuremenStatus::PO_IN_PROGRESS,
             'pick_company_name' => '',
         ]);

+ 182 - 44
application/admin/controller/Procuremenarchive.php

@@ -14,7 +14,7 @@ use think\Db;
  */
 class Procuremenarchive extends Backend
 {
-    protected $searchFields = 'CCYDH,CYJMC,CGYMC,pick_company_name';
+    protected $searchFields = 'CCYDH,CYJMC,CGYMC,pick_company_name,order_batch_no';
 
     public function _initialize()
     {
@@ -96,18 +96,66 @@ class Procuremenarchive extends Backend
      */
     protected function applyPurchaseOrderArchiveWhere($query): void
     {
-        $query->where(function ($q) {
-            $q->whereIn('wflow_status', ProcuremenStatus::wflowApprovedValues())
-                ->whereOr('status', 'in', ProcuremenStatus::poCompletedValues());
-        });
+        // 用 CAST 文案比较,避免数字/中文混存时 whereIn 漏数
+        $wflow = [];
+        foreach (ProcuremenStatus::wflowApprovedValues() as $v) {
+            $wflow[] = "'" . str_replace("'", "''", (string)$v) . "'";
+        }
+        $poDone = [];
+        foreach (ProcuremenStatus::poCompletedValues() as $v) {
+            $poDone[] = "'" . str_replace("'", "''", (string)$v) . "'";
+        }
+        $query->whereRaw(
+            '(TRIM(CAST(`wflow_status` AS CHAR)) IN (' . implode(',', $wflow) . ')'
+            . ' OR TRIM(CAST(`status` AS CHAR)) IN (' . implode(',', $poDone) . '))'
+        );
     }
 
     /**
-     * 与 collapseArchiveRowsByOrder 一致:有订单号按 CCYDH 合并,空单号按 id
+     * 历史存证合并键:订单号 + 批次号 + 中标供应商
+     * (同订单号不同批次/供应商必须分行,避免串单显示)
      */
     protected function archiveOrderGroupKeySql(): string
     {
-        return "IF(TRIM(IFNULL(`CCYDH`,''))='', CONCAT('_id_', `id`), TRIM(`CCYDH`))";
+        return "IF(TRIM(IFNULL(`CCYDH`,''))='', CONCAT('_id_', `id`), CONCAT("
+            . "TRIM(`CCYDH`), CHAR(30), "
+            . "IF(TRIM(IFNULL(`order_batch_no`,''))='','',TRIM(`order_batch_no`)), CHAR(30), "
+            . "IF(TRIM(IFNULL(`pick_company_name`,''))='','',TRIM(`pick_company_name`))"
+            . "))";
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    protected function buildArchiveRowGroupKey(array $row): string
+    {
+        $dh = trim((string)($row['CCYDH'] ?? ''));
+        if ($dh === '') {
+            return '_id_' . (int)($row['id'] ?? 0);
+        }
+        $batch = trim((string)($row['order_batch_no'] ?? ''));
+        $company = trim((string)($row['pick_company_name'] ?? ''));
+
+        return $dh . "\x1e" . $batch . "\x1e" . $company;
+    }
+
+    /**
+     * @return array{type:string,id?:int,CCYDH?:string,order_batch_no?:string,pick_company_name?:string}
+     */
+    protected function parseArchiveGroupKey(string $gk): array
+    {
+        $gk = (string)$gk;
+        if (strpos($gk, '_id_') === 0) {
+            return ['type' => 'id', 'id' => (int)substr($gk, 4)];
+        }
+        $parts = explode("\x1e", $gk, 3);
+
+        return [
+            'type'              => 'scope',
+            'CCYDH'             => (string)($parts[0] ?? ''),
+            'order_batch_no'    => (string)($parts[1] ?? ''),
+            'pick_company_name' => (string)($parts[2] ?? ''),
+        ];
     }
 
     public function index()
@@ -126,7 +174,8 @@ class Procuremenarchive extends Backend
                 }
             };
 
-            $sortField = preg_match('/^[a-zA-Z0-9_]+$/', (string)$sort) ? $sort : 'complete_time';
+            // 默认按 id(近似审批先后);前端默认 sortName=createtime
+            $sortField = preg_match('/^[a-zA-Z0-9_]+$/', (string)$sort) ? $sort : 'createtime';
             $orderDir = strtoupper((string)$order) === 'ASC' ? 'ASC' : 'DESC';
             $groupKeySql = $this->archiveOrderGroupKeySql();
 
@@ -138,10 +187,14 @@ class Procuremenarchive extends Backend
                 $cntRows = Db::query('SELECT COUNT(*) AS c FROM ' . $subSql . ' _archive_g');
                 $total = (int)($cntRows[0]['c'] ?? 0);
             } catch (\Throwable $e) {
+                // 兼容缺 order_batch_no 列时回退按订单号合并
                 try {
+                    $groupKeySql = "IF(TRIM(IFNULL(`CCYDH`,''))='', CONCAT('_id_', `id`), TRIM(`CCYDH`))";
                     $countQuery = Db::table('purchase_order');
                     $applyFilters($countQuery);
-                    $total = (int)$countQuery->count('DISTINCT CCYDH');
+                    $subSql = $countQuery->fieldRaw($groupKeySql . ' AS gk')->group($groupKeySql)->buildSql();
+                    $cntRows = Db::query('SELECT COUNT(*) AS c FROM ' . $subSql . ' _archive_g');
+                    $total = (int)($cntRows[0]['c'] ?? 0);
                 } catch (\Throwable $e2) {
                     $total = 0;
                 }
@@ -151,11 +204,14 @@ class Procuremenarchive extends Backend
                 return json(['total' => 0, 'rows' => []]);
             }
 
+            // 审批通过即可进历史:按最新主单 id 排,保证刚审的在最前
             $orderSql = 'MAX(`id`) ' . $orderDir;
             if ($sortField === 'CCYDH') {
                 $orderSql = $groupKeySql . ' ' . $orderDir;
-            } elseif (in_array($sortField, ['CYJMC', 'CGYMC', 'pick_company_name'], true)) {
+            } elseif (in_array($sortField, ['CYJMC', 'CGYMC', 'pick_company_name', 'order_batch_no'], true)) {
                 $orderSql = 'MAX(`' . $sortField . '`) ' . $orderDir;
+            } elseif (in_array($sortField, ['createtime', 'complete_time'], true)) {
+                $orderSql = 'MAX(`id`) ' . $orderDir;
             }
 
             $pageQuery = Db::table('purchase_order');
@@ -174,51 +230,110 @@ class Procuremenarchive extends Backend
                 return json(['total' => $total, 'rows' => []]);
             }
 
-            $ccydhs = [];
+            $scopes = [];
             $orphanIds = [];
             foreach ($groups as $g) {
                 if (!is_array($g)) {
                     continue;
                 }
-                $gk = trim((string)($g['gk'] ?? ''));
+                $gk = (string)($g['gk'] ?? '');
                 if ($gk === '') {
                     continue;
                 }
-                if (strpos($gk, '_id_') === 0) {
-                    $oid = (int)substr($gk, 4);
+                $parsed = $this->parseArchiveGroupKey($gk);
+                if (($parsed['type'] ?? '') === 'id') {
+                    $oid = (int)($parsed['id'] ?? 0);
                     if ($oid > 0) {
                         $orphanIds[$oid] = true;
                     }
-                } else {
-                    $ccydhs[$gk] = true;
+                } elseif (($parsed['CCYDH'] ?? '') !== '') {
+                    $scopes[] = $parsed;
                 }
             }
-            $ccydhList = array_keys($ccydhs);
             $orphanIdList = array_keys($orphanIds);
-            if ($ccydhList === [] && $orphanIdList === []) {
+            if ($scopes === [] && $orphanIdList === []) {
                 return json(['total' => $total, 'rows' => []]);
             }
 
             $detailQuery = Db::table('purchase_order');
             $applyFilters($detailQuery);
-            $detailQuery->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_company_name,createtime,dStamp,pick_time');
-            $detailQuery->where(function ($q) use ($ccydhList, $orphanIdList) {
-                if ($ccydhList !== []) {
-                    $quoted = [];
-                    foreach ($ccydhList as $dh) {
-                        $quoted[] = "'" . str_replace("'", "''", (string)$dh) . "'";
+            try {
+                $detailQuery->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_company_name,order_batch_no,createtime,dStamp,pick_time,status,wflow_status');
+            } catch (\Throwable $e) {
+                $detailQuery->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_company_name,createtime,dStamp,pick_time,status,wflow_status');
+            }
+            $detailQuery->where(function ($q) use ($scopes, $orphanIdList) {
+                $first = true;
+                foreach ($scopes as $scope) {
+                    if (!is_array($scope)) {
+                        continue;
+                    }
+                    $ccydh = trim((string)($scope['CCYDH'] ?? ''));
+                    if ($ccydh === '') {
+                        continue;
+                    }
+                    $batch = trim((string)($scope['order_batch_no'] ?? ''));
+                    $company = trim((string)($scope['pick_company_name'] ?? ''));
+                    $fn = function ($qq) use ($ccydh, $batch, $company) {
+                        $qq->whereRaw("TRIM(`CCYDH`) = '" . str_replace("'", "''", $ccydh) . "'");
+                        if ($batch === '') {
+                            $qq->whereRaw("TRIM(IFNULL(`order_batch_no`,'')) = ''");
+                        } else {
+                            $qq->whereRaw("TRIM(`order_batch_no`) = '" . str_replace("'", "''", $batch) . "'");
+                        }
+                        if ($company === '') {
+                            $qq->whereRaw("TRIM(IFNULL(`pick_company_name`,'')) = ''");
+                        } else {
+                            $qq->whereRaw("TRIM(`pick_company_name`) = '" . str_replace("'", "''", $company) . "'");
+                        }
+                    };
+                    if ($first) {
+                        $q->where($fn);
+                        $first = false;
+                    } else {
+                        $q->whereOr($fn);
                     }
-                    $q->whereRaw('TRIM(`CCYDH`) IN (' . implode(',', $quoted) . ')');
                 }
                 if ($orphanIdList !== []) {
-                    if ($ccydhList !== []) {
-                        $q->whereOr('id', 'in', $orphanIdList);
-                    } else {
+                    if ($first) {
                         $q->where('id', 'in', $orphanIdList);
+                    } else {
+                        $q->whereOr('id', 'in', $orphanIdList);
                     }
                 }
             });
-            $rows = $detailQuery->select();
+            try {
+                $rows = $detailQuery->select();
+            } catch (\Throwable $e) {
+                // 缺 order_batch_no 时退回按订单号拉取
+                $ccydhs = [];
+                foreach ($scopes as $scope) {
+                    $dh = trim((string)($scope['CCYDH'] ?? ''));
+                    if ($dh !== '') {
+                        $ccydhs[$dh] = true;
+                    }
+                }
+                $detailQuery2 = Db::table('purchase_order');
+                $applyFilters($detailQuery2);
+                $detailQuery2->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,pick_company_name,createtime,dStamp,pick_time,status,wflow_status');
+                $detailQuery2->where(function ($q) use ($ccydhs, $orphanIdList) {
+                    if ($ccydhs !== []) {
+                        $quoted = [];
+                        foreach (array_keys($ccydhs) as $dh) {
+                            $quoted[] = "'" . str_replace("'", "''", (string)$dh) . "'";
+                        }
+                        $q->whereRaw('TRIM(`CCYDH`) IN (' . implode(',', $quoted) . ')');
+                    }
+                    if ($orphanIdList !== []) {
+                        if ($ccydhs !== []) {
+                            $q->whereOr('id', 'in', $orphanIdList);
+                        } else {
+                            $q->where('id', 'in', $orphanIdList);
+                        }
+                    }
+                });
+                $rows = $detailQuery2->select();
+            }
             if (!is_array($rows)) {
                 $rows = [];
             }
@@ -243,15 +358,23 @@ class Procuremenarchive extends Backend
                 ['mark_complete', 'inbound_score']
             );
             $inboundMap = [];
+            $deliveryMap = [];
             $inboundTimeMap = [];
             if ($sidList !== []) {
                 try {
                     $inRows = Db::table('purchase_order_inbound_score')
                         ->where('scydgy_id', 'in', array_keys($sidList))
-                        ->field('scydgy_id,result,updatetime,createtime')
+                        ->field('scydgy_id,result,delivery_status,updatetime,createtime')
                         ->select();
                 } catch (\Throwable $e) {
-                    $inRows = [];
+                    try {
+                        $inRows = Db::table('purchase_order_inbound_score')
+                            ->where('scydgy_id', 'in', array_keys($sidList))
+                            ->field('scydgy_id,result,updatetime,createtime')
+                            ->select();
+                    } catch (\Throwable $e2) {
+                        $inRows = [];
+                    }
                 }
                 if (is_array($inRows)) {
                     foreach ($inRows as $ir) {
@@ -263,6 +386,10 @@ class Procuremenarchive extends Backend
                             continue;
                         }
                         $inboundMap[$isid] = trim((string)($ir['result'] ?? ''));
+                        $ds = trim((string)($ir['delivery_status'] ?? ''));
+                        if ($ds === '准时' || $ds === '滞后') {
+                            $deliveryMap[$isid] = $ds;
+                        }
                         $its = ProcuremenTime::parseToTimestamp($ir['updatetime'] ?? $ir['createtime'] ?? null);
                         if ($its > 946684800) {
                             $inboundTimeMap[$isid] = $its;
@@ -293,7 +420,9 @@ class Procuremenarchive extends Backend
                     'CYJMC'              => trim((string)($r['CYJMC'] ?? '')),
                     'CGYMC'              => trim((string)($r['CGYMC'] ?? '')),
                     'pick_company_name'  => trim((string)($r['pick_company_name'] ?? '')),
+                    'order_batch_no'     => trim((string)($r['order_batch_no'] ?? '')),
                     'inbound_result'     => $inboundMap[$sid] ?? '',
+                    'delivery_status'    => $deliveryMap[$sid] ?? '',
                     'createtime'         => $approve['ts'],
                     'createtime_text'    => $approve['text'],
                     'complete_time'      => $complete['ts'],
@@ -322,7 +451,8 @@ class Procuremenarchive extends Backend
             if (!is_array($g)) {
                 continue;
             }
-            $gk = trim((string)($g['gk'] ?? ''));
+            // 勿 trim 整段 gk:中间分隔符 CHAR(30) 需保留
+            $gk = (string)($g['gk'] ?? '');
             if ($gk !== '') {
                 $pos[$gk] = $i;
             }
@@ -331,14 +461,8 @@ class Procuremenarchive extends Backend
             return $merged;
         }
         usort($merged, function ($a, $b) use ($pos) {
-            $ka = trim((string)($a['CCYDH'] ?? ''));
-            if ($ka === '') {
-                $ka = '_id_' . (int)($a['id'] ?? 0);
-            }
-            $kb = trim((string)($b['CCYDH'] ?? ''));
-            if ($kb === '') {
-                $kb = '_id_' . (int)($b['id'] ?? 0);
-            }
+            $ka = $this->buildArchiveRowGroupKey(is_array($a) ? $a : []);
+            $kb = $this->buildArchiveRowGroupKey(is_array($b) ? $b : []);
             $ia = $pos[$ka] ?? 999999;
             $ib = $pos[$kb] ?? 999999;
             if ($ia === $ib) {
@@ -352,7 +476,7 @@ class Procuremenarchive extends Backend
     }
 
     /**
-     * 历史存证:同一订单号 CCYDH 合并为一行(与审批/确认列表一致)
+     * 历史存证:同一订单号 + 同一批次 + 同一中标供应商合并为一行
      *
      * @param array<int, array<string, mixed>> $rows
      * @return array<int, array<string, mixed>>
@@ -367,8 +491,7 @@ class Procuremenarchive extends Backend
             if (!is_array($row)) {
                 continue;
             }
-            $dh = trim((string)($row['CCYDH'] ?? ''));
-            $key = $dh !== '' ? $dh : ('_id_' . (int)($row['id'] ?? 0));
+            $key = $this->buildArchiveRowGroupKey($row);
             if (!isset($groups[$key])) {
                 $groups[$key] = [];
             }
@@ -386,6 +509,7 @@ class Procuremenarchive extends Backend
             $gymcList = [];
             $supplierList = [];
             $inboundList = [];
+            $deliveryList = [];
             foreach ($groupRows as $r) {
                 $g = trim((string)($r['CGYMC'] ?? ''));
                 if ($g !== '' && !in_array($g, $gymcList, true)) {
@@ -399,13 +523,27 @@ class Procuremenarchive extends Backend
                 if ($ir !== '' && !in_array($ir, $inboundList, true)) {
                     $inboundList[] = $ir;
                 }
+                $ds = trim((string)($r['delivery_status'] ?? ''));
+                if (($ds === '准时' || $ds === '滞后') && !in_array($ds, $deliveryList, true)) {
+                    $deliveryList[] = $ds;
+                }
             }
             $merged = $head;
             if (count($groupRows) > 1) {
                 $merged['CGYMC'] = implode('、', $gymcList);
             }
-            $merged['pick_company_name'] = implode('、', $supplierList);
+            $merged['pick_company_name'] = $supplierList !== []
+                ? implode('、', $supplierList)
+                : trim((string)($head['pick_company_name'] ?? ''));
+            $merged['order_batch_no'] = trim((string)($head['order_batch_no'] ?? ''));
             $merged['inbound_result'] = $inboundList !== [] ? implode('、', $inboundList) : '';
+            if (in_array('滞后', $deliveryList, true)) {
+                $merged['delivery_status'] = '滞后';
+            } elseif (in_array('准时', $deliveryList, true)) {
+                $merged['delivery_status'] = '准时';
+            } else {
+                $merged['delivery_status'] = '';
+            }
             $merged['process_count'] = count($groupRows);
             $latestApproveTs = 0;
             $latestApproveText = '';

+ 21 - 6
application/admin/view/procuremen/audit_issue.html

@@ -1,4 +1,12 @@
 <style>
+    .procuremen-score-tip {
+        margin-left: 2px;
+        cursor: help;
+        color: #999;
+        font-size: 12px;
+        font-weight: normal;
+    }
+    .procuremen-score-tip:hover { color: #3c8dbc; }
     body.is-dialog .audit-issue-wrap {
         padding: 12px 14px 0;
         max-width: 100%;
@@ -516,7 +524,9 @@
         <input type="hidden" id="audit-quote-groups-json" value="{$quoteGroupsJson|htmlentities}"/>
         <input type="hidden" id="audit-bid-open-verified" value="{$bidOpenVerified|default=0}"/>
         <input type="hidden" id="audit-manual-picked" value="{$manualPicked|default=0}"/>
+        <input type="hidden" id="audit-is-rejected" value="{$isRejected|default=0}"/>
         <input type="hidden" id="audit-quote-visible" value="{$quoteVisible|default=0}"/>
+        <input type="hidden" id="audit-quote-group-count" value="{$quoteGroupCount|default=0}"/>
         <input type="hidden" id="audit-manual-pick-reason" value="{$manualPickReason|default=''|htmlentities}"/>
         <input type="hidden" id="audit-append-deadline-passed" value="{$appendSupplierDeadlinePassed|default=0}"/>
         <input type="hidden" id="audit-order-ccydh" value="{$orderCcydh|default=''|htmlentities}"/>
@@ -553,7 +563,12 @@
                 </button>
                 {/if}
                 {/if}
-                {if $bidOpenVerified}
+                {if !empty($isRejected)}
+                <span class="audit-bid-open-verified-tag" style="background:transparent;border-color:transparent;color:#a94442;"><i class="fa fa-undo"></i> 已驳回</span>
+                <span class="audit-manual-pick-inline">
+                    <span class="audit-manual-pick-line"><strong>请重新选定供应商后确认</strong>{if isset($quoteGroupCount) && $quoteGroupCount eq 1}(当前仅一家,已自动选定){/if}</span>
+                </span>
+                {elseif $bidOpenVerified}
                 <span class="audit-bid-open-verified-tag"><i class="fa fa-check-circle"></i> 已开标验证</span>
                 {elseif !empty($manualPicked) /}
                 <span class="audit-bid-open-verified-tag"><i class="fa fa-check-circle"></i> 已指定供应商</span>
@@ -573,7 +588,7 @@
                 {/if}
                 {/if}
             </div>
-            {if !$bidOpenVerified && empty($manualPicked)}
+            {if !$bidOpenVerified && empty($manualPicked) && empty($isRejected)}
             {if $auth->check('procuremen/auditmanualpick') || $auth->check('procuremen/auditsubmit') || $auth->check('procuremen/auditissue')}
             <div class="audit-deadline-item audit-manual-pick-item">
                 <button type="button" class="btn btn-success btn-sm audit-manual-pick-btn" id="btn-audit-manual-pick" title="不通过开标验证时,可指定供应商并填写说明">
@@ -602,10 +617,10 @@
                 <th class="text-center" style="width:42px;">选定</th>
                 <th style="min-width:220px;">供应商信息</th>
                 <th class="text-center col-rank" style="width:64px;">排名</th>
-                <th class="text-center" style="width:88px;">质量得分</th>
-                <th class="text-center" style="width:80px;" title="价格得分=(最低单价合计/本供应商单价合计)×价格权重%×100">价格得分</th>
+                <th class="text-center" style="width:88px;">质量得分 <i class="fa fa-question-circle procuremen-score-tip" title="质量得分取月度评审(当月按上月起往前查,无则默认40)"></i></th>
+                <th class="text-center" style="width:80px;">价格得分 <i class="fa fa-question-circle procuremen-score-tip" title="价格得分=(最低单价合计/本供应商单价合计)×价格权重%×100"></i></th>
                 {if !empty($showServiceLeadScore)}
-                <th class="text-center" style="width:80px;" title="交货得分=月度交货分×50%+报价交期分×50%(按订单算,一批多订单平均;报价交期满分40,提前+1/滞后-1)">交货得分</th>
+                <th class="text-center" style="width:80px;">交货得分 <i class="fa fa-question-circle procuremen-score-tip" title="交货得分=月度交货分×50%+报价交期分×50%(按订单算,一批多订单平均;报价交期满分40,提前+1/滞后-1)"></i></th>
                 {/if}
                 <th class="text-center" style="width:72px;">总分</th>
                 <th style="width:130px;">工序名称</th>
@@ -626,7 +641,7 @@
                     <label>
                         <input type="radio" class="audit-quote-pick" name="audit_quote_pick" value="{$co.name|default=''|htmlentities}"
                             {if condition="$pickedCompanyName neq '' && $pickedCompanyName eq $co.name"} checked="checked"{/if}
-                            {if !empty($manualPicked)} disabled="disabled"{/if}/>
+                            {if condition="!empty($manualPicked) && empty($isRejected)"} disabled="disabled"{/if}/>
                     </label>
                 </td>
                 <td class="audit-supplier-info-cell"{if condition="$co.display_rowspan gt 1"} rowspan="{$co.display_rowspan}"{/if}>

+ 13 - 9
application/admin/view/procuremen/details_fragment.html

@@ -1,4 +1,12 @@
 <style>
+    .procuremen-score-tip {
+        margin-left: 2px;
+        cursor: help;
+        color: #999;
+        font-size: 12px;
+        font-weight: normal;
+    }
+    .procuremen-score-tip:hover { color: #3c8dbc; }
     html.procuremen-details-dialog,
     html.procuremen-details-dialog body.procuremen-details-dialog {
         height: auto !important;
@@ -566,10 +574,10 @@
             <tr>
                 <th class="col-supplier-info">供应商信息</th>
                 <th class="text-center col-rank" style="width:64px;">排名</th>
-                <th class="text-center" style="width:88px;">质量得分</th>
-                <th class="text-center" style="width:80px;" title="价格得分=(最低单价合计/本供应商单价合计)×价格权重%×100">价格得分</th>
+                <th class="text-center" style="width:88px;">质量得分 <i class="fa fa-question-circle procuremen-score-tip" title="质量得分取月度评审(当月按上月起往前查,无则默认40)"></i></th>
+                <th class="text-center" style="width:80px;">价格得分 <i class="fa fa-question-circle procuremen-score-tip" title="价格得分=(最低单价合计/本供应商单价合计)×价格权重%×100"></i></th>
                 {if !empty($showServiceLeadScore)}
-                <th class="text-center" style="width:80px;" title="交货得分=月度交货分×50%+报价交期分×50%(按订单算,一批多订单平均;报价交期满分40,提前+1/滞后-1)">交货得分</th>
+                <th class="text-center" style="width:80px;">交货得分 <i class="fa fa-question-circle procuremen-score-tip" title="交货得分=月度交货分×50%+报价交期分×50%(按订单算,一批多订单平均;报价交期满分40,提前+1/滞后-1)"></i></th>
                 {/if}
                 <th class="text-center" style="width:72px;">总分</th>
                 <th style="width:100px;">工序名称</th>
@@ -630,16 +638,12 @@
             {if !empty($sg.has_remark)}
             <tr class="detail-supplier-remark-row{if !empty($sg.is_void)} text-muted{/if}">
                 <td class="detail-remark-label"><strong>投标备注</strong></td>
-                <td class="detail-remark-text{if !empty($sg.remark_quote_pending)} detail-quote-pending{/if}" colspan="{if !empty($showServiceLeadScore)}12{else /}11{/if}">{$sg.remark|default=''|htmlentities}</td>
+                <td class="detail-remark-text{if !empty($sg.remark_quote_pending)} detail-quote-pending{/if}" colspan="6">{$sg.remark|default=''|htmlentities}</td>
             </tr>
             {/if}
             {if !empty($sg.has_total)}
             <tr class="active detail-supplier-total-row">
-                <td colspan="{if !empty($showServiceLeadScore)}6{else /}5{/if}"><strong>总计</strong></td>
-                <td></td>
-                <td></td>
-                <td></td>
-                <td></td>
+                <td colspan="4"><strong>总计</strong></td>
                 <td class="text-center col-subtotal"><strong>{$sg.total_text|default=''|htmlentities}</strong></td>
                 <td></td>
                 <td></td>

+ 21 - 4
application/admin/view/procuremen/index.html

@@ -875,16 +875,27 @@
     }
     body .layui-layer-procuremen-finish .procuremen-confirm-table th:nth-child(1),
     body .layui-layer-procuremen-finish .procuremen-confirm-table td:nth-child(1) {
-        width: 150px;
-        white-space: nowrap;
+        width: 28%;
+        white-space: normal;
+        word-break: break-all;
+        overflow-wrap: anywhere;
+        vertical-align: top;
     }
     body .layui-layer-procuremen-finish .procuremen-confirm-table th:nth-child(2),
     body .layui-layer-procuremen-finish .procuremen-confirm-table td:nth-child(2) {
-        width: 140px;
+        width: 30%;
+        white-space: normal;
+        word-break: break-word;
+        overflow-wrap: break-word;
+        vertical-align: top;
     }
     body .layui-layer-procuremen-finish .procuremen-confirm-table th:nth-child(3),
     body .layui-layer-procuremen-finish .procuremen-confirm-table td:nth-child(3) {
-        width: auto;
+        width: 42%;
+        white-space: normal;
+        word-break: break-word;
+        overflow-wrap: break-word;
+        vertical-align: top;
     }
     body .layui-layer-procuremen-finish .procuremen-confirm-table thead th {
         position: sticky;
@@ -894,10 +905,16 @@
         border: 1px solid #ddd !important;
         color: #333;
         font-weight: 600;
+        padding: 8px 10px;
     }
     body .layui-layer-procuremen-finish .procuremen-confirm-table tbody td {
         border: 1px solid #ddd !important;
         color: #434343;
+        padding: 8px 10px;
+        line-height: 1.45;
+    }
+    body .layui-layer-procuremen-finish .procuremen-confirm-table .procuremen-multi-lines-wrap {
+        text-align: left !important;
     }
     body .layui-layer-procuremen-finish .layui-layer-btn {
         flex-shrink: 0 !important;

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

@@ -1,4 +1,12 @@
 <style>
+    .procuremen-score-tip {
+        margin-left: 2px;
+        cursor: help;
+        color: #999;
+        font-size: 12px;
+        font-weight: normal;
+    }
+    .procuremen-score-tip:hover { color: #3c8dbc; }
     body.is-dialog .outward-detail-wrap {
         margin: 0;
         padding: 12px 14px 0;
@@ -555,10 +563,10 @@
                 <th class="text-center" style="width:52px;">选定</th>
                 <th class="col-supplier-info">供应商信息</th>
                 <th class="text-center col-rank" style="width:64px;">排名</th>
-                <th class="text-center" style="width:88px;">质量得分</th>
-                <th class="text-center" style="width:80px;" title="价格得分=(最低单价合计/本供应商单价合计)×价格权重%×100">价格得分</th>
+                <th class="text-center" style="width:88px;">质量得分 <i class="fa fa-question-circle procuremen-score-tip" title="质量得分取月度评审(当月按上月起往前查,无则默认40)"></i></th>
+                <th class="text-center" style="width:80px;">价格得分 <i class="fa fa-question-circle procuremen-score-tip" title="价格得分=(最低单价合计/本供应商单价合计)×价格权重%×100"></i></th>
                 {if !empty($showServiceLeadScore)}
-                <th class="text-center" style="width:80px;" title="交货得分=月度交货分×50%+报价交期分×50%(按订单算,一批多订单平均;报价交期满分40,提前+1/滞后-1)">交货得分</th>
+                <th class="text-center" style="width:80px;">交货得分 <i class="fa fa-question-circle procuremen-score-tip" title="交货得分=月度交货分×50%+报价交期分×50%(按订单算,一批多订单平均;报价交期满分40,提前+1/滞后-1)"></i></th>
                 {/if}
                 <th class="text-center" style="width:72px;">总分</th>
                 <th style="width:120px;">工序名称</th>

+ 2 - 0
application/common/library/ProcuremenOperLog.php

@@ -45,6 +45,7 @@ class ProcuremenOperLog
             'save_qty_price'         => '保存数量限价',
             'mark_complete'          => '直接完结',
             'inbound_score'          => '质量评分',
+            'delivery_score'         => '交货情况',
             'arrival_notify'         => '订单已到货通知',
             'audit_abandon'          => '审批重新下发',
             'archive_abandon'        => '历史重新下发',
@@ -76,6 +77,7 @@ class ProcuremenOperLog
             'save_qty_price'        => ['保存数量限价', 'save_qty_price'],
             'mark_complete'         => ['直接完结', '完结', 'mark_complete'],
             'inbound_score'         => ['质量评分', '入库评分', '是否合格', 'inbound_score'],
+            'delivery_score'        => ['交货情况', '确认交货', 'delivery_score'],
             'arrival_notify'        => ['订单已到货通知', '通知已到货', 'arrival_notify'],
             'audit_abandon'         => ['审批重新下发', '重新下发', 'audit_abandon'],
             'archive_abandon'       => ['历史重新下发', '存证重新下发', 'archive_abandon'],

+ 43 - 11
application/common/library/ProcuremenSchema.php

@@ -14,7 +14,7 @@ use think\Log;
  */
 class ProcuremenSchema
 {
-    public const CACHE_KEY = 'procuremen_quote_schema_ok_v1';
+    public const CACHE_KEY = 'procuremen_quote_schema_ok_v2';
 
     /** @var bool */
     protected static $done = false;
@@ -417,17 +417,17 @@ class ProcuremenSchema
 
     protected static function ensurePurchaseEmailSendLogTable(): bool
     {
-        if (self::tableExists('purchase_email_send_log')) {
-            return false;
-        }
-        try {
-            Db::execute("CREATE TABLE IF NOT EXISTS `purchase_email_send_log` (
+        $changed = false;
+        if (!self::tableExists('purchase_email_send_log')) {
+            try {
+                Db::execute("CREATE TABLE IF NOT EXISTS `purchase_email_send_log` (
   `id` int unsigned NOT NULL AUTO_INCREMENT,
   `from_email` varchar(128) NOT NULL DEFAULT '' COMMENT '发件邮箱',
   `to_email` varchar(128) NOT NULL DEFAULT '' COMMENT '收件邮箱',
   `send_time` datetime DEFAULT NULL COMMENT '发送时间',
   `scene` varchar(32) NOT NULL DEFAULT '' COMMENT '场景',
-  `ccydh` varchar(64) NOT NULL DEFAULT '' COMMENT '订单号',
+  `order_batch_no` varchar(32) NOT NULL DEFAULT '' COMMENT '下发批次号',
+  `ccydh` varchar(500) NOT NULL DEFAULT '' COMMENT '订单号(多单逗号分隔)',
   `company_name` varchar(128) NOT NULL DEFAULT '' COMMENT '供应商名称',
   `mail_subject` varchar(255) NOT NULL DEFAULT '' COMMENT '邮件主题',
   `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1成功 0失败',
@@ -440,16 +440,48 @@ class ProcuremenSchema
   PRIMARY KEY (`id`),
   KEY `idx_send_time` (`send_time`),
   KEY `idx_to_email` (`to_email`),
-  KEY `idx_ccydh` (`ccydh`),
+  KEY `idx_ccydh` (`ccydh`(191)),
+  KEY `idx_order_batch_no` (`order_batch_no`),
   KEY `idx_scene` (`scene`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协助采购邮件发送日志'");
+                $changed = true;
+            } catch (\Throwable $e) {
+                Log::write('ProcuremenSchema create email_send_log: ' . $e->getMessage(), 'error');
 
-            return true;
+                return false;
+            }
+        }
+
+        $changed = self::ensureColumns('purchase_email_send_log', [
+            'order_batch_no' => "`order_batch_no` varchar(32) NOT NULL DEFAULT '' COMMENT '下发批次号' AFTER `scene`",
+        ]) || $changed;
+
+        try {
+            $cols = Db::query("SHOW COLUMNS FROM `purchase_email_send_log` LIKE 'ccydh'");
+            if (is_array($cols) && $cols !== []) {
+                $type = strtolower((string)($cols[0]['Type'] ?? $cols[0]['type'] ?? ''));
+                if (preg_match('/varchar\((\d+)\)/', $type, $m) && (int)$m[1] < 500) {
+                    Db::execute(
+                        "ALTER TABLE `purchase_email_send_log` MODIFY COLUMN `ccydh` varchar(500) NOT NULL DEFAULT '' COMMENT '订单号(多单逗号分隔)'"
+                    );
+                    $changed = true;
+                }
+            }
         } catch (\Throwable $e) {
-            Log::write('ProcuremenSchema create email_send_log: ' . $e->getMessage(), 'error');
+            Log::write('ProcuremenSchema email_send_log.ccydh width: ' . $e->getMessage(), 'error');
+        }
 
-            return false;
+        try {
+            $idx = Db::query("SHOW INDEX FROM `purchase_email_send_log` WHERE Key_name = 'idx_order_batch_no'");
+            if (!is_array($idx) || $idx === []) {
+                Db::execute('ALTER TABLE `purchase_email_send_log` ADD INDEX `idx_order_batch_no` (`order_batch_no`)');
+                $changed = true;
+            }
+        } catch (\Throwable $e) {
+            Log::write('ProcuremenSchema email_send_log idx_order_batch_no: ' . $e->getMessage(), 'error');
         }
+
+        return $changed;
     }
 
     protected static function ensurePurchaseSmsTemplateTable(): bool

+ 30 - 9
application/common/library/ProcuremenStatus.php

@@ -12,6 +12,7 @@ namespace app\common\library;
  * purchase_order.wflow_status(主单流程阶段,与 status 分工不同):
  *   待下发 — 尚未协助下发,或退回初选后
  *   待确认 — 已通知供应商报价,待采购选定供应商(原 1)
+ *   已驳回 — 采购终审驳回,退回确认供应商(可再次选定,业务上等同待确认)
  *   待审批 — 已选定供应商,待采购终审(原 2)
  *   已审核 — 采购终审通过,流程结束(原 3)
  *
@@ -30,6 +31,7 @@ class ProcuremenStatus
 
     public const WFLOW_PENDING_ISSUE = '待下发';
     public const WFLOW_PENDING_CONFIRM = '待确认';
+    public const WFLOW_REJECTED = '已驳回';
     public const WFLOW_PENDING_APPROVAL = '待审批';
     public const WFLOW_APPROVED = '已审核';
 
@@ -69,6 +71,9 @@ class ProcuremenStatus
         if ($s === '1' || strcasecmp($s, self::WFLOW_PENDING_CONFIRM) === 0) {
             return self::WFLOW_PENDING_CONFIRM;
         }
+        if ($s === '4' || strcasecmp($s, self::WFLOW_REJECTED) === 0) {
+            return self::WFLOW_REJECTED;
+        }
         if ($s === '2' || strcasecmp($s, self::WFLOW_PENDING_APPROVAL) === 0 || $s === '待审核') {
             return self::WFLOW_PENDING_APPROVAL;
         }
@@ -84,9 +89,19 @@ class ProcuremenStatus
         return self::normalizeWflowStatus($value) === self::WFLOW_PENDING_ISSUE;
     }
 
+    public static function isWflowRejected($value): bool
+    {
+        return self::normalizeWflowStatus($value) === self::WFLOW_REJECTED;
+    }
+
+    /**
+     * 待采购选定供应商:待确认 + 已驳回(驳回后可再次选定)
+     */
     public static function isWflowPendingConfirm($value): bool
     {
-        return self::normalizeWflowStatus($value) === self::WFLOW_PENDING_CONFIRM;
+        $w = self::normalizeWflowStatus($value);
+
+        return $w === self::WFLOW_PENDING_CONFIRM || $w === self::WFLOW_REJECTED;
     }
 
     public static function isWflowPendingApproval($value): bool
@@ -103,7 +118,9 @@ class ProcuremenStatus
     {
         $w = self::normalizeWflowStatus($value);
 
-        return $w === self::WFLOW_PENDING_CONFIRM || $w === self::WFLOW_PENDING_APPROVAL;
+        return $w === self::WFLOW_PENDING_CONFIRM
+            || $w === self::WFLOW_REJECTED
+            || $w === self::WFLOW_PENDING_APPROVAL;
     }
 
     public static function normalizePodPickStatus($value): string
@@ -135,7 +152,8 @@ class ProcuremenStatus
     /** @return string[] */
     public static function wflowPendingConfirmValues(): array
     {
-        return [self::WFLOW_PENDING_CONFIRM, '1'];
+        // 已驳回可再次选定供应商,与待确认同属确认页工作队列
+        return [self::WFLOW_PENDING_CONFIRM, self::WFLOW_REJECTED, '1', '4'];
     }
 
     /** @return string[] */
@@ -151,7 +169,7 @@ class ProcuremenStatus
     }
 
     /**
-     * 确认页 / 审批页共用列表:待确认 + 待审批 + 已审核
+     * 确认页 / 审批页共用列表:待确认 + 已驳回 + 待审批 + 已审核
      *
      * @return string[]
      */
@@ -165,11 +183,14 @@ class ProcuremenStatus
     }
 
     /**
-     * 列表「订单状态」展示:待确认 / 待审核 / 已审核
+     * 列表「订单状态」展示:待确认 / 已驳回 / 待审核 / 已审核
      */
     public static function orderStatusLabelForList($value): string
     {
         $w = self::normalizeWflowStatus($value);
+        if ($w === self::WFLOW_REJECTED || $w === '已驳回') {
+            return '已驳回';
+        }
         if ($w === self::WFLOW_PENDING_CONFIRM || $w === '待确认') {
             return '待确认';
         }
@@ -185,17 +206,17 @@ class ProcuremenStatus
 
     /**
      * 确认/审批列表按订单状态排序权重(越小越靠前)
-     * - audit(采购供应商确认):待确认 → 待审核 → 已审核
-     * - confirm(采购确认/审批):待审核 → 待确认 → 已审核
+     * - audit(采购供应商确认):已驳回 → 待确认 → 待审核 → 已审核
+     * - confirm(采购确认/审批):待审核 → 已驳回 → 待确认 → 已审核
      */
     public static function orderStatusSortWeight(string $stage, $value): int
     {
         $label = self::orderStatusLabelForList($value);
         if ($stage === 'audit') {
-            $map = ['待确认' => 0, '待审核' => 1, '已审核' => 2];
+            $map = ['已驳回' => 0, '待确认' => 1, '待审核' => 2, '已审核' => 3];
         } else {
             // confirm(审批页)等
-            $map = ['待审核' => 0, '待确认' => 1, '已审核' => 2];
+            $map = ['待审核' => 0, '已驳回' => 1, '待确认' => 2, '已审核' => 3];
         }
 
         return $map[$label] ?? 99;

+ 3 - 2
application/extra/mproc.php

@@ -15,8 +15,9 @@
  */
 return [
     // 手机端根地址(邮件链接、送货单二维码共用)
-    // 本地测试写局域网地址,例如 http://20.0.6.57 ;上线改为 https://xh.7in6.com
-    'mobile_base_url'   => 'http://20.0.6.57',
+    // 本地测试写局域网地址,上线改为 https://xh.7in6.com
+    'mobile_base_url'   => 'https://xh.7in6.com',
+
     // 手机端首页路径(相对上项),留空为 /index.php/index/index/index
     'mobile_index_path' => '',
     'session_hours' => 72,

+ 4 - 2
application/extra/purchase_email_send_log_install.sql

@@ -6,7 +6,8 @@ CREATE TABLE IF NOT EXISTS `purchase_email_send_log` (
   `to_email` varchar(128) NOT NULL DEFAULT '' COMMENT '收件邮箱',
   `send_time` datetime DEFAULT NULL COMMENT '发送时间',
   `scene` varchar(32) NOT NULL DEFAULT '' COMMENT '场景:pick_issue初选下发 audit_resend确认页重发',
-  `ccydh` varchar(64) NOT NULL DEFAULT '' COMMENT '订单号',
+  `order_batch_no` varchar(32) NOT NULL DEFAULT '' COMMENT '下发批次号',
+  `ccydh` varchar(500) NOT NULL DEFAULT '' COMMENT '订单号(多单逗号分隔)',
   `company_name` varchar(128) NOT NULL DEFAULT '' COMMENT '供应商名称',
   `mail_subject` varchar(255) NOT NULL DEFAULT '' COMMENT '邮件主题',
   `status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1成功 0失败',
@@ -19,6 +20,7 @@ CREATE TABLE IF NOT EXISTS `purchase_email_send_log` (
   PRIMARY KEY (`id`),
   KEY `idx_send_time` (`send_time`),
   KEY `idx_to_email` (`to_email`),
-  KEY `idx_ccydh` (`ccydh`),
+  KEY `idx_ccydh` (`ccydh`(191)),
+  KEY `idx_order_batch_no` (`order_batch_no`),
   KEY `idx_scene` (`scene`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协助采购邮件发送日志';

+ 200 - 23
application/index/controller/Index.php

@@ -2071,19 +2071,33 @@ class Index extends Frontend
     }
 
     /**
-     * 主单已完结后:中标 / 未中标
+     * 审批通过后:中标 / 未中标
+     * 说明:采购终审通过后主单仍为「进行中」(待入库评分),仅 wflow=已审核;
+     * 不能等主单「已完结」才标中标,否则手机端会误显示「已截止」。
      *
      * @param array<string, mixed>      $row
      * @param array<string, mixed>|null $po
      */
     protected function mprocResolvePickResultText(array $row, ?array $po): string
     {
-        if (!is_array($po) || !ProcuremenStatus::isPoCompleted($po['status'] ?? $po['STATUS'] ?? '')) {
+        if (!is_array($po)) {
+            return '';
+        }
+        $approved = ProcuremenStatus::isWflowApproved($po['wflow_status'] ?? '')
+            || ProcuremenStatus::isPoCompleted($po['status'] ?? $po['STATUS'] ?? '');
+        if (!$approved) {
             return '';
         }
         $detailStatus = $row['status'] ?? $row['STATUS'] ?? '';
+        if (ProcuremenStatus::isPodPicked($detailStatus)) {
+            return '中标';
+        }
+        $sn = trim((string)($row['status_name'] ?? ''));
+        if ($sn === '已完成') {
+            return '中标';
+        }
 
-        return ProcuremenStatus::isPodPicked($detailStatus) ? '中标' : '未中标';
+        return '未中标';
     }
 
     /**
@@ -4877,6 +4891,8 @@ class Index extends Frontend
         $this->mprocEnsureInboundScoreTable();
         $ccydh = trim((string)$this->request->post('ccydh', ''));
         $sid = (int)$this->request->post('scydgy_id', 0);
+        $batchNo = trim((string)$this->request->post('order_batch_no', ''));
+        $companyName = trim((string)$this->request->post('pick_company_name', ''));
         $result = trim((string)$this->request->post('result', ''));
         if ($result !== '合格' && $result !== '不合格') {
             $this->error('请选择生产是否合格');
@@ -4892,7 +4908,7 @@ class Index extends Frontend
         if (mb_strlen($remark) > 500) {
             $remark = mb_substr($remark, 0, 500);
         }
-        $poRows = $this->mprocLoadApprovedPoRowsForScore($ccydh, $sid);
+        $poRows = $this->mprocLoadApprovedPoRowsForScore($ccydh, $sid, $batchNo, $companyName);
         $now = date('Y-m-d H:i:s');
         $adminId = (int)($user['admin_id'] ?? 0);
         $adminName = $this->mprocResolveAdminDisplayName($user, $adminId);
@@ -5146,6 +5162,8 @@ class Index extends Frontend
         $this->mprocEnsureInboundScoreTable();
         $ccydh = trim((string)$this->request->post('ccydh', ''));
         $sid = (int)$this->request->post('scydgy_id', 0);
+        $batchNo = trim((string)$this->request->post('order_batch_no', ''));
+        $companyName = trim((string)$this->request->post('pick_company_name', ''));
         $storageLocation = trim((string)$this->request->post('storage_location', ''));
         if ($storageLocation === '') {
             $this->error('请填写库位');
@@ -5173,7 +5191,7 @@ class Index extends Frontend
         if (mb_strlen($remark) > 500) {
             $remark = mb_substr($remark, 0, 500);
         }
-        $poRows = $this->mprocLoadApprovedPoRowsForScore($ccydh, $sid);
+        $poRows = $this->mprocLoadApprovedPoRowsForScore($ccydh, $sid, $batchNo, $companyName);
         $deadlineYmd = '';
         foreach ($poRows as $po) {
             if (!is_array($po)) {
@@ -5277,7 +5295,7 @@ class Index extends Frontend
         }
         \app\common\library\ProcuremenOperLog::write(
             $firstSid,
-            'inbound_score',
+            'delivery_score',
             $logDetail,
             $firstPoId > 0 ? $firstPoId : null,
             [$adminId, $adminName]
@@ -5480,6 +5498,7 @@ class Index extends Frontend
                     'from_email'        => trim((string)($mailConfig['addr'] ?? '')),
                     'to_email'          => $toEmail,
                     'scene'             => '订单已到货通知',
+                    'order_batch_no'    => trim((string)($order['order_batch_no'] ?? $order['issue_batch_no'] ?? '')),
                     'ccydh'             => $ccydh,
                     'company_name'      => $toName,
                     'mail_subject'      => $mailSubject,
@@ -5496,6 +5515,7 @@ class Index extends Frontend
                     'from_email'        => trim((string)($mailConfig['addr'] ?? '')),
                     'to_email'          => $toEmail,
                     'scene'             => '订单已到货通知',
+                    'order_batch_no'    => trim((string)($order['order_batch_no'] ?? $order['issue_batch_no'] ?? '')),
                     'ccydh'             => $ccydh,
                     'company_name'      => $toName,
                     'mail_subject'      => $mailSubject,
@@ -6088,6 +6108,7 @@ class Index extends Frontend
                 'to_email'          => (string)($row['to_email'] ?? ''),
                 'send_time'         => date('Y-m-d H:i:s'),
                 'scene'             => (string)($row['scene'] ?? ''),
+                'order_batch_no'    => (string)($row['order_batch_no'] ?? ''),
                 'ccydh'             => (string)($row['ccydh'] ?? ''),
                 'company_name'      => (string)($row['company_name'] ?? ''),
                 'mail_subject'      => (string)($row['mail_subject'] ?? ''),
@@ -6098,7 +6119,15 @@ class Index extends Frontend
                 'admin_id'          => (int)($row['admin_id'] ?? 0),
                 'admin_name'        => (string)($row['admin_name'] ?? ''),
             ];
-            Db::table('purchase_email_send_log')->insert($data);
+            try {
+                Db::table('purchase_email_send_log')->insert($data);
+            } catch (\Throwable $e) {
+                if (stripos($e->getMessage(), 'order_batch_no') === false) {
+                    throw $e;
+                }
+                unset($data['order_batch_no']);
+                Db::table('purchase_email_send_log')->insert($data);
+            }
         } catch (\Throwable $e) {
             Log::record('mprocPersistEmailSendLog: ' . $e->getMessage(), 'error');
         }
@@ -6132,7 +6161,40 @@ class Index extends Frontend
     /**
      * @return array<int, array<string, mixed>>
      */
-    protected function mprocLoadApprovedPoRowsForScore(string $ccydh, int $sid): array
+    /**
+     * 交货/质量评分作用域键:订单号 + 批次号 + 中标供应商
+     * (同订单号下不同批次/供应商必须分开,避免串单)
+     */
+    protected function mprocBuildScoreGroupKey(string $ccydh, string $batchNo, string $companyName): string
+    {
+        $ccydh = trim($ccydh);
+        $batchNo = trim($batchNo);
+        $companyName = trim($companyName);
+
+        return $ccydh . "\x1e" . ($batchNo !== '' ? $batchNo : '_') . "\x1e" . ($companyName !== '' ? $companyName : '_');
+    }
+
+    /**
+     * @param array<string, mixed> $po
+     */
+    protected function mprocResolvePoBatchNo(array $po): string
+    {
+        foreach (['order_batch_no', 'issue_batch_no'] as $k) {
+            $v = trim((string)($po[$k] ?? ''));
+            if ($v !== '') {
+                return $v;
+            }
+        }
+
+        return '';
+    }
+
+    /**
+     * 已审核可评分主单:仅限同一订单号 + 同一批次 + 同一中标供应商
+     *
+     * @return array<int, array<string, mixed>>
+     */
+    protected function mprocLoadApprovedPoRowsForScore(string $ccydh, int $sid, string $batchNo = '', string $companyName = ''): array
     {
         $approvedVals = ProcuremenStatus::wflowApprovedValues();
         $approvedIn = implode(',', array_map(static function ($v) {
@@ -6145,28 +6207,111 @@ class Index extends Frontend
                 ->whereRaw("pick_time IS NOT NULL AND CAST(pick_time AS CHAR) NOT LIKE '0000-00-00%' AND TRIM(CAST(pick_time AS CHAR)) <> ''")
                 ->whereRaw("pick_company_name IS NOT NULL AND TRIM(pick_company_name) <> ''")
                 ->whereRaw('TRIM(CAST(wflow_status AS CHAR)) IN (' . $approvedIn . ')')
-                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,delivery_deadline,pick_company_name,pick_time,wflow_status,status');
+                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,delivery_deadline,pick_company_name,pick_time,wflow_status,status,order_batch_no');
             if ($ccydh !== '') {
                 $q->where('CCYDH', $ccydh);
             } elseif ($sid > 0) {
-                $one = Db::table('purchase_order')->where('scydgy_id', $sid)->field('CCYDH')->find();
+                $one = Db::table('purchase_order')->where('scydgy_id', $sid)->field('CCYDH,order_batch_no,pick_company_name')->find();
                 $ccydh = is_array($one) ? trim((string)($one['CCYDH'] ?? '')) : '';
                 if ($ccydh === '') {
                     $this->error('缺少订单号');
                 }
+                if ($batchNo === '' && is_array($one)) {
+                    $batchNo = $this->mprocResolvePoBatchNo($one);
+                }
+                if ($companyName === '' && is_array($one)) {
+                    $companyName = trim((string)($one['pick_company_name'] ?? ''));
+                }
                 $q->where('CCYDH', $ccydh);
             } else {
                 $this->error('缺少订单号');
             }
             $poRows = $q->order('id', 'asc')->select();
         } catch (\Throwable $e) {
-            $poRows = [];
+            // 兼容缺 order_batch_no 列
+            try {
+                $q = Db::table('purchase_order')
+                    ->where('scydgy_id', '>', 0)
+                    ->whereRaw('(mod_rq IS NULL OR TRIM(CAST(mod_rq AS CHAR(32))) = \'\' OR TRIM(CAST(mod_rq AS CHAR(32))) LIKE \'0000-00-00%\')')
+                    ->whereRaw("pick_time IS NOT NULL AND CAST(pick_time AS CHAR) NOT LIKE '0000-00-00%' AND TRIM(CAST(pick_time AS CHAR)) <> ''")
+                    ->whereRaw("pick_company_name IS NOT NULL AND TRIM(pick_company_name) <> ''")
+                    ->whereRaw('TRIM(CAST(wflow_status AS CHAR)) IN (' . $approvedIn . ')')
+                    ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,delivery_deadline,pick_company_name,pick_time,wflow_status,status');
+                if ($ccydh !== '') {
+                    $q->where('CCYDH', $ccydh);
+                } elseif ($sid > 0) {
+                    $one = Db::table('purchase_order')->where('scydgy_id', $sid)->field('CCYDH,pick_company_name')->find();
+                    $ccydh = is_array($one) ? trim((string)($one['CCYDH'] ?? '')) : '';
+                    if ($ccydh === '') {
+                        $this->error('缺少订单号');
+                    }
+                    if ($companyName === '' && is_array($one)) {
+                        $companyName = trim((string)($one['pick_company_name'] ?? ''));
+                    }
+                    $q->where('CCYDH', $ccydh);
+                } else {
+                    $this->error('缺少订单号');
+                }
+                $poRows = $q->order('id', 'asc')->select();
+            } catch (\Throwable $e2) {
+                $poRows = [];
+            }
         }
         if (!is_array($poRows) || $poRows === []) {
             $this->error('未找到可评分的已审核订单');
         }
 
-        return $poRows;
+        // 用锚点工序补齐批次/供应商作用域
+        if ($sid > 0 && ($batchNo === '' || $companyName === '')) {
+            foreach ($poRows as $po) {
+                if (!is_array($po) || (int)($po['scydgy_id'] ?? 0) !== $sid) {
+                    continue;
+                }
+                if ($batchNo === '') {
+                    $batchNo = $this->mprocResolvePoBatchNo($po);
+                }
+                if ($companyName === '') {
+                    $companyName = trim((string)($po['pick_company_name'] ?? ''));
+                }
+                break;
+            }
+        }
+
+        $scopeKey = '';
+        if ($batchNo !== '' || $companyName !== '' || $sid > 0) {
+            // 若仍缺公司名,用 sid 所在行;再不行用首行(兼容仅一单)
+            if ($companyName === '' && $sid <= 0 && count($poRows) === 1 && is_array($poRows[0])) {
+                $companyName = trim((string)($poRows[0]['pick_company_name'] ?? ''));
+                $batchNo = $batchNo !== '' ? $batchNo : $this->mprocResolvePoBatchNo($poRows[0]);
+            }
+            $scopeKey = $this->mprocBuildScoreGroupKey($ccydh, $batchNo, $companyName);
+        }
+
+        $filtered = [];
+        $groupKeys = [];
+        foreach ($poRows as $po) {
+            if (!is_array($po)) {
+                continue;
+            }
+            $rowBatch = $this->mprocResolvePoBatchNo($po);
+            $rowCompany = trim((string)($po['pick_company_name'] ?? ''));
+            $rowCcydh = trim((string)($po['CCYDH'] ?? $ccydh));
+            $gk = $this->mprocBuildScoreGroupKey($rowCcydh, $rowBatch, $rowCompany);
+            $groupKeys[$gk] = true;
+            if ($scopeKey !== '' && $gk !== $scopeKey) {
+                continue;
+            }
+            $filtered[] = $po;
+        }
+
+        if ($scopeKey === '' && count($groupKeys) > 1) {
+            $this->error('该订单存在多个批次或中标供应商,请刷新页面后按卡片分别填写');
+        }
+        if ($filtered === []) {
+            $this->error('未找到可评分的已审核订单(批次/供应商不匹配)');
+        }
+
+        return $filtered;
     }
 
     /**
@@ -6536,15 +6681,29 @@ class Index extends Frontend
                 ->whereRaw("pick_time IS NOT NULL AND CAST(pick_time AS CHAR) NOT LIKE '0000-00-00%' AND TRIM(CAST(pick_time AS CHAR)) <> ''")
                 ->whereRaw("pick_company_name IS NOT NULL AND TRIM(pick_company_name) <> ''")
                 ->whereRaw('TRIM(CAST(wflow_status AS CHAR)) IN (' . $approvedIn . ')')
-                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,CCLBMMC,CDW,This_quantity,NGZL,delivery_deadline,pick_company_name,pick_time,wflow_status,status')
+                ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,CCLBMMC,CDW,This_quantity,NGZL,delivery_deadline,pick_company_name,pick_time,wflow_status,status,order_batch_no')
                 ->order('pick_time', 'desc')
                 ->order('id', 'asc')
                 ->limit(800)
                 ->select();
         } catch (\Throwable $e) {
-            Log::record('mprocLoadInboundScoreRows: ' . $e->getMessage(), 'error');
+            try {
+                $poRows = Db::table('purchase_order')
+                    ->where('scydgy_id', '>', 0)
+                    ->whereRaw('(mod_rq IS NULL OR TRIM(CAST(mod_rq AS CHAR(32))) = \'\' OR TRIM(CAST(mod_rq AS CHAR(32))) LIKE \'0000-00-00%\')')
+                    ->whereRaw("pick_time IS NOT NULL AND CAST(pick_time AS CHAR) NOT LIKE '0000-00-00%' AND TRIM(CAST(pick_time AS CHAR)) <> ''")
+                    ->whereRaw("pick_company_name IS NOT NULL AND TRIM(pick_company_name) <> ''")
+                    ->whereRaw('TRIM(CAST(wflow_status AS CHAR)) IN (' . $approvedIn . ')')
+                    ->field('id,scydgy_id,CCYDH,CYJMC,CGYMC,CCLBMMC,CDW,This_quantity,NGZL,delivery_deadline,pick_company_name,pick_time,wflow_status,status')
+                    ->order('pick_time', 'desc')
+                    ->order('id', 'asc')
+                    ->limit(800)
+                    ->select();
+            } catch (\Throwable $e2) {
+                Log::record('mprocLoadInboundScoreRows: ' . $e2->getMessage(), 'error');
 
-            return [];
+                return [];
+            }
         }
         if (!is_array($poRows) || $poRows === []) {
             return [];
@@ -6614,18 +6773,22 @@ class Index extends Frontend
             if ($sid <= 0 || $ccydh === '') {
                 continue;
             }
-            if (!isset($groups[$ccydh])) {
+            $batchNo = $this->mprocResolvePoBatchNo($po);
+            $company = trim((string)($po['pick_company_name'] ?? ''));
+            $groupKey = $this->mprocBuildScoreGroupKey($ccydh, $batchNo, $company);
+            if (!isset($groups[$groupKey])) {
                 $pickRaw = trim((string)($po['pick_time'] ?? ''));
                 $pickDisp = $pickRaw !== '' ? \app\common\library\ProcuremenTime::formatDisplayDateTime($pickRaw) : '';
-                $groups[$ccydh] = [
+                $groups[$groupKey] = [
                     'CCYDH'             => $ccydh,
+                    'order_batch_no'    => $batchNo,
                     'scydgy_id'         => $sid,
                     'scydgy_ids'        => [],
                     'CYJMC'             => trim((string)($po['CYJMC'] ?? '')),
                     'CCLBMMC'           => trim((string)($po['CCLBMMC'] ?? '')),
                     'CGYMC_list'        => [],
                     'qty_list'          => [],
-                    'pick_company_name' => trim((string)($po['pick_company_name'] ?? '')),
+                    'pick_company_name' => $company,
                     'pick_time'         => $pickDisp !== '' ? $pickDisp : $pickRaw,
                     'pick_time_raw'     => $pickRaw,
                     'process_count'     => 0,
@@ -6646,7 +6809,7 @@ class Index extends Frontend
                     'arrival_notify_time'  => '',
                 ];
             }
-            $g = &$groups[$ccydh];
+            $g = &$groups[$groupKey];
             $g['scydgy_ids'][] = $sid;
             $g['process_count']++;
             if ($g['scydgy_id'] <= 0) {
@@ -6659,7 +6822,10 @@ class Index extends Frontend
                 $g['CCLBMMC'] = trim((string)($po['CCLBMMC'] ?? ''));
             }
             if ($g['pick_company_name'] === '') {
-                $g['pick_company_name'] = trim((string)($po['pick_company_name'] ?? ''));
+                $g['pick_company_name'] = $company;
+            }
+            if ($g['order_batch_no'] === '' && $batchNo !== '') {
+                $g['order_batch_no'] = $batchNo;
             }
             if ($g['delivery_deadline'] === '') {
                 $g['delivery_deadline'] = $this->mprocFormatYmdValue($po['delivery_deadline'] ?? $po['Delivery_deadline'] ?? '');
@@ -6798,12 +6964,14 @@ class Index extends Frontend
         }
 
         $out = [];
-        foreach ($groups as $ccydh => $g) {
+        foreach ($groups as $g) {
+            $ccydh = (string)($g['CCYDH'] ?? '');
             $gymcList = is_array($g['CGYMC_list'] ?? null) ? $g['CGYMC_list'] : [];
             $company = (string)($g['pick_company_name'] ?? '');
+            $batchNo = (string)($g['order_batch_no'] ?? '');
             $cyjmc = (string)($g['CYJMC'] ?? '');
             if ($keyword !== '') {
-                $blob = $ccydh . ' ' . $cyjmc . ' ' . implode('、', $gymcList) . ' ' . $company;
+                $blob = $ccydh . ' ' . $batchNo . ' ' . $cyjmc . ' ' . implode('、', $gymcList) . ' ' . $company;
                 if (mb_stripos($blob, $keyword) === false) {
                     continue;
                 }
@@ -6865,6 +7033,7 @@ class Index extends Frontend
             $qtyList = is_array($g['qty_list'] ?? null) ? $g['qty_list'] : [];
             $out[] = [
                 'CCYDH'              => $ccydh,
+                'order_batch_no'     => $batchNo,
                 'scydgy_id'          => (int)($g['scydgy_id'] ?? 0),
                 'scydgy_ids'         => array_values(array_unique(array_map('intval', $g['scydgy_ids'] ?? []))),
                 'CYJMC'              => $cyjmc,
@@ -6907,7 +7076,15 @@ class Index extends Frontend
                 return strcmp($tb, $ta);
             }
 
-            return strcmp((string)($a['CCYDH'] ?? ''), (string)($b['CCYDH'] ?? ''));
+            $ca = (string)($a['CCYDH'] ?? '');
+            $cb = (string)($b['CCYDH'] ?? '');
+            if ($ca !== $cb) {
+                return strcmp($cb, $ca);
+            }
+            $ba = (string)($a['order_batch_no'] ?? '');
+            $bb = (string)($b['order_batch_no'] ?? '');
+
+            return strcmp($bb, $ba);
         });
 
         return $out;

+ 14 - 3
application/index/view/index/deliveryscore.html

@@ -703,7 +703,7 @@
         <div class="main" id="listMain">
             {if $rows}
             {volist name="rows" id="row"}
-            <div class="card{if $mprocFocusCcydh == $row.CCYDH} is-focus{/if}" data-ccydh="{$row.CCYDH|htmlentities}" data-delivery-deadline="{$row.delivery_deadline|default=''|htmlentities}" data-order-qty="{$row.order_qty|default=''|htmlentities}">
+            <div class="card{if $mprocFocusCcydh == $row.CCYDH} is-focus{/if}" data-ccydh="{$row.CCYDH|htmlentities}" data-scydgy-id="{$row.scydgy_id|default=0}" data-order-batch-no="{$row.order_batch_no|default=''|htmlentities}" data-pick-company="{$row.pick_company_name|default=''|htmlentities}" data-delivery-deadline="{$row.delivery_deadline|default=''|htmlentities}" data-order-qty="{$row.order_qty|default=''|htmlentities}">
                 <div class="ord-row">
                     <div class="ord">{$row.CCYDH|default=''|htmlentities}{if $row.CYJMC} {$row.CYJMC|htmlentities}{/if}</div>
                     {eq name="mprocScoreTab" value="scored"}
@@ -713,6 +713,7 @@
                     {/eq}
                 </div>
                 <div class="meta">
+                    <div><span class="meta-lab">批次号</span><span class="meta-val">{notempty name="row.order_batch_no"}{$row.order_batch_no|htmlentities}{else/}—{/notempty}</span></div>
                     <div><span class="meta-lab">供应商</span><span class="meta-val">{$row.pick_company_name|default='—'|htmlentities}</span></div>
                     <div><span class="meta-lab">工序名称</span><span class="meta-val">{$row.CGYMC|default='—'|htmlentities}</span></div>
                     <div><span class="meta-lab">订单数量</span><span class="meta-val">{$row.order_qty|default=''|htmlentities}</span></div>
@@ -1127,7 +1128,7 @@
             var no = String(row.CCYDH || '').trim();
             var hl = (focusCcydh && no === focusCcydh) ? ' is-focus' : '';
             var deadline = toYmdDisplay(row.delivery_deadline || '');
-            html += '<div class="card' + hl + '" data-ccydh="' + esc(row.CCYDH || '') + '" data-delivery-deadline="' + esc(deadline) + '" data-order-qty="' + esc(row.order_qty || '') + '">';
+            html += '<div class="card' + hl + '" data-ccydh="' + esc(row.CCYDH || '') + '" data-scydgy-id="' + esc(row.scydgy_id || 0) + '" data-order-batch-no="' + esc(row.order_batch_no || '') + '" data-pick-company="' + esc(row.pick_company_name || '') + '" data-delivery-deadline="' + esc(deadline) + '" data-order-qty="' + esc(row.order_qty || '') + '">';
             html += '<div class="ord-row"><div class="ord">' + esc(titleText(row)) + '</div>';
             if (!pending) {
                 var dsHead = String(row.delivery_status || '').trim();
@@ -1137,6 +1138,7 @@
             }
             html += '</div>';
             html += '<div class="meta">';
+            html += '<div><span class="meta-lab">批次号</span><span class="meta-val">' + esc(row.order_batch_no || '—') + '</span></div>';
             html += '<div><span class="meta-lab">供应商</span><span class="meta-val">' + esc(row.pick_company_name || '—') + '</span></div>';
             html += '<div><span class="meta-lab">工序名称</span><span class="meta-val">' + esc(row.CGYMC || '—') + '</span></div>';
             html += '<div><span class="meta-lab">订单数量</span><span class="meta-val">' + esc(row.order_qty || '') + '</span></div>';
@@ -2110,7 +2112,13 @@
                 return;
             }
             notifySendBtn.disabled = true;
-            var payload = { ccydh: ccydh, staff_json: JSON.stringify(names) };
+            var payload = {
+                ccydh: ccydh,
+                scydgy_id: card.getAttribute('data-scydgy-id') || '',
+                order_batch_no: card.getAttribute('data-order-batch-no') || '',
+                pick_company_name: card.getAttribute('data-pick-company') || '',
+                staff_json: JSON.stringify(names)
+            };
             names.forEach(function (n, i) {
                 payload['staff[' + i + ']'] = n;
             });
@@ -2198,6 +2206,9 @@
         if (okBtn) okBtn.disabled = true;
         postForm(saveUrl, {
             ccydh: ccydh,
+            scydgy_id: card.getAttribute('data-scydgy-id') || '',
+            order_batch_no: card.getAttribute('data-order-batch-no') || '',
+            pick_company_name: card.getAttribute('data-pick-company') || '',
             storage_location: payload.storage_location || '',
             delivery_status: payload.delivery_status,
             delivery_date: payload.delivery_date || '',

+ 7 - 2
application/index/view/index/inboundscore.html

@@ -367,9 +367,10 @@
         <div class="main" id="listMain">
             {if $rows}
             {volist name="rows" id="row"}
-            <div class="card" data-ccydh="{$row.CCYDH|htmlentities}">
+            <div class="card" data-ccydh="{$row.CCYDH|htmlentities}" data-scydgy-id="{$row.scydgy_id|default=0}" data-order-batch-no="{$row.order_batch_no|default=''|htmlentities}" data-pick-company="{$row.pick_company_name|default=''|htmlentities}">
                 <div class="ord">{$row.CCYDH|default=''|htmlentities}{if $row.CYJMC} {$row.CYJMC|htmlentities}{/if}</div>
                 <div class="meta">
+                    <div><span class="meta-lab">批次号</span><span class="meta-val">{notempty name="row.order_batch_no"}{$row.order_batch_no|htmlentities}{else/}—{/notempty}</span></div>
                     <div><span class="meta-lab">供应商</span><span class="meta-val">{$row.pick_company_name|default='—'|htmlentities}</span></div>
                     <div><span class="meta-lab">工序名称</span><span class="meta-val">{$row.CGYMC|default='—'|htmlentities}</span></div>
                     <div><span class="meta-lab">库位</span><span class="meta-val">{$row.storage_location|default=''|htmlentities}</span></div>
@@ -691,9 +692,10 @@
         var html = '';
         var pending = currentTab === 'pending';
         rows.forEach(function (row) {
-            html += '<div class="card" data-ccydh="' + esc(row.CCYDH || '') + '">';
+            html += '<div class="card" data-ccydh="' + esc(row.CCYDH || '') + '" data-scydgy-id="' + esc(row.scydgy_id || 0) + '" data-order-batch-no="' + esc(row.order_batch_no || '') + '" data-pick-company="' + esc(row.pick_company_name || '') + '">';
             html += '<div class="ord">' + esc(titleText(row)) + '</div>';
             html += '<div class="meta">';
+            html += '<div><span class="meta-lab">批次号</span><span class="meta-val">' + esc(row.order_batch_no || '—') + '</span></div>';
             html += '<div><span class="meta-lab">供应商</span><span class="meta-val">' + esc(row.pick_company_name || '—') + '</span></div>';
             html += '<div><span class="meta-lab">工序名称</span><span class="meta-val">' + esc(row.CGYMC || '—') + '</span></div>';
             html += '<div><span class="meta-lab">库位</span><span class="meta-val">' + esc(row.storage_location || '') + '</span></div>';
@@ -937,6 +939,9 @@
         if (okBtn) okBtn.disabled = true;
         postForm(saveUrl, {
             ccydh: ccydh,
+            scydgy_id: card.getAttribute('data-scydgy-id') || '',
+            order_batch_no: card.getAttribute('data-order-batch-no') || '',
+            pick_company_name: card.getAttribute('data-pick-company') || '',
             result: payload.result,
             order_interrupt: payload.order_interrupt,
             remark: payload.remark || ''

+ 86 - 4
application/index/view/index/index.html

@@ -209,14 +209,34 @@
             color: #888;
             white-space: nowrap;
         }
-        /* 数量/限价/单价同一行 */
-        .order-line .kv-row.order-line-metrics {
-            flex-wrap: nowrap;
+        /* 数量/限价/单价 与 工期/交货日期 共用三列网格,交货日期对齐最高限价 */
+        .order-line .kv-row.order-line-metrics,
+        .kv-row.order-group-lead-row {
+            display: grid;
+            grid-template-columns: repeat(3, minmax(0, 1fr));
             gap: 6px 8px;
         }
-        .order-line .kv-row.order-line-metrics .kv > span:first-child {
+        .order-line .kv-row.order-line-metrics .kv,
+        .kv-row.order-group-lead-row .kv {
+            flex: none;
+            min-width: 0;
+        }
+        .order-line .kv-row.order-line-metrics .kv > span:first-child,
+        .kv-row.order-group-lead-row .kv > span:first-child {
             flex: 0 0 auto;
             width: auto;
+            color: #888;
+            white-space: nowrap;
+        }
+        .kv-row.order-group-lead-row .kv {
+            display: flex;
+            align-items: baseline;
+            gap: 6px;
+            white-space: nowrap;
+        }
+        /* 交货日期占满后两列,避免日期被挤成两行 */
+        .kv-row.order-group-lead-row .kv:nth-child(2) {
+            grid-column: 2 / -1;
         }
         .order-line .kv .kv-miss {
             margin-right: 0;
@@ -411,6 +431,34 @@
             line-height: 40px;
         }
         #edit-lines-box { margin-top: 0; }
+        .edit-order-deadlines {
+            display: flex;
+            flex-direction: column;
+            gap: 4px;
+            margin: 0 0 10px;
+            padding: 0 0 10px;
+            border-bottom: 1px solid #eef2f5;
+            font-size: 13px;
+            line-height: 1.45;
+            color: #333;
+        }
+        .edit-order-deadlines .edit-deadline-row {
+            display: flex;
+            align-items: baseline;
+            gap: 6px;
+        }
+        .edit-order-deadlines .edit-deadline-label {
+            flex: 0 0 6em;
+            width: 6em;
+            color: #888;
+            white-space: nowrap;
+        }
+        .edit-order-deadlines .edit-deadline-val {
+            flex: 1 1 auto;
+            min-width: 0;
+            font-weight: 600;
+            color: #222;
+        }
         .edit-order-remark {
             display: flex;
             align-items: flex-start;
@@ -1003,6 +1051,7 @@
 <div class="modal-mask" id="edit-mask" aria-hidden="true">
     <div class="modal-sheet" id="edit-sheet">
         <p class="modal-head" id="edit-sheet-title">填写单价/工期</p>
+        <div id="edit-order-deadlines" class="edit-order-deadlines" hidden></div>
         <div id="edit-order-remark" class="edit-order-remark" hidden></div>
         <div id="edit-lines-box"></div>
         <div class="modal-actions">
@@ -2797,6 +2846,38 @@
         return lines;
     }
 
+    
+    function mprocRenderEditOrderDeadlines(groupEl, isRfq) {
+        var box = document.getElementById('edit-order-deadlines');
+        if (!box) {
+            return;
+        }
+        if (isRfq) {
+            box.hidden = true;
+            box.innerHTML = '';
+            return;
+        }
+        var bidRaw = groupEl ? String(groupEl.getAttribute('data-bid-deadline') || '').trim() : '';
+        var delRaw = groupEl ? String(groupEl.getAttribute('data-delivery-deadline') || '').trim() : '';
+        var bidDisp = '';
+        if (typeof mprocFormatBidDeadlineDisplay === 'function') {
+            bidDisp = mprocFormatBidDeadlineDisplay(bidRaw) || '';
+        } else {
+            bidDisp = bidRaw;
+        }
+        var delDisp = '';
+        if (delRaw && /^\d{4}-\d{2}-\d{2}/.test(delRaw)) {
+            delDisp = delRaw.slice(0, 10);
+        } else if (delRaw) {
+            delDisp = delRaw;
+        }
+        box.hidden = false;
+        box.innerHTML = '<div class="edit-deadline-row"><span class="edit-deadline-label">招标截止日期</span><span class="edit-deadline-val">'
+            + mprocEsc(bidDisp || '—') + '</span></div>'
+            + '<div class="edit-deadline-row"><span class="edit-deadline-label">交货截止日期</span><span class="edit-deadline-val">'
+            + mprocEsc(delDisp || '—') + '</span></div>';
+    }
+
     function mprocRenderEditOrderRemark(orderRemark, isRfq) {
         if (!editOrderRemarkEl) {
             return;
@@ -2847,6 +2928,7 @@
         var orderRemark = groupEl
             ? String(groupEl.getAttribute('data-order-remark') || '').replace(/&quot;/g, '"')
             : '';
+        mprocRenderEditOrderDeadlines(groupEl, currentEditIsRfq);
         mprocRenderEditOrderRemark(orderRemark, currentEditIsRfq);
         if (editLinesBox) {
             var groupRemark = String(groupEl.getAttribute('data-remark') || '').replace(/&quot;/g, '"');

+ 105 - 44
public/assets/js/backend/procuremen.js

@@ -533,7 +533,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
             }
         };
 
-        var refreshTimeOptionsForToday = function (showTip) {
+        var refreshTimeOptionsForToday = function () {
             var today = procuremenTodayYmd();
             var d = ($date.val() || '').trim();
             var now = new Date();
@@ -542,7 +542,6 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
             var isToday = (d === today);
             var prevH = parseInt($hour.val(), 10);
             var prevM = parseInt($minute.val(), 10);
-            var changed = false;
 
             if (!isToday) {
                 rebuildHourOptions(0);
@@ -557,7 +556,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 return;
             }
 
-            // 当天:只列出当前时刻之后的时分
+            // 当天:只列出当前时刻之后的时分(过期时分静默校正,不弹提示)
             var minH = curH;
             var minM = curM + 1;
             if (minM >= 60) {
@@ -574,21 +573,18 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 rebuildMinuteOptions(0);
                 $hour.val('00');
                 $minute.val('00');
-                changed = true;
             } else {
                 rebuildHourOptions(minH);
                 var selH = parseInt($hour.val(), 10);
                 if (isNaN(selH) || selH < minH) {
                     selH = minH;
                     $hour.val(procuremenPad2(selH));
-                    changed = true;
                 }
                 if (selH === minH) {
                     rebuildMinuteOptions(minM);
                     var selM = parseInt($minute.val(), 10);
                     if (isNaN(selM) || selM < minM) {
                         $minute.val(procuremenPad2(minM));
-                        changed = true;
                     }
                 } else {
                     rebuildMinuteOptions(0);
@@ -596,26 +592,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         $minute.val(procuremenPad2(prevM));
                     }
                 }
-                if (!isNaN(prevH) && prevH < minH) {
-                    changed = true;
-                }
-                if (!isNaN(prevM) && selH === minH && prevM < minM) {
-                    changed = true;
-                }
             }
 
             syncToHidden();
-            if (changed && showTip && typeof Toastr !== 'undefined' && typeof Toastr.warning === 'function') {
-                Toastr.warning('当天截止时间不能早于当前时间');
-            }
         };
 
-        refreshTimeOptionsForToday(false);
+        refreshTimeOptionsForToday();
 
         $date.add($hour).add($minute)
             .off('change.' + eventNs + ' input.' + eventNs)
             .on('change.' + eventNs + ' input.' + eventNs, function () {
-                refreshTimeOptionsForToday(true);
+                refreshTimeOptionsForToday();
                 syncToHidden();
             });
     }
@@ -1350,6 +1337,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 if (field === 'order_status_text') {
                     return {
                         '待确认': '待确认',
+                        '已驳回': '已驳回',
                         '待审核': '待审核',
                         '已审核': '已审核'
                     };
@@ -1614,7 +1602,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         var t = (v != null && v !== '') ? String(v).trim() : '';
                         if (!t) {
                             var wf = row && row.wflow_status != null ? String(row.wflow_status).trim() : '';
-                            if (wf === '待确认' || wf === '1') {
+                            if (wf === '已驳回' || wf === '4') {
+                                t = '已驳回';
+                            } else if (wf === '待确认' || wf === '1') {
                                 t = '待确认';
                             } else if (wf === '待审批' || wf === '2') {
                                 t = '待审核';
@@ -1629,7 +1619,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                             return '';
                         }
                         var cls = 'text-muted';
-                        if (t === '待确认') {
+                        if (t === '已驳回') {
+                            cls = 'text-danger';
+                        } else if (t === '待确认') {
                             cls = 'text-warning';
                         } else if (t === '待审核') {
                             cls = 'text-info';
@@ -1701,7 +1693,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     }
                 },
                 {field: 'cywyxm', title: __('业务员'), operate: '=', searchList: procuremenSearchList('cywyxm'), table: 'b', width: 80, align: 'center'},
-                {field: 'issue_time', title: '下发时间', operate: false, table: 'a', width: 160, align: 'center',
+                {field: 'issue_time', title: '下发时间', operate: false, table: 'a', width: 160, align: 'center', sortable: true,
                     visible: indexInitWffTab !== 'pick',
                     formatter: function (v, row) {
                         var t = (v != null && String(v).trim() !== '') ? String(v).trim()
@@ -1769,7 +1761,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                             if (stA === '待审批') {
                                 stA = '待审核';
                             }
-                            if (stA === '待确认' && canAuditIssue) {
+                            // 待确认 / 已驳回:均可再次选择供应商
+                            if ((stA === '待确认' || stA === '已驳回') && canAuditIssue) {
                                 primary = '<a class="btn btn-xs btn-primary procuremen-op-open procuremen-btn-audit-issue"' + areaAudit + ' href="procuremen/auditissue" data-row-index="' + index + '" title=""><i class="fa fa-check"></i> 选择供应商</a>';
                             }
                         } else if (tab === 'confirm') {
@@ -1980,7 +1973,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
             table.bootstrapTable({
                 url: Fast.api.fixurl('procuremen/' + listAction),
                 pk: 'ID',
-                sortName: indexInitWffTab === 'pick' ? 'CCYDH' : 'a.pick_time',
+                sortName: indexInitWffTab === 'pick' ? 'CCYDH' : 'issue_time',
                 sortOrder: 'desc',
                 width: 'auto',
                 height: procuremenInitTableHeight,
@@ -2057,6 +2050,11 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     params.ym = Controller.currYm;
                     params.wff_tab = Controller.wffTab;
                     params.need_filter_options = Controller._filterOptionsReady ? '0' : '1';
+                    // 确认/审批列表固定按下发时间倒序,避免表头/缓存把顺序翻成正序
+                    if (Controller.wffTab === 'audit' || Controller.wffTab === 'confirm') {
+                        params.sort = 'issue_time';
+                        params.order = 'desc';
+                    }
                     var filter = {};
                     var op = {};
                     try {
@@ -2241,9 +2239,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 var html = '<table class="table table-bordered table-condensed procuremen-confirm-table">'
                     + '<thead><tr><th>订单号</th><th>工序名称</th><th>印件名称</th></tr></thead><tbody>';
                 $.each(rows, function (i, rd) {
-                    html += '<tr><td>' + procuremenEscHtml(String(rd.CCYDH || '').trim() || '—') + '</td>'
-                        + '<td>' + procuremenEscHtml(String(rd.CGYMC || '').trim() || '—') + '</td>'
-                        + '<td>' + procuremenEscHtml(String(rd.CYJMC || '').trim() || '—') + '</td></tr>';
+                    var ccydhHtml = procuremenMultiLinesWrapCell(rd.CCYDH) || '—';
+                    var cgymcHtml = procuremenMultiLinesWrapCell(rd.CGYMC) || '—';
+                    var cyjmcHtml = procuremenMultiLinesWrapCell(rd.CYJMC) || '—';
+                    html += '<tr><td>' + ccydhHtml + '</td>'
+                        + '<td>' + cgymcHtml + '</td>'
+                        + '<td>' + cyjmcHtml + '</td></tr>';
                 });
                 html += '</tbody></table>';
                 return html;
@@ -2255,7 +2256,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     + '</div>';
             }
             var procuremenConfirmLayerBase = {
-                area: ['680px', '400px'],
+                area: ['760px', '420px'],
                 icon: -1,
                 skin: 'layui-layer-procuremen-finish',
                 success: function (layero) {
@@ -2263,7 +2264,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     $layer.css({
                         display: 'flex',
                         flexDirection: 'column',
-                        height: '400px',
+                        height: '420px',
                         overflow: 'hidden'
                     });
                     $layer.find('.layui-layer-content').css({
@@ -2411,6 +2412,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                             __token__: $('input[name=\'__token__\']').val() || Config.token
                         }
                     }, function (data, ret) {
+                        var okMsg = (ret && ret.msg) ? ret.msg : '重新下发成功';
+                        if (typeof Toastr !== 'undefined' && typeof Toastr.success === 'function') {
+                            Toastr.success(okMsg);
+                        } else if (typeof Layer !== 'undefined' && typeof Layer.msg === 'function') {
+                            Layer.msg(okMsg, {icon: 1, time: 3000});
+                        }
                         table.bootstrapTable('refresh');
                         return false;
                     });
@@ -4698,25 +4705,46 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                         sessionStorage.removeItem('procuremen_merge_rows');
                     } catch (ignoreClr) {
                     }
-                    var index = parent.Layer.getFrameIndex(window.name);
-                    if (typeof parent !== 'undefined' && parent.Layer && index) {
-                        parent.Layer.close(index);
+                    // return false 会关掉 FastAdmin 默认成功提示,必须自己弹出到父页面
+                    var shown = false;
+                    try {
+                        if (typeof parent !== 'undefined' && parent.Toastr) {
+                            if (notifyFailed && typeof parent.Toastr.warning === 'function') {
+                                parent.Toastr.warning(msg);
+                                shown = true;
+                            } else if (typeof parent.Toastr.success === 'function') {
+                                parent.Toastr.success(msg);
+                                shown = true;
+                            }
+                        }
+                    } catch (e1) {}
+                    if (!shown) {
+                        try {
+                            if (typeof parent !== 'undefined' && parent.Layer && typeof parent.Layer.msg === 'function') {
+                                parent.Layer.msg(msg, {icon: notifyFailed ? 0 : 1, time: 4000});
+                                shown = true;
+                            }
+                        } catch (e2) {}
                     }
-                    // 下发已成功:通知未送达用 warning,避免被当成整单失败
-                    var toast = (typeof parent !== 'undefined' && parent.Toastr) ? parent.Toastr
-                        : (typeof Toastr !== 'undefined' ? Toastr : null);
-                    if (toast) {
-                        if (notifyFailed && toast.warning) {
-                            toast.warning(msg);
-                        } else {
-                            toast.success(msg);
+                    if (!shown && typeof Toastr !== 'undefined') {
+                        if (notifyFailed && Toastr.warning) {
+                            Toastr.warning(msg);
+                        } else if (Toastr.success) {
+                            Toastr.success(msg);
                         }
-                    } else if (notifyFailed && typeof parent !== 'undefined' && parent.Layer) {
-                        parent.Layer.msg(msg, {icon: 0, time: 5000});
                     }
-                    if (parent && parent.$ && parent.$('#table').length) {
-                        parent.$('#table').bootstrapTable('refresh', {silent: true});
+                    try {
+                        var index = parent.Layer.getFrameIndex(window.name);
+                        if (typeof parent !== 'undefined' && parent.Layer && index) {
+                            parent.Layer.close(index);
+                        }
+                    } catch (ignoreClose) {
                     }
+                    try {
+                        if (parent && parent.$ && parent.$('#table').length) {
+                            parent.$('#table').bootstrapTable('refresh', {silent: true});
+                        }
+                    } catch (e3) {}
                     return false;
                 }, function (data, ret) {
                     $submitBtn.prop('disabled', false).html(submitBtnHtml);
@@ -5844,6 +5872,30 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 return parseInt($('#audit-manual-picked').val(), 10) === 1;
             }
 
+            function auditIssueIsRejected() {
+                return parseInt($('#audit-is-rejected').val(), 10) === 1;
+            }
+
+            /** 驳回后可重选;仅一家时自动勾选,便于直接确认 */
+            function auditIssueEnsurePickReady() {
+                var $radios = $('input[name="audit_quote_pick"]');
+                if (!$radios.length) {
+                    return;
+                }
+                if (auditIssueIsRejected()) {
+                    $radios.prop('disabled', false);
+                }
+                if ($radios.filter(':checked').length) {
+                    return;
+                }
+                var $enabled = $radios.filter(function () {
+                    return !$(this).prop('disabled');
+                });
+                if ($enabled.length === 1) {
+                    $enabled.prop('checked', true);
+                }
+            }
+
             function auditIssueBidOpenSmsResendCd() {
                 var cd = 55;
                 if (typeof Config !== 'undefined' && Config.mprocSmsResendCd) {
@@ -6500,7 +6552,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     Toastr.warning('未到招标截止日期,暂不可确认');
                     return;
                 }
-                if (!auditIssueBidOpenVerified() && !auditIssueManualPicked()) {
+                if (!auditIssueBidOpenVerified() && !auditIssueManualPicked() && !auditIssueIsRejected()) {
                     Toastr.warning('请先完成开标验证或指定供应商后再确认');
                     return;
                 }
@@ -6552,6 +6604,8 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 }
                 auditIssuePostResendSms(company);
             });
+
+            auditIssueEnsurePickReady();
         },
 
         emaillog: function () {
@@ -6574,7 +6628,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 columns: [
                     [
                         $.extend({field: 'id', title: __('Id'), width: 60, operate: false}, colCenter),
-                        $.extend({field: 'ccydh', title: '订单号', operate: 'LIKE'}, colCenter),
+                        $.extend({field: 'order_batch_no', title: '批次号', operate: 'LIKE'}, colCenter),
+                        $.extend({field: 'ccydh', title: '订单号', operate: 'LIKE', formatter: function (v) {
+                            var s = String(v == null ? '' : v).trim();
+                            if (!s) {
+                                return '';
+                            }
+                            return s.split(/[,,;;\s]+/).filter(Boolean).join('<br>');
+                        }}, colCenter),
                         $.extend({field: 'from_email', title: '发件邮箱', operate: 'LIKE', formatter: function (v, row) {
                             return row.from_email_masked || v || '';
                         }}, colCenter),

+ 265 - 140
public/assets/js/backend/procuremenarchive.js

@@ -34,30 +34,268 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 return "<div class='autocontent-item' style='white-space:nowrap;text-overflow:ellipsis;overflow:hidden;max-width:100%;width:100%;box-sizing:border-box;'>" + text + "</div>";
             }
 
-            /** 让省略宽度跟单元格实际宽度一致,避免列很宽却只显示 250px */
-            function archiveFitAutocontent() {
-                if ($('body').hasClass('fa-sidebar-resizing')) {
+            var archiveColumns = [
+                {checkbox: true, width: 42},
+                {
+                    field: 'order_batch_no',
+                    title: '批次号',
+                    operate: 'LIKE',
+                    width: 150,
+                    align: 'left',
+                    halign: 'left',
+                    formatter: function (value) {
+                        var v = value == null ? '' : String(value).trim();
+                        return v !== '' ? archiveCellText(v) : '';
+                    }
+                },
+                {field: 'CCYDH', title: '订单号', operate: 'LIKE', width: 130, align: 'left', halign: 'left'},
+                {
+                    field: 'CYJMC',
+                    title: '印件名称',
+                    operate: 'LIKE',
+                    width: 200,
+                    align: 'left',
+                    halign: 'left',
+                    class: 'autocontent',
+                    formatter: function (value) {
+                        return archiveCellText(value);
+                    }
+                },
+                {
+                    field: 'CGYMC',
+                    title: '工序名称',
+                    operate: 'LIKE',
+                    width: 160,
+                    align: 'left',
+                    halign: 'left',
+                    class: 'autocontent',
+                    formatter: function (value) {
+                        return archiveCellText(value);
+                    }
+                },
+                {
+                    field: 'pick_company_name',
+                    title: '中标供应商',
+                    operate: 'LIKE',
+                    width: 220,
+                    align: 'left',
+                    halign: 'left',
+                    class: 'autocontent',
+                    formatter: function (value) {
+                        return archiveCellText(value);
+                    }
+                },
+                {
+                    field: 'inbound_result',
+                    title: '是否合格',
+                    operate: false,
+                    width: 100,
+                    align: 'center',
+                    halign: 'center',
+                    formatter: function (value) {
+                        var v = value == null ? '' : String(value).trim();
+                        if (v === '合格') {
+                            return '<span class="label label-success">合格</span>';
+                        }
+                        if (v === '不合格') {
+                            return '<span class="label label-danger">不合格</span>';
+                        }
+                        if (v.indexOf('合格') >= 0 || v.indexOf('不合格') >= 0) {
+                            return archiveCellText(v);
+                        }
+                        return '';
+                    }
+                },
+                {
+                    field: 'delivery_status',
+                    title: '交货情况',
+                    operate: false,
+                    width: 90,
+                    align: 'center',
+                    halign: 'center',
+                    formatter: function (value) {
+                        var v = value == null ? '' : String(value).trim();
+                        if (v === '准时' || v === '滞后') {
+                            return '<span style="color:#303133;">' + archiveEscHtml(v) + '</span>';
+                        }
+                        return '';
+                    }
+                },
+                {
+                    field: 'createtime',
+                    title: '审批时间',
+                    operate: 'RANGE',
+                    addclass: 'datetimerange',
+                    autocomplete: false,
+                    width: 165,
+                    align: 'left',
+                    halign: 'left',
+                    sortable: true,
+                    formatter: function (value, row) {
+                        var text = row && row.createtime_text != null ? String(row.createtime_text).trim() : '';
+                        if (text && text !== '-' && text !== '—') {
+                            return text;
+                        }
+                        if (value == null || value === '' || value === 0 || value === '0') {
+                            return '';
+                        }
+                        var n = parseInt(value, 10);
+                        if (!isNaN(n) && n > 946684800) {
+                            return Table.api.formatter.datetime.call(this, value, row);
+                        }
+                        var s = String(value).trim();
+                        return (s === '-' || s === '—') ? '' : s;
+                    }
+                },
+                {
+                    field: 'complete_time',
+                    title: '完结时间',
+                    operate: false,
+                    sortable: true,
+                    width: 165,
+                    align: 'left',
+                    halign: 'left',
+                    formatter: function (value, row) {
+                        var text = row && row.complete_time_text != null ? String(row.complete_time_text).trim() : '';
+                        if (text && text !== '-' && text !== '—') {
+                            return text;
+                        }
+                        if (value == null || value === '' || value === 0 || value === '0') {
+                            return '';
+                        }
+                        var n = parseInt(value, 10);
+                        if (!isNaN(n) && n > 946684800) {
+                            return Table.api.formatter.datetime.call(this, value, row);
+                        }
+                        var s = String(value).trim();
+                        return (s === '-' || s === '—') ? '' : s;
+                    }
+                },
+                {
+                    field: 'operate',
+                    title: '操作',
+                    width: 100,
+                    align: 'center',
+                    halign: 'center',
+                    operate: false,
+                    table: table,
+                    formatter: function (value, row) {
+                        var sid = row && row.scydgy_id;
+                        if (!sid) {
+                            return '';
+                        }
+                        if (!archiveCan('details')) {
+                            return '';
+                        }
+                        return '<a href="javascript:;" class="btn btn-xs btn-info btn-archive-details" data-scydgy-id="' + sid + '" title="详情"><i class="fa fa-file-text-o"></i> 详情</a>';
+                    },
+                    events: {}
+                }
+            ];
+
+            /** 表头/表体共用固定列宽,避免内容撑开导致竖线错位 */
+            function archiveGetColWidths() {
+                var widths = [];
+                $.each(archiveColumns, function (_, col) {
+                    if (!col || col.visible === false) {
+                        return;
+                    }
+                    var w = parseInt(col.width, 10);
+                    widths.push((!w || isNaN(w)) ? (col.checkbox ? 42 : 120) : w);
+                });
+                return widths;
+            }
+
+            function archiveApplyColgroup($tbl, widths) {
+                if (!$tbl || !$tbl.length || !widths.length) {
                     return;
                 }
-                table.closest('.bootstrap-table').find('td.autocontent').each(function () {
-                    var td = this;
-                    var $item = $(td).children('.autocontent-item').first();
-                    if (!$item.length) {
+                var total = 0;
+                var i;
+                for (i = 0; i < widths.length; i++) {
+                    total += widths[i];
+                }
+                // 撑满容器 100%,列宽作比例;窄屏时至少保留合计最小宽
+                $tbl.css({
+                    tableLayout: 'fixed',
+                    width: '100%',
+                    minWidth: total + 'px'
+                });
+                var $cg = $tbl.children('colgroup');
+                if (!$cg.length) {
+                    $cg = $('<colgroup/>').prependTo($tbl);
+                }
+                $cg.empty();
+                for (i = 0; i < widths.length; i++) {
+                    $cg.append($('<col/>').attr('style', 'width:' + widths[i] + 'px;min-width:' + widths[i] + 'px;'));
+                }
+                $tbl.find('thead tr:first > th').each(function (idx) {
+                    if (idx >= widths.length) {
                         return;
                     }
-                    var cs = window.getComputedStyle(td);
-                    var pad = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0);
-                    var w = Math.floor(td.clientWidth - pad);
-                    if (w > 0) {
-                        $item.css({maxWidth: w + 'px', width: w + 'px'});
+                    var w = widths[idx];
+                    var $th = $(this);
+                    $th.css({width: w + 'px', minWidth: w + 'px', maxWidth: ''});
+                    $th.find('.fht-cell').css({width: w + 'px', minWidth: w + 'px', maxWidth: ''});
+                });
+                $tbl.find('tbody tr:first-child:not(.no-records-found) > *').each(function (idx) {
+                    if (idx >= widths.length) {
+                        return;
                     }
+                    var w = widths[idx];
+                    $(this).css({width: w + 'px', minWidth: w + 'px', maxWidth: ''});
                 });
             }
 
+            function archiveSyncHeaderBodyAlign() {
+                if ($('body').hasClass('fa-sidebar-resizing')) {
+                    return;
+                }
+                var $bt = table.closest('.bootstrap-table');
+                if (!$bt.length) {
+                    return;
+                }
+                var widths = archiveGetColWidths();
+                if (!widths.length) {
+                    return;
+                }
+                var $headerWrap = $bt.find('> .fixed-table-container > .fixed-table-header').first();
+                if (!$headerWrap.length) {
+                    $headerWrap = $bt.find('.fixed-table-header').first();
+                }
+                var $bodyWrap = $bt.find('> .fixed-table-container > .fixed-table-body').first();
+                if (!$bodyWrap.length) {
+                    $bodyWrap = $bt.find('.fixed-table-body').first();
+                }
+                var $headerTable = $headerWrap.length ? $headerWrap.children('table').first() : $();
+                var $bodyTable = $bodyWrap.length ? $bodyWrap.children('table').first() : table;
+                if (!$bodyTable.length) {
+                    $bodyTable = $bt.find('.fixed-table-body > table').first();
+                }
+                if ($headerTable.length && $bodyTable.length && $headerTable[0] !== $bodyTable[0]) {
+                    var bodyEl = $bodyWrap[0];
+                    var sb = bodyEl ? Math.max(0, bodyEl.offsetWidth - bodyEl.clientWidth) : 0;
+                    $headerWrap.css('margin-right', sb > 0 ? sb + 'px' : '');
+                    archiveApplyColgroup($headerTable, widths);
+                    archiveApplyColgroup($bodyTable, widths);
+                    $headerWrap.scrollLeft($bodyWrap.scrollLeft() || 0);
+                } else if ($bodyTable.length) {
+                    archiveApplyColgroup($bodyTable, widths);
+                }
+            }
+
+            function archiveScheduleAlign() {
+                if ($('body').hasClass('fa-sidebar-resizing')) {
+                    return;
+                }
+                setTimeout(archiveSyncHeaderBodyAlign, 0);
+                setTimeout(archiveSyncHeaderBodyAlign, 80);
+            }
+
             table.bootstrapTable({
                 url: $.fn.bootstrapTable.defaults.extend.index_url,
                 pk: 'scydgy_id',
-                sortName: 'complete_time',
+                sortName: 'createtime',
                 sortOrder: 'desc',
                 commonSearch: true,
                 search: true,
@@ -68,144 +306,31 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                 pageSize: Config.pagesize || localStorage.getItem('pagesize') || 20,
                 pageList: [10, 15, 20, 25, 50],
                 showJumpto: true,
-                columns: [
-                    [
-                        {checkbox: true, width: 36},
-                        {field: 'CCYDH', title: '订单号', operate: 'LIKE', width: 140, align: 'left'},
-                        {
-                            field: 'CYJMC',
-                            title: '印件名称',
-                            operate: 'LIKE',
-                            class: 'autocontent',
-                            formatter: function (value) {
-                                return archiveCellText(value);
-                            }
-                        },
-                        {
-                            field: 'CGYMC',
-                            title: '工序名称',
-                            operate: 'LIKE',
-                            width: 140,
-                            class: 'autocontent',
-                            formatter: function (value) {
-                                return archiveCellText(value);
-                            }
-                        },
-                        {
-                            field: 'pick_company_name',
-                            title: '中标供应商',
-                            operate: 'LIKE',
-                            width: 180,
-                            class: 'autocontent',
-                            formatter: function (value) {
-                                return archiveCellText(value);
-                            }
-                        },
-                        {
-                            field: 'inbound_result',
-                            title: '是否合格',
-                            operate: false,
-                            width: 100,
-                            align: 'center',
-                            formatter: function (value) {
-                                var v = value == null ? '' : String(value).trim();
-                                if (v === '合格') {
-                                    return '<span class="label label-success">合格</span>';
-                                }
-                                if (v === '不合格') {
-                                    return '<span class="label label-danger">不合格</span>';
-                                }
-                                if (v.indexOf('合格') >= 0 || v.indexOf('不合格') >= 0) {
-                                    return archiveCellText(v);
-                                }
-                                return '';
-                            }
-                        },
-                        {
-                            field: 'createtime',
-                            title: '审批时间',
-                            operate: 'RANGE',
-                            addclass: 'datetimerange',
-                            autocomplete: false,
-                            width: 165,
-                            formatter: function (value, row) {
-                                var text = row && row.createtime_text != null ? String(row.createtime_text).trim() : '';
-                                if (text && text !== '-' && text !== '—') {
-                                    return text;
-                                }
-                                if (value == null || value === '' || value === 0 || value === '0') {
-                                    return '';
-                                }
-                                var n = parseInt(value, 10);
-                                if (!isNaN(n) && n > 946684800) {
-                                    return Table.api.formatter.datetime.call(this, value, row);
-                                }
-                                var s = String(value).trim();
-                                return (s === '-' || s === '—') ? '' : s;
-                            }
-                        },
-                        {
-                            field: 'complete_time',
-                            title: '完结时间',
-                            operate: false,
-                            sortable: true,
-                            width: 165,
-                            formatter: function (value, row) {
-                                var text = row && row.complete_time_text != null ? String(row.complete_time_text).trim() : '';
-                                if (text && text !== '-' && text !== '—') {
-                                    return text;
-                                }
-                                if (value == null || value === '' || value === 0 || value === '0') {
-                                    return '';
-                                }
-                                var n = parseInt(value, 10);
-                                if (!isNaN(n) && n > 946684800) {
-                                    return Table.api.formatter.datetime.call(this, value, row);
-                                }
-                                var s = String(value).trim();
-                                return (s === '-' || s === '—') ? '' : s;
-                            }
-                        },
-                        {
-                            field: 'operate',
-                            title: '操作',
-                            width: 100,
-                            align: 'center',
-                            operate: false,
-                            table: table,
-                            formatter: function (value, row) {
-                                var sid = row && row.scydgy_id;
-                                if (!sid) {
-                                    return '';
-                                }
-                                if (!archiveCan('details')) {
-                                    return '';
-                                }
-                                return '<a href="javascript:;" class="btn btn-xs btn-info btn-archive-details" data-scydgy-id="' + sid + '" title="详情"><i class="fa fa-file-text-o"></i> 详情</a>';
-                            },
-                            events: {}
-                        }
-                    ]
-                ]
+                columns: [archiveColumns]
             });
 
             Table.api.bindevent(table);
 
-            table.on('post-body.bs.table load-success.bs.table', function () {
-                setTimeout(archiveFitAutocontent, 0);
+            table.on('post-body.bs.table load-success.bs.table post-header.bs.table', function () {
+                archiveScheduleAlign();
             });
+            table.closest('.bootstrap-table').find('.fixed-table-body').off('scroll.procuremenArchive')
+                .on('scroll.procuremenArchive', function () {
+                    var $bt = table.closest('.bootstrap-table');
+                    var $headerWrap = $bt.find('> .fixed-table-container > .fixed-table-header').first();
+                    if ($headerWrap.length) {
+                        $headerWrap.scrollLeft($(this).scrollLeft() || 0);
+                    }
+                });
             $(window).off('resize.procuremenArchive').on('resize.procuremenArchive', function () {
-                if ($('body').hasClass('fa-sidebar-resizing')) {
-                    return;
-                }
-                setTimeout(archiveFitAutocontent, 40);
+                archiveScheduleAlign();
             });
             $(document).off('fa.sidebar.resized.procuremenArchive fa.table.relayout.procuremenArchive')
                 .on('fa.sidebar.resized.procuremenArchive fa.table.relayout.procuremenArchive', function () {
-                    setTimeout(archiveFitAutocontent, 40);
+                    archiveScheduleAlign();
                 });
             $(document).off('click.procuremenArchiveCommonSearch', 'button[name="commonSearch"]').on('click.procuremenArchiveCommonSearch', 'button[name="commonSearch"]', function () {
-                setTimeout(archiveFitAutocontent, 150);
+                setTimeout(archiveScheduleAlign, 150);
             });
 
             $(document).off('click.procuremenArchiveDetails', '.btn-archive-details').on('click.procuremenArchiveDetails', '.btn-archive-details', function (e) {