Files
fdesk/app/command/HesuGoogleAiInquiryImportCommand.php
T
mzl92 89b9162f09 fix: 三个询盘导入命令——未分到询盘的用户不再发送空汇总邮件
sendDigests 循环前校验该用户分配条数,为0则跳过并提示
"user N 未分到询盘,不发送"(询盘数少于人数时避免群发空邮件)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 15:18:16 +08:00

504 lines
21 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 app\crm\model\Country;
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;
/**
* 合塑谷歌AI客服询盘导入
*
* AI客服询盘 Excel(库外数据)-> 建 crm_customer 档案 -> 轮询分配 -> 汇总邮件。
* 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入)、magnitude=1(银牌);
* contacts=第一个非空联系方式(WhatsApp->手机->邮箱);去重:手机/WhatsApp/邮箱任一重复即跳过。
* 未对应库字段的内容分块拼入 desc_collect(询盘信息/意向车型/其他备注/AI评估/完整对话)。
*/
class HesuGoogleAiInquiryImportCommand extends Command
{
protected static $defaultName = 'inquiry:import-hesu-google-ai';
protected static $defaultDescription = '合塑谷歌AI客服询盘导入:新询盘建档+轮询分配+汇总邮件';
private const CORP_ID = 1;
private const CHANNEL_ID = 4; // 合塑谷歌
private const CHANNEL_NAME = '合塑谷歌(AI客服)';
private const TYPE = 2; // 导入数据
private const GRADE = 0;
private const MAGNITUDE = 1; // 银牌商机
/** @var array 国家名(小写)->country_id 缓存 */
private $countryCache = [];
protected function configure()
{
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/AI客服询盘_2026-08-20.xlsx')
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '')
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+去重预检+分配预演,不写库不发邮件')
->addOption('no-notify', null, InputOption::VALUE_NONE, '只建档分配,不发邮件')
->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;
}
}
foreach (['手机', 'WhatsApp', '邮箱'] as $need) {
if (!isset($colMap[$need])) {
throw new \RuntimeException('Excel 缺少必需列头: ' . $need . ';实际列头: ' . implode(',', array_keys($colMap)));
}
}
$get = function (string $header, int $r) use ($sheet, $colMap): string {
if (!isset($colMap[$header])) {
return '';
}
return trim((string)($sheet->getCell($colMap[$header] . $r)->getValue() ?? ''));
};
$rows = [];
for ($r = 2; $r <= $highestRow; $r++) {
$row = [
'excel_row' => $r,
'phone' => $this->normalizeNumber($get('手机', $r)),
'whatsapp' => $this->normalizeNumber($get('WhatsApp', $r)),
'email' => $get('邮箱', $r),
'wechat' => $get('微信', $r),
'intent' => $get('意向', $r),
'country' => $get('目标国家', $r),
'budget' => $get('预算', $r),
'brand' => $get('意向品牌', $r),
'model' => $get('意向车型', $r),
'qty' => $get('采购数量', $r),
'usage' => $get('采购用途', $r),
'note' => $get('其他备注', $r),
'assess' => $get('评估理由', $r),
'dialog' => $get('完整对话记录', $r),
];
if ($row['phone'] === '' && $row['whatsapp'] === '' && $row['email'] === ''
&& $row['wechat'] === '' && $row['dialog'] === '') {
continue; // 整行为空
}
// contacts:第一个非空联系方式
$row['contacts'] = $row['whatsapp'] !== '' ? $row['whatsapp']
: ($row['phone'] !== '' ? $row['phone'] : $row['email']);
$row['intended'] = trim($row['brand'] . ' ' . $row['model']);
$rows[] = $row;
}
return $rows;
}
/**
* 号码归一化:仅保留数字与开头 +(去空格/横线等)
*/
private function normalizeNumber(string $raw): string
{
if ($raw === '') {
return '';
}
$n = preg_replace('/[^0-9+]/', '', trim($raw));
$n = preg_replace('/\++/', '+', $n);
if ($n !== '' && $n[0] !== '+') {
$n = '+' . $n;
}
return $n !== '+' ? $n : '';
}
/**
* 国家名(英文/中文,忽略大小写)匹配 country_id;匹配不上返回 0
*/
private function matchCountryId(string $name): int
{
if ($name === '') {
return 0;
}
$key = strtolower($name);
if (!array_key_exists($key, $this->countryCache)) {
$lower = strtolower($name);
$id = Country::where(function ($q) use ($lower) {
$q->whereRaw('LOWER(en_addr) = ?', [$lower])
->orWhereRaw('LOWER(cn_addr) = ?', [$lower]);
})->value('id');
$this->countryCache[$key] = (int)($id ?? 0);
}
return $this->countryCache[$key];
}
/**
* 未对应库字段的内容 -> 分块 HTML(desc_collect 富文本)
*/
private function buildDescCollect(array $row): string
{
$e = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES);
$blocks = [];
$info = [];
if ($row['intent'] !== '') {
$info[] = '意向:' . $e($row['intent']);
}
if ($row['budget'] !== '') {
$info[] = '预算:' . $e($row['budget']);
}
if ($row['qty'] !== '') {
$info[] = '采购数量:' . $e($row['qty']);
}
if ($row['usage'] !== '') {
$info[] = '采购用途:' . $e($row['usage']);
}
if ($row['country'] !== '') {
$info[] = '目标国家:' . $e($row['country']);
}
if ($row['wechat'] !== '') {
$info[] = '微信:' . $e($row['wechat']);
}
if ($info) {
$blocks[] = $this->block('询盘信息', implode('<br>', $info));
}
if ($row['brand'] !== '' || $row['model'] !== '') {
$blocks[] = $this->block('意向车型',
($row['brand'] !== '' ? '品牌:' . $e($row['brand']) : '')
. ($row['brand'] !== '' && $row['model'] !== '' ? '<br>' : '')
. ($row['model'] !== '' ? '车型:' . $e($row['model']) : ''));
}
if ($row['note'] !== '') {
$blocks[] = $this->block('其他备注', $e($row['note']));
}
$ai = [];
if ($row['intent'] !== '') {
$ai[] = '意向等级:' . $e($row['intent']);
}
if ($row['assess'] !== '') {
$ai[] = '评估理由:' . $e($row['assess']);
}
if ($ai) {
$blocks[] = $this->block('AI评估', implode('<br>', $ai));
}
if ($row['dialog'] !== '') {
$blocks[] = $this->block('完整对话',
'<div style="white-space:pre-wrap;background:#f7f8fa;border:1px solid #eee;'
. 'border-radius:4px;padding:8px;font-size:12px;line-height:1.6;">' . $e($row['dialog']) . '</div>');
}
return implode('', $blocks);
}
private function block(string $title, string $bodyHtml): string
{
return '<div style="margin:0 0 10px;">'
. '<div style="font-weight:700;color:#BB0010;border-left:3px solid #BB0010;'
. 'padding-left:6px;margin-bottom:4px;">【' . htmlspecialchars($title) . '】</div>'
. '<div style="color:#333;">' . $bodyHtml . '</div></div>';
}
/**
* 去重:手机/WhatsApp/邮箱任一与库内对应列重复(含文件内)即跳过
*/
private function dedup(array $rows, OutputInterface $output): array
{
$dbSets = [];
foreach (['phone' => 'phone', 'whatsapp' => 'WhatsApp', 'email' => 'email'] as $field => $col) {
$vals = array_values(array_unique(array_filter(array_column($rows, $field))));
$set = [];
foreach (array_chunk($vals, 500) as $chunk) {
foreach (Db::table('crm_customer')->whereNull('deleted_at')->whereIn($col, $chunk)->pluck($col) as $v) {
$v = trim((string)$v);
if ($v !== '') {
if ($col === 'email') {
$set[strtolower($v)] = 1;
} else {
$set[$this->normalizeNumber($v)] = 1;
}
}
}
}
$dbSets[$field] = $set;
}
$keep = []; $skipped = []; $noContact = [];
$seen = ['phone' => [], 'whatsapp' => [], 'email' => []];
foreach ($rows as $row) {
if ($row['phone'] === '' && $row['whatsapp'] === '' && $row['email'] === '') {
$noContact[] = $row;
continue;
}
$dupReason = '';
foreach (['phone', 'whatsapp', 'email'] as $field) {
if ($row[$field] === '') {
continue;
}
$key = $field === 'email' ? strtolower($row[$field]) : $row[$field];
if (isset($dbSets[$field][$key]) || isset($seen[$field][$key])) {
$dupReason = $field . '重复: ' . $row[$field];
break;
}
}
if ($dupReason !== '') {
$skipped[] = [$row['excel_row'], $dupReason];
continue;
}
foreach (['phone', 'whatsapp', 'email'] as $field) {
if ($row[$field] !== '') {
$key = $field === 'email' ? strtolower($row[$field]) : $row[$field];
$seen[$field][$key] = 1;
}
}
$keep[] = $row;
}
return [$keep, $skipped, $noContact];
}
private function manifestPath(string $date): string
{
return runtime_path() . '/inquiry_hesu_google_ai_' . $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 (array_slice($noContact, 0, 5) as $row) {
$output->writeln('<comment> 无联系方式 行' . $row['excel_row'] . ' 备注:' . mb_substr($row['note'], 0, 30) . '</comment>');
}
// 国家匹配预览
$countryMiss = [];
foreach ($keep as $row) {
if ($row['country'] !== '' && $this->matchCountryId($row['country']) === 0) {
$countryMiss[$row['country']] = 1;
}
}
if ($countryMiss) {
$output->writeln('<comment>未匹配到国家(将留空): ' . implode('、', array_keys($countryMiss)) . '</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 (array_slice($keep, 0, 3) as $row) {
$output->writeln('<comment>样例[行' . $row['excel_row'] . '] ' . $row['contacts'] . ' '
. mb_substr($row['intended'], 0, 24) . ' -> user ' . $row['new_user_id'] . '</comment>');
}
if ($dryRun) {
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
return self::SUCCESS;
}
$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('<error>manifest 不存在或无分配数据: ' . $manifestPath . ';请先执行 --no-notify 或完整命令生成</error>');
return self::FAILURE;
}
$digestGroups = $manifest['groups'];
$output->writeln('<comment>[notify-only] 跳过写库,按 manifest 分配结果补发。</comment>');
} else {
$created = [];
Db::connection()->transaction(function () use ($keep, $output, &$created) {
foreach ($keep as $row) {
$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 = $row['whatsapp'];
$c->contacts = $row['contacts'];
$c->country_id = $this->matchCountryId($row['country']) ?: null;
$c->intended_vehicles = $row['intended'] !== '' ? $row['intended'] : null;
$c->desc_collect = $this->buildDescCollect($row);
$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>');
});
if ($created) {
$digestGroups = [];
foreach ($targetUserIds as $uid) {
$digestGroups[(string)$uid] = [];
}
foreach ($created as $row) {
$digestGroups[(string)$row['new_user_id']][] = $this->digestRow($row);
}
$this->saveManifest($manifestPath, $targetUserIds, $digestGroups);
} elseif (is_file($manifestPath)) {
$manifest = json_decode((string)file_get_contents($manifestPath), true) ?: [];
if (!empty($manifest['groups'])) {
$digestGroups = $manifest['groups'];
$output->writeln('<comment>本次无新建档(可能已导入过),沿用已有 manifest 的分配结果。</comment>');
} else {
$output->writeln('<comment>没有可建档数据,结束。</comment>');
return self::SUCCESS;
}
} else {
$output->writeln('<comment>没有可建档数据,结束。</comment>');
return self::SUCCESS;
}
if ($noNotify) {
$output->writeln('<comment>[no-notify] 已建档,跳过发邮件。核对库后用 --notify-only 补发。</comment>');
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
{
$remark = trim($row['intended'] . ' ' . $row['budget']);
return [
'code' => $row['code'] ?? '',
'contact' => $row['contacts'],
'email' => $row['email'],
'phone' => $row['whatsapp'] !== '' ? $row['whatsapp'] : $row['phone'],
'country_cn' => $row['country'],
'channel' => self::CHANNEL_NAME,
'remark' => mb_substr($remark, 0, 100),
];
}
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('<comment>user ' . $uid . ' 未分到询盘,不发送</comment>');
continue;
}
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 = array_values($digestGroups[(string)$uid] ?? []);
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
$subject = 'AI客服新询盘分配汇总(共 ' . 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 . '>');
}
}
}