Files
fdesk/app/command/HesuGoogleInquiryImportCommand.php
T
mzl92 3a3ed12fd5 feat: 合塑谷歌网站询盘导入命令(新询盘建档+分配+邮件)
新命令 inquiry:import-hesu-google,处理库外新询盘 Excel:
- 按表头名解析(客户姓名/电话WhatsApp合并列/邮箱/留言内容/产品页链接)
- 建档固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入)、
  grade=0、magnitude=2、created_at=当前时间、code 走模型钩子自动生成
- desc_collect=留言内容+产品页链接,intended_vehicles=从 URL 提取车型 slug
- 去重:电话或邮箱与现有未删除客户重复(含文件内重复)跳过并列清单
- 轮询分配 --users;汇总邮件复用 InquiryDigestMail,manifest 防重发
- 支持 --dry-run / --no-notify / --notify-only / --only-user 分步执行

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:40:53 +08:00

347 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\command;
use app\common\model\CrmCustomer;
use app\common\utils\email\InquiryDigestMail;
use app\common\utils\email\MailSend;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
/**
* 合塑谷歌网站询盘导入
*
* Excel 新询盘(库外数据)-> 建 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('<error>--users 不能为空(目标业务员 user_id 列表)</error>');
return self::FAILURE;
}
if (!is_file($file)) {
$output->writeln('<error>文件不存在: ' . $file . '</error>');
return self::FAILURE;
}
if ($notifyOnly && $noNotify) {
$output->writeln('<error>--notify-only 与 --no-notify 不能同时使用</error>');
return self::FAILURE;
}
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
$rows = $this->parseExcel($file);
$output->writeln('<info>数据行: ' . count($rows) . '</info>');
[$keep, $skipped, $noContact] = $this->dedup($rows, $output);
$output->writeln('<info>去重后待建档: ' . count($keep) . ',重复跳过: ' . count($skipped) . ',无联系方式跳过: ' . count($noContact) . '</info>');
foreach (array_slice($skipped, 0, 10) as $s) {
$output->writeln('<comment> 跳过 行' . $s[0] . ' ' . $s[1] . '</comment>');
}
if (count($skipped) > 10) {
$output->writeln('<comment> ...另 ' . (count($skipped) - 10) . ' 条</comment>');
}
foreach ($noContact as $row) {
$output->writeln('<comment> 无联系方式 行' . $row['excel_row'] . ' ' . $row['contacts'] . '</comment>');
}
// 轮询分配(按行序 i % n)
$n = count($targetUserIds);
foreach ($keep as $i => &$row) {
$row['new_user_id'] = $targetUserIds[$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('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
}
foreach ($keep as $i => $row) {
if ($i < 3) {
$output->writeln('<comment>样例[行' . $row['excel_row'] . '] ' . $row['contacts'] . ' ' . $row['phone'] . ' '
. $this->productSlug($row['url']) . ' -> user ' . $row['new_user_id'] . '</comment>');
}
}
if ($dryRun) {
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
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('<info>已建档并分配: ' . count($created) . ' 条(code 由模型自动生成)</info>');
});
$keep = $created;
$groups = [];
foreach ($targetUserIds as $uid) {
$groups[$uid] = [];
}
foreach ($keep as $row) {
$groups[$row['new_user_id']][] = $row;
}
} else {
$output->writeln('<comment>[notify-only] 跳过写库。</comment>');
}
if ($noNotify) {
$output->writeln('<comment>[no-notify] 已建档,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
return self::SUCCESS;
}
$manifestPath = $this->manifestPath($date);
$this->initManifest($manifestPath, $targetUserIds, $groups);
$this->sendDigests($groups, $targetUserIds, $manifestPath, $onlyUser, $output);
return self::SUCCESS;
}
private function initManifest(string $path, array $targetUserIds, array $groups): void
{
if (is_file($path)) {
return;
}
$perUser = []; $emailed = [];
foreach ($targetUserIds as $uid) {
$perUser[(string)$uid] = count($groups[$uid] ?? []);
$emailed[(string)$uid] = false;
}
$data = [
'date' => date('Y-m-d'),
'imported_at' => time(),
'target_users' => $targetUserIds,
'per_user_counts' => $perUser,
'emailed' => $emailed,
];
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
private function sendDigests(array $groups, 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 ($onlyUser > 0 && $uid !== $onlyUser) {
continue;
}
if (!empty($manifest['emailed'][(string)$uid])) {
$output->writeln('<comment>跳过已发送: user ' . $uid . '</comment>');
continue;
}
$u = $userMap[$uid] ?? null;
if (!$u || empty($u->email)) {
$output->writeln('<error>user ' . $uid . ' 无邮箱,跳过</error>');
continue;
}
$inquiries = [];
foreach ($groups[$uid] ?? [] as $row) {
$inquiries[] = [
'code' => $row['code'] ?? '',
'contact' => $row['contacts'],
'email' => $row['email'],
'phone' => $row['phone'],
'country_cn' => '',
'channel' => self::CHANNEL_NAME,
'remark' => $row['message'],
];
}
$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) . ' 条</' . $tag . '>');
}
}
}