m0_70156489 hai 5 días
pai
achega
1b22fb85be

+ 533 - 20
application/admin/controller/Procuremen.php

@@ -10,6 +10,7 @@ use app\common\library\ProcuremenStatus;
 use app\common\library\ProcuremenTime;
 use PHPMailer\PHPMailer\PHPMailer;
 use think\Config;
+use think\Cache;
 use PhpOffice\PhpSpreadsheet\Spreadsheet;
 use PhpOffice\PhpSpreadsheet\Style\Alignment;
 use PhpOffice\PhpSpreadsheet\Style\Border;
@@ -239,6 +240,9 @@ class Procuremen extends Backend
             'archiveabandon'          => $this->hasProcuremenPerm(['archiveabandon', 'archiveAbandon', 'auditabandon', 'auditAbandon']),
             'export_month_outward'    => $this->hasProcuremenPerm(['export_month_outward', 'exportMonthOutward']),
             'review'                  => $this->hasProcuremenPerm(['review', 'picksubmit', 'pickreview']),
+            'bidopenusers'            => $this->hasProcuremenPerm(['bidopenusers', 'bidopen', 'auditissue', 'auditsubmit']),
+            'bidopensendcode'         => $this->hasProcuremenPerm(['bidopensendcode', 'bidopen', 'auditissue', 'auditsubmit']),
+            'bidopenverify'           => $this->hasProcuremenPerm(['bidopenverify', 'bidopen', 'auditissue', 'auditsubmit']),
             'reviewcompanies'         => $this->hasProcuremenPerm(['reviewcompanies', 'reviewCompanies', 'pickreview', 'picksubmit', 'review']),
             'add'                     => $this->hasProcuremenPerm(['add']),
             'rfqlist'                 => $this->hasProcuremenPerm(['rfqlist']),
@@ -1911,20 +1915,20 @@ class Procuremen extends Backend
         $amountFilled = !empty($line['amount_filled']);
         $deliveryFilled = !empty($line['delivery_filled']);
 
-        $line['unit_price_text'] = $amountFilled ? '未到截止时间' : '未填写';
+        $line['unit_price_text'] = $amountFilled ? '开标验证后查看' : '未填写';
         $line['amount_show'] = '';
         if (array_key_exists('amount_text', $line)) {
             $line['amount_text'] = $line['unit_price_text'];
         }
 
-        $deliveryText = $deliveryFilled ? '未到截止时间' : '未填写';
+        $deliveryText = $deliveryFilled ? '开标验证后查看' : '未填写';
         $line['delivery_show'] = $deliveryText;
         if (array_key_exists('delivery_text', $line)) {
             $line['delivery_text'] = $deliveryText;
         }
         $line['delivery_ymd'] = '';
 
-        $line['subtotal_text'] = $amountFilled ? '未到截止时间' : '';
+        $line['subtotal_text'] = $amountFilled ? '' : '';
         $line['subtotal_num'] = null;
 
         $line['amount_quote_pending'] = $amountFilled ? 1 : 0;
