diff --git a/app/command/InquiryBulkAssignCommand.php b/app/command/InquiryBulkAssignCommand.php
new file mode 100644
index 0000000..8c30789
--- /dev/null
+++ b/app/command/InquiryBulkAssignCommand.php
@@ -0,0 +1,209 @@
+addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/崔琪2.xlsx')
+ ->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔', '34,73,72,155,64,211')
+ ->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+匹配+分配,打印报告,不写库不发邮件')
+ ->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0')
+ ->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过写库,按 manifest 补发未成功的邮件')
+ ->addOption('self-test', null, InputOption::VALUE_NONE, '运行纯逻辑自测');
+ }
+
+ /**
+ * 内存轮询分配(确定性 i % n)
+ * 给 $matched 每项追加 new_user_id,返回 [user_id => [rows...]]
+ */
+ public static function assign(array &$matched, array $targetUserIds): array
+ {
+ $groups = [];
+ foreach ($targetUserIds as $uid) {
+ $groups[$uid] = [];
+ }
+ $n = count($targetUserIds);
+ foreach ($matched as $i => &$row) {
+ $uid = $targetUserIds[$i % $n];
+ $row['new_user_id'] = $uid;
+ $groups[$uid][] = $row;
+ }
+ unset($row);
+ return $groups;
+ }
+
+ /**
+ * 解析 Excel,按行顺序返回询盘数组
+ */
+ private function parseExcel(string $filePath): array
+ {
+ $sheet = IOFactory::load($filePath)->getActiveSheet();
+ $highestRow = $sheet->getHighestRow();
+ $rows = [];
+ $get = function (string $col, int $r) use ($sheet): string {
+ return trim((string)($sheet->getCell($col . $r)->getValue() ?? ''));
+ };
+ for ($r = 2; $r <= $highestRow; $r++) {
+ $code = $get(self::COL_CODE, $r);
+ if ($code === '') {
+ continue;
+ }
+ $rows[] = [
+ 'code' => $code,
+ 'channel' => $get(self::COL_CHANNEL, $r),
+ 'email' => $get(self::COL_EMAIL, $r),
+ 'phone' => $get(self::COL_PHONE, $r),
+ 'whatsapp' => $get(self::COL_WHATSAPP, $r),
+ 'contact' => $get(self::COL_CONTACT, $r),
+ 'country_cn' => $get(self::COL_COUNTRY_CN, $r),
+ 'remark' => $get(self::COL_REMARK, $r),
+ 'excel_row' => $r,
+ ];
+ }
+ return $rows;
+ }
+
+ /**
+ * 批量匹配 crm_customer(code 唯一取 MIN(id),排除软删除)
+ * 返回 ['matched' => [...], 'unmatched' => [...]];matched 每项追加 customer_id/old_user_id
+ */
+ private function matchCustomers(array $rows): array
+ {
+ $codes = array_values(array_unique(array_column($rows, 'code')));
+ $map = []; // code => ['id','old_user_id']
+ foreach (array_chunk($codes, 500) as $chunk) {
+ $recs = Db::table('crm_customer')
+ ->whereIn('code', $chunk)
+ ->whereNull('deleted_at')
+ ->orderBy('id', 'asc')
+ ->get(['id', 'code', 'user_id']);
+ foreach ($recs as $rec) {
+ if (!isset($map[$rec->code])) {
+ $map[$rec->code] = ['id' => (int)$rec->id, 'old_user_id' => $rec->user_id];
+ }
+ }
+ }
+ $matched = [];
+ $unmatched = [];
+ foreach ($rows as $row) {
+ if (isset($map[$row['code']])) {
+ $row['customer_id'] = $map[$row['code']]['id'];
+ $row['old_user_id'] = $map[$row['code']]['old_user_id'];
+ $matched[] = $row;
+ } else {
+ $unmatched[] = $row;
+ }
+ }
+ return ['matched' => $matched, 'unmatched' => $unmatched];
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ if ($input->getOption('self-test')) {
+ return $this->runSelfTest($output);
+ }
+
+ $file = $input->getOption('file');
+ $targetUserIds = array_values(array_filter(array_map('intval', explode(',', (string)$input->getOption('users')))));
+ $dryRun = (bool)$input->getOption('dry-run');
+ $notifyOnly = (bool)$input->getOption('notify-only');
+ $onlyUser = (int)$input->getOption('only-user');
+ $date = date('Y-m-d');
+
+ if (!$targetUserIds) {
+ $output->writeln('
收件人:' . htmlspecialchars($userName) . ' 日期:' . $date + . ' 共 ' . $count . ' 条
' + . '| # | ' + . '编号 | ' + . '联系人 | ' + . '邮箱 | ' + . '电话 | ' + . '国家 | ' + . '渠道 | ' + . '意向/备注 | ' + . '
|---|