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('--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; + } +} diff --git a/app/common/utils/email/InquiryDigestMail.php b/app/common/utils/email/InquiryDigestMail.php new file mode 100644 index 0000000..fee32c3 --- /dev/null +++ b/app/common/utils/email/InquiryDigestMail.php @@ -0,0 +1,55 @@ +'; + $rows .= '' + . $td . $idx++ . '' + . $td . htmlspecialchars((string)($q['code'] ?? '')) . '' + . $td . htmlspecialchars((string)($q['contact'] ?? '')) . '' + . $td . htmlspecialchars((string)($q['email'] ?? '')) . '' + . $td . htmlspecialchars((string)($q['phone'] ?? '')) . '' + . $td . htmlspecialchars((string)($q['country_cn'] ?? '')) . '' + . $td . htmlspecialchars((string)($q['channel'] ?? '')) . '' + . $td . htmlspecialchars($remark) . '' + . ''; + } + $thStyle = 'style="border:1px solid #eee;padding:6px;background:#f0f0f0;"'; + return '' + . '' + . '
' + . '

询盘分配汇总

' + . '

收件人:' . htmlspecialchars($userName) . ' 日期:' . $date + . ' 共 ' . $count . ' 条

' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . $rows + . '
#编号联系人邮箱电话国家渠道意向/备注
'; + } +}