Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db0be8486f | |||
| d4deb8855e | |||
| 694056c8cc | |||
| 2de6342058 | |||
| 2e2188d6e5 | |||
| 6fa80336a6 | |||
| 89b9162f09 | |||
| d5f05a81a4 | |||
| 8bf0984a9c | |||
| 705f402c78 | |||
| 21c1d6afd9 | |||
| 09fdd7a5d5 | |||
| 37e63637a4 | |||
| 3a3ed12fd5 | |||
| e096157063 | |||
| 06b0647fc4 | |||
| 6e2e8e1cc6 | |||
| b2684ecbb2 | |||
| 61c34db99e | |||
| 3d3caddc7d | |||
| b89ac48dd8 | |||
| 71dca13119 | |||
| 35d98c16e9 |
@@ -14,3 +14,4 @@
|
||||
/composer.lock
|
||||
.env_copy
|
||||
/public/excel
|
||||
/docs/*.sql
|
||||
@@ -136,6 +136,10 @@ class StockOrdersController extends BaseController
|
||||
];
|
||||
}
|
||||
unset($item['options']);
|
||||
// 附件 url 响应期重签(历史签名URL会过期)
|
||||
if (!empty($item['attachment']) && is_array($item['attachment'])) {
|
||||
$item['attachment'] = \app\common\service\OssService::resignAttachmentUrls($item['attachment']);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\CrmCustomer;
|
||||
use app\common\utils\InquiryFairAssigner;
|
||||
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>');
|
||||
}
|
||||
|
||||
// 公平分配:按近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['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 . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\model\CrmCustomer;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 合塑谷歌网站询盘导入
|
||||
*
|
||||
* Excel 新询盘(库外数据)-> 建 crm_customer 档案 -> 按行序轮询分配业务员 -> 每人一封汇总邮件。
|
||||
* 固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入数据);
|
||||
* 去重:电话或邮箱与现有未删除客户重复(含本文件内重复)则跳过。
|
||||
*/
|
||||
class HesuGoogleInquiryImportCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'inquiry:import-hesu-google';
|
||||
protected static $defaultDescription = '合塑谷歌网站询盘导入:新询盘建档+轮询分配+汇总邮件';
|
||||
|
||||
private const CORP_ID = 1;
|
||||
private const CHANNEL_ID = 4; // 合塑谷歌
|
||||
private const CHANNEL_NAME = '合塑谷歌';
|
||||
private const TYPE = 2; // 导入数据
|
||||
private const GRADE = 0;
|
||||
private const MAGNITUDE = 2;
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption('file', null, InputOption::VALUE_REQUIRED, 'Excel 文件路径', 'docs/询盘明细_2026年6-8月_已清洗.xlsx')
|
||||
->addOption('users', null, InputOption::VALUE_REQUIRED, '目标 user_id 列表,逗号分隔(必填)', '')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, '只解析+去重预检+分配预演,不写库不发邮件')
|
||||
->addOption('no-notify', null, InputOption::VALUE_NONE, '只建档分配,不发邮件(先核对库再 --notify-only 补发)')
|
||||
->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;
|
||||
}
|
||||
}
|
||||
$required = ['客户姓名', '电话/WhatsApp', '邮箱', '留言内容', '产品页链接'];
|
||||
$missing = array_diff($required, array_keys($colMap));
|
||||
if ($missing) {
|
||||
throw new \RuntimeException('Excel 缺少必需列头: ' . implode(', ', $missing) . ';实际列头: ' . implode(',', array_keys($colMap)));
|
||||
}
|
||||
|
||||
$get = function (string $header, int $r) use ($sheet, $colMap): string {
|
||||
return trim((string)($sheet->getCell($colMap[$header] . $r)->getValue() ?? ''));
|
||||
};
|
||||
|
||||
$rows = [];
|
||||
for ($r = 2; $r <= $highestRow; $r++) {
|
||||
$row = [
|
||||
'contacts' => $get('客户姓名', $r),
|
||||
'phone' => $get('电话/WhatsApp', $r),
|
||||
'email' => $get('邮箱', $r),
|
||||
'message' => $get('留言内容', $r),
|
||||
'url' => $get('产品页链接', $r),
|
||||
'excel_row' => $r,
|
||||
];
|
||||
if ($row['contacts'] === '' && $row['phone'] === '' && $row['email'] === '') {
|
||||
continue; // 整行为空
|
||||
}
|
||||
$rows[] = $row;
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从产品页链接提取车型 slug,如 /product/rox-01/ -> rox-01
|
||||
*/
|
||||
private function productSlug(string $url): string
|
||||
{
|
||||
$path = parse_url($url, PHP_URL_PATH) ?: '';
|
||||
$segs = array_values(array_filter(explode('/', $path), fn($s) => $s !== ''));
|
||||
if (empty($segs)) {
|
||||
return '';
|
||||
}
|
||||
$i = array_search('product', $segs, true);
|
||||
if ($i !== false && isset($segs[$i + 1])) {
|
||||
return $segs[$i + 1];
|
||||
}
|
||||
return (string)end($segs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 库内 + 文件内去重。返回 [待建档行, 跳过信息列表]
|
||||
*/
|
||||
private function dedup(array $rows, OutputInterface $output): array
|
||||
{
|
||||
$dbPhones = [];
|
||||
foreach (array_chunk(array_filter(array_column($rows, 'phone')), 500) as $chunk) {
|
||||
$dbPhones = array_merge($dbPhones, Db::table('crm_customer')
|
||||
->whereNull('deleted_at')->whereIn('phone', $chunk)->pluck('phone')->map(fn($v) => trim((string)$v))->all());
|
||||
}
|
||||
$dbEmails = [];
|
||||
foreach (array_chunk(array_filter(array_column($rows, 'email')), 500) as $chunk) {
|
||||
$dbEmails = array_merge($dbEmails, Db::table('crm_customer')
|
||||
->whereNull('deleted_at')->whereIn('email', $chunk)->pluck('email')->map(fn($v) => trim((string)$v))->all());
|
||||
}
|
||||
$dbPhones = array_flip($dbPhones);
|
||||
$dbEmails = array_flip($dbEmails);
|
||||
|
||||
$keep = []; $skipped = []; $noContact = [];
|
||||
$seenPhone = []; $seenEmail = [];
|
||||
foreach ($rows as $row) {
|
||||
if ($row['phone'] === '' && $row['email'] === '') {
|
||||
$noContact[] = $row;
|
||||
continue;
|
||||
}
|
||||
if ($row['phone'] !== '' && (isset($dbPhones[$row['phone']]) || isset($seenPhone[$row['phone']]))) {
|
||||
$skipped[] = [$row['excel_row'], '电话重复: ' . $row['phone']];
|
||||
$seenPhone[$row['phone']] = 1;
|
||||
continue;
|
||||
}
|
||||
if ($row['email'] !== '' && (isset($dbEmails[$row['email']]) || isset($seenEmail[$row['email']]))) {
|
||||
$skipped[] = [$row['excel_row'], '邮箱重复: ' . $row['email']];
|
||||
$seenEmail[$row['email']] = 1;
|
||||
continue;
|
||||
}
|
||||
if ($row['phone'] !== '') {
|
||||
$seenPhone[$row['phone']] = 1;
|
||||
}
|
||||
if ($row['email'] !== '') {
|
||||
$seenEmail[$row['email']] = 1;
|
||||
}
|
||||
$keep[] = $row;
|
||||
}
|
||||
return [$keep, $skipped, $noContact];
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_hesu_google_import_' . $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 ($noContact as $row) {
|
||||
$output->writeln('<comment> 无联系方式 行' . $row['excel_row'] . ' ' . $row['contacts'] . '</comment>');
|
||||
}
|
||||
|
||||
// 公平分配:按近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 ($keep as $i => $row) {
|
||||
if ($i < 3) {
|
||||
$output->writeln('<comment>样例[行' . $row['excel_row'] . '] ' . $row['contacts'] . ' ' . $row['phone'] . ' '
|
||||
. $this->productSlug($row['url']) . ' -> user ' . $row['new_user_id'] . '</comment>');
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->writeln('<info>[dry-run] 不写库、不发邮件。确认后去掉 --dry-run 执行。</info>');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$notifyOnly) {
|
||||
$created = [];
|
||||
Db::connection()->transaction(function () use ($keep, $targetUserIds, $output, &$created) {
|
||||
foreach ($keep as $row) {
|
||||
$desc = $row['message'];
|
||||
if ($row['url'] !== '') {
|
||||
$desc .= ($desc !== '' ? "\n" : '') . '产品页:' . $row['url'];
|
||||
}
|
||||
$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 = '';
|
||||
$c->contacts = $row['contacts'];
|
||||
$c->desc_collect = $desc;
|
||||
$slug = $this->productSlug($row['url']);
|
||||
$c->intended_vehicles = $slug !== '' ? $slug : null;
|
||||
$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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 建档行 -> 邮件汇总行
|
||||
*/
|
||||
private function digestRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'code' => $row['code'] ?? '',
|
||||
'contact' => $row['contacts'],
|
||||
'email' => $row['email'],
|
||||
'phone' => $row['phone'],
|
||||
'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 = '新询盘分配汇总(共 ' . 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 . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?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 . '>');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use app\common\utils\email\InquiryDigestMail;
|
||||
use app\common\utils\email\MailSend;
|
||||
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('no-notify', null, InputOption::VALUE_NONE, '只写库不发邮件(先核对库再用 --notify-only 补发)')
|
||||
->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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 单事务:更新 user_id + 写历史。用 Db::table() 不触发 model 事件。
|
||||
*/
|
||||
private function applyAssignments(array $matched): void
|
||||
{
|
||||
Db::connection()->transaction(function () use ($matched) {
|
||||
foreach ($matched as $row) {
|
||||
Db::table('crm_customer')
|
||||
->where('id', $row['customer_id'])
|
||||
->update(['user_id' => $row['new_user_id']]);
|
||||
CustomerHis::create([
|
||||
'customer_id' => $row['customer_id'],
|
||||
'user_his' => $row['old_user_id'],
|
||||
'user_id' => $row['new_user_id'],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function manifestPath(string $date): string
|
||||
{
|
||||
return runtime_path() . '/inquiry_bulk_assign_' . $date . '.json';
|
||||
}
|
||||
|
||||
private function initManifest(string $path, array $targetUserIds, array $matched, array $groups): void
|
||||
{
|
||||
if (is_file($path)) {
|
||||
return; // 已存在则保留(含 emailed 标记),供补发使用
|
||||
}
|
||||
$perUser = [];
|
||||
$emailed = [];
|
||||
foreach ($targetUserIds as $uid) {
|
||||
$perUser[(string)$uid] = count($groups[$uid] ?? []);
|
||||
$emailed[(string)$uid] = false;
|
||||
}
|
||||
$data = [
|
||||
'date' => date('Y-m-d'),
|
||||
'assigned_at' => time(),
|
||||
'target_users' => $targetUserIds,
|
||||
'total_matched' => count($matched),
|
||||
'per_user_counts' => $perUser,
|
||||
'emailed' => $emailed,
|
||||
];
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
private function loadManifest(string $path): array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return ['emailed' => []];
|
||||
}
|
||||
return json_decode((string)file_get_contents($path), true) ?: ['emailed' => []];
|
||||
}
|
||||
|
||||
private function saveManifest(string $path, array $data): void
|
||||
{
|
||||
file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 targetUserIds 顺序发汇总邮件;跳过已标记成功与 --only-user 之外的人。
|
||||
*/
|
||||
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 = $this->loadManifest($manifestPath);
|
||||
$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 = $groups[$uid] ?? [];
|
||||
$html = InquiryDigestMail::build((string)$u->name, $inquiries, $date);
|
||||
$subject = '询盘分配汇总(共 ' . count($inquiries) . ' 条)- ' . $u->name . ' - ' . $date;
|
||||
|
||||
$ok = MailSend::send($u->email, $subject, $html);
|
||||
if (!$ok) {
|
||||
$ok = MailSend::send($u->email, $subject, $html); // 重试 1 次
|
||||
}
|
||||
$manifest['emailed'][(string)$uid] = (bool)$ok;
|
||||
$this->saveManifest($manifestPath, $manifest);
|
||||
|
||||
$tag = $ok ? 'info' : 'error';
|
||||
$output->writeln('<' . $tag . '>user ' . $uid . ' (' . $u->email . '): '
|
||||
. ($ok ? '发送成功' : '发送失败') . ',共 ' . count($inquiries) . ' 条</' . $tag . '>');
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
$noNotify = (bool)$input->getOption('no-notify');
|
||||
$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;
|
||||
}
|
||||
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>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;
|
||||
}
|
||||
|
||||
if (!$notifyOnly) {
|
||||
$this->applyAssignments($matched);
|
||||
$output->writeln('<info>已写库(user_id 更新 + crm_customer_his),共 ' . count($matched) . ' 条。</info>');
|
||||
} 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, $matched, $groups);
|
||||
$this->sendDigests($groups, $targetUserIds, $manifestPath, $onlyUser, $output);
|
||||
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,152 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\model\StockOrders;
|
||||
use app\common\model\StockOrderItems;
|
||||
use app\common\model\User;
|
||||
use support\Request;
|
||||
|
||||
class PublicApiController extends BaseController
|
||||
{
|
||||
// 业务人员固定角色ID
|
||||
protected const STAFF_ROLE_ID = 2;
|
||||
|
||||
protected $noNeedLogin = ['piList', 'vinList', 'staffList'];
|
||||
protected $noNeedHeaderData = ['piList', 'vinList', 'staffList'];
|
||||
|
||||
/**
|
||||
* PI 号列表(含关联的 VIN 和车型名称)
|
||||
* GET /common/public/pi/list
|
||||
*/
|
||||
public function piList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$piNo = $request->input('pi_no', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrders::where('approval_status', 2)
|
||||
->whereNotNull('pi_no')
|
||||
->where('pi_no', '<>', '');
|
||||
|
||||
if ($piNo !== '') {
|
||||
$query->where('pi_no', 'like', '%' . addcslashes($piNo, '%_') . '%');
|
||||
}
|
||||
|
||||
$query->with(['items' => function ($q) {
|
||||
$q->select('id', 'order_id', 'vin', 'model_snapshot');
|
||||
}]);
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'pi_no'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($order) {
|
||||
return [
|
||||
'pi_no' => $order->pi_no,
|
||||
'vins' => $order->items->pluck('vin')->filter()->values()->toArray(),
|
||||
'model_names' => $order->items->pluck('model_snapshot')->filter()->values()->toArray(),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN 号列表(含车型名称)
|
||||
* GET /common/public/vin/list
|
||||
*/
|
||||
public function vinList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$vin = $request->input('vin', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrderItems::whereHas('order', function ($q) {
|
||||
$q->where('approval_status', 2);
|
||||
})->whereNotNull('vin')
|
||||
->where('vin', '<>', '');
|
||||
|
||||
if ($vin !== '') {
|
||||
$query->where('vin', 'like', '%' . addcslashes($vin, '%_') . '%');
|
||||
}
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'vin', 'model_snapshot'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($item) {
|
||||
return [
|
||||
'vin' => $item->vin,
|
||||
'model_name' => $item->model_snapshot,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务人员列表(role_id=2,在职,全量返回)
|
||||
* GET /common/public/staff/list
|
||||
*/
|
||||
public function staffList(Request $request)
|
||||
{
|
||||
try {
|
||||
$keyword = $request->input('keyword', '');
|
||||
|
||||
// 通过 user_roles 关联,筛选指定角色的用户
|
||||
$query = User::whereHas('roles', function ($q) {
|
||||
$q->where('roles.id', static::STAFF_ROLE_ID);
|
||||
})->where('status', 1);
|
||||
|
||||
if ($keyword !== '') {
|
||||
$kw = '%' . addcslashes($keyword, '%_') . '%';
|
||||
$query->where(function ($q) use ($kw) {
|
||||
$q->where('name', 'like', $kw)
|
||||
->orWhere('sn', 'like', $kw)
|
||||
->orWhere('phone', 'like', $kw)
|
||||
->orWhere('email', 'like', $kw);
|
||||
});
|
||||
}
|
||||
|
||||
$list = $query->orderBy('id', 'asc')
|
||||
->get(['id', 'sn', 'name', 'phone', 'email']);
|
||||
|
||||
$data = $list->map(function ($user) {
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'sn' => $user->sn,
|
||||
'phone' => $user->phone,
|
||||
'email' => $user->email,
|
||||
];
|
||||
})->values()->toArray();
|
||||
|
||||
return $this->success($data);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('查询失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,14 @@ class StockOrderItems extends Model
|
||||
'id', 'order_id', 'product_code', 'sku_id', 'sku_snapshot',
|
||||
'model_snapshot', 'quantity', 'color_interior_code', 'color_interior_snapshot',
|
||||
'color_exterior_code', 'color_exterior_snapshot', 'remark', 'vin', 'created_at', 'updated_at',
|
||||
'mileage', 'unique_id'
|
||||
'mileage', 'unique_id', 'attachment'
|
||||
];
|
||||
|
||||
/**
|
||||
* 字段类型转换:attachment 以 JSON 数组存取(写自动 json_encode、读自动 decode)
|
||||
*/
|
||||
protected $casts = [
|
||||
'attachment' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace app\common\service;
|
||||
|
||||
use app\common\model\User;
|
||||
use app\common\model\UserGroupMembers;
|
||||
use support\Db;
|
||||
use app\common\validate\ApprovalRecordsValidate;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\common\model\ApprovalRecords;
|
||||
@@ -118,6 +119,31 @@ class ApprovalRecordsService
|
||||
$config['where']['status'] = ['!=', 'pending'];
|
||||
}
|
||||
|
||||
// 业务筛选:pi_no 精确到PI号;keyword 匹配 单号/PI号(历史页原有搜索框此前后端未实现)
|
||||
// 两个条件同时传时取交集
|
||||
$bizIdFilter = null;
|
||||
if (!empty($p['pi_no'])) {
|
||||
$ids = Db::table('stock_orders')
|
||||
->where('pi_no', 'like', '%' . trim((string)$p['pi_no']) . '%')
|
||||
->pluck('id')->toArray();
|
||||
$bizIdFilter = $ids ?: [0];
|
||||
}
|
||||
if (!empty($p['keyword'])) {
|
||||
$kw = '%' . trim((string)$p['keyword']) . '%';
|
||||
$ids = Db::table('stock_orders')
|
||||
->where(function ($q) use ($kw) {
|
||||
$q->where('order_no', 'like', $kw)
|
||||
->orWhere('pi_no', 'like', $kw)
|
||||
->orWhere('customer_snapshot', 'like', $kw);
|
||||
})
|
||||
->pluck('id')->toArray();
|
||||
$ids = $ids ?: [0];
|
||||
$bizIdFilter = $bizIdFilter === null ? $ids : array_values(array_intersect($bizIdFilter, $ids));
|
||||
}
|
||||
if ($bizIdFilter !== null) {
|
||||
$config['where']['instance.biz_id'] = ['in', $bizIdFilter];
|
||||
}
|
||||
|
||||
$config['order']['id'] = 'desc';
|
||||
|
||||
$config['with'] = [
|
||||
|
||||
@@ -57,6 +57,42 @@ class OssService
|
||||
return $this->ossClient->signUrl($this->bucket, $objectKey, $expireTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件数组的 url 重签为新的私有访问地址(响应期调用,防止历史签名过期)。
|
||||
* 兼容三种存量格式:完整签名URL / 完整未签名URL / 纯对象键。
|
||||
* @param array $attachments [{url, original_name}, ...]
|
||||
* @param int $expireTime
|
||||
* @return array
|
||||
*/
|
||||
public static function resignAttachmentUrls(array $attachments, int $expireTime = 604800): array
|
||||
{
|
||||
foreach ($attachments as &$a) {
|
||||
if (empty($a['url']) || !is_string($a['url'])) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$url = $a['url'];
|
||||
if (strpos($url, 'http') === 0) {
|
||||
$path = (string)parse_url($url, PHP_URL_PATH);
|
||||
if ($path === '') {
|
||||
continue;
|
||||
}
|
||||
$key = ltrim(urldecode($path), '/');
|
||||
} else {
|
||||
$key = ltrim($url, '/'); // 已是对象键
|
||||
}
|
||||
if ($key === '') {
|
||||
continue;
|
||||
}
|
||||
$a['url'] = (new self())->generatePrivateUrl($key, $expireTime);
|
||||
} catch (\Throwable $e) {
|
||||
// 重签失败保留原样
|
||||
}
|
||||
}
|
||||
unset($a);
|
||||
return $attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 OSS 上的文件
|
||||
* @param string $objectKey OSS上存储的对象名称
|
||||
|
||||
@@ -644,6 +644,8 @@ class StockOrdersService
|
||||
foreach ($vehList as $veh) {
|
||||
$veh['order_id'] = $order->id;
|
||||
$veh['unique_id'] = $this->generateVehicleUniqueId($veh);
|
||||
// 车辆附件:归一化数组(由模型 attachment=>'array' cast 自动序列化为 JSON 落库)
|
||||
$veh['attachment'] = is_array($veh['attachment'] ?? null) ? array_values($veh['attachment']) : [];
|
||||
|
||||
$item = StockOrderItems::create($veh);
|
||||
|
||||
@@ -667,7 +669,7 @@ class StockOrdersService
|
||||
*/
|
||||
private function generateVehicleUniqueId(array $veh): string
|
||||
{
|
||||
$keysToRemove = ['remark', 'modify_list', 'order_id'];
|
||||
$keysToRemove = ['remark', 'modify_list', 'order_id', 'attachment'];
|
||||
$array = array_diff_key($veh, array_flip($keysToRemove));
|
||||
$uniqueIdStr = json_encode($array);
|
||||
return md5($uniqueIdStr);
|
||||
@@ -882,6 +884,18 @@ class StockOrdersService
|
||||
$vehList[$v->unique_id]['remark'] = $v->remark;
|
||||
}
|
||||
}
|
||||
// 拼接附件(并集去重,按 url+文件名)
|
||||
$currAttach = is_array($v->attachment) ? $v->attachment : [];
|
||||
if (!empty($currAttach)) {
|
||||
$union = [];
|
||||
foreach (array_merge($vehList[$v->unique_id]['attachment'], $currAttach) as $a) {
|
||||
$key = ($a['url'] ?? '') . '|' . ($a['original_name'] ?? '');
|
||||
if (!isset($union[$key])) {
|
||||
$union[$key] = $a;
|
||||
}
|
||||
}
|
||||
$vehList[$v->unique_id]['attachment'] = array_values($union);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
$index++;
|
||||
@@ -897,8 +911,16 @@ class StockOrdersService
|
||||
'mileage' => $v->mileage,
|
||||
'remark' => $v->remark,
|
||||
'vin' => $v->vin,
|
||||
'attachment' => is_array($v->attachment) ? $v->attachment : [],
|
||||
] + $moneyValues;
|
||||
}
|
||||
// 附件 url 响应期重签(历史存的是上传时的签名URL,会过期)
|
||||
foreach ($vehList as &$row) {
|
||||
if (!empty($row['attachment'])) {
|
||||
$row['attachment'] = \app\common\service\OssService::resignAttachmentUrls($row['attachment']);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
return ['list' => $vehList];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils;
|
||||
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 询盘公平分配排序器(合塑谷歌三个导入脚本共用)
|
||||
*
|
||||
* 按"最近窗口期内实际分到的条数"升序排用户:分得少的优先、最久未分到的优先、
|
||||
* 再按传入名单顺序。统计池:crm_customer 中 channel_id=4(合塑谷歌)、type=2(导入)、
|
||||
* 未删除、窗口期内新建的记录。
|
||||
*
|
||||
* 场景自适应:
|
||||
* - 名单增删(离职移除/新人加入):每次执行按当次名单统计,无残留状态;
|
||||
* - 暂停一段时间再恢复:暂停期间窗口计数自然衰减,回归后适度优先拉平,
|
||||
* 不会饿死也不会被灌顶。
|
||||
*/
|
||||
class InquiryFairAssigner
|
||||
{
|
||||
/** 默认窗口期(天) */
|
||||
public const DEFAULT_WINDOW_DAYS = 10;
|
||||
|
||||
/**
|
||||
* 计算公平分配顺序
|
||||
*
|
||||
* @param array $userIds 本次参与分配的 user_id 列表
|
||||
* @param int $windowDays 统计窗口(天)
|
||||
* @return array{ordered: array, counts: array<int,int>, window_days: int}
|
||||
* ordered=排好序的 user_id 列表;counts=窗口内每人已有条数
|
||||
*/
|
||||
public static function fairOrder(array $userIds, int $windowDays = self::DEFAULT_WINDOW_DAYS): array
|
||||
{
|
||||
$since = time() - $windowDays * 86400;
|
||||
$counts = []; $lastIds = [];
|
||||
foreach (Db::table('crm_customer')
|
||||
->where('channel_id', 4)
|
||||
->where('type', 2)
|
||||
->whereNull('deleted_at')
|
||||
->whereIn('user_id', $userIds)
|
||||
->where('created_at', '>=', $since)
|
||||
->groupBy('user_id')
|
||||
->selectRaw('user_id, COUNT(*) AS cnt, MAX(id) AS last_id')
|
||||
->get() as $r) {
|
||||
$uid = (int)$r->user_id;
|
||||
$counts[$uid] = (int)$r->cnt;
|
||||
$lastIds[$uid] = (int)$r->last_id;
|
||||
}
|
||||
|
||||
$pos = array_flip(array_map('intval', $userIds));
|
||||
$ordered = array_map('intval', $userIds);
|
||||
usort($ordered, function ($a, $b) use ($counts, $lastIds, $pos) {
|
||||
$ca = $counts[$a] ?? 0; $cb = $counts[$b] ?? 0;
|
||||
if ($ca !== $cb) {
|
||||
return $ca <=> $cb; // 窗口内分到少的优先
|
||||
}
|
||||
$la = $lastIds[$a] ?? 0; $lb = $lastIds[$b] ?? 0;
|
||||
if ($la !== $lb) {
|
||||
return $la <=> $lb; // 最久未分到的优先
|
||||
}
|
||||
return $pos[$a] <=> $pos[$b]; // 名单顺序(稳定)
|
||||
});
|
||||
|
||||
return ['ordered' => $ordered, 'counts' => $counts, 'window_days' => $windowDays];
|
||||
}
|
||||
}
|
||||
@@ -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>';
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,10 @@ class StockOrdersController extends BaseController
|
||||
];
|
||||
}
|
||||
unset($item['options']);
|
||||
// 附件 url 响应期重签(历史签名URL会过期)
|
||||
if (!empty($item['attachment']) && is_array($item['attachment'])) {
|
||||
$item['attachment'] = \app\common\service\OssService::resignAttachmentUrls($item['attachment']);
|
||||
}
|
||||
}
|
||||
unset($item);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use app\common\model\Corp;
|
||||
use app\common\model\CustomerAudit;
|
||||
use app\common\model\InquiryLog;
|
||||
use app\common\model\User;
|
||||
use app\common\service\OssService;
|
||||
use app\common\traits\crud\CrudServiceTrait;
|
||||
use app\crm\logic\CustomerLogic;
|
||||
use app\crm\model\Channel;
|
||||
@@ -377,9 +378,36 @@ class CustomerService
|
||||
}
|
||||
}
|
||||
|
||||
// WhatsApp导入截图:desc_collect 里的 OSS 对象键 -> 带签名 <img>(前端 v-html 渲染)
|
||||
$customer['desc_collect'] = self::signScreenshotImages((string)($customer['desc_collect'] ?? ''));
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 desc_collect 中 crm_whatsapp 截图的 OSS 对象键替换为带签名的 <img> 标签。
|
||||
* 键按原文匹配签名(历史记录为双斜杠键,对象即存于该键,不可归一)。
|
||||
*/
|
||||
private static function signScreenshotImages(string $desc): string
|
||||
{
|
||||
if ($desc === '' || strpos($desc, 'crm_whatsapp/') === false) {
|
||||
return $desc;
|
||||
}
|
||||
return preg_replace_callback(
|
||||
'/([A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*\/+crm_whatsapp\/[A-Za-z0-9_\-\.]+\.(?:png|jpe?g|webp))/',
|
||||
function ($m) {
|
||||
try {
|
||||
$url = (new OssService())->generatePrivateUrl($m[1], 7 * 86400);
|
||||
return '<img src="' . htmlspecialchars($url, ENT_QUOTES) . '" '
|
||||
. 'style="max-width:100%;height:auto;display:block;margin:4px 0" />';
|
||||
} catch (\Throwable $e) {
|
||||
return $m[0];
|
||||
}
|
||||
},
|
||||
$desc
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function detail($params)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,25 @@ class StockOrdersService extends \app\common\service\StockOrdersService
|
||||
switch ($scene) {
|
||||
case 'index':
|
||||
|
||||
// ====== view_scope:仅 user_id=73 生效,最高优先级,覆盖下方业务id默认值与角色范围逻辑 ======
|
||||
$viewScope = $p['view_scope'] ?? '';
|
||||
if ($request->user->uid == 73 && in_array($viewScope, ['all', 'self'], true)) {
|
||||
if ($viewScope === 'self') {
|
||||
$config['where']['salesperson_id'] = $request->user->uid; // 只看自己(含自己草稿)
|
||||
} else { // 'all':自己和别人的,排除草稿(approval_status=0)
|
||||
$config['where']['approval_status'] = ['>=', 1];
|
||||
}
|
||||
$config['where']['is_final'] = $p['is_final'] ?? 0;
|
||||
if (isset($p['keyword']) && $p['keyword']) {
|
||||
$k = '%' . $p['keyword'] . '%';
|
||||
foreach (['pi_no', 'order_no', 'customer_snapshot'] as $col) {
|
||||
$config['orWhere'][] = [$col, 'like', $k];
|
||||
}
|
||||
}
|
||||
$config['order']['id'] = 'desc';
|
||||
break; // 最高优先级:跳过下方业务id默认值与角色范围逻辑
|
||||
}
|
||||
|
||||
// ====== ====== 筛选 ====== ======
|
||||
// 业务id
|
||||
if (isset($p['salesperson_id']) && $p['salesperson_id']) {
|
||||
@@ -42,7 +61,7 @@ class StockOrdersService extends \app\common\service\StockOrdersService
|
||||
|
||||
$user_role_id = UserRoles::where('user_id', $request->user->uid)->pluck('role_id')->toArray();
|
||||
//$roles_id = array_intersect([1,6], $request->user->role_ids);
|
||||
$roles_id = array_intersect([1,5,6], $user_role_id);
|
||||
$roles_id = array_intersect([1,4,5,6], $user_role_id);
|
||||
if (count($roles_id) != 0){
|
||||
// if(in_array(4, $request->user->role_ids)){ //业务主管
|
||||
// $report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
|
||||
@@ -69,6 +88,10 @@ class StockOrdersService extends \app\common\service\StockOrdersService
|
||||
$config['orWhere'][] = ['approval_status','>=',1];
|
||||
$config['orWhere'][] = ['salesperson_id','=',$request->user->uid];
|
||||
unset($config['where']['salesperson_id']);
|
||||
} elseif (in_array(4,$roles_id)) { //业务主管:自己+直属下属(report_to指向他,一级,含草稿)
|
||||
$report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
|
||||
$report[] = $request->user->uid;
|
||||
$config['where']['salesperson_id'] = ['in',$report];
|
||||
}else{
|
||||
$config['where']['salesperson_id'] = $request->user->uid;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ class Util
|
||||
if($length <= 6){
|
||||
return $str;
|
||||
}
|
||||
return substr($str, 0, 3) . '****' . substr($str, -4);
|
||||
return mb_substr($str, 0, 3) . '****' . mb_substr($str, -4);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -463,9 +463,36 @@ class CustomerService
|
||||
$customer['enter_user_name'] = empty($customer['enter_user_id']) ? '' : User::where('id',$customer['enter_user_id'])->value('name');
|
||||
$customer['user_name'] = empty($customer['user_id']) ? '公海' : User::where('id',$customer['user_id'])->value('name');
|
||||
|
||||
// WhatsApp导入截图:desc_collect 里的 OSS 对象键 -> 带签名 <img>(前端 v-html 渲染)
|
||||
$customer['desc_collect'] = self::signScreenshotImages((string)($customer['desc_collect'] ?? ''));
|
||||
|
||||
return $customer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 desc_collect 中 crm_whatsapp 截图的 OSS 对象键替换为带签名的 <img> 标签。
|
||||
* 键按原文匹配签名(历史记录为双斜杠键,对象即存于该键,不可归一)。
|
||||
*/
|
||||
private static function signScreenshotImages(string $desc): string
|
||||
{
|
||||
if ($desc === '' || strpos($desc, 'crm_whatsapp/') === false) {
|
||||
return $desc;
|
||||
}
|
||||
return preg_replace_callback(
|
||||
'/([A-Za-z0-9_\-]+(?:\/[A-Za-z0-9_\-]+)*\/+crm_whatsapp\/[A-Za-z0-9_\-\.]+\.(?:png|jpe?g|webp))/',
|
||||
function ($m) {
|
||||
try {
|
||||
$url = (new OssService())->generatePrivateUrl($m[1], 7 * 86400);
|
||||
return '<img src="' . htmlspecialchars($url, ENT_QUOTES) . '" '
|
||||
. 'style="max-width:100%;height:auto;display:block;margin:4px 0" />';
|
||||
} catch (\Throwable $e) {
|
||||
return $m[0];
|
||||
}
|
||||
},
|
||||
$desc
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function detail($params)
|
||||
{
|
||||
|
||||
@@ -114,6 +114,11 @@ class FbFileImportService
|
||||
$item['phone'] = $this->normalizePhone($raw);
|
||||
break;
|
||||
|
||||
// ✅ 姓名:去掉粘连的 URL,并截断到下游 inquiry_log.contacts 列长度(255)
|
||||
case 'full_name':
|
||||
$item['full_name'] = $this->normalizeFullName($raw);
|
||||
break;
|
||||
|
||||
default:
|
||||
if (is_string($raw)) $raw = trim($raw);
|
||||
$item[$field] = $raw;
|
||||
@@ -192,6 +197,27 @@ class FbFileImportService
|
||||
return $s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 姓名清洗:
|
||||
* - 去掉粘连的 URL(如 "Saif Ullahhttps://..." → "Saif Ullah")
|
||||
* - 纯链接记录清洗后为空字符串
|
||||
* - 截断到 255(下游 inquiry_log.contacts 列长度,避免同步入库时超长报错)
|
||||
*/
|
||||
protected function normalizeFullName($raw): string
|
||||
{
|
||||
if ($raw === null) return '';
|
||||
|
||||
$s = trim((string)$raw);
|
||||
if ($s === '') return '';
|
||||
|
||||
// 去掉 URL(连同前面可能的空白/不可见字符)
|
||||
$s = preg_replace('#\s*https?://\S+#i', '', $s);
|
||||
$s = trim($s);
|
||||
|
||||
// 截断到下游 contacts 列长度兜底
|
||||
return mb_substr($s, 0, 255);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 规范化 is_organic 字段为 0/1
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
class ApiKeyAuth implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$apiKey = $request->header('Fdesk-Api-Key', '');
|
||||
$validKey = getenv('PUBLIC_API_KEY');
|
||||
|
||||
if ($validKey === false || $apiKey === '' || !hash_equals($validKey, $apiKey)) {
|
||||
$body = [
|
||||
'status' => 'fail',
|
||||
'code' => 401,
|
||||
'message' => '无效的 API Key',
|
||||
'data' => [],
|
||||
];
|
||||
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
}
|
||||
@@ -638,3 +638,10 @@ Route::group('/crm', function () {
|
||||
Route::any('/destroy', [app\common\controller\VehModificationController::class, 'destroy']);
|
||||
});
|
||||
});
|
||||
|
||||
// 公共 API(API Key 鉴权;通过 noNeedLogin/noNeedHeaderData 跳过 JWT 和 BasicData)
|
||||
Route::group('/common/public', function () {
|
||||
Route::any('/pi/list', [app\common\controller\PublicApiController::class, 'piList']);
|
||||
Route::any('/vin/list', [app\common\controller\PublicApiController::class, 'vinList']);
|
||||
Route::any('/staff/list', [app\common\controller\PublicApiController::class, 'staffList']);
|
||||
})->middleware([app\middleware\AccessControl::class, app\middleware\ApiKeyAuth::class]);
|
||||
@@ -0,0 +1,225 @@
|
||||
# 备货单公共 API 对接文档
|
||||
|
||||
## 基础信息
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| Base URL | `https://your-domain.com` |
|
||||
| 鉴权方式 | 请求头 `Fdesk-Api-Key` |
|
||||
| 响应格式 | JSON |
|
||||
|
||||
## 鉴权
|
||||
|
||||
所有请求必须携带 API Key 请求头:
|
||||
|
||||
```
|
||||
Fdesk-Api-Key: <your-api-key>
|
||||
```
|
||||
|
||||
缺少或无效时返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "fail",
|
||||
"code": 401,
|
||||
"message": "无效的 API Key",
|
||||
"data": []
|
||||
}
|
||||
```
|
||||
|
||||
## 通用响应结构
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success | fail",
|
||||
"code": 200,
|
||||
"message": "描述信息",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
| code | 含义 |
|
||||
|------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 查询失败 |
|
||||
| 401 | API Key 无效或缺失 |
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. PI 号列表
|
||||
|
||||
查询已审批通过的备货单 PI 号,含关联的车架号和车型。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /common/public/pi/list
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| page | int | 否 | 页码,默认 1 |
|
||||
| page_size | int | 否 | 每页条数,默认 20,最大 200 |
|
||||
| pi_no | string | 否 | PI 号筛选,模糊匹配 |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"message": "请求成功",
|
||||
"data": {
|
||||
"total": 150,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"pi_no": "PI20250601001",
|
||||
"vins": ["LSVAU2A39N2123456", "LSVAU2A39N2123457"],
|
||||
"model_names": ["BYD Song Plus DM-i", "BYD Seal"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| pi_no | string | 备货单 PI 号 |
|
||||
| vins | string[] | 该 PI 下所有车架号 |
|
||||
| model_names | string[] | 该 PI 下所有车型名称(与 vins 一一对应) |
|
||||
|
||||
---
|
||||
|
||||
### 2. VIN 号列表
|
||||
|
||||
查询已审批通过的备货单车架号及对应车型。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /common/public/vin/list
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| page | int | 否 | 页码,默认 1 |
|
||||
| page_size | int | 否 | 每页条数,默认 20,最大 200 |
|
||||
| vin | string | 否 | 车架号筛选,模糊匹配 |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"message": "请求成功",
|
||||
"data": {
|
||||
"total": 500,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"vin": "LSVAU2A39N2123456",
|
||||
"model_name": "BYD Song Plus DM-i"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| vin | string | 车架号 |
|
||||
| model_name | string | 车型名称 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 业务人员列表
|
||||
|
||||
查询业务人员(角色 role_id=2,在职),全量返回。
|
||||
|
||||
**请求**
|
||||
|
||||
```
|
||||
GET /common/public/staff/list
|
||||
```
|
||||
|
||||
**参数**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| keyword | string | 否 | 关键词,模糊匹配姓名/编号/电话/邮箱 |
|
||||
|
||||
**响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"message": "请求成功",
|
||||
"data": [
|
||||
{
|
||||
"id": 17,
|
||||
"name": "张三",
|
||||
"sn": "S0017",
|
||||
"phone": "13800138000",
|
||||
"email": "zhangsan@example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int | 用户ID |
|
||||
| name | string | 姓名 |
|
||||
| sn | string | 编号 |
|
||||
| phone | string | 电话 |
|
||||
| email | string | 邮箱 |
|
||||
|
||||
---
|
||||
|
||||
## 调用示例
|
||||
|
||||
```bash
|
||||
# 查询 PI 列表(第一页,每页10条)
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/pi/list?page=1&page_size=10"
|
||||
|
||||
# 按 PI 号搜索
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/pi/list?pi_no=PI202506"
|
||||
|
||||
# 查询 VIN 列表(第一页,每页50条)
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/vin/list?page=1&page_size=50"
|
||||
|
||||
# 按 VIN 号搜索
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/vin/list?vin=LSVAU2A3"
|
||||
|
||||
# 查询业务人员列表
|
||||
curl -H "Fdesk-Api-Key: your-api-key" \
|
||||
"https://your-domain.com/common/public/staff/list?page=1&page_size=50"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 两个接口仅返回审批状态为"已通过"(approval_status=2)的备货单数据
|
||||
- 已删除的备货单不会出现在查询结果中
|
||||
- 接口为只读,不支持新增、修改、删除操作
|
||||
- `pi_no` 和 `vin` 筛选参数支持模糊匹配(包含即可)
|
||||
@@ -0,0 +1,290 @@
|
||||
# Public Stock Order API Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add two read-only public APIs (PI list, VIN list) for internal systems to query approved stock orders, authenticated via API Key.
|
||||
|
||||
**Architecture:** New `PublicApiController` in `app/common/controller/` with a dedicated `ApiKeyAuth` middleware. Routes registered in a standalone route group that bypasses JWT and BasicData middleware. Pagination via Eloquent's `paginate()`.
|
||||
|
||||
**Tech Stack:** PHP 8.1+, Webman framework, Eloquent ORM, illuminate/pagination
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Create | `app/middleware/ApiKeyAuth.php` | Validate `Fdesk-Api-Key` header against env config |
|
||||
| Create | `app/common/controller/PublicApiController.php` | Two endpoints: PI list, VIN list |
|
||||
| Modify | `config/route.php` | Add `/common/public/*` route group with ApiKeyAuth middleware |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create ApiKeyAuth Middleware
|
||||
|
||||
**Files:**
|
||||
- Create: `app/middleware/ApiKeyAuth.php`
|
||||
|
||||
- [ ] **Step 1: Create the middleware file**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace app\middleware;
|
||||
|
||||
use Webman\MiddlewareInterface;
|
||||
use Webman\Http\Response;
|
||||
use Webman\Http\Request;
|
||||
|
||||
class ApiKeyAuth implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$apiKey = $request->header('Fdesk-Api-Key');
|
||||
$validKey = getenv('PUBLIC_API_KEY');
|
||||
|
||||
if (!$apiKey || !$validKey || $apiKey !== $validKey) {
|
||||
$body = [
|
||||
'status' => 'fail',
|
||||
'code' => 401,
|
||||
'message' => '无效的 API Key',
|
||||
'data' => [],
|
||||
];
|
||||
return response(json_encode($body, JSON_UNESCAPED_UNICODE), 401, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/middleware/ApiKeyAuth.php
|
||||
git commit -m "feat: add ApiKeyAuth middleware for public API endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create PublicApiController
|
||||
|
||||
**Files:**
|
||||
- Create: `app/common/controller/PublicApiController.php`
|
||||
|
||||
- [ ] **Step 1: Create the controller file**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace app\common\controller;
|
||||
|
||||
use app\common\model\StockOrders;
|
||||
use app\common\model\StockOrderItems;
|
||||
use support\Request;
|
||||
|
||||
class PublicApiController extends BaseController
|
||||
{
|
||||
protected $noNeedLogin = ['piList', 'vinList'];
|
||||
protected $noNeedHeaderData = ['piList', 'vinList'];
|
||||
|
||||
/**
|
||||
* PI 号列表(含关联的 VIN 和车型名称)
|
||||
* GET /common/public/pi/list
|
||||
*/
|
||||
public function piList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$piNo = $request->input('pi_no', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrders::where('approval_status', 2)
|
||||
->whereNotNull('pi_no')
|
||||
->where('pi_no', '<>', '');
|
||||
|
||||
if ($piNo !== '') {
|
||||
$query->where('pi_no', 'like', "%{$piNo}%");
|
||||
}
|
||||
|
||||
$query->with(['items' => function ($q) {
|
||||
$q->select('id', 'order_id', 'vin', 'model_snapshot');
|
||||
}]);
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'pi_no'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($order) {
|
||||
return [
|
||||
'pi_no' => $order->pi_no,
|
||||
'vins' => $order->items->pluck('vin')->filter()->values()->toArray(),
|
||||
'model_names' => $order->items->pluck('model_snapshot')->filter()->values()->toArray(),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VIN 号列表(含车型名称)
|
||||
* GET /common/public/vin/list
|
||||
*/
|
||||
public function vinList(Request $request)
|
||||
{
|
||||
try {
|
||||
$page = (int)($request->input('page', 1));
|
||||
$pageSize = (int)($request->input('page_size', 20));
|
||||
$vin = $request->input('vin', '');
|
||||
|
||||
if ($page < 1) $page = 1;
|
||||
if ($pageSize < 1 || $pageSize > 200) $pageSize = 20;
|
||||
|
||||
$query = StockOrderItems::whereHas('order', function ($q) {
|
||||
$q->where('approval_status', 2);
|
||||
})->whereNotNull('vin')
|
||||
->where('vin', '<>', '');
|
||||
|
||||
if ($vin !== '') {
|
||||
$query->where('vin', 'like', "%{$vin}%");
|
||||
}
|
||||
|
||||
$paginator = $query->orderBy('id', 'desc')
|
||||
->paginate($pageSize, ['id', 'vin', 'model_snapshot'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function ($item) {
|
||||
return [
|
||||
'vin' => $item->vin,
|
||||
'model_name' => $item->model_snapshot,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return $this->success([
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'data' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add app/common/controller/PublicApiController.php
|
||||
git commit -m "feat: add PublicApiController with PI and VIN list endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Register Routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/route.php`
|
||||
|
||||
- [ ] **Step 1: Add the route group**
|
||||
|
||||
在 `config/route.php` 文件末尾(最后一个 `});` 之后)追加:
|
||||
|
||||
```php
|
||||
// 公共 API(API Key 鉴权,不走 JWT / BasicData)
|
||||
Route::group('/common/public', function () {
|
||||
Route::any('/pi/list', [app\common\controller\PublicApiController::class, 'piList']);
|
||||
Route::any('/vin/list', [app\common\controller\PublicApiController::class, 'vinList']);
|
||||
})->middleware([app\middleware\AccessControl::class, app\middleware\ApiKeyAuth::class]);
|
||||
```
|
||||
|
||||
This route group only uses `AccessControl` (CORS) + `ApiKeyAuth`. It explicitly bypasses `BasicData` and `AuthCheck`.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add config/route.php
|
||||
git commit -m "feat: register public API routes with ApiKeyAuth middleware"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Configure API Key in Environment
|
||||
|
||||
**Files:**
|
||||
- Modify: `.env` (or equivalent environment config)
|
||||
|
||||
- [ ] **Step 1: Add the API key to the environment config**
|
||||
|
||||
Add to the `.env` file (or equivalent environment variable source):
|
||||
|
||||
```
|
||||
PUBLIC_API_KEY=your-secret-api-key-here
|
||||
```
|
||||
|
||||
Generate a real key, e.g.:
|
||||
|
||||
```bash
|
||||
php -r "echo bin2hex(random_bytes(32)) . PHP_EOL;"
|
||||
```
|
||||
|
||||
Use the generated value as the key.
|
||||
|
||||
- [ ] **Step 2: Verify the middleware reads it correctly**
|
||||
|
||||
The middleware uses `getenv('PUBLIC_API_KEY')`. Confirm this is consistent with how other env vars are read in the project (e.g., `getenv('DB_HOST')` in `config/database.php`).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manual Verification
|
||||
|
||||
- [ ] **Step 1: Test PI list endpoint (no auth)**
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8787/common/public/pi/list | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: `{"status":"fail","code":401,"message":"无效的 API Key"}`
|
||||
|
||||
- [ ] **Step 2: Test PI list endpoint (with auth)**
|
||||
|
||||
```bash
|
||||
curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/pi/list?page=1&page_size=5" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: JSON with `status: success`, `data` containing `total`, `per_page`, `current_page`, and array of `{pi_no, vins, model_names}`.
|
||||
|
||||
- [ ] **Step 3: Test VIN list endpoint (with auth)**
|
||||
|
||||
```bash
|
||||
curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/vin/list?page=1&page_size=5" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: JSON with `status: success`, `data` containing array of `{vin, model_name}`.
|
||||
|
||||
- [ ] **Step 4: Test with pi_no filter**
|
||||
|
||||
```bash
|
||||
curl -s -H "Fdesk-Api-Key: your-secret-api-key-here" "http://localhost:8787/common/public/pi/list?pi_no=PI2025" | php -r 'echo json_encode(json_decode(file_get_contents("php://stdin")), JSON_PRETTY_PRINT);'
|
||||
```
|
||||
|
||||
Expected: filtered results or empty data array.
|
||||
|
||||
- [ ] **Step 5: Commit final verification**
|
||||
|
||||
No code changes expected. If fixes were needed during testing, commit them:
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: adjustments from manual verification of public stock API"
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# Public Stock Order API Design
|
||||
|
||||
## Goal
|
||||
|
||||
Provide two read-only public APIs for internal systems to query approved stock order PI numbers and VIN numbers, with API Key authentication.
|
||||
|
||||
## Data Model
|
||||
|
||||
- `stock_orders.approval_status = 2` means approved
|
||||
- `stock_orders.pi_no` — PI number (string)
|
||||
- `stock_order_items.vin` — VIN/chassis number (string)
|
||||
- `stock_order_items.model_snapshot` — car model name (plain string)
|
||||
- `stock_order_items.order_id` → `stock_orders.id`
|
||||
|
||||
## Authentication
|
||||
|
||||
New middleware `ApiKeyAuth`:
|
||||
|
||||
- Read `Fdesk-Api-Key` header
|
||||
- Compare against key stored in `.env` (`API_KEY`) or `config/app.php`
|
||||
- Reject with 401 on mismatch or missing key
|
||||
- Bypass both `AuthCheck` (JWT) and `BasicData` middleware
|
||||
|
||||
## Endpoints
|
||||
|
||||
### A. PI Number List
|
||||
|
||||
```
|
||||
GET /common/public/pi/list
|
||||
Headers: Fdesk-Api-Key: <key>
|
||||
Query: page, page_size, pi_no (optional filter)
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"total": 150,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"pi_no": "PI20250601001",
|
||||
"vins": ["LSVAU2A39N2123456", "LSVAU2A39N2123457"],
|
||||
"model_names": ["Model Y", "Model 3"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. Query `stock_orders` where `approval_status = 2` and `pi_no IS NOT NULL` and `pi_no != ''`
|
||||
2. Optional filter by `pi_no` (LIKE match)
|
||||
3. Paginate
|
||||
4. For each order, eager-load `items` → collect `vin` and `model_snapshot`
|
||||
5. Return aggregated result
|
||||
|
||||
### B. VIN Number List
|
||||
|
||||
```
|
||||
GET /common/public/vin/list
|
||||
Headers: Fdesk-Api-Key: <key>
|
||||
Query: page, page_size, vin (optional filter)
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"total": 500,
|
||||
"per_page": 20,
|
||||
"current_page": 1,
|
||||
"data": [
|
||||
{
|
||||
"vin": "LSVAU2A39N2123456",
|
||||
"model_name": "Model Y"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. Query `stock_order_items` via `stock_orders` where `approval_status = 2`
|
||||
2. Optional filter by `vin` (LIKE match)
|
||||
3. Paginate
|
||||
4. Return `vin` + `model_snapshot` per item
|
||||
|
||||
## File Changes
|
||||
|
||||
| Action | File | Description |
|
||||
|--------|------|-------------|
|
||||
| New | `app/middleware/ApiKeyAuth.php` | API Key auth middleware |
|
||||
| New | `app/common/controller/PublicApiController.php` | Two endpoints |
|
||||
| Modify | `config/route.php` | Add `/common/public/*` route group with ApiKeyAuth |
|
||||
|
||||
## Constraints
|
||||
|
||||
- Read-only, no write operations
|
||||
- Only `approval_status = 2` records
|
||||
- Pagination required (default page_size = 20)
|
||||
- No JWT / BasicData middleware for these routes
|
||||
@@ -0,0 +1,112 @@
|
||||
# Inquiry Bulk Assign + Digest Email Design
|
||||
|
||||
## Goal
|
||||
|
||||
一次性将 `docs/崔琪2.xlsx` 中的 1427 条询盘,按 Excel 行顺序轮询分配给 6 个业务员(user_id: 34、73、72、155、64、211),更新 `crm_customer.user_id`,并为每人发送一封汇总邮件,列出分给其名下的全部询盘。
|
||||
|
||||
这是一次性数据操作,以 webman console 命令形式交付,**不做成永久 API / 控制器 / 定时任务**。
|
||||
|
||||
## Data Model(已核实)
|
||||
|
||||
- **匹配键**:Excel 第一列 `编号` = `crm_customer.code`(如 `CM815EE43E7D4C1C`)。
|
||||
- `crm_customer.code` —— varchar(32),**无唯一索引**(仅主键 + USER_ID/SN/TYPE 普通索引),匹配需防御撞多条。
|
||||
- `crm_customer.user_id` —— 待更新字段(`所属用户`,bigint unsigned nullable)。
|
||||
- `crm_customer.deleted_at` —— int 时间戳软删除标记,匹配需 `IS NULL`。
|
||||
- `crm_customer_his` —— 历史表,记录 `customer_id / user_his(原 user_id) / user_id(新)`,复用 `CustomerHis` model。
|
||||
- `user` 表:`id / name / email / corp_id / status`,6 人收件邮箱取自 `user.email`。
|
||||
- Excel 行数:1428(含表头)→ **1427 条询盘**,13 列(编号/录入人/接单人/渠道/邮箱/电话/WhatsApp/联系人/备注/国家(中)/国家(英)/备注_原始HTML/x)。
|
||||
|
||||
## 复用的现成组件(不引入新依赖)
|
||||
|
||||
- `app/command/CrmRotateUserCommand.php` —— 轮转 user_id + 事务 + 写 CustomerHis 的模板。
|
||||
- `app/common/utils/RedisUidRotator.php` —— 轮询分配器(本方案改用**内存确定性轮询**,见下)。
|
||||
- `app/common/utils/email/MailSend.php` —— `MailSend::send($to, $subject, $html, $attachments)`,SMTP 配置在 `config/imap.smtp_xyh`。
|
||||
- `app/admin/controller/InquiryTransmitController.php::emailHtml()` —— 现成询盘 HTML 邮件模板,改写为列表版复用。
|
||||
- `phpoffice/phpspreadsheet`(已在依赖)—— 读 Excel。
|
||||
|
||||
## Command Interface
|
||||
|
||||
```
|
||||
php webman inquiry:bulk-assign \
|
||||
--file=docs/崔琪2.xlsx \
|
||||
--users=34,73,72,155,64,211 \
|
||||
[--dry-run] # 只解析+匹配+分配,打印报告,不写库不发邮件(强制先跑)
|
||||
[--only-user=34] # 仅给指定 user_id 发邮件(单点试发)
|
||||
[--notify-only] # 跳过写库,按 runtime 清单补发未成功的邮件
|
||||
```
|
||||
|
||||
webman console 自动发现 `app/command/` 下命令,无需改 `config/command.php`。
|
||||
|
||||
## Execution Flow
|
||||
|
||||
### ① 解析 Excel(PhpSpreadsheet)
|
||||
读 Sheet1(跳过表头),按行顺序提取 `code` 及邮件所需字段:联系人、邮箱、电话、WhatsApp、国家(中)、渠道、备注_原始HTML。得到有序列表,长度 N≈1427。
|
||||
|
||||
### ② 匹配 crm_customer(关键防错)
|
||||
批量查询:
|
||||
```sql
|
||||
SELECT id, code, user_id FROM crm_customer
|
||||
WHERE code IN (...) AND deleted_at IS NULL
|
||||
```
|
||||
- 撞多条 code → 取 `MIN(id)`,记 warning;
|
||||
- Excel 有、库查不到(或仅软删除)→ 记入「未匹配清单」,**跳过不分配**(不中止);
|
||||
- 输出匹配率;保留 Excel 行顺序,记下每个客户原 `user_id`。
|
||||
|
||||
### ③ 内存轮询分配(确定性 `i % 6`)
|
||||
`targetUserIds = [34, 73, 72, 155, 64, 211]`,第 i 条匹配客户 → `targetUserIds[i % 6]`。
|
||||
**不依赖 Redis**(避免跨 run 状态残留、保证可重跑确定性),按目标用户分组得到每人清单。
|
||||
`--dry-run` 到此为止:打印匹配率、6 人条数(应 ≈238 均衡)、未匹配清单、抽样 3 条后退出。
|
||||
|
||||
### ④ 写库(单事务,可回滚)
|
||||
事务内对每条匹配客户:
|
||||
```sql
|
||||
UPDATE crm_customer SET user_id = ? WHERE id = ?;
|
||||
INSERT INTO crm_customer_his (customer_id, user_his, user_id) VALUES (?, ?, ?);
|
||||
```
|
||||
提交后才进入发邮件。`CustomerHis` 保留原归属以便回滚。
|
||||
|
||||
### ⑤ 发汇总邮件(每人 1 封,共 6 封)
|
||||
- 从 `user` 表取 6 人 `email/name`,空邮箱跳过并告警;
|
||||
- 复用 `emailHtml()` 改成列表版,把该用户分到的询盘做成 HTML 表格;
|
||||
- 每条展示字段:**编号 / 联系人 / 邮箱 / 电话 / 国家 / 渠道 / 意向车型或备注**;
|
||||
- 主题:`询盘分配汇总(共 N 条)- {name} - 2026-07-17`;
|
||||
- `MailSend::send($user_email, $subject, $html)`。
|
||||
|
||||
## Engineering Points
|
||||
|
||||
### (a) DB 与邮件解耦 + 防重复发送
|
||||
- 顺序:**先事务提交 → 再发邮件**(邮件不可回滚)。
|
||||
- 写运行清单 `runtime/inquiry_bulk_assign_2026-07-17.json`:分配结果 + 每人发送状态。
|
||||
- 重跑时:写库幂等(覆盖 user_id,会追加 CustomerHis 历史);**邮件步骤只给清单中未标记成功的人发**——杜绝跑两次双倍发。
|
||||
|
||||
### (b) 邮件失败不连坐
|
||||
`MailSend` 失败返回 false 并记日志。命令收集每人结果、失败重试 1 次,**最终汇总报告**,单封失败不中断(库已提交)。
|
||||
|
||||
### (c) 回滚
|
||||
- DB 事务失败 → 回滚,零影响。
|
||||
- 已提交但分错 → `crm_customer_his.user_his` 保留原归属,可反查/还原。
|
||||
- 邮件发出无法撤回 → 故**强制 dry-run**,并建议先 `--only-user` 单点试发。
|
||||
|
||||
## File Changes
|
||||
|
||||
| Action | File | Description |
|
||||
|--------|------|-------------|
|
||||
| New | `app/command/InquiryBulkAssignCommand.php` | 主命令:解析→匹配→分配→写库→发邮件,含 dry-run/only-user/notify-only |
|
||||
| New | `app/common/utils/email/InquiryDigestMail.php` | 汇总邮件 HTML 模板构建(基于 emailHtml 改写) |
|
||||
| Write | `runtime/inquiry_bulk_assign_2026-07-17.json` | 运行清单(分配结果 + 发送状态,防重复发,.gitignore 已忽略 runtime) |
|
||||
|
||||
不修改任何现有文件、不改路由、不改表结构。
|
||||
|
||||
## Verification
|
||||
|
||||
1. `--dry-run`:核对匹配率、6 人分配条数均衡、未匹配清单、抽样分配结果。
|
||||
2. `--only-user=34`:单点试发,确认模板与送达。
|
||||
3. 全量执行,检查运行清单 + 终端汇总报告。
|
||||
|
||||
## Constraints / Out of Scope
|
||||
|
||||
- 仅处理 `deleted_at IS NULL` 的活跃客户。
|
||||
- 未匹配 code 跳过 + 记清单,**不**中止整个流程。
|
||||
- 轮询顺序 = Excel 行顺序(用户已确认)。
|
||||
- 邮件粒度 = 每人一封汇总(用户已确认),不做逐条邮件。
|
||||
- 不做永久 API / Web 界面 / 定时任务;不引入新依赖。
|
||||
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
备货单成交统计报告生成器(固定脚本)
|
||||
|
||||
口径:
|
||||
成交 = 有备货单(stock_orders);时间按单据日期 biz_date;
|
||||
剔除 草稿/未提交(approval_status=0)、已拒绝(3)、已作废(4);排除软删除;
|
||||
初版与关联终版(object_id 互指)同月并存时只计终版,跨月各计各月;
|
||||
车况:明细 mileage>0 为二手车、=0 为新车;国家取清关目的国(去英文括号后缀)。
|
||||
|
||||
用法:
|
||||
python3 scripts/stock_deal_report.py --year 2026 --month 7 # 单月
|
||||
python3 scripts/stock_deal_report.py --from 2026-01 --to 2026-06 # 区间
|
||||
可选:--file runtime/fdesk-20260813.sql(默认)
|
||||
|
||||
输出:runtime/<期间>成交统计报告.pdf + runtime/<期间>成交详单.csv
|
||||
依赖:python3 + weasyprint(首次对每个 dump 建解析缓存,之后秒级重跑)
|
||||
"""
|
||||
import argparse, re, csv, html, os, pickle
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from weasyprint import HTML
|
||||
|
||||
TZ = timezone(timedelta(hours=8))
|
||||
EXCLUDE = {0, 3, 4} # 草稿/拒绝/作废
|
||||
DEFAULT_DUMP = 'runtime/fdesk-20260813.sql'
|
||||
|
||||
# ---------------- SQL dump 解析 ----------------
|
||||
def pvl(s, i):
|
||||
vals = []; cur = ''; instr = False; n = len(s)
|
||||
while i < n:
|
||||
c = s[i]
|
||||
if instr:
|
||||
if c == '\\' and i + 1 < n: cur += c + s[i + 1]; i += 2; continue
|
||||
if c == "'":
|
||||
if i + 1 < n and s[i + 1] == "'": cur += "''"; i += 2; continue
|
||||
cur += "'"; i += 1; instr = False; continue
|
||||
cur += c; i += 1; continue
|
||||
else:
|
||||
if c == "'": instr = True; cur += "'"; i += 1; continue
|
||||
if c == ',': vals.append(cur.strip()); cur = ''; i += 1; continue
|
||||
if c == ')': vals.append(cur.strip()); i += 1; break
|
||||
cur += c; i += 1
|
||||
return vals, i
|
||||
|
||||
def unesc(raw):
|
||||
raw = raw.strip()
|
||||
if raw.upper() == 'NULL': return None
|
||||
if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'":
|
||||
inner = raw[1:-1]; out = []; i = 0; L = len(inner)
|
||||
mp = {'n': '\n', 'r': '\r', 't': '\t', '0': '\x00', '\\': '\\', "'": "'", '"': '"', 'b': '\b', 'Z': '\x1a'}
|
||||
while i < L:
|
||||
c = inner[i]
|
||||
if c == '\\' and i + 1 < L: out.append(mp.get(inner[i + 1], inner[i + 1])); i += 2
|
||||
elif c == "'" and i + 1 < L and inner[i + 1] == "'": out.append("'"); i += 2
|
||||
else: out.append(c); i += 1
|
||||
return ''.join(out)
|
||||
try: return int(raw)
|
||||
except:
|
||||
try: return float(raw)
|
||||
except: return raw
|
||||
|
||||
def load_dump(path):
|
||||
"""返回 (orders: id->dict, items: [(order_id, model, qty, mileage)])"""
|
||||
cache = path + '.cache.pkl'
|
||||
if os.path.exists(cache):
|
||||
with open(cache, 'rb') as f:
|
||||
return pickle.load(f)
|
||||
cols = {}; cur_create = None
|
||||
orders = {}; items = []
|
||||
with open(path, encoding='utf-8', errors='replace') as f:
|
||||
for line in f:
|
||||
if cur_create is not None:
|
||||
m = re.match(r'\s*`(\w+)`\s', line)
|
||||
if m: cols[cur_create].append(m.group(1))
|
||||
if re.search(r'\)\s*ENGINE', line): cur_create = None
|
||||
continue
|
||||
if line.startswith('CREATE TABLE `'):
|
||||
t = line[14:line.index('`', 14)]
|
||||
if t in ('stock_orders', 'stock_order_items'):
|
||||
cur_create = t; cols[t] = []
|
||||
continue
|
||||
if line.startswith('INSERT INTO `stock_orders` VALUES ('):
|
||||
v = [unesc(x) for x in pvl(line, line.index('VALUES (') + 8)[0]]
|
||||
ci = {c: i for i, c in enumerate(cols['stock_orders'])}
|
||||
orders[v[ci['id']]] = dict(
|
||||
order_no=v[ci['order_no']], biz=v[ci['biz_date']] or 0,
|
||||
fin=v[ci['is_final']], oid=v[ci['object_id']] or 0,
|
||||
ap=v[ci['approval_status']], deleted=v[ci['deleted_at']] is not None,
|
||||
country=str(v[ci['clearance_country_snapshot']] or '').strip(),
|
||||
sales=str(v[ci['salesperson_snapshot']] or '').strip(),
|
||||
)
|
||||
elif line.startswith('INSERT INTO `stock_order_items` VALUES ('):
|
||||
v = [unesc(x) for x in pvl(line, line.index('VALUES (') + 8)[0]]
|
||||
ci = {c: i for i, c in enumerate(cols['stock_order_items'])}
|
||||
items.append((v[ci['order_id']], str(v[ci['model_snapshot']] or '').strip(),
|
||||
int(v[ci['quantity']] or 0), int(v[ci['mileage']] or 0)))
|
||||
with open(cache, 'wb') as f:
|
||||
pickle.dump((orders, items), f)
|
||||
return orders, items
|
||||
|
||||
# ---------------- 统计 ----------------
|
||||
_PAREN = re.compile(r'\((?:[^()]|\([^()]*\))*\)\s*$')
|
||||
|
||||
def clean_country(c):
|
||||
while True:
|
||||
c2 = _PAREN.sub('', c).strip()
|
||||
if c2 == c: return c
|
||||
c = c2
|
||||
|
||||
def month_key(ts):
|
||||
return datetime.fromtimestamp(ts, TZ).strftime('%Y-%m')
|
||||
|
||||
def build_report(orders, items, months):
|
||||
"""months: [(y, m), ...] 连续月份列表"""
|
||||
for o in orders.values():
|
||||
o['country'] = clean_country(o['country'])
|
||||
|
||||
t0 = int(datetime(months[0][0], months[0][1], 1, tzinfo=TZ).timestamp())
|
||||
ly, lm = months[-1]
|
||||
t1 = int((datetime(ly, lm, 1, tzinfo=TZ) + timedelta(days=32)).replace(day=1).timestamp())
|
||||
|
||||
def in_range(o):
|
||||
return (not o['deleted']) and t0 <= o['biz'] < t1
|
||||
|
||||
cand = {i: o for i, o in orders.items() if in_range(o) and o['ap'] not in EXCLUDE}
|
||||
excluded = {a: sum(1 for o in orders.values() if in_range(o) and o['ap'] == a) for a in sorted(EXCLUDE)}
|
||||
|
||||
# 初终版去重(按月):初版的关联终版与初版同月 -> 跳过初版
|
||||
sel = {}; skipped = []; cross_month_fin = 0
|
||||
for i, o in cand.items():
|
||||
if o['fin'] == 0 and o['oid'] > 0:
|
||||
fin = orders.get(o['oid'])
|
||||
if fin and fin['fin'] == 1 and not fin['deleted']:
|
||||
if month_key(fin['biz']) == month_key(o['biz']):
|
||||
skipped.append(i); continue
|
||||
cross_month_fin += 1
|
||||
sel[i] = o
|
||||
|
||||
order_ids = set(sel)
|
||||
items_by_order = defaultdict(list)
|
||||
for oid, model, qty, mile in items:
|
||||
if oid in order_ids:
|
||||
items_by_order[oid].append((model, qty, mile))
|
||||
|
||||
by_model = Counter(); by_mc = defaultdict(Counter); by_country = Counter()
|
||||
by_sales = Counter(); by_sales_orders = Counter(); by_cond = Counter()
|
||||
by_model_cond = defaultdict(Counter); by_country_cond = defaultdict(Counter)
|
||||
by_sales_cond = defaultdict(Counter); by_month = defaultdict(lambda: Counter())
|
||||
detail = []
|
||||
|
||||
for oid, o in sel.items():
|
||||
sp = o['sales'] or '(未填)'
|
||||
c = o['country'] or '(未填)'
|
||||
mk = month_key(o['biz'])
|
||||
by_sales_orders[sp] += 1
|
||||
by_month[mk]['orders'] += 1
|
||||
for model, qty, mile in items_by_order.get(oid, []):
|
||||
m = model or '(未填车型)'
|
||||
cd = '二手车' if (mile or 0) > 0 else '新车'
|
||||
by_model[m] += qty; by_mc[m][c] += qty; by_country[c] += qty; by_sales[sp] += qty
|
||||
by_cond[cd] += qty
|
||||
by_model_cond[m][cd] += qty; by_country_cond[c][cd] += qty; by_sales_cond[sp][cd] += qty
|
||||
by_month[mk][cd] += qty; by_month[mk]['veh'] += qty
|
||||
detail.append(dict(model=m, country=c, qty=qty, sales=sp, month=mk,
|
||||
cond=cd if cd == '新车' else f'二手车({mile:,}km)',
|
||||
order_no=o['order_no'], date=datetime.fromtimestamp(o['biz'], TZ).strftime('%m-%d')))
|
||||
detail.sort(key=lambda r: (r['month'], r['sales'], r['order_no']))
|
||||
return dict(sel=sel, excluded=excluded, skipped=skipped, cross=cross_month_fin,
|
||||
by_model=by_model, by_mc=by_mc, by_country=by_country, by_sales=by_sales,
|
||||
by_sales_orders=by_sales_orders, by_cond=by_cond,
|
||||
by_model_cond=by_model_cond, by_country_cond=by_country_cond,
|
||||
by_sales_cond=by_sales_cond, by_month=by_month, detail=detail)
|
||||
|
||||
# ---------------- 渲染 ----------------
|
||||
def esc(x): return html.escape(str(x))
|
||||
def tr(rows):
|
||||
return ''.join('<tr><td>' + '</td><td>'.join(esc(c) for c in r) + '</td></tr>' for r in rows)
|
||||
|
||||
def render_pdf(R, months, label, out_pdf):
|
||||
total = sum(R['by_model'].values())
|
||||
new_n = R['by_cond'].get('新车', 0); used_n = R['by_cond'].get('二手车', 0)
|
||||
multi = len(months) > 1
|
||||
|
||||
model_rows = [(m, R['by_model_cond'][m].get('新车', 0), R['by_model_cond'][m].get('二手车', 0), n,
|
||||
'、'.join(f'{c}×{k}' for c, k in R['by_mc'][m].most_common()))
|
||||
for m, n in sorted(R['by_model'].items(), key=lambda x: -x[1])]
|
||||
country_rows = [(c, R['by_country_cond'][c].get('新车', 0), R['by_country_cond'][c].get('二手车', 0), n, f'{n / total * 100:.1f}%')
|
||||
for c, n in R['by_country'].most_common()]
|
||||
sales_rows = [(s, R['by_sales_orders'][s], R['by_sales_cond'][s].get('新车', 0), R['by_sales_cond'][s].get('二手车', 0),
|
||||
R['by_sales'][s], f'{R["by_sales"][s] / total * 100:.1f}%')
|
||||
for s, _ in sorted(R['by_sales'].items(), key=lambda x: -R['by_sales'][x[0]])]
|
||||
month_rows = [(mk, c['orders'], c['veh'], c.get('新车', 0), c.get('二手车', 0))
|
||||
for mk, c in sorted(R['by_month'].items())]
|
||||
dcol = [(r['sales'], r['month'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']) for r in R['detail']] \
|
||||
if multi else \
|
||||
[(r['sales'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']) for r in R['detail']]
|
||||
|
||||
sec = []
|
||||
if multi:
|
||||
sec.append(('月度分布', '<table><thead><tr><th style="width:14%">月份</th><th style="width:14%">单数</th>'
|
||||
'<th style="width:16%">车辆数</th><th style="width:14%">新车</th><th>二手车</th></tr></thead>'
|
||||
f'<tbody>{tr(month_rows)}</tbody></table>'))
|
||||
sec.append(('车型成交排行', '<table><thead><tr><th style="width:36%">车型</th><th style="width:6%">新车</th>'
|
||||
'<th style="width:6%">二手</th><th style="width:6%">合计</th><th>成交国家(国家×辆数)</th></tr></thead>'
|
||||
f'<tbody>{tr(model_rows)}</tbody></table>'))
|
||||
sec.append(('国家成交排行', '<table><thead><tr><th style="width:22%">国家(清关目的国)</th><th style="width:8%">新车</th>'
|
||||
'<th style="width:8%">二手</th><th style="width:8%">合计</th><th>占比</th></tr></thead>'
|
||||
f'<tbody>{tr(country_rows)}</tbody></table>'))
|
||||
sec.append(('业务员成交排行', '<table><thead><tr><th style="width:14%">业务员</th><th style="width:8%">单数</th>'
|
||||
'<th style="width:8%">新车</th><th style="width:8%">二手</th><th style="width:10%">辆数合计</th>'
|
||||
'<th>占车辆数比</th></tr></thead>'
|
||||
f'<tbody>{tr(sales_rows)}</tbody></table>'))
|
||||
dhead = ('<th style="width:8%">业务员</th><th style="width:7%">月份</th><th>车型</th><th style="width:10%">国家</th>'
|
||||
'<th style="width:11%">车况</th><th style="width:5%">数量</th><th style="width:12%">单号</th><th style="width:7%">单据日期</th>') if multi else \
|
||||
('<th style="width:8%">业务员</th><th>车型</th><th style="width:10%">国家</th><th style="width:11%">车况</th>'
|
||||
'<th style="width:5%">数量</th><th style="width:12%">单号</th><th style="width:7%">单据日期</th>')
|
||||
sec.append(('成交详单(按月份、业务员)', f'<table><thead><tr>{dhead}</tr></thead><tbody>{tr(dcol)}</tbody></table>'))
|
||||
sections = ''.join(f'<h2>{i}、{t}</h2>{b}' for i, (t, b) in enumerate(sec, 1))
|
||||
|
||||
ex = R['excluded']
|
||||
doc = f"""<!doctype html><html><head><meta charset="utf-8"><style>
|
||||
@page {{ size: A4 landscape; margin: 1.3cm; }}
|
||||
body {{ font-family:"WenQuanYi Zen Hei","Noto Sans CJK SC",sans-serif; color:#222; font-size:10.5px; }}
|
||||
h1 {{ font-size:19px; margin:0 0 2px; }}
|
||||
.meta {{ color:#666; font-size:9px; margin-bottom:5px; }}
|
||||
.summary {{ background:#f5f7fa; border:1px solid #e3e8ef; padding:7px 10px; border-radius:4px; margin:5px 0 10px; font-size:10.5px; line-height:1.7; }}
|
||||
h2 {{ font-size:12.5px; margin:12px 0 5px; border-left:4px solid #BB0010; padding-left:7px; }}
|
||||
table {{ border-collapse:collapse; width:100%; margin-bottom:4px; }}
|
||||
th,td {{ border:1px solid #cfd6e0; padding:3px 5px; vertical-align:top; font-size:9px; }}
|
||||
th {{ background:#eef1f6; text-align:left; }}
|
||||
thead {{ display:table-header-group; }}
|
||||
tr {{ page-break-inside:avoid; }}
|
||||
.kpis {{ width:100%; margin:8px 0 10px; }}
|
||||
.kpis td {{ border:none; text-align:center; padding:6px 2px; }}
|
||||
.kpis .v {{ font-size:19px; font-weight:bold; color:#BB0010; }}
|
||||
.kpis .l {{ font-size:9px; color:#666; }}
|
||||
.foot {{ color:#888; font-size:8.5px; margin-top:8px; line-height:1.6; }}
|
||||
</style></head><body>
|
||||
<h1>备货单成交统计报告 · {label}</h1>
|
||||
<div class="meta">数据来源:stock_orders / stock_order_items · 生成日期 {datetime.now(TZ).date().isoformat()} · 统计口径:有备货单即成交</div>
|
||||
<div class="summary"><b>口径说明:</b>单据日期(biz_date)落在 {label};已剔除 草稿/未提交({ex.get(0, 0)} 单)、已拒绝({ex.get(3, 0)} 单)、已作废({ex.get(4, 0)} 单);
|
||||
初版与关联终版(object_id 互指)同月并存时只计终版(本期跳过初版 {len(R['skipped'])} 单);跨月关联终版 {R['cross']} 单按所属月份计入。
|
||||
<b>车况判定:</b>系统无显式新车/二手车字段,按明细 mileage 判定:里程>0 为<b>二手车</b>、=0 为新车。国家按清关目的国。</div>
|
||||
<table class="kpis"><tr>
|
||||
<td><div class="v">{len(R['sel'])}</div><div class="l">成交单数</div></td>
|
||||
<td><div class="v">{total}</div><div class="l">成交车辆数</div></td>
|
||||
<td><div class="v">{new_n}</div><div class="l">新车(辆)</div></td>
|
||||
<td><div class="v">{used_n}</div><div class="l">二手车(辆)</div></td>
|
||||
<td><div class="v">{len(R['by_model'])}</div><div class="l">成交车型数</div></td>
|
||||
<td><div class="v">{len(R['by_country'])}</div><div class="l">覆盖国家数</div></td>
|
||||
</tr></table>
|
||||
{sections}
|
||||
<p class="foot">注:明细按 stock_order_items 逐行展开(数量取 quantity);车况括号内为二手车明细里程(km);业务员为建单时快照(salesperson_snapshot)。</p>
|
||||
</body></html>"""
|
||||
HTML(string=doc).write_pdf(out_pdf)
|
||||
|
||||
def write_csv(R, label, multi):
|
||||
path = f'runtime/{label}成交详单.csv'
|
||||
with open(path, 'w', newline='', encoding='utf-8-sig') as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(['业务员', '月份', '车型', '国家', '车况', '数量', '单号', '单据日期'] if multi
|
||||
else ['业务员', '车型', '国家', '车况', '数量', '单号', '单据日期'])
|
||||
for r in R['detail']:
|
||||
row = [r['sales'], r['month'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']] if multi \
|
||||
else [r['sales'], r['model'], r['country'], r['cond'], r['qty'], r['order_no'], r['date']]
|
||||
w.writerow(row)
|
||||
return path
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='备货单成交统计报告')
|
||||
ap.add_argument('--file', default=DEFAULT_DUMP, help='Navicat 全库 dump 路径')
|
||||
g = ap.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument('--year', type=int); ap.add_argument('--month', type=int, default=None)
|
||||
g.add_argument('--frm', '--from', dest='frm', default=None, help='起始月 2026-01')
|
||||
ap.add_argument('--to', default=None, help='结束月 2026-06')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.frm:
|
||||
y1, m1 = map(int, args.frm.split('-'))
|
||||
y2, m2 = map(int, (args.to or args.frm).split('-'))
|
||||
months = []
|
||||
y, m = y1, m1
|
||||
while (y, m) <= (y2, m2):
|
||||
months.append((y, m)); m += 1
|
||||
if m == 13: y, m = y + 1, 1
|
||||
else:
|
||||
months = [(args.year, args.month or datetime.now(TZ).month)]
|
||||
|
||||
if len(months) == 1:
|
||||
y, m = months[0]; label = f'{y}年{m}月'
|
||||
else:
|
||||
(y1, m1), (y2, m2) = months[0], months[-1]
|
||||
label = f'{y1}年{m1}-{m2}月' if y1 == y2 else f'{y1}年{m1}月-{y2}年{m2}月'
|
||||
|
||||
orders, items = load_dump(args.file)
|
||||
print(f'dump: {args.file}(orders {len(orders)} / items {len(items)}),期间: {label}')
|
||||
R = build_report(orders, items, months)
|
||||
total = sum(R['by_model'].values())
|
||||
print(f"入选 {len(R['sel'])} 单 / {total} 辆(新车 {R['by_cond'].get('新车', 0)} / 二手 {R['by_cond'].get('二手车', 0)}),"
|
||||
f"车型 {len(R['by_model'])},国家 {len(R['by_country'])},剔除 {R['excluded']},同月终版跳过初版 {len(R['skipped'])} 单")
|
||||
pdf = f'runtime/{label}成交统计报告.pdf'
|
||||
render_pdf(R, months, label, pdf)
|
||||
csvp = write_csv(R, label, len(months) > 1)
|
||||
print('已生成:', pdf, '|', csvp)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user