m0_70156489 1 month ago
parent
commit
d7732c4b19

+ 331 - 79
application/admin/controller/Procuremen.php

@@ -437,6 +437,7 @@ class Procuremen extends Backend
                 ->where('a.dStamp', '<=', $endTime)
                 ->order('a.dStamp', 'desc')
                 ->select();
+
             if (!is_array($list)) {
                 return [];
             }
@@ -2230,6 +2231,7 @@ class Procuremen extends Backend
 
     /**
      * 分配手工新增工序行 ID(负数递减)
+     * 调用方须在命名锁内执行,避免并发重复
      */
     protected function allocateManualScydgyId(): int
     {
@@ -2244,7 +2246,8 @@ class Procuremen extends Backend
     }
 
     /**
-     * 手工新增订单号:YW + 年月日 + 3 位当日序号(如 YW20260618001)
+     * 手工新增订单号:YW + 年月日 + 当日序号(如 YW20260618001)
+     * 调用方须在命名锁内执行,避免并发重复
      */
     protected function allocateManualOrderCcydh(): string
     {
@@ -2257,15 +2260,53 @@ class Procuremen extends Backend
             if (is_array($rows)) {
                 foreach ($rows as $ccydh) {
                     $ccydh = trim((string)$ccydh);
-                    if (preg_match('/^' . preg_quote($prefix, '/') . '(\d{3})$/', $ccydh, $m)) {
+                    if (preg_match('/^' . preg_quote($prefix, '/') . '(\d{3,})$/', $ccydh, $m)) {
                         $maxSeq = max($maxSeq, (int)$m[1]);
                     }
                 }
             }
         } catch (\Throwable $e) {
         }
+        $next = $maxSeq + 1;
+        $width = $next > 999 ? strlen((string)$next) : 3;
+
+        return $prefix . str_pad((string)$next, $width, '0', STR_PAD_LEFT);
+    }
+
+    /**
+     * MySQL 命名锁(跨连接防并发撞号)
+     */
+    protected function acquireProcuremenNamedLock(string $name, int $timeoutSec = 10): bool
+    {
+        $name = trim($name);
+        if ($name === '') {
+            return false;
+        }
+        try {
+            $nameSql = str_replace(['\\', "'"], ['\\\\', "\\'"], $name);
+            $rows = Db::query('SELECT GET_LOCK(\'' . $nameSql . '\', ' . max(1, $timeoutSec) . ') AS locked');
+            if (!is_array($rows) || $rows === []) {
+                return false;
+            }
+            $v = $rows[0]['locked'] ?? $rows[0]['LOCKED'] ?? null;
+
+            return (int)$v === 1;
+        } catch (\Throwable $e) {
+            return false;
+        }
+    }
 
