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('--users 不能为空'); return self::FAILURE; } if (!is_file($file)) { $output->writeln('文件不存在: ' . $file . ''); return self::FAILURE; } $output->writeln('解析 Excel: ' . $file . ''); $rows = $this->parseExcel($file); $output->writeln('Excel 询盘数: ' . count($rows) . ''); $res = $this->matchCustomers($rows); $matched = $res['matched']; $unmatched = $res['unmatched']; $output->writeln('匹配成功: ' . count($matched) . ',未匹配: ' . count($unmatched) . ''); if ($unmatched) { $sample = implode(', ', array_slice(array_column($unmatched, 'code'), 0, 10)); $output->writeln('未匹配 code 样例: ' . $sample . ''); } $groups = self::assign($matched, $targetUserIds); foreach ($targetUserIds as $uid) { $output->writeln('user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条'); } foreach ($matched as $i => $row) { if ($i < 3) { $output->writeln('样例[' . $i . '] code=' . $row['code'] . ' -> user ' . $row['new_user_id'] . ' (原 ' . $row['old_user_id'] . ')'); } } if ($dryRun) { $output->writeln('[dry-run] 不写库、不发邮件。确认无误后去掉 --dry-run 执行。'); return self::SUCCESS; } $output->writeln('[未实现] 写库与发邮件见 Task 3/Task 4。'); return self::SUCCESS; } private function runSelfTest(OutputInterface $output): int { $fail = 0; // 1) assign: 7 条 / 3 人 -> 计数 [3,2,2] $items = array_fill(0, 7, []); $groups = self::assign($items, [10, 20, 30]); $counts = [count($groups[10]), count($groups[20]), count($groups[30])]; if ($counts !== [3, 2, 2]) { $output->writeln('assign 计数错误: ' . json_encode($counts) . ''); $fail++; } // 2) InquiryDigestMail::build 内容校验 $html = InquiryDigestMail::build('张三', [[ 'code' => 'CM1', 'contact' => 'A', 'email' => 'a@x.com', 'phone' => '1', 'country_cn' => '中国', 'channel' => 'fb', 'remark' => '意向车型:X', ]], '2026-07-17'); if (strpos($html, '张三') === false || strpos($html, 'CM1') === false || strpos($html, '共 1 条') === false) { $output->writeln('InquiryDigestMail::build 内容校验失败'); $fail++; } if ($fail === 0) { $output->writeln('self-test 通过'); return self::SUCCESS; } return self::FAILURE; } }