Files
fdesk/app/command/InquiryBulkAssignCommand.php
T
mzl92 61c34db99e feat: 新增询盘批量分配命令(骨架+Excel解析+code匹配+dry-run)
- InquiryDigestMail: 汇总邮件 HTML 模板
- InquiryBulkAssignCommand: assign 轮询、parseExcel、matchCustomers(code+排除软删除)、dry-run 报告、--self-test

写库/发邮件逻辑见后续提交。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:56:36 +08:00

210 lines
8.2 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\utils\email\InquiryDigestMail;
use app\crm\model\CustomerHis;
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;
class InquiryBulkAssignCommand extends Command
{
protected static $defaultName = 'inquiry:bulk-assign';
protected static $defaultDescription = '按 Excel 行序轮询分配询盘给业务员,更新 crm_customer.user_id 并发汇总邮件';
// Excel 列映射(A=编号/code)
private const COL_CODE = 'A';
private const COL_CHANNEL = 'D';
private const COL_EMAIL = 'E';
private const COL_PHONE = 'F';
private const COL_WHATSAPP = 'G';
private const COL_CONTACT = 'H';
private const COL_REMARK = 'I';
private const COL_COUNTRY_CN = 'J';
protected function configure()
{
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/崔琪2.xlsx')
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔', '34,73,72,155,64,211')
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+匹配+分配,打印报告,不写库不发邮件')
->addOption('only-user', null, InputOption::VALUE_REQUIRED, '仅给指定 user_id 发邮件(试发)', '0')
->addOption('notify-only', null, InputOption::VALUE_NONE, '跳过写库,按 manifest 补发未成功的邮件')
->addOption('self-test', null, InputOption::VALUE_NONE, '运行纯逻辑自测');
}
/**
* 内存轮询分配(确定性 i % n)
* 给 $matched 每项追加 new_user_id,返回 [user_id => [rows...]]
*/
public static function assign(array &$matched, array $targetUserIds): array
{
$groups = [];
foreach ($targetUserIds as $uid) {
$groups[$uid] = [];
}
$n = count($targetUserIds);
foreach ($matched as $i => &$row) {
$uid = $targetUserIds[$i % $n];
$row['new_user_id'] = $uid;
$groups[$uid][] = $row;
}
unset($row);
return $groups;
}
/**
* 解析 Excel,按行顺序返回询盘数组
*/
private function parseExcel(string $filePath): array
{
$sheet = IOFactory::load($filePath)->getActiveSheet();
$highestRow = $sheet->getHighestRow();
$rows = [];
$get = function (string $col, int $r) use ($sheet): string {
return trim((string)($sheet->getCell($col . $r)->getValue() ?? ''));
};
for ($r = 2; $r <= $highestRow; $r++) {
$code = $get(self::COL_CODE, $r);
if ($code === '') {
continue;
}
$rows[] = [
'code' => $code,
'channel' => $get(self::COL_CHANNEL, $r),
'email' => $get(self::COL_EMAIL, $r),
'phone' => $get(self::COL_PHONE, $r),
'whatsapp' => $get(self::COL_WHATSAPP, $r),
'contact' => $get(self::COL_CONTACT, $r),
'country_cn' => $get(self::COL_COUNTRY_CN, $r),
'remark' => $get(self::COL_REMARK, $r),
'excel_row' => $r,
];
}
return $rows;
}
/**
* 批量匹配 crm_customer(code 唯一取 MIN(id),排除软删除)
* 返回 ['matched' => [...], 'unmatched' => [...]];matched 每项追加 customer_id/old_user_id
*/
private function matchCustomers(array $rows): array
{
$codes = array_values(array_unique(array_column($rows, 'code')));
$map = []; // code => ['id','old_user_id']
foreach (array_chunk($codes, 500) as $chunk) {
$recs = Db::table('crm_customer')
->whereIn('code', $chunk)
->whereNull('deleted_at')
->orderBy('id', 'asc')
->get(['id', 'code', 'user_id']);
foreach ($recs as $rec) {
if (!isset($map[$rec->code])) {
$map[$rec->code] = ['id' => (int)$rec->id, 'old_user_id' => $rec->user_id];
}
}
}
$matched = [];
$unmatched = [];
foreach ($rows as $row) {
if (isset($map[$row['code']])) {
$row['customer_id'] = $map[$row['code']]['id'];
$row['old_user_id'] = $map[$row['code']]['old_user_id'];
$matched[] = $row;
} else {
$unmatched[] = $row;
}
}
return ['matched' => $matched, 'unmatched' => $unmatched];
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
if ($input->getOption('self-test')) {
return $this->runSelfTest($output);
}
$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');
$onlyUser = (int)$input->getOption('only-user');
$date = date('Y-m-d');
if (!$targetUserIds) {
$output->writeln('<error>--users 不能为空</error>');
return self::FAILURE;
}
if (!is_file($file)) {
$output->writeln('<error>文件不存在: ' . $file . '</error>');
return self::FAILURE;
}
$output->writeln('<info>解析 Excel: ' . $file . '</info>');
$rows = $this->parseExcel($file);
$output->writeln('<info>Excel 询盘数: ' . count($rows) . '</info>');
$res = $this->matchCustomers($rows);
$matched = $res['matched'];
$unmatched = $res['unmatched'];
$output->writeln('<info>匹配成功: ' . count($matched) . ',未匹配: ' . count($unmatched) . '</info>');
if ($unmatched) {
$sample = implode(', ', array_slice(array_column($unmatched, 'code'), 0, 10));
$output->writeln('<comment>未匹配 code 样例: ' . $sample . '</comment>');
}
$groups = self::assign($matched, $targetUserIds);
foreach ($targetUserIds as $uid) {
$output->writeln('<info>user ' . $uid . ' 分配: ' . count($groups[$uid]) . ' 条</info>');
}
foreach ($matched as $i => $row) {
if ($i < 3) {
$output->writeln('<comment>样例[' . $i . '] code=' . $row['code']
. ' -> user ' . $row['new_user_id'] . ' (原 ' . $row['old_user_id'] . ')</comment>');
}
}
if ($dryRun) {
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认无误后去掉 --dry-run 执行。</info>');
return self::SUCCESS;
}
$output->writeln('<error>[未实现] 写库与发邮件见 Task 3/Task 4。</error>');
return self::SUCCESS;
}
private function runSelfTest(OutputInterface $output): int
{
$fail = 0;
// 1) assign: 7 条 / 3 人 -> 计数 [3,2,2]
$items = array_fill(0, 7, []);
$groups = self::assign($items, [10, 20, 30]);
$counts = [count($groups[10]), count($groups[20]), count($groups[30])];
if ($counts !== [3, 2, 2]) {
$output->writeln('<error>assign 计数错误: ' . json_encode($counts) . '</error>');
$fail++;
}
// 2) InquiryDigestMail::build 内容校验
$html = InquiryDigestMail::build('张三', [[
'code' => 'CM1', 'contact' => 'A', 'email' => 'a@x.com',
'phone' => '1', 'country_cn' => '中国', 'channel' => 'fb', 'remark' => '意向车型:X',
]], '2026-07-17');
if (strpos($html, '张三') === false || strpos($html, 'CM1') === false
|| strpos($html, '共 <strong>1</strong> 条') === false) {
$output->writeln('<error>InquiryDigestMail::build 内容校验失败</error>');
$fail++;
}
if ($fail === 0) {
$output->writeln('<info>self-test 通过</info>');
return self::SUCCESS;
}
return self::FAILURE;
}
}