-        return $prefix . str_pad((string)($maxSeq + 1), 3, '0', STR_PAD_LEFT);
+    protected function releaseProcuremenNamedLock(string $name): void
+    {
+        $name = trim($name);
+        if ($name === '') {
+            return;
+        }
+        try {
+            $nameSql = str_replace(['\\', "'"], ['\\\\', "\\'"], $name);
+            Db::query('SELECT RELEASE_LOCK(\'' . $nameSql . '\')');
+        } catch (\Throwable $e) {
+        }
     }
 
     /**
@@ -2422,45 +2463,51 @@ class Procuremen extends Backend
     {
         $cyjmc = trim((string)($params['CYJMC'] ?? ''));
         $cgymc = trim((string)($params['CGYMC'] ?? ''));
-        $ccydh = trim((string)($params['CCYDH'] ?? ''));
-        if ($ccydh === '') {
-            $ccydh = $this->allocateManualOrderCcydh();
-        }
-        $now = date('Y-m-d H:i:s');
-        $sid = $this->allocateManualScydgyId();
-        $this->ensurePurchaseOrderNotifySalesmanColumn();
-        $this->ensurePurchaseOrderRfqSalesmanNotifiedColumn();
-        // 选择供应商 / 通知业务员:支持多选,落库用顿号拼接
-        $supplierName = $this->joinRfqMultiSelectValues($params['cGzzxMc'] ?? ($params['pick_company_name'] ?? ''));
-        $notifySalesman = $this->joinRfqMultiSelectValues($params['notify_salesman'] ?? '');
-        $data = [
-            'scydgy_id'              => $sid,
-            'CCYDH'                  => $ccydh,
-            'CYJMC'                  => $cyjmc,
-            'CGYMC'                  => $cgymc,
-            'CCLBMMC'                => trim((string)($params['CCLBMMC'] ?? '')),
-            'CDW'                    => trim((string)($params['CDW'] ?? '')),
-            'NGZL'                   => trim((string)($params['NGZL'] ?? '')),
-            'CDF'                    => trim((string)($params['CDF'] ?? '')),
-            'cGzzxMc'                => $supplierName,
-            'MBZ'                    => trim((string)($params['MBZ'] ?? '')),
-            'cywyxm'                 => trim((string)($params['cywyxm'] ?? '')),
-            'notify_salesman'        => $notifySalesman,
-            'rfq_salesman_notified'  => 0,
-            'rfq_salesman_notify_time' => null,
-            'This_quantity'          => trim((string)($params['This_quantity'] ?? '')),
-            'ceilingPrice'           => trim((string)($params['ceilingPrice'] ?? '')),
-            'wflow_status'           => ProcuremenStatus::WFLOW_PENDING_ISSUE,
-            'status'                 => ProcuremenStatus::PO_IN_PROGRESS,
-            'createtime'             => $now,
-            'dStamp'                 => $now,
-            'dputrecord'             => $now,
-        ];
-        Db::table('purchase_order')->insert($data);
-        $poId = (int)Db::getLastInsID();
-        $this->addOrderLog($sid, 'manual_add', '手工新增协助工序', $poId > 0 ? $poId : null);
+        $ccydhIn = trim((string)($params['CCYDH'] ?? ''));
+        $lockName = 'procuremen_manual_order_alloc';
+        if (!$this->acquireProcuremenNamedLock($lockName, 10)) {
+            throw new \RuntimeException('系统繁忙,请稍后重试');
+        }
+        try {
+            $ccydh = $ccydhIn !== '' ? $ccydhIn : $this->allocateManualOrderCcydh();
+            $now = date('Y-m-d H:i:s');
+            $sid = $this->allocateManualScydgyId();
+            $this->ensurePurchaseOrderNotifySalesmanColumn();
+            $this->ensurePurchaseOrderRfqSalesmanNotifiedColumn();
+            // 选择供应商 / 通知业务员:支持多选,落库用顿号拼接
+            $supplierName = $this->joinRfqMultiSelectValues($params['cGzzxMc'] ?? ($params['pick_company_name'] ?? ''));
+            $notifySalesman = $this->joinRfqMultiSelectValues($params['notify_salesman'] ?? '');
+            $data = [
+                'scydgy_id'              => $sid,
+                'CCYDH'                  => $ccydh,
+                'CYJMC'                  => $cyjmc,
+                'CGYMC'                  => $cgymc,
+                'CCLBMMC'                => trim((string)($params['CCLBMMC'] ?? '')),
+                'CDW'                    => trim((string)($params['CDW'] ?? '')),
+                'NGZL'                   => trim((string)($params['NGZL'] ?? '')),
+                'CDF'                    => trim((string)($params['CDF'] ?? '')),
+                'cGzzxMc'                => $supplierName,
+                'MBZ'                    => trim((string)($params['MBZ'] ?? '')),
+                'cywyxm'                 => trim((string)($params['cywyxm'] ?? '')),
+                'notify_salesman'        => $notifySalesman,
+                'rfq_salesman_notified'  => 0,
+                'rfq_salesman_notify_time' => null,
+                'This_quantity'          => trim((string)($params['This_quantity'] ?? '')),
+                'ceilingPrice'           => trim((string)($params['ceilingPrice'] ?? '')),
+                'wflow_status'           => ProcuremenStatus::WFLOW_PENDING_ISSUE,
+                'status'                 => ProcuremenStatus::PO_IN_PROGRESS,
+                'createtime'             => $now,
+                'dStamp'                 => $now,
+                'dputrecord'             => $now,
+            ];
+            Db::table('purchase_order')->insert($data);
+            $poId = (int)Db::getLastInsID();
+            $this->addOrderLog($sid, 'manual_add', '手工新增协助工序', $poId > 0 ? $poId : null);
 
-        return $sid;
+            return $sid;
+        } finally {
+            $this->releaseProcuremenNamedLock($lockName);
+        }
     }
 
     protected function formatProcuremenRowLabel(array $row): string
@@ -4994,7 +5041,7 @@ class Procuremen extends Backend
             $bundle = $this->loadOrderBundleForConfirmNotify(array_column($picks, 'scydgy_id'));
             $this->dispatchPurchaseConfirmPickNotifications($bundle, $results);
         } catch (\Throwable $e) {
-            Log::write('采购确认短信批量发送异常: ' . $e->getMessage(), 'error');
+            Log::write('采购确认通知批量发送异常: ' . $e->getMessage(), 'error');
         }
 
         $this->pickHiddenScydgySetCache = null;
@@ -5126,7 +5173,7 @@ class Procuremen extends Backend
                     'purchase_order_id' => $purchaseOrderId,
                 ]]);
             } catch (\Throwable $e) {
-                Log::write('采购确认短信发送异常 scydgy_id=' . $sid . ' ' . $e->getMessage(), 'error');
+                Log::write('采购确认通知发送异常 scydgy_id=' . $sid . ' ' . $e->getMessage(), 'error');
             }
         }
 
@@ -5193,7 +5240,7 @@ class Procuremen extends Backend
     }
 
     /**
-     * 审批通过/未通过短信:同一供应商只发一条,process_lines 含本次全部工序
+     * 审批通过/未通过:短信 + 邮件(同一供应商只发一条;邮件含平台链接)
      *
      * @param array{ccydh?:string,pos?:array,merge_rows?:array} $bundle
      * @param array<int, array<string, mixed>> $picks
@@ -5203,6 +5250,7 @@ class Procuremen extends Backend
         if ($picks === []) {
             return;
         }
+        $this->ensureConfirmResultEmailTemplates();
         $fallbackDetailId = 0;
         $winners = [];
         $losers = [];
@@ -5241,6 +5289,11 @@ class Procuremen extends Backend
             return;
         }
         $confirmNotifyVars = $this->buildPurchaseConfirmNotifyVars(0, $fallbackDetailId, $bundle);
+        $poIdLog = 0;
+        $pos = is_array($bundle['pos'] ?? null) ? $bundle['pos'] : [];
+        if (isset($pos[0]) && is_array($pos[0])) {
+            $poIdLog = (int)($pos[0]['id'] ?? 0);
+        }
         $sendSmsSafe = function ($phone, $content) {
             $phone = trim((string)$phone);
             if ($phone === '') {
@@ -5252,6 +5305,60 @@ class Procuremen extends Backend
                 Log::write('采购确认短信失败 phone=' . $phone . ' ' . $e->getMessage(), 'error');
             }
         };
+        $sendConfirmEmailSafe = function (array $dr, string $emailScene) use ($confirmNotifyVars, $poIdLog) {
+            $cname = trim((string)($dr['company_name'] ?? ''));
+            $ph = trim((string)($dr['phone'] ?? ''));
+            $toEmail = trim((string)($dr['email'] ?? ''));
+            if ($toEmail === '' || !filter_var($toEmail, FILTER_VALIDATE_EMAIL)) {
+                Log::write('采购确认邮件跳过:无有效邮箱 company=' . $cname, 'error');
+
+                return;
+            }
+            $detailId = (int)($dr['id'] ?? $dr['ID'] ?? 0);
+            if ($detailId <= 0) {
+                $detailId = (int)($confirmNotifyVars['_fallback_detail_id'] ?? 0);
+            }
+            try {
+                $platformUrl = $this->buildMprocMobileOrderUrl($detailId);
+            } catch (\Throwable $e) {
+                Log::write('采购确认邮件链接失败: ' . $e->getMessage(), 'error');
+                $platformUrl = '';
+            }
+            $vars = array_merge($confirmNotifyVars, [
+                'company_name' => $cname,
+                'contact_name' => $this->resolveCustomerContactName($ph, $cname),
+                'phone'        => $ph,
+                'email'        => $toEmail,
+                'platform_url' => $platformUrl,
+            ]);
+            unset($vars['_fallback_detail_id']);
+            try {
+                $mailPlain = $this->renderNotifyTemplate($emailScene, $vars);
+                $mailBody = $this->plainTextToHtmlEmailBody($mailPlain);
+                // 平台链接做成可点击
+                if ($platformUrl !== '') {
+                    $esc = htmlspecialchars($platformUrl, ENT_QUOTES, 'UTF-8');
+                    $mailBody = str_replace($esc, '<a href="' . $esc . '">' . $esc . '</a>', $mailBody);
+                }
+                $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'           => 'confirm_result',
+                    'email_log_scydgy_id'       => (int)($dr['scydgy_id'] ?? 0),
+                    'email_log_purchase_order_id' => $poIdLog,
+                ], $mailConfig, $mailSubject);
+            } catch (\Throwable $e) {
+                Log::write('采购确认邮件失败 company=' . $cname . ' email=' . $toEmail . ' ' . $e->getMessage(), 'error');
+            }
+        };
+        $confirmNotifyVars['_fallback_detail_id'] = $fallbackDetailId;
         foreach ($winners as $dr) {
             $cname = trim((string)($dr['company_name'] ?? ''));
             $ph = trim((string)($dr['phone'] ?? ''));
@@ -5261,6 +5368,7 @@ class Procuremen extends Backend
                 'phone'        => $ph,
             ]));
             $sendSmsSafe($ph, $sms);
+            $sendConfirmEmailSafe($dr, 'confirm_ok_email');
         }
         foreach ($losers as $dr) {
             $cname = trim((string)($dr['company_name'] ?? ''));
@@ -5271,6 +5379,7 @@ class Procuremen extends Backend
                 'phone'        => $ph,
             ]));
             $sendSmsSafe($ph, $sms);
+            $sendConfirmEmailSafe($dr, 'confirm_fail_email');
         }
     }
 
@@ -7962,6 +8071,8 @@ class Procuremen extends Backend
             'deadline'            => (string)($ctx['deadline'] ?? ''),
             'process_lines'       => (string)($ctx['process_plain'] ?? ''),
             'process_lines_html'  => (string)($ctx['process_html'] ?? ''),
+            'order_type'          => (string)($ctx['order_type'] ?? ''),
+            'score_weights'       => (string)($ctx['score_weights'] ?? ''),
             'platform_url'        => $platformUrl,
             'platform_links'      => $platformLinksPlain,
             'platform_links_html' => $platformLinksHtml,
@@ -8084,6 +8195,9 @@ class Procuremen extends Backend
 
         $emailTplScene = trim($emailTplScene) !== '' ? trim($emailTplScene) : 'review_email';
         $ctx = $this->buildIssueNotifyContext($mergeRows, $sysRqNotify);
+        $orderTypeCtx = $this->resolveIssueNotifyOrderTypeFromBundle($bundle);
+        $ctx['order_type'] = (string)($orderTypeCtx['order_type'] ?? '');
+        $ctx['score_weights'] = (string)($orderTypeCtx['score_weights'] ?? '');
         $notifyBundle = $this->composeSupplierNotifyBundle($chosen, $ctx, $detailLinks, $emailTplScene);
         $notifyBundle['email_log_scene'] = $emailLogScene;
         $notifyBundle['email_log_scydgy_id'] = (int)array_key_first($sids);
@@ -8389,6 +8503,88 @@ class Procuremen extends Backend
         ]);
     }
 
+    /**
+     * 下发通知:解析订单类型名称与评分比例文案
+     *
+     * @param array<string, mixed> $bundle
+     * @return array{order_type:string,score_weights:string}
+     */
+    protected function resolveIssueNotifyOrderTypeFromBundle(array $bundle): array
+    {
+        $orderType = '';
+        $scoreWeights = '';
+        $rule = null;
+        if (!empty($bundle['score_rule']) && is_array($bundle['score_rule'])) {
+            $rule = $bundle['score_rule'];
+        }
+        $pos = $bundle['pos'] ?? [];
+        $po0 = (is_array($pos) && isset($pos[0]) && is_array($pos[0])) ? $pos[0] : null;
+        if ($rule === null && is_array($po0)) {
+            $ruleId = (int)($po0['score_rule_id'] ?? 0);
+            if ($ruleId > 0) {
+                try {
+                    $rule = ProcuremenSupplierScore::getRuleById($ruleId);
+                } catch (\Throwable $e) {
+                    $rule = null;
+                }
+            }
+            if (!is_array($rule) || (int)($rule['id'] ?? 0) <= 0) {
+                $qw = (int)($po0['score_quality_weight'] ?? 0);
+                $pw = (int)($po0['score_price_weight'] ?? 0);
+                $lw = (int)($po0['score_lead_weight'] ?? 0);
+                if ($qw > 0 || $pw > 0 || $lw > 0) {
+                    $rule = [
+                        'name'            => trim((string)($po0['score_rule_name'] ?? '')),
+                        'quality_weight'  => $qw,
+                        'price_weight'    => $pw,
+                        'lead_weight'     => $lw,
+                    ];
+                }
+            }
+        }
+        if (is_array($rule)) {
+            $orderType = trim((string)($rule['name'] ?? ''));
+            $scoreWeights = $this->formatScoreRuleWeightsText($rule);
+        }
+        if ($orderType === '' && is_array($po0)) {
+            $orderType = trim((string)($po0['score_rule_name'] ?? ''));
+        }
+
+        return [
+            'order_type'    => $orderType,
+            'score_weights' => $scoreWeights,
+        ];
+    }
+
+    /**
+     * 评分比例文案:质量30%、价格50%、交货20%
+     *
+     * @param array{quality_weight?:mixed,price_weight?:mixed,lead_weight?:mixed} $rule
+     */
+    protected function formatScoreRuleWeightsText(array $rule): string
+    {
+        $parts = [];
+        $qw = max(0, (float)($rule['quality_weight'] ?? 0));
+        $pw = max(0, (float)($rule['price_weight'] ?? 0));
+        $lw = max(0, (float)($rule['lead_weight'] ?? 0));
+        $fmt = static function ($n) {
+            $s = rtrim(rtrim(sprintf('%.2F', (float)$n), '0'), '.');
+
+            return $s === '' ? '0' : $s;
+        };
+        if ($qw > 0) {
+            $parts[] = '质量' . $fmt($qw) . '%';
+        }
+        if ($pw > 0) {
+            $parts[] = '价格' . $fmt($pw) . '%';
+        }
+        if ($lw > 0) {
+            $parts[] = '交货' . $fmt($lw) . '%';
+        }
+
+        return implode('、', $parts);
+    }
+
     /**
      * 协助下发通知上下文(订单号、工序明细等,供短信/邮件共用)
      *
@@ -8408,6 +8604,8 @@ class Procuremen extends Backend
             'process_plain' => $this->buildProcessLinesPlain($mergeRows),
             'process_html'  => $this->buildProcessLinesHtml($mergeRows),
             'deadline'      => $sysRqNotify,
+            'order_type'    => '',
+            'score_weights' => '',
         ];
     }
 
@@ -8626,6 +8824,74 @@ class Procuremen extends Backend
         }
     }
 
+    /**
+     * 确保「采购确认通过/未通过」邮箱模版存在(正文同短信,末尾加平台链接)
+     */
+    protected function ensureConfirmResultEmailTemplates(): void
+    {
+        static $done = false;
+        if ($done) {
+            return;
+        }
+        $done = true;
+        $now = date('Y-m-d H:i:s');
+        $seeds = [
+            'confirm_ok_email' => [
+                'title'   => '采购确认结果:已通过',
+                'content' => "【可集达】您好,{company_name}:\n\n您参与的外发加工订单采购确认结果:已通过。\n{process_lines}\n\n请前往平台查看:{platform_url}\n",
+                'remark'  => '审批通过-中标供应商邮箱通知;模版名称作邮件主题',
+            ],
+            'confirm_fail_email' => [
+                'title'   => '采购确认结果:未通过',
+                'content' => "【可集达】您好,{company_name}:\n\n您参与的外发加工订单采购确认结果:未通过。\n{process_lines}\n\n请前往平台查看:{platform_url}\n",
+                'remark'  => '审批通过-未中标供应商邮箱通知;模版名称作邮件主题',
+            ],
+        ];
+        try {
+            $cols = Db::query("SHOW COLUMNS FROM `purchase_sms_template`");
+        } catch (\Throwable $e) {
+            return;
+        }
+        $colNames = [];
+        foreach ($cols as $c) {
+            if (!is_array($c)) {
+                continue;
+            }
+            $n = (string)($c['Field'] ?? $c['field'] ?? '');
+            if ($n !== '') {
+                $colNames[$n] = true;
+            }
+        }
+        foreach ($seeds as $scene => $seed) {
+            try {
+                $row = Db::table('purchase_sms_template')->where('scene', $scene)->find();
+            } catch (\Throwable $e) {
+                continue;
+            }
+            if (is_array($row) && $row !== []) {
+                continue;
+            }
+            $data = [
+                'scene'   => $scene,
+                'title'   => $seed['title'],
+                'content' => $seed['content'],
+                'remark'  => $seed['remark'],
+                'status'  => 1,
+            ];
+            if (isset($colNames['updatetime'])) {
+                $data['updatetime'] = $now;
+            }
+            if (isset($colNames['createtime'])) {
+                $data['createtime'] = $now;
+            }
+            try {
+                Db::table('purchase_sms_template')->insert($data);
+            } catch (\Throwable $e) {
+                // ignore
+            }
+        }
+    }
+
     /**
      * 确保「供应商询价通知」邮箱模版存在(查询询价-询价/发邮件;可在短信模版配置中修改)
      */
