建 crm_customer 档案 -> 按行序轮询分配业务员 -> 每人一封汇总邮件。 * 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入数据); * 去重:电话或邮箱与现有未删除客户重复(含本文件内重复)则跳过。 */ class HesuGoogleInquiryImportCommand extends Command { protected static $defaultName = 'inquiry:import-hesu-google'; protected static $defaultDescription = '合塑谷歌网站询盘导入:新询盘建档+轮询分配+汇总邮件'; private const CORP_ID = 1; private const CHANNEL_ID = 4; // 合塑谷歌 private const CHANNEL_NAME = '合塑谷歌'; private const TYPE = 2; // 导入数据 private const GRADE = 0; private const MAGNITUDE = 2; protected function configure() { $this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/询盘明细_2026年6-8月_已清洗.xlsx') ->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '') ->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+去重预检+分配预演,不写库不发邮件') ->addOption('no-notify', null, InputOption::VALUE_NONE, '只建档分配,不发邮件(先核对库再 --notify-only 补发)') ->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过写库,按 manifest 补发未成功的邮件') ->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0'); } /** * 解析 Excel(按表头名定位列,顺序无关)。返回行数组。 */ private function parseExcel(string $filePath): array { $sheet = IOFactory::load($filePath)->getActiveSheet(); $highestRow = $sheet->getHighestRow(); $colMap = []; foreach (range('A', 'Z') as $col) { $v = trim((string)($sheet->getCell($col . '1')->getValue() ?? '')); if ($v !== '') { $colMap[$v] = $col; } } $required = ['客户姓名', '电话/WhatsApp', '邮箱', '留言内容', '产品页链接']; $missing = array_diff($required, array_keys($colMap)); if ($missing) { throw new \RuntimeException('Excel 缺少必需列头: ' . implode(', ', $missing) . ';实际列头: ' . implode(',', array_keys($colMap))); } $get = function (string $header, int $r) use ($sheet, $colMap): string { return trim((string)($sheet->getCell($colMap[$header] . $r)->getValue() ?? '')); }; $rows = []; for ($r = 2; $r <= $highestRow; $r++) { $row = [ 'contacts' => $get('客户姓名', $r), 'phone' => $get('电话/WhatsApp', $r), 'email' => $get('邮箱', $r), 'message' => $get('留言内容', $r), 'url' => $get('产品页链接', $r), 'excel_row' => $r, ]; if ($row['contacts'] === '' && $row['phone'] === '' && $row['email'] === '') { continue; // 整行为空 } $rows[] = $row; } return $rows; } /** * 从产品页链接提取车型 slug,如 /product/rox-01/ -> rox-01 */ private function productSlug(string $url): string { $path = parse_url($url, PHP_URL_PATH) ?: ''; $segs = array_values(array_filter(explode('/', $path), fn($s) => $s !== '')); if (empty($segs)) { return ''; } $i = array_search('product', $segs, true); if ($i !== false && isset($segs[$i + 1])) { return $segs[$i + 1]; } return (string)end($segs); } /** * 库内 + 文件内去重。返回 [待建档行, 跳过信息列表] */ private function dedup(array $rows, OutputInterface $output): array { $dbPhones = []; foreach (array_chunk(array_filter(array_column($rows, 'phone')), 500) as $chunk) { $dbPhones = array_merge($dbPhones, Db::table('crm_customer') ->whereNull('deleted_at')->whereIn('phone', $chunk)->pluck('phone')->map(fn($v) => trim((string)$v))->all()); } $dbEmails = []; foreach (array_chunk(array_filter(array_column($rows, 'email')), 500) as $chunk) { $dbEmails = array_merge($dbEmails, Db::table('crm_customer') ->whereNull('deleted_at')->whereIn('email', $chunk)->pluck('email')->map(fn($v) => trim((string)$v))->all()); } $dbPhones = array_flip($dbPhones); $dbEmails = array_flip($dbEmails); $keep = []; $skipped = []; $noContact = []; $seenPhone = []; $seenEmail = []; foreach ($rows as $row) { if ($row['phone'] === '' && $row['email'] === '') { $noContact[] = $row; continue; } if ($row['phone'] !== '' && (isset($dbPhones[$row['phone']]) || isset($seenPhone[$row['phone']]))) { $skipped[] = [$row['excel_row'], '电话重复: ' . $row['phone']]; $seenPhone[$row['phone']] = 1; continue; } if ($row['email'] !== '' && (isset($dbEmails[$row['email']]) || isset($seenEmail[$row['email']]))) { $skipped[] = [$row['excel_row'], '邮箱重复: ' . $row['email']]; $seenEmail[$row['email']] = 1; continue; } if ($row['phone'] !== '') { $seenPhone[$row['phone']] = 1; } if ($row['email'] !== '') { $seenEmail[$row['email']] = 1; } $keep[] = $row; } return [$keep, $skipped, $noContact]; } private function manifestPath(string $date): string { return runtime_path() . '/inquiry_hesu_google_import_' . $date . '.json'; } protected function execute(InputInterface $input, OutputInterface $output): int { $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'); $noNotify = (bool)$input->getOption('no-notify'); $onlyUser = (int)$input->getOption('only-user'); $date = date('Y-m-d'); if (!$targetUserIds) { $output->writeln('--users 不能为空(目标业务员 user_id 列表)'); return self::FAILURE; } if (!is_file($file)) { $output->writeln('文件不存在: ' . $file . ''); return self::FAILURE; } if ($notifyOnly && $noNotify) { $output->writeln('--notify-only 与 --no-notify 不能同时使用'); return self::FAILURE; } $output->writeln('解析 Excel: ' . $file . ''); $rows = $this->parseExcel($file); $output->writeln('数据行: ' . count($rows) . ''); [$keep, $skipped, $noContact] = $this->dedup($rows, $output); $output->writeln('去重后待建档: ' . count($keep) . ',重复跳过: ' . count($skipped) . ',无联系方式跳过: ' . count($noContact) . ''); foreach (array_slice($skipped, 0, 10) as $s) { $output->writeln(' 跳过 行' . $s[0] . ' ' . $s[1] . ''); } if (count($skipped) > 10) { $output->writeln(' ...另 ' . (count($skipped) - 10) . ' 条'); } foreach ($noContact as $row) { $output->writeln(' 无联系方式 行' . $row['excel_row'] . ' ' . $row['contacts'] . ''); } // 公平分配:按近10天窗口内实际分到数升序(少者优先、最久未分到优先),再按序轮询 $fair = InquiryFairAssigner::fairOrder($targetUserIds); $orderedUsers = $fair['ordered']; $output->writeln('分配顺序(近' . $fair['window_days'] . '天已有条数):' . implode('、', array_map(fn($u) => $u . '(' . ($fair['counts'][$u] ?? 0) . ')', $orderedUsers)) . ''); $n = count($orderedUsers); foreach ($keep as $i => &$row) { $row['new_user_id'] = $orderedUsers[$i % $n]; } unset($row); $groups = []; foreach ($targetUserIds as $uid) { $groups[$uid] = []; } foreach ($keep as $row) { $groups[$row['new_user_id']][] = $row; } foreach ($targetUserIds as $uid) { $output->writeln('user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条'); } foreach ($keep as $i => $row) { if ($i < 3) { $output->writeln('样例[行' . $row['excel_row'] . '] ' . $row['contacts'] . ' ' . $row['phone'] . ' ' . $this->productSlug($row['url']) . ' -> user ' . $row['new_user_id'] . ''); } } if ($dryRun) { $output->writeln('[dry-run] 不写库、不发邮件。确认后去掉 --dry-run 执行。'); return self::SUCCESS; } if (!$notifyOnly) { $created = []; Db::connection()->transaction(function () use ($keep, $targetUserIds, $output, &$created) { foreach ($keep as $row) { $desc = $row['message']; if ($row['url'] !== '') { $desc .= ($desc !== '' ? "\n" : '') . '产品页:' . $row['url']; } $c = new CrmCustomer(); $c->corp_id = self::CORP_ID; $c->user_id = $row['new_user_id']; $c->type = self::TYPE; $c->channel_id = self::CHANNEL_ID; $c->grade = self::GRADE; $c->magnitude = self::MAGNITUDE; $c->email = $row['email']; $c->phone = $row['phone']; $c->WhatsApp = ''; $c->contacts = $row['contacts']; $c->desc_collect = $desc; $slug = $this->productSlug($row['url']); $c->intended_vehicles = $slug !== '' ? $slug : null; $c->obtain = time(); if (!$c->save()) { throw new \RuntimeException('行' . $row['excel_row'] . ' 建档失败'); } $row['code'] = $c->code; $created[] = $row; } $output->writeln('已建档并分配: ' . count($created) . ' 条(code 由模型自动生成)'); }); $keep = $created; $groups = []; foreach ($targetUserIds as $uid) { $groups[$uid] = []; } foreach ($keep as $row) { $groups[$row['new_user_id']][] = $row; } } else { $output->writeln('[notify-only] 跳过写库。'); } $manifestPath = $this->manifestPath($date); if ($notifyOnly) { // 建档后号码/邮箱已入库,重新解析会被去重清空——补发必须用 manifest 保存的分配结果 $manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : []; if (empty($manifest['groups'])) { $output->writeln('manifest 不存在或无分配数据: ' . $manifestPath . ';请先执行 --no-notify 或完整命令生成'); return self::FAILURE; } $digestGroups = $manifest['groups']; } else { $totalRows = array_sum(array_map('count', $groups)); if ($totalRows > 0) { $digestGroups = []; foreach ($targetUserIds as $uid) { $digestGroups[(string)$uid] = []; } foreach ($groups as $uid => $rows) { foreach ($rows as $row) { $digestGroups[(string)$uid][] = $this->digestRow($row); } } $this->saveManifest($manifestPath, $targetUserIds, $digestGroups); } else { $manifest = is_file($manifestPath) ? (json_decode((string)@file_get_contents($manifestPath), true) ?: []) : []; if (!empty($manifest['groups'])) { $digestGroups = $manifest['groups']; $output->writeln('本次无新建档(可能已导入过),沿用已有 manifest 的分配结果。'); } else { $output->writeln('没有可建档数据,结束。'); return self::SUCCESS; } } if ($noNotify) { $output->writeln('[no-notify] 已建档,跳过发邮件。核对库后用 --notify-only 补发。'); return self::SUCCESS; } } $this->sendDigests($digestGroups, $targetUserIds, $manifestPath, $onlyUser, $output); return self::SUCCESS; } private function saveManifest(string $path, array $targetUserIds, array $digestGroups): void { $perUser = []; $emailed = []; foreach ($targetUserIds as $uid) { $perUser[(string)$uid] = count($digestGroups[(string)$uid] ?? []); $emailed[(string)$uid] = false; } $data = [ 'date' => date('Y-m-d'), 'imported_at' => time(), 'target_users' => $targetUserIds, 'per_user_counts' => $perUser, 'groups' => $digestGroups, 'emailed' => $emailed, ]; file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); } /** * 建档行 -> 邮件汇总行 */ private function digestRow(array $row): array { return [ 'code' => $row['code'] ?? '', 'contact' => $row['contacts'], 'email' => $row['email'], 'phone' => $row['phone'], 'country_cn' => '', 'channel' => self::CHANNEL_NAME, 'remark' => $row['message'], ]; } private function sendDigests(array $digestGroups, array $targetUserIds, string $manifestPath, int $onlyUser, OutputInterface $output): void { $users = Db::table('user')->whereIn('id', $targetUserIds)->get(['id', 'name', 'email']); $userMap = []; foreach ($users as $u) { $userMap[(int)$u->id] = $u; } $manifest = is_file($manifestPath) ? (json_decode((string)file_get_contents($manifestPath), true) ?: ['emailed' => []]) : ['emailed' => []]; $date = date('Y-m-d'); foreach ($targetUserIds as $uid) { if (count($digestGroups[(string)$uid] ?? []) === 0) { $output->writeln('user ' . $uid . ' 未分到询盘,不发送'); continue; } if ($onlyUser > 0 && $uid !== $onlyUser) { continue; } if (!empty($manifest['emailed'][(string)$uid])) { $output->writeln('跳过已发送: user ' . $uid . ''); continue; } $u = $userMap[$uid] ?? null; if (!$u || empty($u->email)) { $output->writeln('user ' . $uid . ' 无邮箱,跳过'); continue; } $inquiries = array_values($digestGroups[(string)$uid] ?? []); $html = InquiryDigestMail::build((string)$u->name, $inquiries, $date); $subject = '新询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date; $ok = MailSend::send($u->email, $subject, $html); if (!$ok) { $ok = MailSend::send($u->email, $subject, $html); } $manifest['emailed'][(string)$uid] = (bool)$ok; file_put_contents($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); $tag = $ok ? 'info' : 'error'; $output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): ' . ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条'); } } }