23 Commits

Author SHA1 Message Date
mzl92 db0be8486f fix: 公海列表空响应——Util::mask 按字节截断多字节字符串产生非法UTF-8
mask() 用 mb_strlen 量长度却用 substr 按字节截断,俄语等非ASCII
联系方式被拦腰截断产生非法 UTF-8,json_encode 整包失败 → 接口返回
200+空体 → 前端"请求失败"、公海列表打不开(用户72等 hesu 侧必现,
因公海最新数据含俄语询盘)。
修复:substr 改 mb_substr(ASCII 行为不变)。使用处仅公海三字段脱敏。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 11:10:16 +08:00
mzl92 d4deb8855e feat: 审批历史支持PI号筛选,并修复原搜索框后端未实现的问题
- 新增 pi_no 参数:模糊匹配备货单 pi_no -> 单id -> 审批实例biz_id
  (whereHas,todo/history 场景通用)
- 顺带修复:历史页一直发送的 keyword 参数后端从未处理(搜索框是摆设),
  现按 单号/PI号/客户名称 任一模糊匹配;与 pi_no 同传时取交集

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 10:10:31 +08:00
mzl92 694056c8cc fix: 业务主管备货单列表恢复"自己+直属下属"视图
原因:生效角色列表 [1,5,6] 不含业务主管(4),且主管分支被注释,
导致主管落到默认条件只看自己。
修复:intersect 改为 [1,4,5,6],新增主管分支——
salesperson_id IN (自己 + report_to 指向自己的直属下属,一级,
不过滤审批状态即含草稿)。内勤/超管分支优先级不变。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 20:39:28 +08:00
mzl92 2de6342058 fix: 备货单车辆附件图片过期打不开——url 改为响应期重签
原因:attachment[].url 存的是上传时生成的带签名OSS地址(默认1小时
有效),过期后 AccessDenied/Request has expired。
修复:OssService 新增 resignAttachmentUrls(),从存量 url(完整签名URL/
未签名URL/纯对象键三种格式)提取对象键重新签名(7天);
detail(prepareVehicleListData)与 show(crm/admin 两控制器 showFormat
的 items)三处响应时统一重签。存储不动,前端无需改。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-26 11:51:40 +08:00
mzl92 2e2188d6e5 feat: 备货单 detail 接口车辆列表补充返回 attachment
prepareVehicleListData 组装每辆车时带上 attachment(模型 array cast
取出即 [{url, original_name}]);同款车按 unique_id 合并的行,
各车附件按 url+文件名并集去重后拼接。show 接口本就带,不动。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 15:40:45 +08:00
mzl92 89b9162f09 fix: 三个询盘导入命令——未分到询盘的用户不再发送空汇总邮件
sendDigests 循环前校验该用户分配条数,为0则跳过并提示
"user N 未分到询盘,不发送"(询盘数少于人数时避免群发空邮件)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 15:18:16 +08:00
mzl92 d5f05a81a4 feat: 合塑谷歌AI客服询盘导入命令(新询盘建档+分配+邮件)
新命令 inquiry:import-hesu-google-ai:
- 按表头名解析16列(手机/WhatsApp/邮箱/微信/意向/目标国家/预算/
  意向品牌/意向车型/采购数量/采购用途/其他备注/评估理由/完整对话记录等)
- 入库映射:phone/WhatsApp/email、country_id(en/cn忽略大小写匹配,
  匹配不上留空并在dry-run提示)、intended_vehicles=品牌+车型
- contacts=第一个非空联系方式;magnitude=1 银牌;corp=1/channel=4/type=2
- 未对应字段分块拼入 desc_collect 富文本:询盘信息/意向车型/其他备注/
  AI评估/完整对话(对话灰底pre-wrap保留换行),空字段空块自动跳过