@@ -10912,6 +11178,7 @@ class Procuremen extends Backend
             'ccydh'      => $ccydhPick,
             'pos'        => $posNotify,
             'merge_rows' => $mergeRows,
+            'score_rule' => is_array($scoreRule) ? $scoreRule : null,
         ];
         try {
             $this->notifyDryRunPreview = [];
@@ -12631,28 +12898,8 @@ class Procuremen extends Backend
             $completeTsMap = ProcuremenTime::loadOperLogTimestampMap(array_keys($sidList), ['purchase_confirm', 'mark_complete']);
         }
 
-        $searchKw = trim((string)$this->request->get('search', ''));
-        if ($searchKw === '') {
-            $searchKw = trim((string)$this->request->request('search', ''));
-        }
-        $filterRaw = $this->request->get('filter', '');
-        $filterArrTmp = [];
-        if (is_string($filterRaw) && $filterRaw !== '') {
-            $filterArrTmp = (array)json_decode($filterRaw, true);
-        } elseif (is_array($filterRaw)) {
-            $filterArrTmp = $filterRaw;
-        }
-        $hasSearch = ($searchKw !== '');
-        if (!$hasSearch) {
-            foreach ($filterArrTmp as $fv) {
-                if (trim((string)$fv) !== '') {
-                    $hasSearch = true;
-                    break;
-                }
-            }
-        }
-
-        $dbRows = array_values(array_filter($dbRows, function ($dbRow) use ($ym, $completeTsMap, $hasSearch) {
+        // 导出始终按「查看月份」取当月完结单;搜索只在当月内再缩小(勿跨月,否则会与列表不一致)
+        $dbRows = array_values(array_filter($dbRows, function ($dbRow) use ($ym, $completeTsMap) {
             if (!is_array($dbRow)) {
                 return false;
             }
@@ -12660,9 +12907,6 @@ class Procuremen extends Backend
             if ($done['ts'] <= 0) {
                 return false;
             }
-            if ($hasSearch) {
-                return true;
-            }
 
             return date('Y-m', $done['ts']) === $ym;
         }));
@@ -13312,15 +13556,21 @@ class Procuremen extends Backend
 
     protected function resolveProcuremenEmailSubject(string $scene): string
     {
-        // 询价通知业务员 / 供应商询价:模版名称即邮件主题
-        if ($scene === 'rfq_salesman_email' || $scene === 'rfq_supplier_email') {
+        // 询价 / 采购确认结果邮箱:模版名称即邮件主题
+        if (in_array($scene, ['rfq_salesman_email', 'rfq_supplier_email', 'confirm_ok_email', 'confirm_fail_email'], true)) {
             $row = $this->loadNotifyTemplateRow($scene);
             $title = is_array($row) ? trim((string)($row['title'] ?? '')) : '';
             if ($title !== '') {
                 return $title;
             }
+            $fallback = [
+                'rfq_supplier_email'   => '供应商询价通知',
+                'rfq_salesman_email'   => '协助采购询价通知',
+                'confirm_ok_email'     => '采购确认结果:已通过',
+                'confirm_fail_email'   => '采购确认结果:未通过',
+            ];
 
-            return $scene === 'rfq_supplier_email' ? '供应商询价通知' : '协助采购询价通知';
+            return $fallback[$scene] ?? '协助采购通知';
         }
         $map = [
             'review_email' => '您有新的协助加工订单',
@@ -13336,13 +13586,15 @@ class Procuremen extends Backend
     protected function notifyTemplateMissingMessage(string $scene, bool $titleRequired = false): string
     {
         $map = [
-            'review_email'       => '协助下发-邮箱',
-            'review_sms'         => '协助下发-短信',
-            'confirm_ok'         => '采购确认-通过',
-            'confirm_fail'       => '采购确认-未通过',
-            'bid_open'           => '开标双重验证',
-            'rfq_salesman_email' => '询价通知业务员-邮箱',
-            'rfq_supplier_email' => '供应商询价通知',
+            'review_email'         => '协助下发-邮箱',
+            'review_sms'           => '协助下发-短信',
+            'confirm_ok'           => '采购确认-通过',
+            'confirm_fail'         => '采购确认-未通过',
+            'confirm_ok_email'     => '采购确认通过-邮箱',
+            'confirm_fail_email'   => '采购确认未通过-邮箱',
+            'bid_open'             => '开标双重验证',
+            'rfq_salesman_email'   => '询价通知业务员-邮箱',
+            'rfq_supplier_email'   => '供应商询价通知',
         ];
         $label = $map[$scene] ?? $scene;
         if ($titleRequired) {

+ 20 - 9
application/admin/model/Purchasesmstemplate.php

@@ -18,13 +18,15 @@ class Purchasesmstemplate extends Model
     public function getSceneList()
     {
         return [
-            'review_email'        => '协助下发-邮箱',
-            'review_sms'          => '协助下发-短信',
-            'confirm_ok'          => '采购确认-通过',
-            'confirm_fail'        => '采购确认-未通过',
-            'bid_open'            => '开标双重验证',
-            'rfq_salesman_email'  => '询价通知业务员-邮箱',
-            'rfq_supplier_email'  => '供应商询价通知',
+            'review_email'         => '协助下发-邮箱',
+            'review_sms'           => '协助下发-短信',
+            'confirm_ok'           => '采购确认-通过',
+            'confirm_fail'         => '采购确认-未通过',
+            'confirm_ok_email'     => '采购确认通过-邮箱',
+            'confirm_fail_email'   => '采购确认未通过-邮箱',
+            'bid_open'             => '开标双重验证',
+            'rfq_salesman_email'   => '询价通知业务员-邮箱',
+            'rfq_supplier_email'   => '供应商询价通知',
         ];
     }
 
@@ -36,7 +38,14 @@ class Purchasesmstemplate extends Model
 
     public static function isEmailScene(string $scene): bool
     {
-        return in_array($scene, ['review_email', 'review', 'rfq_salesman_email', 'rfq_supplier_email'], true);
+        return in_array($scene, [
+            'review_email',
+            'review',
+            'rfq_salesman_email',
+            'rfq_supplier_email',
+            'confirm_ok_email',
+            'confirm_fail_email',
+        ], true);
     }
 
     public static function isSmsScene(string $scene): bool
@@ -83,7 +92,9 @@ class Purchasesmstemplate extends Model
             ['tag' => '{deadline}', 'label' => '截止时间', 'example' => '2026-05-18 14:30', 'scenes' => '协助下发/供应商询价'],
             ['tag' => '{process_lines}', 'label' => '订单工序明细(文本)', 'example' => "订单号:YW20240629001\n印件名称:藏书票2\n1.工序名称:做刀版 单位:张 本次数量:500", 'scenes' => '全部'],
             ['tag' => '{process_lines_html}', 'label' => '订单工序明细(表格)', 'example' => '<table>…</table>', 'scenes' => '协助下发/供应商询价邮箱'],
-            ['tag' => '{platform_url}', 'label' => '平台链接', 'example' => 'https://…', 'scenes' => '协助下发/供应商询价邮箱'],
+            ['tag' => '{order_type}', 'label' => '订单类型', 'example' => '普通订单(价格优先)', 'scenes' => '协助下发邮箱'],
+            ['tag' => '{score_weights}', 'label' => '评分比例', 'example' => '质量30%、价格50%、交货20%', 'scenes' => '协助下发邮箱'],
+            ['tag' => '{platform_url}', 'label' => '平台链接', 'example' => 'https://…', 'scenes' => '协助下发/供应商询价/采购确认邮箱'],
         ];
     }
 }

+ 1 - 1
application/admin/view/procuremen/outward_detail.html

@@ -478,7 +478,7 @@
 
     <p class="audit-notify-tip">
         <i class="fa fa-exclamation-triangle"></i>
-        <strong>重要提示:</strong>审批通过:将向中标供应商发送「已通过」短信、向未中标供应商发送「未通过」短信。
+        <strong>重要提示:</strong>审批通过:将向中标供应商发送「已通过」短信与邮件、向未中标供应商发送「未通过」短信与邮件(邮件含平台查看链接)
     </p>
     {if !empty($quoteVisible)}
     <p class="audit-score-rule-tip">

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

@@ -9,7 +9,7 @@
                 <a href="javascript:;" class="btn btn-success" id="btn-export-month-outward" title="导出外协加工明细 Excel(不含报价明细)"><i class="fa fa-download"></i> 导出外协加工明细</a>
                 {/if}
                 {if $canExportMonthQuote}
-                <a href="javascript:;" class="btn btn-info" id="btn-export-month-quote" title="导出供应商报价明细 Excel(含未中标)"><i class="fa fa-download"></i> 导出报价明细</a>
+                <a href="javascript:;" class="btn btn-info" id="btn-export-month-quote" title="按上方「查看月份」导出该月完结订单的供应商报价(含未中标;一单一供应商一行)"><i class="fa fa-download"></i> 导出报价明细</a>
                 {/if}
                 <span class="procuremen-export-ym-wrap" style="display:inline-block;margin-left:12px;vertical-align:middle;">
                     <label style="margin:0 6px 0 0;font-weight:normal;">查看月份</label>

+ 83 - 32
application/index/controller/Index.php

@@ -5332,45 +5332,90 @@ class Index extends Frontend
         if ($cywyxm === '') {
             $cywyxm = trim((string)($user['username'] ?? ''));
         }
-        $ccydh = $this->mprocAllocateManualOrderCcydh();
-        $sid = $this->mprocAllocateManualScydgyId();
         $now = date('Y-m-d H:i:s');
-        $data = [
-            'scydgy_id'                => $sid,
-            'CCYDH'                    => $ccydh,
-            'CYJMC'                    => $cyjmc,
-            'CGYMC'                    => $cgymc,
-            'CCLBMMC'                  => $cclbmmc,
-            'CDW'                      => trim((string)$this->request->post('CDW', '')),
-            'NGZL'                     => '',
-            'CDF'                      => trim((string)$this->request->post('CDF', '')),
-            'cGzzxMc'                  => '',
-            'MBZ'                      => trim((string)$this->request->post('MBZ', '')),
-            'cywyxm'                   => $cywyxm,
-            'notify_salesman'          => '',
-            'rfq_salesman_notified'    => 0,
-            'rfq_salesman_notify_time' => null,
-            'This_quantity'            => trim((string)$this->request->post('This_quantity', '')),
-            'ceilingPrice'             => trim((string)$this->request->post('ceilingPrice', '')),
-            'wflow_status'             => ProcuremenStatus::WFLOW_PENDING_ISSUE,
-            'status'                   => ProcuremenStatus::PO_IN_PROGRESS,
-            'createtime'               => $now,
-            'dStamp'                   => $now,
-            'dputrecord'               => $now,
-        ];
+        $lockName = 'procuremen_manual_order_alloc';
+        if (!$this->mprocAcquireNamedLock($lockName, 10)) {
+            $this->error('系统繁忙,请稍后重试');
+        }
         try {
-            $this->mprocEnsureRfqSalesmanColumns();
-            Db::table('purchase_order')->insert($data);
-        } catch (\Throwable $e) {
-            $this->error('新增失败:' . $e->getMessage());
+            $ccydh = $this->mprocAllocateManualOrderCcydh();
+            $sid = $this->mprocAllocateManualScydgyId();
+            $data = [
+                'scydgy_id'                => $sid,
+                'CCYDH'                    => $ccydh,
+                'CYJMC'                    => $cyjmc,
+                'CGYMC'                    => $cgymc,
+                'CCLBMMC'                  => $cclbmmc,
+                'CDW'                      => trim((string)$this->request->post('CDW', '')),
+                'NGZL'                     => '',
+                'CDF'                      => trim((string)$this->request->post('CDF', '')),
+                'cGzzxMc'                  => '',
+                'MBZ'                      => trim((string)$this->request->post('MBZ', '')),
+                'cywyxm'                   => $cywyxm,
+                'notify_salesman'          => '',
+                'rfq_salesman_notified'    => 0,
+                'rfq_salesman_notify_time' => null,
+                'This_quantity'            => trim((string)$this->request->post('This_quantity', '')),
+                'ceilingPrice'             => trim((string)$this->request->post('ceilingPrice', '')),
+                'wflow_status'             => ProcuremenStatus::WFLOW_PENDING_ISSUE,
+                'status'                   => ProcuremenStatus::PO_IN_PROGRESS,
+                'createtime'               => $now,
+                'dStamp'                   => $now,
+                'dputrecord'               => $now,
+            ];
+            try {
+                $this->mprocEnsureRfqSalesmanColumns();
+                Db::table('purchase_order')->insert($data);
+            } catch (\Throwable $e) {
+                $this->error('新增失败:' . $e->getMessage());
+            }
+            $nextCcydh = $this->mprocAllocateManualOrderCcydh();
+        } finally {
+            $this->mprocReleaseNamedLock($lockName);
         }
         $this->success('新增成功', '', [
             'scydgy_id'      => $sid,
             'CCYDH'          => $ccydh,
-            'nextOrderCcydh' => $this->mprocAllocateManualOrderCcydh(),
+            'nextOrderCcydh' => $nextCcydh,
         ]);
     }
 
+    /**
+     * MySQL 命名锁(跨连接防并发撞号)
+     */
+    protected function mprocAcquireNamedLock(string $name, int $timeoutSec = 10): bool
+    {
+        $name = trim($name);
+        if ($name === '') {
+            return false;
+        }
+        try {
+            $nameSql = str_replace(['\\', "'"], ['\\\\', "\\'"], $name);
+            $rows = Db::query('SELECT GET_LOCK(\'' . $nameSql . '\', ' . max(1, $timeoutSec) . ') AS locked');
+            if (!is_array($rows) || $rows === []) {
+                return false;
+            }
+            $v = $rows[0]['locked'] ?? $rows[0]['LOCKED'] ?? null;
+
+            return (int)$v === 1;
+        } catch (\Throwable $e) {
+            return false;
+        }
+    }
+
+    protected function mprocReleaseNamedLock(string $name): void
+    {
+        $name = trim($name);
+        if ($name === '') {
+            return;
+        }
+        try {
+            $nameSql = str_replace(['\\', "'"], ['\\\\', "\\'"], $name);
+            Db::query('SELECT RELEASE_LOCK(\'' . $nameSql . '\')');
+        } catch (\Throwable $e) {
+        }
+    }
+
     protected function mprocAllocateManualScydgyId(): int
     {
         try {
@@ -5383,6 +5428,10 @@ class Index extends Frontend
         }
     }
 
+    /**
+     * 手工新增订单号:YW + 年月日 + 3 位当日序号(如 YW20260618001)
+     * 调用方须在命名锁内执行,避免并发重复
+     */
     protected function mprocAllocateManualOrderCcydh(): string
     {
         $prefix = 'YW' . date('Ymd');
@@ -5394,15 +5443,17 @@ class Index extends Frontend
             if (is_array($rows)) {
                 foreach ($rows as $ccydh) {
                     $ccydh = trim((string)$ccydh);
-                    if (preg_match('/^' . preg_quote($prefix, '/') . '(\d{3})$/', $ccydh, $m)) {
+                    if (preg_match('/^' . preg_quote($prefix, '/') . '(\d{3,})$/', $ccydh, $m)) {
                         $maxSeq = max($maxSeq, (int)$m[1]);
                     }
                 }
             }
         } catch (\Throwable $e) {
         }
+        $next = $maxSeq + 1;
+        $width = $next > 999 ? strlen((string)$next) : 3;
 
-        return $prefix . str_pad((string)($maxSeq + 1), 3, '0', STR_PAD_LEFT);
+        return $prefix . str_pad((string)$next, $width, '0', STR_PAD_LEFT);
     }
 
     /**

+ 3 - 3
public/assets/js/backend/procuremen.js

@@ -4116,10 +4116,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
 
                 var confirmHtml = ''
                     + '<div style="text-align:left;line-height:1.75;font-size:13px;">'
-                    + '<p style="margin:0 0 10px 0;">提交后将<strong>立即发送短信</strong>,且<strong>不可撤回或更改</strong>。请确认以下通知:</p>'
+                    + '<p style="margin:0 0 10px 0;">提交后将<strong>立即发送短信与邮件</strong>,且<strong>不可撤回或更改</strong>。请确认以下通知:</p>'
                     + '<ul style="margin:0;padding-left:1.2em;">'
-                    + '<li style="margin-bottom:6px;"><strong>中标 ' + nOk + ' 家</strong>:将向 <strong>' + escHtml(okListText) + '</strong> 发送「已通过」短信;</li>'
-                    + '<li><strong>未中标 ' + nUn + ' 家</strong>:将向 <strong>' + (nUn ? escHtml(unListText) : '对应供应商') + '</strong> 发送「未通过」短信。</li>'
+                    + '<li style="margin-bottom:6px;"><strong>中标 ' + nOk + ' 家</strong>:将向 <strong>' + escHtml(okListText) + '</strong> 发送「已通过」短信与邮件;</li>'
+                    + '<li><strong>未中标 ' + nUn + ' 家</strong>:将向 <strong>' + (nUn ? escHtml(unListText) : '对应供应商') + '</strong> 发送「未通过」短信与邮件。</li>'
                     + '</ul>'
                     + '<p style="margin:12px 0 0 0;"><strong>是否确认提交?</strong></p>'
                     + '</div>';