feat: 新增询盘批量分配命令(骨架+Excel解析+code匹配+dry-run)
- InquiryDigestMail: 汇总邮件 HTML 模板 - InquiryBulkAssignCommand: assign 轮询、parseExcel、matchCustomers(code+排除软删除)、dry-run 报告、--self-test 写库/发邮件逻辑见后续提交。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils\email;
|
||||
|
||||
class InquiryDigestMail
|
||||
{
|
||||
/**
|
||||
* 构建汇总邮件 HTML
|
||||
*
|
||||
* @param string $userName 收件人姓名
|
||||
* @param array $inquiries 询盘数组,每项含 code/contact/email/phone/country_cn/channel/remark
|
||||
* @param string $date 日期串 Y-m-d
|
||||
* @return string HTML
|
||||
*/
|
||||
public static function build(string $userName, array $inquiries, string $date): string
|
||||
{
|
||||
$count = count($inquiries);
|
||||
$rows = '';
|
||||
$idx = 1;
|
||||
foreach ($inquiries as $q) {
|
||||
$remark = mb_substr((string)($q['remark'] ?? ''), 0, 100);
|
||||
$td = '<td style="border:1px solid #eee;padding:6px;">';
|
||||
$rows .= '<tr>'
|
||||
. $td . $idx++ . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['code'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['contact'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['email'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['phone'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['country_cn'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars((string)($q['channel'] ?? '')) . '</td>'
|
||||
. $td . htmlspecialchars($remark) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
$thStyle = 'style="border:1px solid #eee;padding:6px;background:#f0f0f0;"';
|
||||
return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"></head>'
|
||||
. '<body style="margin:0;padding:0;background:#f6f7f9;font-family:Arial,Helvetica,sans-serif;">'
|
||||
. '<div style="max-width:960px;margin:0 auto;padding:20px;">'
|
||||
. '<h2 style="color:#111;">询盘分配汇总</h2>'
|
||||
. '<p>收件人:<strong>' . htmlspecialchars($userName) . '</strong> 日期:' . $date
|
||||
. ' 共 <strong>' . $count . '</strong> 条</p>'
|
||||
. '<table style="border-collapse:collapse;width:100%;font-size:13px;color:#333;">'
|
||||
. '<thead><tr>'
|
||||
. '<th ' . $thStyle . '>#</th>'
|
||||
. '<th ' . $thStyle . '>编号</th>'
|
||||
. '<th ' . $thStyle . '>联系人</th>'
|
||||
. '<th ' . $thStyle . '>邮箱</th>'
|
||||
. '<th ' . $thStyle . '>电话</th>'
|
||||
. '<th ' . $thStyle . '>国家</th>'
|
||||
. '<th ' . $thStyle . '>渠道</th>'
|
||||
. '<th ' . $thStyle . '>意向/备注</th>'
|
||||
. '</tr></thead><tbody>'
|
||||
. $rows
|
||||
. '</tbody></table></div></body></html>';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user