- 去重:手机/WhatsApp/邮箱任一重复(库内+文件内)跳过
- --users 轮询分配;汇总邮件 remark=车型+预算;manifest(groups)防重发
- 支持 --dry-run / --no-notify / --notify-only / --only-user

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 14:41:12 +08:00
mzl92 8bf0984a9c feat: 管理后台客户详情同步显示WhatsApp导入截图
crmAdmin 的 getCustomer() 增加与业务端相同的截图键->签名<img>转换
(列表/详情弹窗 v-html 渲染)。注:Excel 导出走 strip_tags,
截图键文本将不再出现在导出文件中(私有图片本也无法嵌入Excel)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 09:20:24 +08:00
mzl92 705f402c78 feat: 业务端客户详情显示WhatsApp导入截图
getCustomer()(/crm/customer/list 列表与详情弹窗共用)把 desc_collect 中
crm_whatsapp 截图的 OSS 对象键替换为带签名 <img>(7天有效期,每次请求重签)。
键按原文签名(历史双斜杠键的对象即存于该键);前端 v-html 渲染,零改动。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 11:05:01 +08:00
mzl92 21c1d6afd9 fix: WhatsApp截图OSS对象键双斜杠(OSS_MAIN_DIR自带尾斜杠)
rtrim 尾斜杠后再拼接,避免 x_server//crm_whatsapp/;已入库记录的键与
OSS对象位置一致不受影响,仅影响后续导入。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 10:45:35 +08:00
mzl92 09fdd7a5d5 fix: 询盘导入命令 notify-only 改为从 manifest 读分配结果
问题:--no-notify 建档后号码/邮箱已入库,--notify-only 重新解析时会被
去重逻辑全部判为重复,导致补发邮件变"共 0 条"。
修复:建档成功即把每人分配明细存入 manifest(groups),notify-only 从
manifest 读取补发(无 manifest 则报错提示先跑 --no-notify/完整命令)。
顺带修复重跑场景:无新建档时沿用已有 manifest 结果,不会误发空邮件。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-19 10:14:40 +08:00
mzl92 37e63637a4 feat: 合塑谷歌网站whatsapp询盘导入命令(截图传OSS+建档+分配+邮件)
新命令 inquiry:import-hesu-google-wa:
- 解析 WhatsApp 询盘 Excel(列头:WhatsApp号码/最后留言/截图文件名等)
- 号码归一化(去横线空格);contacts=号码、号码存 WhatsApp 字段、phone/email 空
- 按号码去重:对比库内 crm_customer 的 WhatsApp 与 phone 两列 + 文件内同号只留首条
- 截图按文件名从 --images-dir 找图传 OSS(OSS_MAIN_DIR/crm_whatsapp/),
  对象键写入 desc_collect;缺失/失败不阻断,dry-run 只报数量不上传
- 建档口径:corp_id=1、channel_id=4、type=2、grade=0、magnitude=2、created_at=当前
- --users 轮询分配;汇总邮件复用 InquiryDigestMail,manifest 防重发
- 支持 --dry-run / --no-notify / --notify-only / --only-user 分步执行

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 16:26:31 +08:00
mzl92 3a3ed12fd5 feat: 合塑谷歌网站询盘导入命令(新询盘建档+分配+邮件)
新命令 inquiry:import-hesu-google,处理库外新询盘 Excel:
- 按表头名解析(客户姓名/电话WhatsApp合并列/邮箱/留言内容/产品页链接)
- 建档固定口径:corp_id=1(合塑)、channel_id=4(合塑谷歌)、type=2(导入)、
  grade=0、magnitude=2、created_at=当前时间、code 走模型钩子自动生成