@@ -1934,7 +1938,380 @@ class Procuremen extends Backend
         $line['delivery_filled'] = false;
         $line['is_quoted'] = false;
         if (array_key_exists('quote_label', $line)) {
-            $line['quote_label'] = ($amountFilled || $deliveryFilled) ? '未到截止时间' : '未报价';
+            $line['quote_label'] = ($amountFilled || $deliveryFilled) ? '未开标验证' : '未报价';
+        }
+    }
+
+    protected function ensurePurchaseOrderBidOpenTable(): void
+    {
+        try {
+            Db::query('SELECT 1 FROM `purchase_order_bid_open` LIMIT 1');
+        } catch (\Throwable $e) {
+            Db::execute(
+                "CREATE TABLE IF NOT EXISTS `purchase_order_bid_open` (
+                  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+                  `ccydh` varchar(64) NOT NULL DEFAULT '' COMMENT '订单号',
+                  `verifier1_id` int(10) unsigned NOT NULL DEFAULT 0,
+                  `verifier1_name` varchar(64) NOT NULL DEFAULT '',
+                  `verifier2_id` int(10) unsigned NOT NULL DEFAULT 0,
+                  `verifier2_name` varchar(64) NOT NULL DEFAULT '',
+                  `operator_id` int(10) unsigned NOT NULL DEFAULT 0,
+                  `operator_name` varchar(64) NOT NULL DEFAULT '',
+                  `createtime` datetime DEFAULT NULL,
+                  PRIMARY KEY (`id`),
+                  UNIQUE KEY `uk_ccydh` (`ccydh`)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协助采购开标双重验证'"
+            );
+        }
+    }
+
+    protected function isProcuremenBidOpenVerified(string $ccydh): bool
+    {
+        $ccydh = trim($ccydh);
+        if ($ccydh === '') {
+            return false;
+        }
+        $this->ensurePurchaseOrderBidOpenTable();
+        try {
+            $row = Db::table('purchase_order_bid_open')->where('ccydh', $ccydh)->find();
+
+            return is_array($row);
+        } catch (\Throwable $e) {
+            return false;
+        }
+    }
+
+    /**
+     * @param array{ccydh?:string} $bundle
+     */
+    protected function isProcuremenBidOpenVerifiedForBundle(array $bundle): bool
+    {
+        return $this->isProcuremenBidOpenVerified(trim((string)($bundle['ccydh'] ?? '')));
+    }
+
+    protected function canViewProcuremenSupplierQuotesForBundle(array $bundle): bool
+    {
+        return $this->isProcuremenBidOpenVerifiedForBundle($bundle);
+    }
+
+    protected function loadBidOpenAdminUser(int $adminId): ?array
+    {
+        if ($adminId <= 0) {
+            return null;
+        }
+        try {
+            $row = Db::name('admin')->where('id', $adminId)->find();
+        } catch (\Throwable $e) {
+            return null;
+        }
+        if (!is_array($row)) {
+            return null;
+        }
+        $status = strtolower(trim((string)($row['status'] ?? '')));
+        if ($status !== '' && $status !== 'normal') {
+            return null;
+        }
+        if (!$this->isBidOpenVerifierAdmin((int)($row['id'] ?? 0))) {
+            return null;
+        }
+        $phone = preg_replace('/\s+/', '', trim((string)($row['mobile'] ?? '')));
+        if (!preg_match('/^1\d{10}$/', $phone)) {
+            return null;
+        }
+        $name = trim((string)($row['nickname'] ?? ''));
+        if ($name === '') {
+            $name = trim((string)($row['username'] ?? ''));
+        }
+
+        return [
+            'id'       => (int)$row['id'],
+            'username' => trim((string)($row['username'] ?? '')),
+            'nickname' => $name,
+            'mobile'   => $phone,
+        ];
+    }
+
+    /**
+     * 开标验证人可选范围:auth_group 根组 id(含其全部子组)
+     */
+    protected function bidOpenVerifierAuthGroupRootId(): int
+    {
+        $id = (int)Config::get('mproc.bid_open_auth_group_root_id');
+
+        return $id > 0 ? $id : 10;
+    }
+
+    /**
+     * @return int[]
+     */
+    protected function loadBidOpenVerifierAuthGroupIds(): array
+    {
+        static $memo = [];
+        $rootId = $this->bidOpenVerifierAuthGroupRootId();
+        if (isset($memo[$rootId])) {
+            return $memo[$rootId];
+        }
+        try {
+            $groups = Db::name('auth_group')->where('status', 'normal')->field('id,pid')->select();
+        } catch (\Throwable $e) {
+            $memo[$rootId] = [$rootId];
+
+            return $memo[$rootId];
+        }
+        if (!is_array($groups) || $groups === []) {
+            $memo[$rootId] = [$rootId];
+
+            return $memo[$rootId];
+        }
+        $groupList = [];
+        foreach ($groups as $g) {
+            if (is_array($g)) {
+                $groupList[] = $g;
+            }
+        }
+        if ($groupList === []) {
+            $memo[$rootId] = [$rootId];
+
+            return $memo[$rootId];
+        }
+        $tree = \fast\Tree::instance();
+        $tree->init($groupList);
+        $ids = $tree->getChildrenIds($rootId, true);
+        if (!is_array($ids) || $ids === []) {
+            $memo[$rootId] = [$rootId];
+
+            return $memo[$rootId];
+        }
+        $memo[$rootId] = array_values(array_unique(array_filter(array_map('intval', $ids))));
+
+        return $memo[$rootId];
+    }
+
+    protected function isBidOpenVerifierAdmin(int $adminId): bool
+    {
+        if ($adminId <= 0) {
+            return false;
+        }
+        $groupIds = $this->loadBidOpenVerifierAuthGroupIds();
+        if ($groupIds === []) {
+            return false;
+        }
+        try {
+            return (bool)Db::name('auth_group_access')
+                ->where('uid', $adminId)
+                ->where('group_id', 'in', $groupIds)
+                ->count();
+        } catch (\Throwable $e) {
+            return false;
+        }
+    }
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    protected function listBidOpenVerifierCandidates(): array
+    {
+        $groupIds = $this->loadBidOpenVerifierAuthGroupIds();
+        if ($groupIds === []) {
+            return [];
+        }
+        try {
+            $adminIds = Db::name('auth_group_access')
+                ->where('group_id', 'in', $groupIds)
+                ->column('uid');
+        } catch (\Throwable $e) {
+            $adminIds = [];
+        }
+        $adminIds = is_array($adminIds)
+            ? array_values(array_unique(array_filter(array_map('intval', $adminIds))))
+            : [];
+        if ($adminIds === []) {
+            return [];
+        }
+        try {
+            $rows = Db::name('admin')
+                ->where('status', 'normal')
+                ->where('id', 'in', $adminIds)
+                ->where('mobile', '<>', '')
+                ->order('id', 'asc')
+                ->select();
+        } catch (\Throwable $e) {
+            return [];
+        }
+        if (!is_array($rows)) {
+            return [];
+        }
+        $groupNameMap = $this->loadBidOpenVerifierGroupNameMap($adminIds);
+        $out = [];
+        foreach ($rows as $row) {
+            if (!is_array($row)) {
+                continue;
+            }
+            $one = $this->loadBidOpenAdminUser((int)($row['id'] ?? 0));
+            if ($one === null) {
+                continue;
+            }
+            $out[] = [
+                'id'         => $one['id'],
+                'username'   => $one['username'],
+                'nickname'   => $one['nickname'],
+                'group_name' => $groupNameMap[$one['id']] ?? '',
+                'mobile'     => mask_phone($one['mobile']),
+            ];
+        }
+
+        return $out;
+    }
+
+    /**
+     * @param int[] $adminIds
+     * @return array<int, string>
+     */
+    protected function loadBidOpenVerifierGroupNameMap(array $adminIds): array
+    {
+        $adminIds = array_values(array_unique(array_filter(array_map('intval', $adminIds))));
+        if ($adminIds === []) {
+            return [];
+        }
+        $groupIds = $this->loadBidOpenVerifierAuthGroupIds();
+        try {
+            $rows = Db::name('auth_group_access')
+                ->alias('aga')
+                ->join('auth_group ag', 'aga.group_id = ag.id')
+                ->where('aga.uid', 'in', $adminIds)
+                ->where('aga.group_id', 'in', $groupIds)
+                ->field('aga.uid,ag.name')
+                ->order('aga.uid', 'asc')
+                ->order('aga.group_id', 'asc')
+                ->select();
+        } catch (\Throwable $e) {
+            return [];
+        }
+        if (!is_array($rows)) {
+            return [];
+        }
+        $out = [];
+        foreach ($rows as $row) {
+            if (!is_array($row)) {
+                continue;
+            }
+            $uid = (int)($row['uid'] ?? 0);
+            $name = trim((string)($row['name'] ?? ''));
+            if ($uid <= 0 || $name === '' || isset($out[$uid])) {
+                continue;
+            }
+            $out[$uid] = $name;
+        }
+
+        return $out;
+    }
+
+    protected function bidOpenSmsCacheKey(int $adminId, string $ccydh): string
+    {
+        return 'procuremen_bidopen_code_' . $adminId . '_' . md5($ccydh);
+    }
+
+    protected function bidOpenSmsWaitCacheKey(int $adminId): string
+    {
+        return 'procuremen_bidopen_wait_' . $adminId;
+    }
+
+    protected function renderBidOpenVerifySms(string $code): string
+    {
+        $tpl = '';
+        try {
+            $row = $this->loadNotifyTemplateRow('bid_open');
+            if (is_array($row)) {
+                $tpl = trim((string)($row['content'] ?? ''));
+            }
+        } catch (\Throwable $e) {
+            $tpl = '';
+        }
+        if ($tpl === '') {
+            $tpl = '【可集达】您的短信验证码是{code}';
+        }
+
+        return str_replace('{code}', $code, $tpl);
+    }
+
+    protected function sendBidOpenVerifySms(string $phone, string $code): void
+    {
+        $this->smsbao($phone, $this->renderBidOpenVerifySms($code));
+    }
+
+    protected function verifyBidOpenSmsCode(int $adminId, string $ccydh, string $code): bool
+    {
+        $code = trim($code);
+        if (!preg_match('/^\d{6}$/', $code)) {
+            return false;
+        }
+        $mock = Config::get('mproc.mock_sms_code');
+        if ($mock !== null && $mock !== '' && (string)$mock === $code) {
+            Cache::rm($this->bidOpenSmsCacheKey($adminId, $ccydh));
+
+            return true;
+        }
+        $cached = Cache::get($this->bidOpenSmsCacheKey($adminId, $ccydh));
+        if ($cached === false || $cached === null || (string)$cached !== $code) {
+            return false;
+        }
+        Cache::rm($this->bidOpenSmsCacheKey($adminId, $ccydh));
+
+        return true;
+    }
+
+    /**
+     * @param array{ccydh?:string,pos?:array,merge_rows?:array} $bundle
+     */
+    protected function recordProcuremenBidOpenVerification(array $bundle, array $verifier1, array $verifier2): void
+    {
+        $ccydh = trim((string)($bundle['ccydh'] ?? ''));
+        if ($ccydh === '') {
+            throw new \InvalidArgumentException('订单号无效');
+        }
+        $this->ensurePurchaseOrderBidOpenTable();
+        list($operatorId, $operatorName) = $this->GetUseName();
+        $now = date('Y-m-d H:i:s');
+        $exists = Db::table('purchase_order_bid_open')->where('ccydh', $ccydh)->find();
+        $payload = [
+            'ccydh'           => $ccydh,
+            'verifier1_id'    => (int)($verifier1['id'] ?? 0),
+            'verifier1_name'  => trim((string)($verifier1['nickname'] ?? '')),
+            'verifier2_id'    => (int)($verifier2['id'] ?? 0),
+            'verifier2_name'  => trim((string)($verifier2['nickname'] ?? '')),
+            'operator_id'     => (int)$operatorId,
+            'operator_name'   => trim((string)$operatorName),
+            'createtime'      => $now,
+        ];
+        if (is_array($exists)) {
+            Db::table('purchase_order_bid_open')->where('ccydh', $ccydh)->update($payload);
+        } else {
+            Db::table('purchase_order_bid_open')->insert($payload);
+        }
+
+        $name1 = $payload['verifier1_name'] !== '' ? $payload['verifier1_name'] : ('ID' . $payload['verifier1_id']);
+        $name2 = $payload['verifier2_name'] !== '' ? $payload['verifier2_name'] : ('ID' . $payload['verifier2_id']);
+        $orderPart = '(订单' . $ccydh . ')';
+        $logMsg = '开标验证' . $orderPart . ':' . $name1 . '、' . $name2 . ' 于 ' . $now . ' 完成双重验证,可查看供应商报价信息';
+
+        $seenSid = [];
+        foreach ($bundle['pos'] ?? [] as $poRow) {
+            if (!is_array($poRow)) {
+                continue;
+            }
+            $sid = (int)($poRow['scydgy_id'] ?? 0);
+            if (!$this->isValidScydgyRowId($sid) || isset($seenSid[$sid])) {
+                continue;
+            }
+            $seenSid[$sid] = true;
+            $poIdLog = (int)($poRow['id'] ?? $poRow['ID'] ?? 0);
+            $this->addOrderLog($sid, 'bid_open_verify', $logMsg, $poIdLog > 0 ? $poIdLog : null);
+        }
+        if ($seenSid === []) {
+            $anchor = (int)($this->request->post('scydgy_id', $this->request->param('ids', 0)));
+            if ($this->isValidScydgyRowId($anchor)) {
+                $this->addOrderLog($anchor, 'bid_open_verify', $logMsg, null);
+            }
         }
     }
 
