feat: 合塑谷歌网站whatsapp询盘导入命令(截图传OSS+建档+分配+邮件)
新命令 inquiry:import-hesu-google-wa: - 解析 WhatsApp 询盘 Excel(列头:WhatsApp号码/最后留言/截图文件名等) - 号码归一化(去横线空格);contacts=号码、号码存 WhatsApp 字段、phone/email 空 - 按号码去重:对比库内 crm_customer 的 WhatsApp 与 phone 两列 + 文件内同号只留首条 - 截图按文件名从 --images-dir 找图传 OSS(OSS_MAIN_DIR/crm_whatsapp/), 对象键写入 desc_collect;缺失/失败不阻断,dry-run 只报数量不上传 - 建档口径:corp_id=1、channel_id=4、type=2、grade=0、magnitude=2、created_at=当前 - --users 轮询分配;汇总邮件复用 InquiryDigestMail,manifest 防重发 - 支持 --dry-run / --no-notify / --notify-only / --only-user 分步执行 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\CrmCustomer;
|
||||
use app\common\service\OssService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 合塑谷歌网站whatsapp询盘导入
|
||||
*
|
||||
* WhatsApp 询盘 Excel(库外数据)-> 截图传 OSS -> 建 crm_customer 档案 -> 轮询分配 -> 汇总邮件。
|
||||
* 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入数据);
|
||||
* contacts=归一化号码、号码存 WhatsApp 字段;按号码去重(对比库内 WhatsApp/phone 两列 + 文件内)。
|
||||
*/
|
||||
class HesuGoogleWhatsAppImportCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:import-hesu-google-wa';
|
||||
protected static $defaultDescription = '合塑谷歌网站whatsapp询盘导入:截图传OSS+建档+轮询分配+汇总邮件';
|
||||
|
||||
private const CORP_ID = 1;
|
||||
private const CHANNEL_ID = 4; // 合塑谷歌
|
||||
private const CHANNEL_NAME = '合塑谷歌(WhatsApp)';
|
||||
private const TYPE = 2; // 导入数据
|
||||
private const GRADE = 0;
|
||||
private const MAGNITUDE = 2;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/WhatsApp询盘_6-8月_明细.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '')
|
||||
->addOption('images-dir', null, InputOption::VALUE_REQUIRED, '截图所在目录', 'docs/whatsapp_screenshots')
|
||||
->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;
|
||||
}
|
||||
}
|
||||
if (!isset($colMap['WhatsApp号码'])) {
|
||||
throw new \RuntimeException('Excel 缺少必需列头: WhatsApp号码;实际列头: ' . 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++) {
|
||||
$number = $this->normalizeNumber($get('WhatsApp号码', $r));
|
||||
if ($number === '') {
|
||||
$blank = $get('最后留言', $r) === '' && $get('截图文件名', $r) === '';
|
||||
if ($blank) {
|
||||
continue; // 整行为空
|
||||
}
|
||||
$rows[] = ['excel_row' => $r, 'number' => '', 'message' => $get('最后留言', $r), 'shot' => $get('截图文件名', $r)];
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'excel_row' => $r,
|
||||
'number' => $number,
|
||||
'message' => $get('最后留言', $r),
|
||||
'shot' => $get('截图文件名', $r),
|
||||
];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 号码归一化:仅保留数字与开头 +(去横线/空格等)
|
||||
*/
|
||||
private function normalizeNumber(string $raw): string
|
||||
{
|
||||
$n = preg_replace('/[^0-9+]/', '', trim($raw));
|
||||
$n = preg_replace('/\++/', '+', $n);
|
||||
if ($n !== '' && $n[0] !== '+') {
|
||||
$n = '+' . $n;
|
||||
}
|
||||
return $n !== '+' ? $n : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按号码去重:库内(WhatsApp 与 phone 两列)+ 文件内。返回 [待建档行, 跳过信息, 无号码行]
|
||||
*/
|
||||
private function dedup(array $rows, OutputInterface $output): array
|
||||
{
|
||||
$numbers = array_values(array_unique(array_filter(array_column($rows, 'number'))));
|
||||
$exists = [];
|
||||
foreach (array_chunk($numbers, 500) as $chunk) {
|
||||
foreach (['WhatsApp', 'phone'] as $col) {
|
||||
foreach (Db::table('crm_customer')->whereNull('deleted_at')->whereIn($col, $chunk)->pluck($col) as $v) {
|
||||
$n = $this->normalizeNumber((string)$v);
|
||||
if ($n !== '') {
|
||||
$exists[$n] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$keep = []; $skipped = []; $noNumber = []; $seen = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['number'] === '') {
|
||||
$noNumber[] = $row;
|
||||
continue;
|
||||
}
|
||||
if (isset($exists[$row['number']]) || isset($seen[$row['number']])) {
|
||||
$skipped[] = [$row['excel_row'], '号码重复: ' . $row['number']];
|
||||
continue;
|
||||
}
|
||||
$seen[$row['number']] = 1;
|
||||
$keep[] = $row;
|
||||
}
|
||||
return [$keep, $skipped, $noNumber];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传截图到 OSS。返回 [对象键或null, 是否成功];文件缺失返回 [null, false]。
|
||||
*/
|
||||
private function uploadShot(string $imagesDir, string $filename, OutputInterface $output): ?string
|
||||
{
|
||||
if ($filename === '') {
|
||||
return null;
|
||||
}
|
||||
$path = rtrim($imagesDir, '/') . '/' . ltrim($filename, '/');
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
$mainDir = getenv('OSS_MAIN_DIR') ?: 'x_server';
|
||||
$key = $mainDir . '/crm_whatsapp/' . basename($filename);
|
||||
try {
|
||||
(new OssService())->uploadImage($path, $key); // 返回带签名的临时URL,这里只关心落 OSS 的对象键
|
||||
return $key;
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln('<comment> 截图上传失败 ' . $filename . ': ' . $e->getMessage() . '</comment>');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_hesu_google_wa_' . $date . '.json';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$file = $input->getOption('file');
|
||||
$imagesDir = (string)$input->getOption('images-dir');
|
||||
$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, $noNumber] = $this->dedup($rows, $output);
|
||||
$output->writeln('<info>去重后待建档: ' . count($keep) . ',号码重复跳过: ' . count($skipped) . ',无号码跳过: ' . count($noNumber) . '</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($noNumber, 0, 5) as $row) {
|
||||
$output->writeln('<comment> 无号码 行' . $row['excel_row'] . ' 留言:' . mb_substr($row['message'], 0, 30) . '</comment>');
|
||||
}
|
||||
|
||||
// 截图可用性
|
||||
$shotFound = 0; $shotMissing = 0;
|
||||
foreach ($keep as $row) {
|
||||
if ($row['shot'] === '') {
|
||||
continue;
|
||||
}
|
||||
if (is_file(rtrim($imagesDir, '/') . '/' . ltrim($row['shot'], '/'))) {
|
||||
$shotFound++;
|
||||
} else {
|
||||
$shotMissing++;
|
||||
}
|
||||
}
|
||||
$output->writeln('<info>截图: 找到 ' . $shotFound . ' / 缺失 ' . $shotMissing . '(目录: ' . $imagesDir . ')</info>');
|
||||
|
||||
// 轮询分配(按行序 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['number'] . ' '
|
||||
. mb_substr($row['message'], 0, 20) . ' -> user ' . $row['new_user_id'] . '</comment>');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不上传截图、不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$notifyOnly) {
|
||||
// 先传截图(外部操作,不在事务内;失败不阻断,仅记录),再事务建档
|
||||
$uploaded = 0;
|
||||
foreach ($keep as &$row) {
|
||||
$key = $this->uploadShot($imagesDir, $row['shot'], $output);
|
||||
$row['shot_key'] = $key;
|
||||
if ($key !== null) {
|
||||
$uploaded++;
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
$output->writeln('<info>截图已上传 OSS: ' . $uploaded . '</info>');
|
||||
|
||||
$created = [];
|
||||
Db::connection()->transaction(function () use ($keep, $output, &$created) {
|
||||
foreach ($keep as $row) {
|
||||
$desc = $row['message'];
|
||||
if ($row['shot'] !== '') {
|
||||
$desc .= ($desc !== '' ? "\n" : '') . '截图:' . ($row['shot_key'] ?? $row['shot']);
|
||||
}
|
||||
$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 = '';
|
||||
$c->phone = '';
|
||||
$c->WhatsApp = $row['number'];
|
||||
$c->contacts = $row['number'];
|
||||
$c->desc_collect = $desc;
|
||||
$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['number'],
|
||||
'email' => '',
|
||||
'phone' => $row['number'], // 邮件里电话列显示 WhatsApp 号
|
||||
'country_cn' => '',
|
||||
'channel' => self::CHANNEL_NAME,
|
||||
'remark' => $row['message'],
|
||||
];
|
||||
}
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = 'WhatsApp新询盘分配汇总(共 ' . 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 . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user