- desc_collect=留言内容+产品页链接,intended_vehicles=从 URL 提取车型 slug
- 去重:电话或邮箱与现有未删除客户重复(含文件内重复)跳过并列清单
- 轮询分配 --users;汇总邮件复用 InquiryDigestMail,manifest 防重发
- 支持 --dry-run / --no-notify / --notify-only / --only-user 分步执行

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 14:40:53 +08:00
mzl92 e096157063 feat: 备货单成交统计报告生成脚本(固定工具)
scripts/stock_deal_report.py:从 Navicat 全库 dump 流式解析 stock_orders/items,
按单据日期统计成交(有备货单即成交),支持单月/区间:
- 剔除草稿/拒绝/作废;初终版(object_id 互指)同月并存只计终版
- 新车/二手车按明细 mileage 判定;国家取清关目的国
- 输出 PDF 报告(月度分布/车型/国家/业务员/详单)+ 详单 CSV
- 每个 dump 自动建解析缓存,重跑秒级

用法:python3 scripts/stock_deal_report.py --year 2026 --month 7
      python3 scripts/stock_deal_report.py --frm 2026-01 --to 2026-06

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-14 09:49:25 +08:00
mzl92 06b0647fc4 feat: 备货单车辆明细支持 attachment 附件字段
按前端对接文档,stock_order_items 每辆车支持附件 [{url,original_name}]:
- 模型 StockOrderItems:fillable 增加 attachment,新增 $casts=['attachment'=>'array']
  (写自动 json_encode 落库、读自动 decode 回显)
- processVehicleItems:create 前归一化 attachment 为数组(store/update 共用此路径)
- generateVehicleUniqueId:排除 attachment,避免附件影响车辆去重/数量

