Files
fdesk/app/command/HesuGoogleWhatsAppImportCommand.php
T
mzl92 6fa80336a6 feat: 询盘导入改为公平分配——按近10天窗口实际分到数排序(三脚本共用)
新增 InquiryFairAssigner:统计 crm_customer(合塑谷歌/导入/未删/近10天)
中名单内每人的已有条数,按 少者优先 -> 最久未分到优先 -> 名单顺序 排序,
三个导入命令分配前统一调用并打印排序结果(含各人窗口内条数)。
解决:日询盘量少于人数时固定只分给名单前几人的问题;
名单增删(离职/暂停回归)自适应,跨脚本/跨天共享,无额外状态。

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

428 lines
18 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\service\OssService;
use app\common\utils\InquiryFairAssigner;
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 = rtrim((string)(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>');
// 公平分配:按近10天窗口内实际分到数升序(少者优先、最久未分到优先),再按序轮询
$fair = InquiryFairAssigner::fairOrder($targetUserIds);
$orderedUsers = $fair['ordered'];
$output->writeln('<info>分配顺序(近' . $fair['window_days'] . '天已有条数):'
. implode('、', array_map(fn($u) => $u . '(' . ($fair['counts'][$u] ?? 0) . ')', $orderedUsers)) . '</info>');
$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('<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>');
}
$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'];
} 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('<comment>本次无新建档(可能已导入过),沿用已有 manifest 的分配结果。</comment>');
} 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));
}
/**
* 建档行 -> 邮件汇总行(联系人/电话列显示 WhatsApp 号)
*/
private function digestRow(array $row): array
{
return [
'code' => $row['code'] ?? '',
'contact' => $row['number'],
'email' => '',
'phone' => $row['number'],
'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('<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 = '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 . '>');
}
}
}