@@ -1986,7 +2363,7 @@ class Procuremen extends Backend
         if ($sids === []) {
             return [];
         }
-        $deadlineReached = $this->isProcuremenQuoteDeadlineReached($this->resolveBundleSysRq($bundle));
+        $quoteVisible = $this->canViewProcuremenSupplierQuotesForBundle($bundle);
         $gymcMap = [];
         $qtyBySid = [];
         foreach ($bundle['merge_rows'] ?? [] as $mr) {
@@ -2094,7 +2471,7 @@ class Procuremen extends Backend
                 'is_quoted'       => $amountFilled,
                 'quote_label'     => $amountFilled ? '已报价' : '未报价',
             ];
-            if (!$deadlineReached) {
+            if (!$quoteVisible) {
                 $this->maskSupplierQuoteLineBeforeDeadline($lineRow);
             }
             $byCompany[$cn]['lines'][] = $this->ensureSupplierQuoteLineDisplayKeys($lineRow);
@@ -2104,7 +2481,7 @@ class Procuremen extends Backend
             $quoted = 0;
             $groupTotal = 0.0;
             $groupHasTotal = false;
-            if ($deadlineReached) {
+            if ($quoteVisible) {
                 foreach ($g['lines'] as $ln) {
                     $am = trim((string)($ln['amount'] ?? ''));
                     if ($am !== '' && $am !== '0' && $am !== '0.00') {
@@ -2116,10 +2493,10 @@ class Procuremen extends Backend
                     }
                 }
             }
-            $byCompany[$cn]['has_quote'] = $deadlineReached && $total > 0 && $quoted === $total;
+            $byCompany[$cn]['has_quote'] = $quoteVisible && $total > 0 && $quoted === $total;
             $byCompany[$cn]['line_count'] = $total;
             $byCompany[$cn]['has_total'] = $total > 0;
-            $byCompany[$cn]['total_text'] = ($deadlineReached && $groupHasTotal) ? $this->formatProcuremenMoneyDisplay($groupTotal) : '';
+            $byCompany[$cn]['total_text'] = ($quoteVisible && $groupHasTotal) ? $this->formatProcuremenMoneyDisplay($groupTotal) : '';
             $byCompany[$cn]['display_rowspan'] = $total + ($total > 0 ? 1 : 0);
         }
 
@@ -2149,7 +2526,7 @@ class Procuremen extends Backend
         if ($sids === []) {
             return [];
         }
-        $deadlineReached = $this->isProcuremenQuoteDeadlineReached($this->resolveBundleSysRq($bundle));
+        $quoteVisible = $this->canViewProcuremenSupplierQuotesForBundle($bundle);
         $gymcMap = [];
         $qtyBySid = [];
         $orderedSids = [];
@@ -2260,7 +2637,7 @@ class Procuremen extends Backend
                 'subtotal_num'    => $subtotal,
                 'is_quoted'       => $amountFilled,
             ];