show/detail 经模型 items 关系 + cast 自动回显 attachment;字段已由前端在库表就绪。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 15:01:03 +08:00
mzl92 6e2e8e1cc6 feat: 备货单列表支持 view_scope(all/self) 视图切换(仅 user_id=73)
在 /crm/stock/order/list 的 buildQueryConfig index 分支最前面短路:
- 仅当前用户 uid=73 且传 view_scope 时生效,最高优先级,覆盖角色范围逻辑
- all:自己和别人的单,排除草稿(approval_status>=1)
- self:只看自己(salesperson_id=73,含自己草稿)
- 非73用户或未传该参数:走原逻辑,向后兼容,无越权

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 11:49:28 +08:00
mzl92 b2684ecbb2 feat: 询盘批量分配接入事务写库与汇总邮件发送
- applyAssignments: 单事务更新 crm_customer.user_id + 写 crm_customer_his
- sendDigests: 按 user 表邮箱每人发一封汇总邮件,失败重试1次
- manifest: runtime 下 JSON 记录 emailed 状态,防重复发送
- 新增 --no-notify(只写库不发邮件),与 --notify-only 配合分步执行

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:09:52 +08:00
mzl92 61c34db99e feat: 新增询盘批量分配命令(骨架+Excel解析+code匹配+dry-run)
- InquiryDigestMail: 汇总邮件 HTML 模板
- InquiryBulkAssignCommand: assign 轮询、parseExcel、matchCustomers(code+排除软删除)、dry-run 报告、--self-test

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:56:36 +08:00
mzl92 3d3caddc7d docs: 询盘批量分配+汇总邮件方案设计
崔琪2.xlsx 1427 条询盘按 Excel 行序轮询分配给 6 名业务员,
更新 crm_customer.user_id 并发每人一封汇总邮件。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:46:04 +08:00
mzl92 b89ac48dd8 feat: 新增业务人员公共接口(role_id=2,全量返回)
- PublicApiController 新增 staffList():通过 user_roles 关联筛选业务人员
- 返回 id/name/sn/phone/email,支持 keyword 模糊匹配,不分页全量返回
- 路由组新增 /common/public/staff/list
- .gitignore 忽略 docs/*.sql(数据导出文件,不入库)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-18 13:37:48 +08:00
mzl92 71dca13119 fix: fb_leads 导入时对 full_name 增加 URL 过滤与长度截断
修复询盘自动导入因 full_name 含粘连 URL(如 "Saif Ullahhttps://...")
导致同步到 inquiry_log.contacts(varchar 255) 时超长报错、整批回滚卡死的问题。

- 新增 normalizeFullName():去掉粘连 URL + 截断到 255
- switch 新增 full_name 分支

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-16 16:47:42 +08:00
mzl92 35d98c16e9 feat: 新增备货单公共 API 接口(PI 号/VIN 号列表)
- 新增 ApiKeyAuth 中间件(hash_equals 防时序攻击)
- 新增 PublicApiController(piList/vinList 两个只读接口)
- 仅返回已审批通过(approval_status=2)的数据,支持分页和模糊搜索
- LIKE 通配符已转义,异常信息不暴露内部细节
- 附对接文档 docs/public-stock-api.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:44:53 +08:00
26 changed files with 3219 additions and 6 deletions
+2 -1
View File
@@ -13,4 +13,5 @@
/bootstrap.php /bootstrap.php
/composer.lock /composer.lock
.env_copy .env_copy
/public/excel /public/excel
/docs/*.sql
@@ -136,6 +136,10 @@ class StockOrdersController extends BaseController
]; ];
} }
unset($item['options']); unset($item['options']);
// 附件 url 响应期重签(历史签名URL会过期)
if (!empty($item['attachment']) && is_array($item['attachment'])) {
$item['attachment'] = \app\common\service\OssService::resignAttachmentUrls($item['attachment']);
}
} }
unset($item); 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 . '>');
}
}
}
+332
View File
@@ -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('查询失败,请稍后重试');
}
}
}
+8 -1
View File
@@ -45,7 +45,14 @@ class StockOrderItems extends Model
'id', 'order_id', 'product_code', 'sku_id', 'sku_snapshot', 'id', 'order_id', 'product_code', 'sku_id', 'sku_snapshot',
'model_snapshot', 'quantity', 'color_interior_code', 'color_interior_snapshot', 'model_snapshot', 'quantity', 'color_interior_code', 'color_interior_snapshot',
'color_exterior_code', 'color_exterior_snapshot', 'remark', 'vin', 'created_at', 'updated_at', '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\User;
use app\common\model\UserGroupMembers; use app\common\model\UserGroupMembers;
use support\Db;
use app\common\validate\ApprovalRecordsValidate; use app\common\validate\ApprovalRecordsValidate;
use app\common\traits\crud\CrudServiceTrait; use app\common\traits\crud\CrudServiceTrait;
use app\common\model\ApprovalRecords; use app\common\model\ApprovalRecords;
@@ -118,6 +119,31 @@ class ApprovalRecordsService
$config['where']['status'] = ['!=', 'pending']; $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['order']['id'] = 'desc';
$config['with'] = [ $config['with'] = [
+36
View File
@@ -57,6 +57,42 @@ class OssService
return $this->ossClient->signUrl($this->bucket, $objectKey, $expireTime); 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 上的文件 * 删除 OSS 上的文件
* @param string $objectKey OSS上存储的对象名称 * @param string $objectKey OSS上存储的对象名称
+23 -1
View File
@@ -644,6 +644,8 @@ class StockOrdersService
foreach ($vehList as $veh) { foreach ($vehList as $veh) {
$veh['order_id'] = $order->id; $veh['order_id'] = $order->id;
$veh['unique_id'] = $this->generateVehicleUniqueId($veh); $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); $item = StockOrderItems::create($veh);
@@ -667,7 +669,7 @@ class StockOrdersService
*/ */
private function generateVehicleUniqueId(array $veh): string 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)); $array = array_diff_key($veh, array_flip($keysToRemove));
$uniqueIdStr = json_encode($array); $uniqueIdStr = json_encode($array);
return md5($uniqueIdStr); return md5($uniqueIdStr);
@@ -882,6 +884,18 @@ class StockOrdersService
$vehList[$v->unique_id]['remark'] = $v->remark; $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; continue;
} }
$index++; $index++;
@@ -897,8 +911,16 @@ class StockOrdersService
'mileage' => $v->mileage, 'mileage' => $v->mileage,
'remark' => $v->remark, 'remark' => $v->remark,
'vin' => $v->vin, 'vin' => $v->vin,
'attachment' => is_array($v->attachment) ? $v->attachment : [],
] + $moneyValues; ] + $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]; return ['list' => $vehList];
} }
+66
View File
@@ -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']); unset($item['options']);
// 附件 url 响应期重签(历史签名URL会过期)
if (!empty($item['attachment']) && is_array($item['attachment'])) {
$item['attachment'] = \app\common\service\OssService::resignAttachmentUrls($item['attachment']);
}
} }
unset($item); unset($item);
+28
View File
@@ -6,6 +6,7 @@ use app\common\model\Corp;
use app\common\model\CustomerAudit; use app\common\model\CustomerAudit;
use app\common\model\InquiryLog; use app\common\model\InquiryLog;
use app\common\model\User; use app\common\model\User;
use app\common\service\OssService;
use app\common\traits\crud\CrudServiceTrait; use app\common\traits\crud\CrudServiceTrait;
use app\crm\logic\CustomerLogic; use app\crm\logic\CustomerLogic;
use app\crm\model\Channel; 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; 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) public function detail($params)
{ {
+24 -1
View File
@@ -21,6 +21,25 @@ class StockOrdersService extends \app\common\service\StockOrdersService
switch ($scene) { switch ($scene) {
case 'index': 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 // 业务id
if (isset($p['salesperson_id']) && $p['salesperson_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(); $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,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 (count($roles_id) != 0){
// if(in_array(4, $request->user->role_ids)){ //业务主管 // if(in_array(4, $request->user->role_ids)){ //业务主管
// $report = User::where('report_to',$request->user->uid)->pluck('id')->toArray(); // $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'][] = ['approval_status','>=',1];
$config['orWhere'][] = ['salesperson_id','=',$request->user->uid]; $config['orWhere'][] = ['salesperson_id','=',$request->user->uid];
unset($config['where']['salesperson_id']); 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{ }else{
$config['where']['salesperson_id'] = $request->user->uid; $config['where']['salesperson_id'] = $request->user->uid;
} }
+1 -1
View File
@@ -134,7 +134,7 @@ class Util
if($length <= 6){ if($length <= 6){
return $str; return $str;
} }
return substr($str, 0, 3) . '****' . substr($str, -4); return mb_substr($str, 0, 3) . '****' . mb_substr($str, -4);
} }
} }
+27
View File
@@ -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['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'); $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; 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) public function detail($params)
{ {
@@ -114,6 +114,11 @@ class FbFileImportService
$item['phone'] = $this->normalizePhone($raw); $item['phone'] = $this->normalizePhone($raw);
break; break;
// ✅ 姓名:去掉粘连的 URL,并截断到下游 inquiry_log.contacts 列长度(255)
case 'full_name':
$item['full_name'] = $this->normalizeFullName($raw);
break;
default: default:
if (is_string($raw)) $raw = trim($raw); if (is_string($raw)) $raw = trim($raw);
$item[$field] = $raw; $item[$field] = $raw;
@@ -192,6 +197,27 @@ class FbFileImportService
return $s; 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 * 规范化 is_organic 字段为 0/1
+28
View File
@@ -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);
}
}
+8 -1
View File
@@ -637,4 +637,11 @@ Route::group('/crm', function () {
Route::any('/update', [app\common\controller\VehModificationController::class, 'update']); Route::any('/update', [app\common\controller\VehModificationController::class, 'update']);
Route::any('/destroy', [app\common\controller\VehModificationController::class, 'destroy']); 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]);
+225
View File
@@ -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 界面 / 定时任务;不引入新依赖。
+309
View File
@@ -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 判定:里程&gt;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()