-            if (!$deadlineReached) {
+            if (!$quoteVisible) {
                 $this->maskSupplierQuoteLineBeforeDeadline($pickLine);
             }
             $byCompany[$cn]['pick_lines'][$sid] = $this->ensureSupplierQuoteLineDisplayKeys($pickLine);
@@ -2294,7 +2671,7 @@ class Procuremen extends Backend
             }
             $groupTotal = 0.0;
             $groupHasTotal = false;
-            if ($deadlineReached) {
+            if ($quoteVisible) {
                 foreach ($pickLines as $ln) {
                     if (isset($ln['subtotal_num']) && $ln['subtotal_num'] !== null) {
                         $groupTotal += (float)$ln['subtotal_num'];
@@ -2306,7 +2683,7 @@ class Procuremen extends Backend
             $g['pick_lines'] = $pickLines;
             $g['line_count'] = $lineCount;
             $g['has_total'] = $lineCount > 0;
-            $g['total_text'] = ($deadlineReached && $groupHasTotal) ? $this->formatProcuremenMoneyDisplay($groupTotal) : '';
+            $g['total_text'] = ($quoteVisible && $groupHasTotal) ? $this->formatProcuremenMoneyDisplay($groupTotal) : '';
             $g['display_rowspan'] = $lineCount + ($lineCount > 0 ? 1 : 0);
             $detailPicks = $g['detail_picks'] ?? [];
             $g['detail_picks'] = array_values(is_array($detailPicks) ? $detailPicks : []);
@@ -4921,7 +5298,8 @@ class Procuremen extends Backend
     protected function buildProcuremenDetailsQuoteView(array $mergeRows, array $details, array $main): array
     {
         $processRows = $this->buildAuditProcessDisplayRows($mergeRows);
-        $deadlineReached = $this->isProcuremenQuoteDeadlineReached($main['sys_rq'] ?? '');
+        $ccydh = trim((string)($main['CCYDH'] ?? ''));
+        $quoteVisible = $this->isProcuremenBidOpenVerified($ccydh);
         $qtyBySid = [];
         $gymcBySid = [];
         $orderedSids = [];
@@ -4972,7 +5350,7 @@ class Procuremen extends Backend
                 &$groupVoid,
                 $qtyBySid,
                 $gymcBySid,
-                $deadlineReached
+                $quoteVisible
             ) {
                 $isVoid = $this->isPurchaseOrderDetailVoid($d);
                 if (!$isVoid) {
@@ -5002,7 +5380,7 @@ class Procuremen extends Backend
                     'is_void'         => $isVoid,
                     'oper_time_text'  => $ct,
                 ];
-                if (!$deadlineReached && !$isVoid) {
+                if (!$quoteVisible && !$isVoid) {
                     $this->maskSupplierQuoteLineBeforeDeadline($line);
                 } elseif ($sub !== null) {
                     $groupTotal += $sub;
@@ -5046,7 +5424,7 @@ class Procuremen extends Backend
                 'lines'          => $lines,
                 'line_count'     => $lineCount,
                 'display_rowspan'=> $lineCount + ($lineCount > 0 ? 1 : 0),
-                'total_text'     => ($deadlineReached && $groupHas) ? $this->formatProcuremenMoneyDisplay($groupTotal) : '',
+                'total_text'     => ($quoteVisible && $groupHas) ? $this->formatProcuremenMoneyDisplay($groupTotal) : '',
                 'has_total'      => $lineCount > 0,
             ];
         }
@@ -6510,11 +6888,145 @@ class Procuremen extends Backend
         $this->view->assign('quoteGroupsJson', json_encode($this->maskSupplierContactList($quoteGroups), JSON_UNESCAPED_UNICODE));
         $this->view->assign('pickedCompanyName', $pickedName);
         $this->view->assign('sysRq', $this->formatProcuremenSysRqForInput($po['sys_rq'] ?? null));
-        $this->view->assign('quoteDeadlineReached', $this->isProcuremenQuoteDeadlineReached($this->resolveBundleSysRq($bundle)) ? 1 : 0);
+        $this->view->assign('bidOpenVerified', $this->isProcuremenBidOpenVerifiedForBundle($bundle) ? 1 : 0);
+        $this->view->assign('quoteDeadlineReached', $this->isProcuremenBidOpenVerifiedForBundle($bundle) ? 1 : 0);
 
         return $this->view->fetch('procuremen/audit_issue');
     }
 
+    /**
+     * 开标双重验证 — 可选验证人列表
+     */
+    public function bidOpenUsers()
+    {
+        if (!$this->hasProcuremenPerm(['bidopenusers', 'bidopen', 'auditissue', 'auditsubmit'])) {
+            $this->error('无权限');
+        }
+        $scydgyId = trim((string)$this->request->param('scydgy_id', $this->request->param('ids', '')));
+        if ($scydgyId !== '') {
+            $bundle = $this->loadAuditOrderBundleByScydgyId($scydgyId);
+            if ($this->isProcuremenBidOpenVerifiedForBundle($bundle)) {
+                $this->success('已完成开标验证', null, [
+                    'verified' => 1,
+                    'users'    => [],
+                ]);
+            }
+        }
+        $this->success('', null, [
+            'verified' => 0,
+            'users'    => $this->listBidOpenVerifierCandidates(),
+        ]);
+    }
+
+    /**
+     * 开标双重验证 — 向指定管理员发送短信验证码
+     */
+    public function bidOpenSendCode()
+    {
+        if (!$this->request->isPost()) {
+            $this->error(__('Invalid parameters'));
+        }
+        if (!$this->hasProcuremenPerm(['bidopensendcode', 'bidopen', 'auditissue', 'auditsubmit'])) {
+            $this->error('无权限');
+        }
+        $scydgyId = trim((string)$this->request->post('scydgy_id', ''));
+        $adminId = (int)$this->request->post('admin_id', 0);
+        if ($scydgyId === '' || $adminId <= 0) {
+            $this->error('参数无效');
+        }
+        $bundle = $this->loadAuditOrderBundleByScydgyId($scydgyId);
+        if ($bundle['pos'] === []) {
+            $this->error('该订单不在待确认供应商状态');
+        }
+        if ($this->isProcuremenBidOpenVerifiedForBundle($bundle)) {
+            $this->success('已完成开标验证,无需重复发送');
+        }
+        $ccydh = trim((string)($bundle['ccydh'] ?? ''));
+        if ($ccydh === '') {
+            $this->error('订单号无效');
+        }
+        $admin = $this->loadBidOpenAdminUser($adminId);
+        if ($admin === null) {
+            $this->error('验证人无效或未绑定手机号');
+        }
+        $cd = (int)(Config::get('mproc.sms_resend_cd') ?: 55);
+        $cd = max(30, min(120, $cd));
+        if (Cache::get($this->bidOpenSmsWaitCacheKey($adminId))) {
+            $this->error('发送过于频繁,请稍后再试');
+        }
+        $code = (string)random_int(100000, 999999);
+        $ttl = (int)(Config::get('mproc.sms_code_ttl') ?: 300);
+        $ttl = max(60, min(600, $ttl));
+        Cache::set($this->bidOpenSmsCacheKey($adminId, $ccydh), $code, $ttl);
+        Cache::set($this->bidOpenSmsWaitCacheKey($adminId), 1, $cd);
+        try {
+            $this->sendBidOpenVerifySms($admin['mobile'], $code);
+        } catch (\Throwable $e) {
+            Cache::rm($this->bidOpenSmsCacheKey($adminId, $ccydh));
+            Cache::rm($this->bidOpenSmsWaitCacheKey($adminId));
+            $this->error($e->getMessage());
+        }
+        $this->success('验证码已发送至「' . ($admin['nickname'] !== '' ? $admin['nickname'] : $admin['username']) . '」');
+    }
+
+    /**
+     * 开标双重验证 — 两人验证码校验并记录
+     */
+    public function bidOpenVerify()
+    {
+        if (!$this->request->isPost()) {
+            $this->error(__('Invalid parameters'));
+        }
+        if (!$this->hasProcuremenPerm(['bidopenverify', 'bidopen', 'auditissue', 'auditsubmit'])) {
+            $this->error('无权限');
+        }
+        $scydgyId = trim((string)$this->request->post('scydgy_id', ''));
+        $verifier1Id = (int)$this->request->post('verifier1_id', 0);
+        $verifier2Id = (int)$this->request->post('verifier2_id', 0);
+        $code1 = trim((string)$this->request->post('code1', ''));
+        $code2 = trim((string)$this->request->post('code2', ''));
+        if ($scydgyId === '') {
+            $this->error('参数无效');
+        }
+        if ($verifier1Id <= 0 || $verifier2Id <= 0) {
+            $this->error('请选择两位验证人');
+        }
+        if ($verifier1Id === $verifier2Id) {
+            $this->error('两位验证人不能相同');
+        }
+        if (!preg_match('/^\d{6}$/', $code1) || !preg_match('/^\d{6}$/', $code2)) {
+            $this->error('请输入6位短信验证码');
+        }
+        $bundle = $this->loadAuditOrderBundleByScydgyId($scydgyId);
+        if ($bundle['pos'] === []) {
+            $this->error('该订单不在待确认供应商状态');
+        }
+        $ccydh = trim((string)($bundle['ccydh'] ?? ''));
+        if ($ccydh === '') {
+            $this->error('订单号无效');
+        }
+        if ($this->isProcuremenBidOpenVerifiedForBundle($bundle)) {
+            $this->success('已完成开标验证');
+        }
+        $verifier1 = $this->loadBidOpenAdminUser($verifier1Id);
+        $verifier2 = $this->loadBidOpenAdminUser($verifier2Id);
+        if ($verifier1 === null || $verifier2 === null) {
+            $this->error('验证人无效或未绑定手机号');
+        }
+        if (!$this->verifyBidOpenSmsCode($verifier1Id, $ccydh, $code1)) {
+            $this->error('验证人「' . ($verifier1['nickname'] !== '' ? $verifier1['nickname'] : $verifier1['username']) . '」验证码不正确或已过期');
+        }
+        if (!$this->verifyBidOpenSmsCode($verifier2Id, $ccydh, $code2)) {
+            $this->error('验证人「' . ($verifier2['nickname'] !== '' ? $verifier2['nickname'] : $verifier2['username']) . '」验证码不正确或已过期');
+        }
+        try {
+            $this->recordProcuremenBidOpenVerification($bundle, $verifier1, $verifier2);
+        } catch (\Throwable $e) {
+            $this->error($e->getMessage());
+        }
+        $this->success('开标验证成功,可查看供应商报价与交货日期');
+    }
+
     /**
      * 确认供应商提交(选定唯一供应商,进入采购终审;默认不发短信)
      */
@@ -6557,8 +7069,8 @@ class Procuremen extends Backend
         }
 
         $sysRqDb = $this->parseProcuremenSysRqInput(trim((string)$this->request->post('sys_rq', '')), true);
-        if (!$this->isProcuremenQuoteDeadlineReached($sysRqDb)) {
-            $this->error('报价截止时间未到,暂不能确认供应商');
+        if (!$this->isProcuremenBidOpenVerifiedForBundle($bundle)) {
+            $this->error('请先完成开标双重验证后再确认供应商');
         }
 
         $ccydhLog = trim((string)($bundle['ccydh'] ?? ''));
@@ -8136,6 +8648,7 @@ class Procuremen extends Backend
             'review_sms'   => '协助下发-短信',
             'confirm_ok'   => '采购确认-通过',
             'confirm_fail' => '采购确认-未通过',
+            'bid_open'     => '开标双重验证',
         ];
         $label = $map[$scene] ?? $scene;
         if ($titleRequired) {

+ 2 - 1
application/admin/model/Purchasesmstemplate.php

@@ -22,6 +22,7 @@ class Purchasesmstemplate extends Model
             'review_sms'   => '协助下发-短信',
             'confirm_ok'   => '采购确认-通过',
             'confirm_fail' => '采购确认-未通过',
+            'bid_open'     => '开标双重验证',
         ];
     }
 
@@ -38,7 +39,7 @@ class Purchasesmstemplate extends Model
 
     public static function isSmsScene(string $scene): bool
     {
-        return in_array($scene, ['review_sms', 'confirm_ok', 'confirm_fail'], true);
+        return in_array($scene, ['review_sms', 'confirm_ok', 'confirm_fail', 'bid_open'], true);
     }
 
     /**

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

@@ -98,7 +98,7 @@
     }
     .audit-issue-wrap .audit-deadline-input-wrap {
         position: relative;
-        flex: 1 1 auto;
+        flex: 0 1 auto;
         min-width: 0;
         z-index: 62;
     }
@@ -147,6 +147,64 @@
     .audit-issue-wrap .audit-quote-detail-table td.audit-quote-empty {
         color: #e67e22;
     }
+    .audit-issue-wrap .audit-deadline-row .audit-bid-open-btn {
+        flex-shrink: 0;
+        height: 32px;
+        line-height: 20px;
+        margin-left: 4px;
+    }
+    .audit-issue-wrap .audit-deadline-row .audit-bid-open-verified-tag {
+        flex-shrink: 0;
+        height: 32px;
+        line-height: 32px;
+        font-size: 15px;
+        font-weight: 600;
+        color: #3c763d;
+        white-space: nowrap;
+        margin-left: 8px;
+    }
+    .audit-issue-wrap .audit-deadline-row .audit-bid-open-verified-tag .fa {
+        font-size: 16px;
+        margin-right: 4px;
+        vertical-align: -1px;
+    }
+    .audit-bid-open-modal {
+        padding: 16px 18px 8px;
+        font-size: 13px;
+    }
+    .audit-bid-open-modal .bid-open-tip {
+        margin: 0 0 12px;
+        padding: 8px 10px;
+        color: #8a6d3b;
+        background: #fcf8e3;
+        border: 1px solid #faebcc;
+        border-radius: 3px;
+        line-height: 1.6;
+    }
+    .audit-bid-open-modal .bid-open-row {
+        display: flex;
+        align-items: center;
+        flex-wrap: wrap;
+        gap: 8px;
+        margin-bottom: 10px;
+    }
+    .audit-bid-open-modal .bid-open-row label {
+        width: 72px;
+        margin: 0;
+        font-weight: 600;
+        color: #333;
+    }
+    .audit-bid-open-modal .bid-open-row select {
+        width: 180px;
+        height: 32px;
+    }
+    .audit-bid-open-modal .bid-open-row .bid-open-code {
+        width: 110px;
+        height: 32px;
+    }
+    .audit-bid-open-modal .bid-open-row .btn-send-code {
+        min-width: 88px;
+    }
     .audit-issue-wrap .audit-quote-detail-table td.audit-quote-pending {
         color: #e67e22;
     }
@@ -190,6 +248,8 @@
         {:token()}
         <input type="hidden" name="scydgy_id" value="{$scydgyId|htmlentities}"/>
         <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-order-ccydh" value="{$orderCcydh|default=''|htmlentities}"/>
         <div class="audit-deadline-row">
             <span class="audit-field-label">截止时间:<span class="text-danger">*</span></span>
             <div class="audit-deadline-input-wrap procuremen-sys-rq-split" id="audit-sys-rq-wrap">
@@ -201,17 +261,26 @@
                         <span class="procuremen-sys-rq-colon">:</span>
                         <select class="form-control procuremen-sys-rq-minute" title="分"></select>
                     </div>
+                    {if $bidOpenVerified}
+                    <span class="audit-bid-open-verified-tag"><i class="fa fa-check-circle"></i> 已开标验证</span>
+                    {else /}
+                    {if $auth->check('procuremen/bidopenverify') || $auth->check('procuremen/bidopen') || $auth->check('procuremen/auditissue')}
+                    <button type="button" class="btn btn-warning btn-sm audit-bid-open-btn" id="btn-audit-bid-open">
+                        <i class="fa fa-unlock-alt"></i> 开标
+                    </button>
+                    {/if}
+                    {/if}
                 </div>
             </div>
         </div>
     </form>
-    <p class="audit-notify-tip" style="margin-top:-2px;">
-        <i class="fa fa-clock-o"></i>
-        报价截止时间未到:未报价显示「未填写」,已报价显示「未到截止时间」;到期后可查看具体金额与交期。
-    </p>
+<!--    <p class="audit-notify-tip" style="margin-top:-2px;">-->
+<!--        <i class="fa fa-shield"></i>-->
+<!--        未开标验证前,供应商单价与交货日期显示「未开标验证」;请点击「开标」完成双重验证后方可查看报价与交期并确认供应商(验证一次后本单可持续操作)。-->
+<!--    </p>-->
     <p class="audit-notify-tip">
         <i class="fa fa-exclamation-triangle"></i>
-        <strong>重要提示:</strong>请查看各供应商报价,<strong>选定唯一一家</strong>后点击「确认供应商」进入采购终审(本步<strong>任何发送短信通知</strong>)。
+        <strong>重要提示:</strong>可先预选供应商,但点击「确认」前须完成开标验证。确认后将进入采购终审(本步<strong>不发送短信通知</strong>)。
         需再次提醒报价时,请在右侧<strong>操作</strong>列点击「发邮件」或「发短信」。
     </p>
     <div class="audit-table-wrap audit-quote-table-wrap">

+ 24 - 7
application/common.php

@@ -32,8 +32,8 @@ if (!function_exists('__')) {
 if (!function_exists('mask_email')) {
 
     /**
-     * 邮箱脱敏:保留本地部分前三位,其余用 * 代替,域名完整保留
-     * 例:14906570@qq.com → 149***@qq.com
+     * 邮箱脱敏:本地部分前三位 + *** + 域名中最后一个点及其后缀
+     * 例:14906570@qq.com → 149***.com;yjf123@163.com → yjf***.com
      */
     function mask_email($email)
     {
@@ -41,19 +41,36 @@ if (!function_exists('mask_email')) {
         if ($email === '') {
             return '';
         }
-        $at = strpos($email, '@');
+        $at = mb_strpos($email, '@', 0, 'UTF-8');
         if ($at === false) {
             if (mb_strlen($email, 'UTF-8') <= 3) {
                 return $email;
             }
-            return mb_substr($email, 0, 3, 'UTF-8') . '***';
+            $lastDot = mb_strrpos($email, '.', 0, 'UTF-8');
+            if ($lastDot === false || $lastDot < 3) {
+                return mb_substr($email, 0, 3, 'UTF-8') . '***';
+            }
+
+            return mb_substr($email, 0, 3, 'UTF-8') . '***' . mb_substr($email, $lastDot, null, 'UTF-8');
         }
         $local = mb_substr($email, 0, $at, 'UTF-8');
         $domain = mb_substr($email, $at + 1, null, 'UTF-8');
-        if (mb_strlen($local, 'UTF-8') <= 3) {
-            return $local . '@' . $domain;
+        if ($domain === '') {
+            if (mb_strlen($local, 'UTF-8') <= 3) {
+                return $local;
+            }
+
+            return mb_substr($local, 0, 3, 'UTF-8') . '***';
+        }
+        $first3 = mb_strlen($local, 'UTF-8') <= 3
+            ? $local
+            : mb_substr($local, 0, 3, 'UTF-8');
+        $lastDot = mb_strrpos($domain, '.', 0, 'UTF-8');
+        if ($lastDot === false) {
+            return $first3 . '***@' . $domain;
         }
-        return mb_substr($local, 0, 3, 'UTF-8') . '***@' . $domain;
+
+        return $first3 . '***' . mb_substr($domain, $lastDot, null, 'UTF-8');
     }
 }
 

+ 6 - 0
application/common/library/ProcuremenTime.php

@@ -174,6 +174,12 @@ class ProcuremenTime
                 return '确认供应商:已选定「' . $name . '」';
             }
         }
+        if ($action === 'bid_open_verify') {
+            if (preg_match('/开标验证/u', $content)) {
+                return $content;
+            }
+            return '开标验证:' . $content;
+        }
         if ($action === 'issue_submit') {
             if (preg_match('/通知供应商[((](\d+)\s*家[))].*?[::]\s*(.+)$/u', $content, $m)) {
                 return '下发:通知供应商(' . $m[1] . ' 家):' . trim($m[2]);

+ 3 - 1
application/extra/mproc.php

@@ -21,5 +21,7 @@ return [
     // 'smsbao_user' => 'zhuwei123',
     // 'smsbao_pass' => '明文密码',
     // 仅本地调试:与登录页输入的 6 位验证码一致时跳过短信缓存校验;上线请注释或删除
-    'mock_sms_code' => '123123',
+    'mock_sms_code' => '',
+    // 开标双重验证:可选验证人所属角色组根 id(含全部子组),默认 10(管理员/业务员/审批员/采购员等)
+    'bid_open_auth_group_root_id' => 10,
 ];

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

@@ -7,14 +7,26 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
         }
         var at = email.indexOf('@');
         if (at < 0) {
-            return email.length <= 3 ? email : email.slice(0, 3) + '***';
+            if (email.length <= 3) {
+                return email;
+            }
+            var dotIdx = email.lastIndexOf('.');
+            if (dotIdx < 0 || dotIdx < 3) {
+                return email.slice(0, 3) + '***';
+            }
+            return email.slice(0, 3) + '***' + email.slice(dotIdx);
         }
         var local = email.slice(0, at);
         var domain = email.slice(at + 1);
-        if (local.length <= 3) {
-            return local + '@' + domain;
+        if (!domain) {
+            return local.length <= 3 ? local : local.slice(0, 3) + '***';
+        }
+        var first3 = local.length <= 3 ? local : local.slice(0, 3);
+        var domainDot = domain.lastIndexOf('.');
+        if (domainDot < 0) {
+            return first3 + '***@' + domain;
         }
-        return local.slice(0, 3) + '***@' + domain;
+        return first3 + '***' + domain.slice(domainDot);
     }
 
     function maskProcuremenPhone(phone) {
@@ -2934,6 +2946,241 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
 
             procuremenInitSysRqSplit($('#audit-sys-rq-wrap'), 'auditIssue');
 
+            function auditIssueBidOpenVerified() {
+                return parseInt($('#audit-bid-open-verified').val(), 10) === 1;
+            }
+
+            function auditIssueBidOpenSmsResendCd() {
+                var cd = 55;
+                if (typeof Config !== 'undefined' && Config.mprocSmsResendCd) {
+                    cd = parseInt(Config.mprocSmsResendCd, 10) || cd;
+                }
+                return Math.max(30, Math.min(120, cd));
+            }
+
+            function auditIssueResetBidOpenSendBtn($btn) {
+                if (!$btn || !$btn.length) {
+                    return;
+                }
+                var timer = $btn.data('cdTimer');
+                if (timer) {
+                    clearInterval(timer);
+                    $btn.removeData('cdTimer');
+                }
+                $btn.data('loading', 0);
+                $btn.prop('disabled', false).text('发送验证码');
+            }
+
+            function auditIssueStartBidOpenSendCountdown($btn) {
+                if (!$btn || !$btn.length) {
+                    return;
+                }
+                var timer = $btn.data('cdTimer');
+                if (timer) {
+                    clearInterval(timer);
+                    $btn.removeData('cdTimer');
+                }
+                var left = auditIssueBidOpenSmsResendCd();
+                $btn.data('loading', 0).prop('disabled', true).text('已发送');
+                setTimeout(function () {
+                    if (!$btn.closest('body').length) {
+                        return;
+                    }
+                    $btn.text(left + '秒后重发');
+                    timer = setInterval(function () {
+                        left--;
+                        if (!$btn.closest('body').length) {
+                            clearInterval(timer);
+                            return;
+                        }
+                        if (left <= 0) {
+                            clearInterval(timer);
+                            $btn.removeData('cdTimer');
+                            $btn.prop('disabled', false).text('发送验证码');
+                            return;
+                        }
+                        $btn.text(left + '秒后重发');
+                    }, 1000);
+                    $btn.data('cdTimer', timer);
+                }, 1000);
+            }
+
+            function auditIssueOpenBidModal() {
+                if (!procuremenCan('bidopenverify') && !procuremenCan('bidopen') && !procuremenCan('auditissue')) {
+                    Toastr.warning('无开标权限');
+                    return;
+                }
+                if (auditIssueBidOpenVerified()) {
+                    Toastr.info('本单已完成开标验证');
+                    return;
+                }
+                var users = [];
+
+                function auditIssueRenderBidOpenUserOptions(excludeId, selectedId) {
+                    var opts = '<option value="">请选择</option>';
+                    $.each(users, function (i, u) {
+                        var uid = parseInt(u.id, 10) || 0;
+                        if (!uid) {
+                            return;
+                        }
+                        if (excludeId && uid === parseInt(excludeId, 10)) {
+                            return;
+                        }
+                        var label = (u.nickname || u.username || ('ID' + uid));
+                        if (u.group_name || u.mobile) {
+                            label += '(';
+                            if (u.group_name) {
+                                label += u.group_name;
+                            }
+                            if (u.group_name && u.mobile) {
+                                label += ' · ';
+                            }
+                            if (u.mobile) {
+                                label += u.mobile;
+                            }
+                            label += ')';
+                        }
+                        var selected = selectedId && uid === parseInt(selectedId, 10) ? ' selected="selected"' : '';
+                        opts += '<option value="' + uid + '"' + selected + '>' + $('<div>').text(label).html() + '</option>';
+                    });
+                    return opts;
+                }
+
+                function auditIssueSyncBidOpenUserSelects(changedSlot) {
+                    var v1 = parseInt($('#bid-open-user-1').val(), 10) || 0;
+                    var v2 = parseInt($('#bid-open-user-2').val(), 10) || 0;
+                    if (v1 && v2 && v1 === v2) {
+                        if (changedSlot === 1) {
+                            v2 = 0;
+                            $('#bid-open-user-2').val('');
+                            $('#bid-open-code-2').val('');
+                        } else {
+                            v1 = 0;
+                            $('#bid-open-user-1').val('');
+                            $('#bid-open-code-1').val('');
+                        }
+                    }
+                    $('#bid-open-user-1').html(auditIssueRenderBidOpenUserOptions(v2, v1));
+                    $('#bid-open-user-2').html(auditIssueRenderBidOpenUserOptions(v1, v2));
+                }
+
+                var html = [
+                    '<div class="audit-bid-open-modal">',
+                    '<p class="bid-open-tip"><i class="fa fa-info-circle"></i> 请选择两名用户,分别发送验证码并填写。验证通过后可查看供应商报价信息。</p>',
+                    '<div class="bid-open-row bid-open-row-1">',
+                    '<label>验证人1</label>',
+                    '<select class="form-control bid-open-user" id="bid-open-user-1"><option value="">请选择</option></select>',
+                    '<button type="button" class="btn btn-default btn-sm btn-send-code" data-slot="1">发送验证码</button>',
+                    '<input type="text" class="form-control bid-open-code" id="bid-open-code-1" maxlength="6" placeholder="6位验证码"/>',
+                    '</div>',
+                    '<div class="bid-open-row bid-open-row-2">',
+                    '<label>验证人2</label>',
+                    '<select class="form-control bid-open-user" id="bid-open-user-2"><option value="">请选择</option></select>',
+                    '<button type="button" class="btn btn-default btn-sm btn-send-code" data-slot="2">发送验证码</button>',
+                    '<input type="text" class="form-control bid-open-code" id="bid-open-code-2" maxlength="6" placeholder="6位验证码"/>',
+                    '</div>',
+                    '</div>'
+                ].join('');
+                var layerIdx = Layer.open({
+                    type: 1,
+                    title: '开标验证',
+                    area: ['560px', 'auto'],
+                    content: html,
+                    btn: ['提交验证', '取消'],
+                    yes: function (idx) {
+                        var v1 = parseInt($('#bid-open-user-1').val(), 10) || 0;
+                        var v2 = parseInt($('#bid-open-user-2').val(), 10) || 0;
+                        var c1 = $.trim($('#bid-open-code-1').val());
+                        var c2 = $.trim($('#bid-open-code-2').val());
+                        if (!v1 || !v2) {
+                            Toastr.warning('请选择两位验证人');
+                            return;
+                        }
+                        if (v1 === v2) {
+                            Toastr.warning('两位验证人不能相同');
+                            return;
+                        }
+                        if (!/^\d{6}$/.test(c1) || !/^\d{6}$/.test(c2)) {
+                            Toastr.warning('请输入6位短信验证码');
+                            return;
+                        }
+                        Fast.api.ajax({
+                            url: 'procuremen/bidopenverify',
+                            type: 'POST',
+                            data: auditIssueAjaxData({
+                                verifier1_id: v1,
+                                verifier2_id: v2,
+                                code1: c1,
+                                code2: c2
+                            })
+                        }, function (data, ret) {
+                            Layer.close(idx);
+                            var msg = (ret && ret.msg) ? ret.msg : '开标验证成功';
+                            Toastr.success(msg);
+                            setTimeout(function () {
+                                window.location.reload();
+                            }, 400);
+                            return false;
+                        });
+                    }
+                });
+                Fast.api.ajax({
+                    url: 'procuremen/bidopenusers',
+                    type: 'GET',
+                    data: {scydgy_id: $('input[name=\'scydgy_id\']').val()}
+                }, function (data, ret) {
+                    var payload = (ret && ret.data) ? ret.data : (data || {});
+                    if (parseInt(payload.verified, 10) === 1) {
+                        Layer.close(layerIdx);
+                        Toastr.info('本单已完成开标验证');
+                        setTimeout(function () {
+                            window.location.reload();
+                        }, 400);
+                        return false;
+                    }
+                    users = payload.users || [];
+                    auditIssueSyncBidOpenUserSelects();
+                    $(document).off('change.bidOpenUser', '#bid-open-user-1, #bid-open-user-2')
+                        .on('change.bidOpenUser', '#bid-open-user-1', function () {
+                            auditIssueResetBidOpenSendBtn($('.btn-send-code[data-slot="1"]'));
+                            auditIssueSyncBidOpenUserSelects(1);
+                        })
+                        .on('change.bidOpenUser', '#bid-open-user-2', function () {
+                            auditIssueResetBidOpenSendBtn($('.btn-send-code[data-slot="2"]'));
+                            auditIssueSyncBidOpenUserSelects(2);
+                        });
+                    return false;
+                });
+                $(document).off('click.bidOpenSend', '.btn-send-code').on('click.bidOpenSend', '.btn-send-code', function () {
+                    var slot = $(this).attr('data-slot');
+                    var adminId = parseInt($('#bid-open-user-' + slot).val(), 10) || 0;
+                    if (!adminId) {
+                        Toastr.warning('请先选择验证人' + slot);
+                        return;
+                    }
+                    var $btn = $(this);
+                    if ($btn.data('loading') || $btn.prop('disabled')) {
+                        return;
+                    }
+                    $btn.data('loading', 1).prop('disabled', true).text('发送中…');
+                    Fast.api.ajax({
+                        url: 'procuremen/bidopensendcode',
+                        type: 'POST',
+                        data: auditIssueAjaxData({admin_id: adminId})
+                    }, function (data, ret) {
+                        Toastr.success((ret && ret.msg) ? ret.msg : '验证码已发送');
+                        auditIssueStartBidOpenSendCountdown($btn);
+                        return false;
+                    }, function () {
+                        auditIssueResetBidOpenSendBtn($btn);
+                    });
+                });
+            }
+
+            $('#btn-audit-bid-open').off('click.auditBidOpen').on('click.auditBidOpen', function () {
+                auditIssueOpenBidModal();
+            });
+
             function auditIssueSelectedCompany() {
                 var $checked = $('input[name="audit_quote_pick"]:checked');
                 if (!$checked.length) {
@@ -3015,6 +3262,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
                     Toastr.warning('请选择一家供应商');
                     return;
                 }
+                if (!auditIssueBidOpenVerified()) {
+                    Toastr.warning('请先完成开标验证后再确认供应商');
+                    return;
+                }
                 if (!auditIssueValidateSysRq()) {
                     return;
                 }