157 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
mzl92 929bae6f13 Finish Hotfix-1 2026-03-16 14:37:54 +08:00
mzl92 a72b30a699 调整合塑询单分配人 2026-03-16 14:37:39 +08:00
18338781771 75a59a7197 优化 2026-03-03 17:44:10 +08:00
18338781771 30f3e211af 抓取品牌 2026-02-05 09:16:24 +08:00
18338781771 eee09316df 抓取车型信息 2026-02-04 17:35:08 +08:00
mzl 2da3804953 Finish Hotfix-1 2026-02-03 11:15:03 +08:00
mzl eae699cd27 ts 2026-02-03 11:14:45 +08:00
18338781771 4ea4204ff4 渠道调整 2026-02-03 10:51:08 +08:00
18338781771 67a39917f0 过滤未提交的 2026-01-22 17:20:29 +08:00
mzl92 b6851cdef0 Finish Hotfix-1 2026-01-22 10:15:19 +08:00
mzl92 733996f346 修改询盘自动配置 2026-01-22 10:15:05 +08:00
18338781771 607de6c70a 超管 2026-01-20 15:41:24 +08:00
18338781771 e7c5e17592 内勤可以看自己组员的备货单 2026-01-20 15:30:24 +08:00
mzl92 2ec89a880a Finish Hotfix-1 2026-01-08 15:08:44 +08:00
mzl92 dffaac5b07 ts 2026-01-08 15:08:35 +08:00
mzl92 907be759a0 Finish Hotfix-1 2026-01-08 15:04:47 +08:00
mzl92 f7b94da39f ts 2026-01-08 15:04:39 +08:00
mzl92 c6fada47fc Finish Hotfix-1 2026-01-08 15:02:05 +08:00
mzl92 b09e695ca7 ts 2026-01-08 15:01:51 +08:00
mzl92 de39108efa Finish Hotfix-1 2026-01-08 14:49:54 +08:00
mzl92 7dceb4b828 ts 2026-01-08 14:49:44 +08:00
mzl92 d056ba5f6f Finish Hotfix-1 2026-01-08 09:14:34 +08:00
mzl92 99551bba7f 移除加纳询盘独立分配 2026-01-08 09:14:17 +08:00
18338781771 5e06b1737c Merge branch 'hotfix/email' 2026-01-05 09:44:33 +08:00
18338781771 b43335c0d9 邮件清洗查询状态过滤 2026-01-05 09:44:26 +08:00
18338781771 ac6bfcf16c Merge branch 'hotfix/email' 2026-01-04 16:13:55 +08:00
18338781771 9f74934f37 邮件 2026-01-04 16:13:21 +08:00
18338781771 9776df8289 Merge branch 'hotfix/email' 2026-01-04 15:50:50 +08:00
18338781771 6178084612 邮件 2026-01-04 15:50:43 +08:00
18338781771 d58c33e758 Merge branch 'hotfix/email' 2026-01-04 14:40:54 +08:00
18338781771 24e45f93db 邮件 2026-01-04 14:40:43 +08:00
18338781771 530d72071f Merge branch 'hotfix/email' 2026-01-04 13:39:59 +08:00
18338781771 8382a47a8c 邮件处理 2026-01-04 13:32:15 +08:00
18338781771 f83e578bed Merge branch 'hotfix/email' 2026-01-04 13:26:12 +08:00
18338781771 2c00a35b6a 发布人 2026-01-04 13:26:00 +08:00
18338781771 cd597b9605 Merge branch 'hotfix/email' 2026-01-04 11:46:28 +08:00
18338781771 259a7d2ae6 判断询盘 2026-01-04 11:46:04 +08:00
18338781771 10d3701ab0 Merge branch 'hotfix/email' 2026-01-04 11:02:59 +08:00
18338781771 279139e18f 1 2026-01-04 11:01:29 +08:00
mzl92 4d957e8f43 Finish Hotfix-1 2026-01-04 10:48:03 +08:00
mzl92 57251718b4 调整询盘分配人员 2026-01-04 10:47:46 +08:00
18338781771 0bcd9bf029 多邮箱处理 2025-12-26 14:12:54 +08:00
18338781771 927f1d9c28 邮件分配 2025-12-26 14:04:42 +08:00
mzl92 05096bc5ed Finish Hotfix-1 2025-12-25 16:11:52 +08:00
mzl92 ece685fbe5 ts 2025-12-25 16:09:50 +08:00
mzl92 8072388da2 ts 2025-12-25 16:07:33 +08:00
mzl92 f2b7d40b17 ts 2025-12-25 15:59:07 +08:00
18338781771 e514129ff6 业务主管备货单只看自己的 2025-12-24 14:02:52 +08:00
18338781771 a951fc39b3 业务主管只看自己的备货单 2025-12-24 13:41:14 +08:00
mzl92 7c12ab54e1 Finish Hotfix-1 2025-12-23 14:44:51 +08:00
mzl92 d81fdae406 备货单审批增加兜底检查 2025-12-23 14:44:43 +08:00
mzl92 1e70bbe0ad Finish Hotfix-1 2025-12-23 14:36:06 +08:00
mzl92 861b8b216f 增加备货单修改兜底检查 2025-12-23 14:35:57 +08:00
mzl92 5b2590bff8 Finish Hotfix-1 2025-12-22 16:22:33 +08:00
mzl92 7d1ea71e94 ts 2025-12-22 16:22:20 +08:00
mzl92 d171ad58c7 Finish Hotfix-1 2025-12-18 15:10:56 +08:00
mzl92 31a610664f inquiry ts 2025-12-18 15:10:43 +08:00
mzl92 8f60c79b7c Finish Hotfix-1 2025-12-16 14:22:29 +08:00
mzl92 5aadcac7e3 ts 2025-12-16 14:22:21 +08:00
mzl92 ca3455b219 Finish Hotfix-1 2025-12-16 13:44:47 +08:00
mzl92 56f8657886 ts 2025-12-16 13:44:38 +08:00
mzl92 c29c4941fc Merge branch 'develop' 2025-12-15 16:06:51 +08:00
mzl92 e960dd9b36 Finish Hotfix-1 2025-12-12 18:37:11 +08:00
mzl92 cd622addec ts 2025-12-12 18:37:03 +08:00
mzl92 da3794db50 Finish Hotfix-1 2025-12-12 18:11:53 +08:00
mzl92 8fab10b972 ts 2025-12-12 18:11:44 +08:00
mzl92 eea13a2a58 Merge branch 'develop'
# Conflicts:
#	app/command/InquiryImportFbCommand.php
#	app/inquiry/utils/FbLeadHeaderMapper.php
2025-12-12 17:52:20 +08:00
mzl92 1d0b51d598 Finish Hotfix-1 2025-12-11 15:32:43 +08:00
mzl92 f0af3a0cdb ts 2025-12-11 15:32:36 +08:00
mzl92 45a77ba12e Finish Hotfix-1 2025-12-11 15:23:13 +08:00
mzl92 85a79193a6 ts 2025-12-11 15:23:02 +08:00
mzl92 38deea3b0d Merge branch 'develop' 2025-12-11 15:07:55 +08:00
mzl92 b37cdc6834 Finish Hotfix-1 2025-12-10 20:26:35 +08:00
mzl92 b60df7f5cf ts 2025-12-10 20:26:21 +08:00
mzl92 628429af66 Merge branch 'develop' 2025-12-10 20:18:46 +08:00
18338781771 2a6d517a74 无报价时价格处理 2025-12-10 13:39:08 +08:00
18338781771 f58b0ee986 无报价时价格处理 2025-12-10 13:34:55 +08:00
18338781771 1207352e5a 日志 2025-12-10 13:12:02 +08:00
18338781771 14967f28c2 车价 2025-12-10 12:11:31 +08:00
18338781771 4ff2c2493b 文件大小限制调整 2025-12-09 11:51:22 +08:00
18338781771 b391608d87 内勤备货单查询 2025-12-09 11:12:36 +08:00
18338781771 0ace4a7b0b 内勤备货单查询 2025-12-09 11:08:51 +08:00
18338781771 7258d9890e 内勤备货单查询 2025-12-09 10:54:58 +08:00
18338781771 8c3fbd4d9a 内勤备货单查询 2025-12-09 10:48:59 +08:00
18338781771 a33b109ddb 内勤备货单查询 2025-12-08 15:01:02 +08:00
18338781771 4bcc6c53f7 备货单与审批数据过滤 2025-12-08 13:42:20 +08:00
18338781771 32aa7170a5 备货单与审批数据过滤 2025-12-08 13:24:37 +08:00
18338781771 59994d1f5a 备货单与审批数据过滤 2025-12-08 13:22:37 +08:00
18338781771 a0a71161d3 备货单与审批数据过滤 2025-12-08 13:19:16 +08:00
18338781771 06a264ff21 登录后首页 2025-12-05 16:27:22 +08:00
18338781771 82673cdcfa 仪表盘路由 2025-12-05 10:34:34 +08:00
18338781771 b4b4f6cd5f 仪表盘路由 2025-12-05 10:28:36 +08:00
18338781771 143dde08ce 仪表板路由 2025-12-04 18:11:45 +08:00
18338781771 2ffd053723 仪表盘路由 2025-12-04 18:10:09 +08:00
18338781771 d238b47aa8 仪表盘路由 2025-12-04 18:08:26 +08:00
18338781771 5c3f8679ce 仪表盘路由规则 2025-12-04 17:54:40 +08:00
18338781771 3520f10572 直属上级与内勤 2025-12-04 15:06:34 +08:00
18338781771 1858d3e586 返回角色id 2025-12-04 14:34:38 +08:00
mzl92 c0a5904c12 Finish Hotfix-1 2025-12-02 18:02:20 +08:00
mzl92 e2f79598b2 调整邮件转发移除不参与分配人员 2025-12-02 18:02:12 +08:00
18338781771 75a333b756 取消日志记录 2025-12-02 17:53:45 +08:00
mzl92 0d979934be Finish Hotfix-1 2025-12-02 17:34:12 +08:00
mzl92 0153f40127 调整依赖包 2025-12-02 17:34:02 +08:00
mzl92 fb5b523264 Finish Hotfix-1 2025-12-02 17:30:48 +08:00
mzl92 54c23a26b7 修改依赖包 2025-12-02 17:30:39 +08:00
mzl92 bbb3f2f70e ts 2025-12-02 14:14:25 +08:00
mzl92 707fd275c1 Finish Hotfix-1 2025-12-02 10:00:33 +08:00
mzl92 80d458a8b0 修改邮件转发接收人规则 2025-12-02 01:58:54 +00:00
18338781771 fe78500ef0 转发 2025-12-01 18:15:53 +08:00
18338781771 db7c8a650b 询单转发 2025-12-01 17:51:16 +08:00
18338781771 bf74d9b917 询单转发 2025-12-01 17:48:44 +08:00
18338781771 05e39d2d65 询单转发 2025-11-29 17:50:08 +08:00
18338781771 af8577064d 记录寻盘 2025-11-29 16:13:53 +08:00
18338781771 22ba33125a 询单 2025-11-29 15:55:48 +08:00
18338781771 6997947717 询单 2025-11-29 15:51:34 +08:00
18338781771 1e37560301 询单转发 2025-11-29 15:46:11 +08:00
18338781771 8a94de610d 询盘转发 2025-11-29 15:36:53 +08:00
18338781771 6bdcc73ee8 模糊抓取优化 2025-11-28 16:12:10 +08:00
18338781771 fa22cfe132 Merge branch 'feature/crm' 2025-11-18 11:00:32 +08:00
18338781771 6096f7ee01 删除原因 2025-11-17 14:39:52 +08:00
18338781771 cb1b208190 移除原因 2025-11-17 10:05:29 +08:00
18338781771 3d96152ea9 品牌关键词抓取 2025-11-14 15:25:08 +08:00
18338781771 cd0d850f89 关键词抓取 2025-11-14 14:11:54 +08:00
mzl92 515a16a633 Finish Hotfix-1 2025-11-13 11:47:16 +08:00
mzl92 f0abdca45a ts 2025-11-13 11:47:07 +08:00
mzl92 00ab988469 Finish Hotfix-1 2025-11-13 11:42:34 +08:00
mzl92 d388b6ace9 ts 2025-11-13 11:42:22 +08:00
mzl92 f388ad41d6 Finish Hotfix-1 2025-11-13 10:06:30 +08:00
mzl92 8b3778e83e 调整 2025-11-13 10:06:19 +08:00
mzl92 80fbf6c35a Merge branch 'develop'
# Conflicts:
#	config/route.php
2025-11-11 16:46:22 +08:00
mzl92 6c59fdef10 Merge branch 'develop'
# Conflicts:
#	app/common/service/StockOrdersService.php
2025-11-04 15:43:42 +08:00
mzl92 aabd0f4634 Merge branch 'develop'
# Conflicts:
#	app/admin/controller/dashboard/FinStatisticsController.php
#	app/common/controller/FinPaymentApplyController.php
#	app/common/controller/FinReceiptController.php
#	app/common/logic/FinReceiptLogic.php
#	app/common/model/FinPaymentApply.php
#	app/common/model/FinReceipt.php
#	app/common/service/FinPaymentApplyService.php
#	app/common/service/FinReceiptService.php
#	app/common/service/StockOrdersService.php
#	config/route.php
2025-11-04 10:36:52 +08:00
mzl92 359755fdf1 Merge branch 'release' 2025-11-03 18:16:51 +08:00
18338781771 a0935c4332 暂停抓取车辆信息 2025-10-31 16:33:02 +08:00
142 changed files with 15159 additions and 782 deletions
+7
View File
@@ -8,3 +8,10 @@
/tests/.phpunit.result.cache
/public/uploads
/webman
/start.php
/windows.php
/bootstrap.php
/composer.lock
.env_copy
/public/excel
/docs/*.sql
@@ -0,0 +1,33 @@
<?php
namespace app\admin\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\FinanceReconDubaiFilesService;
class FinanceReconDubaiFilesController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new FinanceReconDubaiFilesService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -0,0 +1,33 @@
<?php
namespace app\admin\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\InquiryTransmitConfigService;
class InquiryTransmitConfigController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new InquiryTransmitConfigService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -0,0 +1,280 @@
<?php
namespace app\admin\controller;
use app\common\controller\BaseController;
use app\common\model\InquiryTransmit;
use app\common\model\InquiryTransmitConfig;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\InquiryTransmitService;
use support\Log;
use support\Request;
class InquiryTransmitController extends BaseController
{
use CrudControllerTrait;
protected $noNeedLogin = ['form'];
protected $noNeedHeaderData = ['form'];
protected $service;
public function __construct()
{
$this->service = new InquiryTransmitService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
// }
public function form(Request $request)
{
try {
$params = $request->all();
$url = $request->header('referer');
$domain = parse_url($url, PHP_URL_HOST);
$transmitConfig = InquiryTransmitConfig::where('domain', $domain)->first();
if ($transmitConfig) {
$mailto = $transmitConfig->mailto;
$fromDomain = $domain;
} else {
$mailto = '1696136552@qq.com';
$fromDomain = 'referer Failed to obtain';
}
if ($request->method() == 'GET') {
$config = [
'iLang' => 'en',
'Act' => 'Inquiry',
'post_url' => getenv('MAIN_DOMAIN').'/inquiry/transmit/form'.'?m=Data&a=save',
'mailto' => $mailto,
'fromDomain' => $fromDomain,
'fromPath' => parse_url($params['fromlink'], PHP_URL_PATH),
'm' => $params['m'],
'a' => $params['a'],
'tid' => $params['tid'],
'cid' => $params['cid'],
'version' => $params['version'],
'fromlink' => $params['fromlink']
];
$html = match ($params['tid']) {
'en-5-LP-pnx' => $this->formHmtl($config),
'en-3-P-vdx' => $this->formHmtl2($config)
};
return $this->success(['html' => $html], 'success');
// return json([
// 'code' => 200,
// 'data' => [
// 'html' => $html
// ],
// 'message' => 'success'
// ]);
}
if ($request->method() == 'POST') {
$ip = $request->getRealIp($safe_mode=true);
$edata = [
'mailto' => $params['mailto'],
'Date' => date('Y-m-d H:i:s', time()),
'Email' => $params['userEMail'],
'Name' => $params['userName'],
'Tel' => $params['userTel'],
'Company' => '',
'Country' => $params['uCountry'],
'Form Url' => $params['fromlink'],
'Message' => $params['Message'],
'Ip' => $ip,
'IpLocation' => ip2region($ip).'(仅供参考)',
'iLang' => $params['iLang'],
'mailto' => $params['mailto'],
'navigator_language' => $params['navigator_language'],
'navigator_platform' => $params['navigator_platform'],
'screen_width' => $params['screen_width'],
'screen_height' => $params['screen_height'],
'uCountry' => $params['uCountry']
];
$this->service->serviceStore($request, $edata, $domain);
$this->emailHtml($edata);
return $this->success([], 'Thank you for your message! We will contact with you soon.');
// return json([
// 'code' => 200,
// 'data' => [],
// 'message' => 'Thank you for your message! We will contact with you soon.'
// ]);
}
} catch (\Throwable $th) {
Log::info('Data:'.date('Y-m-d H:i:s').':'.$th->getMessage().'|'.$th->getLine().'|'.$th->getFile());
}
}
public function formHmtl($config)
{
// https://v1.xzgoogle.com/form.php?m=Temp&a=getTpl&tid=en-5-LP-pnx&cid=760&version=v4&fromlink=https://magicbridgeauto.com/contact-us/
$html = '<form class="xz-form-x xz-form-pnx" action="'.$config['post_url'].'"> <style> .xz-form-pnx { --c-theme: #BB0010; --c-border: #f0f0f0; --c-bg: #fff; --c-label: #333; --c-font: #666; --c-green: #00B160; --size: 1.6rem; --gap: 1.25em; --h: 3em; --lh: 1.8em; --pd: .4em .625em; --br: 4px; --btn-w: 18.75em; --btn-jc: center; --lines: 4; } .xz-form-pnx { font-size: var(--size); } .xz-form-pnx .xz-form-infos { display: grid; grid-template-columns: 1fr 1fr; grid-gap: var(--gap); margin-bottom: var(--gap); } .xz-form-pnx .xz-form-info, .xz-form-pnx .xz-form-msg { border: solid 1px var(--c-border); background: var(--c-bg); padding: var(--pd); display: flex; align-items: center; border-radius: var(--br); box-sizing: border-box; } .xz-form-pnx .xz-form-info { height: var(--h); } .xz-form-pnx .xz-form-msg { align-items: flex-start; } .xz-form-pnx .xz-form-input, .xz-form-pnx .xz-form-textarea { width: 100%; border: none; font-size: var(--size); line-height: var(--lh); outline: 0; padding: 0; margin-left: 1em; color: var(--c-font); } .xz-form-pnx .xz-form-textarea { height: calc(var(--lh) * var(--lines)); min-height: var(--h); font-family: inherit; resize: none; } .xz-form-pnx .xz-form-label { display: block; color: var(--c-label); line-height: var(--lh); white-space: nowrap; } .xz-form-pnx .xz-form-label.required:after { content: "*"; margin-left: .625em; color: var(--c-theme); } .xz-form-pnx .xz-form-btns { margin-top: var(--gap); display: flex; justify-content: var(--btn-jc); } .xz-form-pnx .xz-form-button { font-size: var(--size); border-radius: var(--br); display: inline-flex; justify-content: center; align-items: center; width: var(--btn-w); height: var(--h); background: var(--c-theme); color: #fff; border: none; transition: all .3s; display: flex; justify-content: center; align-items: center; gap: 1em; } .xz-form-pnx .xz-form-button path { fill: #fff; } .xz-form-pnx .xz-form-button:hover { filter: brightness(1.1); } .xz-form-pnx .xz-form-button:active { transform: scale(.98); } @media screen and (max-width: 500px){ .xz-form-pnx { --gap: .8em; } .xz-form-pnx .xz-form-infos { grid-template-columns: 1fr; } .xz-form-pnx .xz-form-button { width: 100%; } } .xzform_o { position: relative; } .xz-form-pnx .xz-form-message.success { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); z-index: 9; max-width: 75%; display: flex; text-align: center; align-items: center; padding: 4em; background: rgba(255,255,255,.8); color: var(--c-green); font-size: 1.125em; line-height: 1.8em; font-weight: 500; box-shadow: 0 .5rem 3rem -.5rem rgba(0,0,0,.3); } </style> <input type="hidden" name="iLang" value="'.$config['iLang'].'"> <input type="hidden" name="Act" value="'.$config['Act'].'"> <input type="hidden" name="mailto" value="'.$config['mailto'].'"> <input type="hidden" name="customerId" value="'.$config['cid'].'"> <input type="hidden" name="fromDomain" value="'.$config['fromDomain'].'"> <input type="hidden" name="fromlink" value="'.$config['fromlink'].'"> <input type="hidden" name="fromPath" value="'.$config['fromPath'].'"> <input type="hidden" name="navigator.language"> <input type="hidden" name="navigator.platform"> <input type="hidden" name="screen.width"> <input type="hidden" name="screen.height"> <div class="xz-form-infos"> <div class="xz-form-info"> <label class="xz-form-label" for="userName-pnx">Name</label> <input type="text" name="userName" id="userName-pnx" maxlength="80" class="xz-form-input" required> </div> <div class="xz-form-info"> <label class="xz-form-label" for="uCountry-pnx">Country/Region</label> <input type="text" name="uCountry" id="uCountry-pnx" maxlength="80" class="xz-form-input" required> </div> <div class="xz-form-info"> <label class="xz-form-label" for="userEMail-pnx">Email</label> <input type="email" name="userEMail" id="userEMail-pnx" maxlength="80" class="xz-form-input"> </div> <div class="xz-form-info"> <label class="xz-form-label" for="userTel-pnx">Mobile</label> <input type="tel" name="userTel" id="userTel-pnx" maxlength="80" class="xz-form-input" required placeholder="Or Whatsapp/Skype"> </div> </div> <div class="xz-form-msg"> <label class="xz-form-label" for="Message-pnx">Message</label> <textarea name="Message" id="Message-pnx" class="xz-form-textarea" required></textarea> </div> <div class="xz-form-btns"> <button class="xz-form-button"> Submit <svg t="1664258923484" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3462" width="20" height="20"> <path d="M871.04 89.770667L120.064 380.16a51.2 51.2 0 0 0-1.792 94.762667l303.36 130.56 131.072 303.957333a51.2 51.2 0 0 0 94.805333-1.877333l289.792-751.573334a51.2 51.2 0 0 0-66.261333-66.133333z m-41.130667 107.392l-231.978666 601.642666-97.962667-227.114666-3.584-7.338667a85.333333 85.333333 0 0 0-41.045333-37.248l-226.56-97.536 601.173333-232.405333z" p-id="3463"></path> </svg> </button> </div></form>';
return $html;
}
public function formHmtl2($config)
{
// https://v1.xzgoogle.com/form.php?m=Temp&a=getTpl&tid=en-3-P-vdx&cid=760&version=v4&fromlink=https://magicbridgeauto.com/news/
$html = '<form class="xz-form-x xz-form-vdx" action="'.$config['post_url'].'"> <style> .xz-form-vdx { --c-theme: #2C9F1C; --c-border: #f8f8f8; --c-bg: #f8f8f8; --c-font: #333; --c-green: #2C9F1C; --size: 1.6rem; --gap: .625em; --h: 3.5em; --lh: 1.25em; --pd: 1em; --br: .625em; --btn-w: 100%; --lines: 1; font-size: var(--size); } .xz-form-vdx .xz-form-infos { display: grid; grid-template-columns: 1fr; grid-gap: var(--gap); margin-bottom: var(--gap); } .xz-form-vdx .xz-form-info, .xz-form-vdx .xz-form-msg { border: solid 1px var(--c-border); background: var(--c-bg); padding: var(--pd); display: flex; align-items: center; border-radius: var(--br); box-sizing: border-box; } .xz-form-vdx .xz-form-info { height: var(--h); } .xz-form-vdx .xz-form-input, .xz-form-vdx .xz-form-textarea { width: 100%; border: none; font-size: var(--size); line-height: var(--lh); outline: 0; padding: 0; color: var(--c-font); background: transparent; } .xz-form-vdx .xz-form-textarea { height: calc(var(--lh) * var(--lines)); font-family: inherit; resize: none; } .xz-form-vdx .xz-form-btns { margin-top: var(--gap); display: flex; justify-content: var(--btn-jc); } .xz-form-vdx .xz-form-button { font-size: var(--size); border-radius: var(--br); display: inline-flex; justify-content: center; align-items: center; width: var(--btn-w); height: var(--h); background: var(--c-theme); color: #fff; border: none; transition: all .3s; display: flex; justify-content: center; align-items: center; } .xz-form-vdx .xz-form-button:hover { filter: brightness(1.1); } .xz-form-vdx .xz-form-button:active { transform: scale(.98); } .xzform_o { position: relative; } .xz-form-vdx .xz-form-message.success { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 9; max-width: 90%; display: flex; text-align: center; align-items: center; padding: 2em; background: rgba(255, 255, 255, .8); color: var(--c-green); font-size: 1em; line-height: 1.5em; box-shadow: 0 .5rem 3rem -.5rem rgba(0, 0, 0, .3); } </style> <input type="hidden" name="iLang" value="'.$config['iLang'].'"> <input type="hidden" name="Act" value="'.$config['Act'].'"> <input type="hidden" name="mailto" value="'.$config['mailto'].'"> <input type="hidden" name="customerId" value="'.$config['cid'].'"> <input type="hidden" name="fromDomain" value="'.$config['fromDomain'].'"> <input type="hidden" name="fromlink" value="'.$config['fromlink'].'"> <input type="hidden" name="fromPath" value="'.$config['fromPath'].'"> <input type="hidden" name="navigator.language"> <input type="hidden" name="navigator.platform"> <input type="hidden" name="screen.width"> <input type="hidden" name="screen.height"> <div class="xz-form-infos"> <div class="xz-form-info"> <input type="text" name="userName" id="userName-vdx" maxlength="80" class="xz-form-input" placeholder="Name"> </div> <div class="xz-form-info"> <input type="email" name="userEMail" id="userEMail-vdx" maxlength="80" class="xz-form-input" placeholder="Email"> </div> </div> <div class="xz-form-msg"> <textarea name="Message" id="Message-vdx" class="xz-form-textarea" placeholder="What can we do for you? *" required></textarea> </div> <div class="xz-form-btns"> <button class="xz-form-button"> SUBMIT <svg t="1690535811615" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5056" width="1em" height="1em" fill="#fff"> <path d="M851.2 166.4c-12.8-6.4-25.6-6.4-32 0l-640 320C166.4 486.4 160 499.2 160 512s6.4 25.6 12.8 25.6l153.6 96c12.8 6.4 25.6 6.4 38.4-6.4L704 320l12.8 6.4-307.2 326.4c-6.4 6.4-6.4 12.8-6.4 19.2l0 140.8c0 12.8 6.4 25.6 19.2 32 12.8 6.4 25.6 0 32-6.4l76.8-76.8 153.6 102.4c6.4 6.4 12.8 6.4 19.2 6.4 6.4 0 6.4 0 12.8 0 12.8-6.4 19.2-12.8 19.2-25.6l128-640C864 185.6 864 172.8 851.2 166.4z" p-id="5057"></path> </svg> </button> </div></form>';
return $html;
}
public function emailHtml($data)
{
$html = '<!doctype html>
<html lang="zh-CN">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Lead Preview</title>
<meta name="x-apple-disable-message-reformatting">
</head>
<body style="margin:0;padding:0;background:#f6f7f9;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#f6f7f9;">
<tr>
<td align="center" style="padding:24px 12px;">
<!-- container -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="width:600px; max-width:100%; background:#ffffff; border:1px solid #e5e7eb; border-radius:8px;">
<!-- header -->
<tr>
<td style="padding:16px 20px; border-bottom:1px solid #e5e7eb; font:700 18px/1.4 Arial, Helvetica, sans-serif; color:#111;">
Preview
</td>
</tr>
<!-- Preview section -->
<tr>
<td style="padding:18px 20px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<!-- 一行:左 label / 右 value -->
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Date:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['Date'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Email:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">
<a href="mailto:muhseen2222@yaho.com" style="color:#2563eb; text-decoration:none;">'.$data['Email'].'</a>
</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Name:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;" dir="auto">'.$data['Name'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Tel:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['Tel'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Company:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#666;"></td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Country:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;" dir="auto">'.$data['Country'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Form Url:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222; word-break:break-word;">
<a href="'.$data['Form Url'].'" style="color:#2563eb; text-decoration:none;">'.$data['Form Url'].'</a>
</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Message:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;" dir="auto">'.$data['Message'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">Ip:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['Ip'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">IpLocation:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['IpLocation'].'</td>
</tr>
</table>
</td>
</tr>
<!-- divider + More -->
<tr>
<td style="padding:0 20px;">
<div style="height:1px; background:#e5e7eb; line-height:1px; font-size:0;">&nbsp;</div>
</td>
</tr>
<tr>
<td style="padding:14px 20px 0; font:700 18px Arial, Helvetica, sans-serif; color:#111;">More</td>
</tr>
<tr>
<td style="padding:6px 20px 18px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">iLang:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['iLang'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">mailto:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">
<a href="mailto:info@borderlesscar.com" style="color:#2563eb; text-decoration:none;">'.$data['mailto'].'</a>
</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">navigator_language:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['navigator_language'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">navigator_platform:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['navigator_platform'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">screen_width:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['screen_width'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">screen_height:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;">'.$data['screen_height'].'</td>
</tr>
<tr>
<td style="width:140px; padding:6px 0; font:700 14px Arial, Helvetica, sans-serif; color:#111;">uCountry:</td>
<td style="padding:6px 0; font:14px Arial, Helvetica, sans-serif; color:#222;" dir="auto">'.$data['uCountry'].'</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- /container -->
</td>
</tr>
</table>
</body>
</html>';
// $email = new Email();
//return $email->send($data['mailto'], 'Inquiry_Of_'.$data['Name'], $html);
return \app\common\utils\email\MailSend::send($data['mailto'], 'Inquiry_Of_'.$data['Name'], $html);
}
}
+11 -4
View File
@@ -81,19 +81,22 @@ class StockOrdersController extends BaseController
if (!isset($groupedFiles[$file['file_code']])) {
$groupedFiles[$file['file_code']] = [];
}
$groupedFiles[$file['file_code']][] = $file['file_url'];
$groupedFiles[$file['file_code']][] = [
'url' => $file['file_url'],
'original_name' => $file['original_name']
];
}
// 映射到对应字段
foreach ($fileTypes as $field => $fileCode) {
if (isset($groupedFiles[$fileCode])) {
// 原始URL数组
// 文件信息数组,包含url和original_name
$data[$field] = $groupedFiles[$fileCode];
// 完整URL数组
$fullUrls = [];
foreach ($groupedFiles[$fileCode] as $url) {
$fullUrls[] = (new \app\common\service\OssService)->generatePrivateUrl($url);
foreach ($groupedFiles[$fileCode] as $fileInfo) {
$fullUrls[] = (new \app\common\service\OssService)->generatePrivateUrl($fileInfo['url']);
}
$data[$field . '_url'] = $fullUrls;
}
@@ -133,6 +136,10 @@ class StockOrdersController extends BaseController
];
}
unset($item['options']);
// 附件 url 响应期重签(历史签名URL会过期)
if (!empty($item['attachment']) && is_array($item['attachment'])) {
$item['attachment'] = \app\common\service\OssService::resignAttachmentUrls($item['attachment']);
}
}
unset($item);
@@ -0,0 +1,48 @@
<?php
namespace app\admin\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\admin\service\SysConfigService;
class SysConfigController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new SysConfigService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
protected function indexFormat($data)
{
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
return $data;
}
/**
* 数据格式化
*
* @param array $data
* @return array
*/
protected function showFormat($data)
{
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
return $data;
}
}
+5
View File
@@ -118,6 +118,11 @@ class UserController extends BaseController
$data['system_ids'][] = $system['id'];
}
}
if ($data['reportToInfo']) {
$data['report_to_name'] = $data['reportToInfo']['id'] == $data['id'] ? '/' : $data['reportToInfo']['name'];
} else {
$data['report_to_name'] = '-';
}
return $data;
}
@@ -0,0 +1,47 @@
<?php
namespace app\admin\controller;
use app\common\controller\BaseController;
use app\common\model\Resource;
use app\common\model\Role;
use app\common\model\User;
use app\common\model\UserGroup;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\UserDashboardRuleService;
use Couchbase\Group;
class UserDashboardRuleController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new UserDashboardRuleService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
protected function indexFormat($data)
{
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
if (isset($data['resources_id']))
$data['resources'] = Resource::where(['id' => $data['resources_id']])->first();
if (isset($data['rule_type']) && $data['rule_type'] == 'role'){
$data['related_rt'] = Role::where(['id' => $data['related']])->value('name');
}elseif (isset($data['rule_type']) && $data['rule_type'] == 'user'){
$data['related_rt'] = User::where(['id' => $data['related']])->value('name');
}elseif (isset($data['rule_type']) && $data['rule_type'] == 'group'){
$data['related_rt'] = UserGroup::where(['id' => $data['related']])->value('title');
}
return $data;
}
}
@@ -0,0 +1,134 @@
<?php
namespace app\admin\controller\dashboard;
use app\common\controller\BaseController;
use app\common\model\FinPaymentApply;
use app\common\model\FinReceipt;
use app\common\model\SysCurrency;
use support\Request;
class FinStatisticsController extends BaseController
{
/**
* 收款记录统计
* @param Request $request
* @return \support\Response
*/
public function statistics(Request $request)
{
try {
$currency = SysCurrency::where('status',1)->get();
$query = FinReceipt::where('payment_apply_id','>',0);
$result = [];
//已认领数量
$result['claimed_num'] = $query->count();
//已认领金额
// $result['claimed_amount'] = $query->sum('amount');
$result['claimed_amount'] = array_values($this->receiptStatisticsSum($query, $currency)); //各币种的已认领金额
$result['claimed_amount_sum'] =$this->amountSum($result['claimed_amount']); //已认领的人民币总金额
//未入账数量
$query2 = FinReceipt::whereNull('received_at');
$result['unreceived_num'] = $query2->count();
//未入账金额
// $result['unreceived_amount'] = $query2->sum('amount');
$result['unreceived_amount'] = array_values($this->receiptStatisticsSum($query2, $currency)); //各币种的未入账金额
$result['unreceived_amount_sum'] = $this->amountSum($result['unreceived_amount']); //未入账的已认领的人民币总金额
//今日入账
$query6 = FinReceipt::where('received_at','>',strtotime(date("Y-m-d")));
//今日入账数量
$result['received_num'] = $query6->count();
//未入账金额
// $result['unreceived_amount'] = $query2->sum('amount');
$result['received_amount'] = array_values($this->receiptStatisticsSum($query6, $currency)); //各币种的未入账金额
$result['received_amount_sum'] = $this->amountSum($result['unreceived_amount']); //未入账的已认领的人民币总金额
$query3 = FinReceipt::where('payment_apply_id',0);
//未认领数量
$result['unclaimed_num'] = $query3->count();
//未认领金额
// $result['unclaimed_amount'] = $query3->sum('amount');
$result['unclaimed_amount'] = $this->receiptStatisticsSum($query3, $currency); //各币种的未认领金额
$result['unclaimed_amount_sum'] = $this->amountSum($result['unclaimed_amount']); //未认领的已认领的人民币总金额
//已超时未认领数量
$query4 = $query3->where('created_at','<',time()-24*3600);
$result['timeout'] = $query4->count();
//在途资金
//1、没有认领的付款资金
$query5 = FinPaymentApply::where('receipt_id', '=', 0);
$unclaimed_payment_amount = $this->receiptStatisticsSum($query5, $currency);
foreach ($unclaimed_payment_amount as $key => &$value) {
$value['amount'] = $value['amount'] - $result['unclaimed_amount'][$key]['amount'];
}
$result['unclaimed_payment_num'] = $query5->count();
$result['unclaimed_payment_amount'] = array_values($unclaimed_payment_amount);
$result['unclaimed_payment_amount_sum'] = $this->amountSum($result['unclaimed_payment_amount']);
//在途资金 = 没有认领的付款资金-未认领的收款资金
$result['intransit_amount_sum'] = $result['unclaimed_payment_amount_sum'] - $result['unclaimed_amount_sum'];
$intransit_amount_amount = [];
//在途资金各币种明细
foreach ($unclaimed_payment_amount as $key => $value) {
$value['amount'] = $value['amount'] - $result['unclaimed_amount'][$key]['amount'];
$intransit_amount_amount[] = $value;
}
$result['intransit_amount_amount'] = $intransit_amount_amount;
//在途数量 = 没有认领的付款数量-未认领的收款数量
$result['intransit_count'] = $result['unclaimed_payment_num'] - $result['unclaimed_num'];
//未认领金额重新组织索引
$result['unclaimed_amount'] = array_values($result['unclaimed_amount']);
return $this->success($result);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 根据不同条件查询收款金额
* @param $query
* @param $currency
* @return array
*/
public function receiptStatisticsSum($query, $currency)
{
$amount = [];
foreach ($currency as $k => $v) {
$amount[$v->currency_code] = [
'currency_code' => $v->currency_code,
'exchange_rate' => $v->exchange_rate,
'amount' => $query->where('currency_id',$v->id)->sum('amount'),
];
}
return $amount;
}
/**
* 不同币种汇率换算之后求和
* @param $query
* @param $currency
*/
public function amountSum($amount)
{
$amountSum = 0;
foreach ($amount as $v) {
// 转换为整数(乘以100避免浮点数问题)
$v['amount'] = (int) round($v['amount'] * 100);
$v['exchange_rate'] = (int) round($v['exchange_rate'] * 10000); // 汇率保留4位小数
$amountSum += $v['amount'] * $v['exchange_rate'];
}
// 转换回金额(除以1000000,因为汇率乘了10000)
$result = $amountSum / 1000000;
// 四舍五入到2位小数
return round($amountSum, 2);
}
}
@@ -0,0 +1,182 @@
<?php
namespace app\admin\controller\dashboard;
use app\common\controller\BaseController;
use support\Db;
use support\Request;
class OfficeClerkController extends BaseController
{
protected $service;
public function __construct()
{}
// 卡片
public function card(Request $request)
{
$approved = Db::table('approval_records')
->where('approver_id', $request->user->uid)
->where('status', 'approved')
->count('id');
$pending = Db::table('approval_records')
->where('approver_id', $request->user->uid)
->where('status', 'pending')
->count('id');
$unclaimed = Db::table('fin_payment_apply')
->where('doc_user_id', $request->user->uid)
->where('receipt_id', 0)
->count('id');
$claimed = Db::table('fin_payment_apply')
->where('doc_user_id', $request->user->uid)
->where('receipt_id', '>', 0)
->count('id');
$twentyFourHoursAgo = time() - (24 * 3600);
$timeout = Db::table('fin_payment_apply')
->where('doc_user_id', $request->user->uid)
->where('fin_payment_apply.submit_files', '')
->where('receipt_id', '>', 0)
->where('claimed_at', '<', $twentyFourHoursAgo)
->count('id');
return $this->success([
'approved' => $approved,
'pending' => $pending,
'unclaimed' => $unclaimed,
'claimed' => $claimed,
'timeout' => $timeout
]);
}
// 曲线
public function curve(Request $request)
{
$groupId = Db::table('user_group_members as ugm')
->join('user_group as ug', 'ugm.group_id', '=', 'ug.id')
->where('ugm.user_id', $request->user->uid)
->where('ugm.is_leader', 1)
->where('ugm.status', 1)
->where('ug.scenario_code', 'stock_orders')
->where('ug.status', 1)
->value('ug.id');
$userIds = Db::table('user_group_members')
->where('group_id', $groupId)
->pluck('user_id');
$list = Db::table('stock_orders')
->join('user', 'stock_orders.salesperson_id', '=', 'user.id')
->whereIn('stock_orders.salesperson_id', $userIds)
->whereIn('stock_orders.approval_status', [0, 1, 2])
->whereNull('stock_orders.deleted_at')
->groupBy('stock_orders.salesperson_id', 'user.name')
->selectRaw('user.name, count(*) as count')
->pluck('count', 'name')
->toArray();
$optionType = [];
$counts = [];
foreach ($list as $key=>$val) {
$optionType[] = $key;
$counts[] = $val;
}
$arr = [
"optionType" => $optionType,
"counts" => $counts
];
return $this->success($arr);
}
// 审批
public function approval(Request $request)
{
$list = Db::table('approval_records as ar')
->join('approval_instances as ai', 'ar.instance_id', '=', 'ai.id')
->join('stock_orders as so', 'ai.biz_id', '=', 'so.id')
->where('ar.approver_id', $request->user->uid)
->where('ar.status', 'pending')
->where('ai.flow_type', 'stock_orders')
->where('ai.biz_type', 'stock_orders')
->where('ai.status', 'processing')
->whereNull('so.deleted_at')
->select(
'so.order_no',
'ai.created_at as approval_created_at',
'so.salesperson_snapshot as salesperson_name'
)
->get()
->map(function($item) {
return [
'order_no' => $item->order_no,
'approval_created_at' => date('Y-m-d H:i:s', strtotime($item->approval_created_at)), // 格式化时间
'salesperson_name' => $item->salesperson_name
];
})
->toArray();
return $this->success($list);
}
// 认领
public function claim(Request $request)
{
$type = $request->get('type');
if ($type == 'unclaimed') {
$list = Db::table('fin_payment_apply')
->join('sys_currency', 'fin_payment_apply.currency_id', '=', 'sys_currency.id')
->where('fin_payment_apply.doc_user_id', $request->user->uid)
->where('fin_payment_apply.receipt_id', 0)
->get([
'fin_payment_apply.id',
'fin_payment_apply.created_at',
'fin_payment_apply.doc_user_id',
'fin_payment_apply.payment_entity',
'fin_payment_apply.payment_account',
'fin_payment_apply.currency_id',
'sys_currency.currency_name',
'fin_payment_apply.amount',
'fin_payment_apply.apply_no'
]);
foreach ($list as $key => &$val) {
$val->created_at = date('Y-m-d H:i:s', $val->created_at);
}
unset($val);
} else {
$twentyFourHoursAgo = time() - (24 * 3600);
$list = Db::table('fin_payment_apply')
->join('user', 'fin_payment_apply.doc_user_id', '=', 'user.id')
->join('sys_currency', 'fin_payment_apply.currency_id', '=', 'sys_currency.id')
->where('fin_payment_apply.doc_user_id', $request->user->uid)
->where('fin_payment_apply.submit_files', '')
->where('fin_payment_apply.receipt_id', '>', 0)
->where('fin_payment_apply.claimed_at', '<', $twentyFourHoursAgo)
->get([
'fin_payment_apply.id',
'fin_payment_apply.created_at',
'fin_payment_apply.doc_user_id',
'user.name',
'fin_payment_apply.payment_entity',
'fin_payment_apply.payment_account',
'fin_payment_apply.currency_id',
'sys_currency.currency_name',
'fin_payment_apply.amount',
'fin_payment_apply.apply_no'
]);
foreach ($list as $key => &$val) {
$stockIds = Db::table('fin_payment_stock')
->where('payment_apply_id', $val->id)
->pluck('stock_id')
->toArray();
$val->order_no = Db::table('stock_orders')
->whereIn('id', $stockIds)
->pluck('order_no')
->toArray();
$val->created_at = date('Y-m-d H:i:s', $val->created_at);
}
unset($val);
}
return $this->success($list);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\admin\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\SysConfig;
class SysConfigLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = SysConfig::class;
}
+324 -55
View File
@@ -168,50 +168,8 @@ class BrandSeriesService
throw new \Exception('抓取失败,未找到该系列车型');
}
$client = new Client([
'verify' => false, // 禁用 SSL 验证
'timeout' => 10
]);
try {
$response = $client->get('https://www.dongchedi.com/motor/pc/car/series/car_list', [
'query' => ['series_id' => $seriesId],
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
]
]);
$data = json_decode($response->getBody()->getContents(), true);
if($data['status'] != 0 || (!isset($data['data']) && !isset($data['data']['tab_list']))){
Log::error('抓取车型数据失败' . $data['message'], $data);
throw new \Exception('抓取车型数据失败' . $data['message']);
}
$models = [];
foreach ($data['data']['tab_list'] as $item) {
if($item['tab_text'] == '在售' || $item['tab_text'] == '停售'){
unset($item['data'][0]);
$models = array_merge($models, $item['data']);
}
}
foreach ($models as $key => $model) {
if(!isset($model["info"]['car_name']) || $model['type'] != "1115"){
unset($models[$key]);
continue;
}
$official_price = 0;
if(!isset($model["info"]['official_price'])){
$official_price = (int)(str_replace("万", "", $model["info"]['official_price_str']) * 10000) ?? 0;
}else{
$official_price = $model["info"]['official_price'] * 10000;
}
$models[$key] = [
'title' => $model["info"]['year'] . '款 ' .$model["info"]['car_name'],
'brand_name' => $model["info"]['brand_name'],
'series_name' => $model["info"]['series_name'],
'year' => $model["info"]['year'],
'msrp' => $official_price,
'car_id' => $model["info"]['id'],
];
}
return array_values($models);
return $this->crawlSeriesModel($seriesId);
} catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
@@ -296,10 +254,13 @@ class BrandSeriesService
// 验证系列是否存在
$series = BrandSeries::where('id', $series_id)->first();
if (!$series) {
throw new \Exception("系列ID {$series_id} 不存在");
throw new \Exception("车辆系列未收录,联系管理员处理");
}
// 获取品牌信息
$brand = Brand::where('id', $series->brand_id)->first();
if (!$brand) {
throw new \Exception("车辆品牌未收录,联系管理员处理");
}
// 保存车型数据
$createdCount = 0;
@@ -354,24 +315,332 @@ class BrandSeriesService
return $msg;
}
/**
* 抓取车型数据(通过接口)
* @param Request $request
* @return array
* @throws \Exception
*/
public function crawlKeyword(Request $request): array
{
$keyword = $request->get('keyword');
// 执行抓取
$crawlService = new CrawlService();
//懂车帝模糊搜索返回车系地址|单个车系id抓取
$href = $crawlService->modelSearch($keyword);
$seriesId = $this->extractSeriesId($href); //懂车帝车系id
// if (empty($seriesId)) { //没有抓取到懂车帝系列id时,按关键词为品牌名抓取所有的系列名
// $crawlSeries = $crawlService->brandSeries($keyword);
// }
//按关键词为品牌名抓取所有的系列名
$crawlSeries = $crawlService->brandSeries($keyword);
if (empty($seriesId) && empty($crawlSeries)) {
throw new \Exception('抓取失败,未找到该系列车型');
}
$brandSerieses = [];
if(empty($seriesId) && !empty($crawlSeries) || count($crawlSeries)>1){ //关键词为品牌名时抓取所有车系的懂车帝id
$href = $crawlService->modelSearch($crawlSeries[0]);
$seriesId = $this->extractSeriesId($href); //懂车帝车系id
//抓取品牌与系列
$brandSeries = $crawlService->crawlBrandSeries($seriesId);
foreach ($crawlSeries as $item) {
// $brandSerieses[] = $crawlService->brandSeries($keyword);
$brandSerieses[] = ['brand' => $brandSeries['brand'], 'series' => $item];
}
}elseif (!empty($seriesId) && empty($crawlSeries)){
//抓取品牌与系列
$brandSerieses[] = $crawlService->crawlBrandSeries($seriesId);
}
$result = [];
try {
foreach ($brandSerieses as $brandSeries) {
$item = [];
// 验证品牌是否存在
$brand = Brand::where('brand_name', $brandSeries['brand'])->first();
// 获取品牌信息
$series = BrandSeries::where('series_name', $brandSeries['series'])->first();
$item['brand'] = ['brand_name' => $brandSeries['brand'], 'exists' => $brand ? 1 : 0];
if ($brand) {
$item['brand']['id'] = $brand['id'];
}
$item['series'] = ['series_name' => $brandSeries['series'], 'exists' => $series ? 1 : 0];
if ($series) {
$item['series']['id'] = $series['id'];
if (empty($item['brand']['id'])) {
$item['brand']['id'] = $series['brand_id'];
}
}
//获取车型数据
$item['model'] = [];
if (count($brandSerieses)<2) {
$item['model'] = $this->crawlSeriesModel($seriesId);
}
$result[] = $item;
}
return ['is_series' => (count($brandSerieses)<2)?1:0, 'data' => $result];
} catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
}
/**
* 从车型名中提取年份
* @param string $carModel 车型名称
* @return string|null 提取到的年份或null
* @param Request $request
* @return array|string
* @throws \Exception
*/
function extractYearFromCarModel($carModel)
public function crawlKeywordCreate(Request $request)
{
// 匹配4位数字 + "款" 的模式
if (preg_match('/(\d{4})款/', $carModel, $matches)) {
return $matches[1];
$brand_id = $request->input('brand_id');
$brand_name = $request->input('brand_name');
$brand_code = $request->input('brand_code');
$series_id = $request->input('series_id');
$series_name = $request->input('series_name');
$models = $request->input('models');
if(empty($brand_id) && empty($brand_name)){
throw new \Exception("请选择品牌");
}
if(empty($series_id) && empty($series_name)){
throw new \Exception("请选择车系");
}
// 匹配4位数字 + 空格或结尾的模式
if (preg_match('/^(\d{4})(?=\s|$)/', $carModel, $matches)) {
return $matches[1];
// 获取品牌信息
if (empty($brand_id)) {
$brand = Brand::where('id', $brand_id)->where('brand_name', $brand_name)->first();
if (empty($brand)) { //如果没有该车系则创建
$brandService = new \app\admin\service\BrandService();
$brandData = [
'brand_code' => $brandService->generateUniqueCode(),
'brand_name' => $brand_name,
];
$brand = CrudUtil::create(Brand::class, $brandData);
}
$brand_id = $brand->id;
}else{
$brand = Brand::where('id', $brand_id)->first();
}
return null;
if (empty($series_id)) {
$series = BrandSeries::where('brand_id', $brand_id)->where('series_name', $series_name)->first();
if (empty($series)) { //如果没有该车系则创建
$seriesService = new \app\admin\service\BrandSeriesService();
$seriesData = [
'brand_id' => $brand_id,
'series_code' => $seriesService->generateUniqueModificationCode($brand_id),
'series_name' => $series_name,
];
$series = CrudUtil::create(BrandSeries::class, $seriesData);
}
$series_id = $series->id;
}else{
$series = BrandSeries::where('id', $series_id)->first();
}
// 保存车型数据
$createdCount = 0;
$repeat = 0;
$fail = 0;
$msg = '';
foreach ($models as $item) {
$modelData = [
'brand_code' => $brand->brand_code,
'brand_name' => $brand->brand_name,
'series_code' => $series->series_code,
'series_name' => $series->series_name,
'model_name' => $item['title'],
'model_year' => $item['year'],
'msrp' => $item['msrp'],
];
//指纹数组
$fingerprintArr = [
'brand_name' => $modelData['brand_name'],
'series_name' => $modelData['series_name'],
'model_name' => $modelData['model_name'],
'model_year' => $modelData['model_year'],
'msrp' => $modelData['msrp'],
];
// 生成指纹
$modelData['fingerprint'] = Fingerprint::makeFromArray($fingerprintArr);
$modelData['model_code'] = ModelNaming::buildModelCode($modelData);
$modelData['sku_code'] = SkuCodeBuilder::build($modelData);
try {
$model = VehVehicleSku::where('brand_name', $brand->brand_name)->where('series_name', $series->series_name)->where('model_name', $item['title'])->first();
if (!empty($model)) {
$repeat++;
continue;
}
CrudUtil::create(VehVehicleSku::class, $modelData);
$createdCount++;
} catch (\Exception $e) {
// 记录错误但继续处理其他车型
$msg .= "创建车型失败: " . $e->getMessage() . "\n";
Log::error("创建车型失败:" . $msg, ['series_id' => $series_id, 'model' => $item]);
$fail++;
}
}
Log::info('series-crawl-' . $series_id . ' ' . $msg);
$msg = '创建车型成功' . $createdCount . '条';
$msg .= (!empty($repeat)) ? ',重复' . $repeat . '条' : '';
$msg .= (!empty($fail)) ? ',失败' . $fail . '条' : '';
return $msg;
}
/**
* 抓取
*/
public function crawl(Request $request): array
{
$keyword = $request->get('keyword');
if(filter_var($keyword, FILTER_VALIDATE_URL)){
return $this->crawlUrl($request);
}else{
return $this->crawlKeyword($request);
}
}
/**
* 抓取车型数据(通过接口)
* @param Request $request
* @return array
* @throws \Exception
*/
public function crawlUrl(Request $request): array
{
//$url = $request->get('url');
$url = $request->get('keyword');
// 执行抓取
$crawlService = new CrawlService();
//懂车帝提取车系id
$seriesId = $this->get_series_from_url($url); //懂车帝车系id
if (empty($seriesId)) {
throw new \Exception('抓取失败,未找到该系列车型');
}
$brandSerieses = [];
//抓取品牌与系列
$brandSeries = $crawlService->crawlBrandSeries($seriesId);
$brandSerieses[] = ['brand' => $brandSeries['brand'], 'series' => $brandSeries['series']];
$result = [];
try {
foreach ($brandSerieses as $brandSeries) {
$item = [];
// 验证品牌是否存在
$brand = Brand::where('brand_name', $brandSeries['brand'])->first();
// 获取品牌信息
$series = BrandSeries::where('series_name', $brandSeries['series'])->first();
$item['brand'] = ['brand_name' => $brandSeries['brand'], 'exists' => $brand ? 1 : 0];
if ($brand) {
$item['brand']['id'] = $brand['id'];
}
$item['series'] = ['series_name' => $brandSeries['series'], 'exists' => $series ? 1 : 0];
if ($series) {
$item['series']['id'] = $series['id'];
if (empty($item['brand']['id'])) {
$item['brand']['id'] = $series['brand_id'];
}
}
//获取车型数据
$item['model'] = [];
if (count($brandSerieses)<2) {
$item['model'] = $this->crawlSeriesModel($seriesId);
}
$result[] = $item;
}
return ['is_series' => (count($brandSerieses)<2)?1:0, 'data' => $result];
} catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
}
public function get_series_from_url($url) {
// 使用正则表达式匹配 "https://www.dongchedi.com/auto/series/{数字}"
$pattern = '/^https:\/\/www\.dongchedi\.com\/auto\/series\/(\d+)$/';
if (preg_match($pattern, $url, $matches)) {
// 提取数字部分作为 series ID
$series_id = $matches[1];
return $series_id; // 返回系列ID
} else {
throw new \Exception("URL格式不匹配");
}
}
/**
* 获取车系下车型数据
* @return array
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function crawlSeriesModel($seriesId)
{
$client = new Client([
'verify' => false, // 禁用 SSL 验证
'timeout' => 10
]);
try {
$response = $client->get('https://www.dongchedi.com/motor/pc/car/series/car_list', [
'query' => ['series_id' => $seriesId],
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
]
]);
$data = json_decode($response->getBody()->getContents(), true);
if($data['status'] != 0 || (!isset($data['data']) && !isset($data['data']['tab_list']))){
Log::error('抓取车型数据失败' . $data['message'], $data);
throw new \Exception('抓取车型数据失败' . $data['message']);
}
$models = [];
foreach ($data['data']['tab_list'] as $item) {
if($item['tab_text'] == '在售' || $item['tab_text'] == '停售'){
unset($item['data'][0]);
$models = array_merge($models, $item['data']);
}
}
foreach ($models as $key => $model) {
if(!isset($model["info"]['car_name']) || $model['type'] != "1115"){
unset($models[$key]);
continue;
}
$official_price = 0;
if(!isset($model["info"]['official_price'])){
if(str_contains($model["info"]['official_price_str'],'万')){
$official_price = (int)(str_replace("万", "", $model["info"]['official_price_str']) * 10000) ?? 0;
}else{
$official_price = 0;
}
}else{
$official_price = $model["info"]['official_price'] * 10000;
}
$models[$key] = [
'title' => $model["info"]['year'] . '款 ' .$model["info"]['car_name'],
'brand_name' => $model["info"]['brand_name'],
'series_name' => $model["info"]['series_name'],
'year' => $model["info"]['year'],
'msrp' => $official_price,
'car_id' => $model["info"]['id'],
];
}
return array_values($models);
} catch (\Exception $e) {
throw new \Exception($e->getMessage());
}
}
}
+33
View File
@@ -151,4 +151,37 @@ class BrandService
$msg .= (!empty($fail))?',失败' . $fail . '条':'';
return $msg;
}
/**
* 生成唯一的7位颜色编码
* 格式:C + (O/I) + 5位字母数字组合
*
* @param int $type 类型
* @return string 7位唯一编码
*/
public function generateUniqueCode()
{
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$maxAttempts = 10; // 防止无限循环
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
// 生成后5位随机字符
$suffix = '';
for ($i = 0; $i < 8; $i++) {
$suffix .= $chars[rand(0, strlen($chars) - 1)];
}
$code = $suffix;
// 检查是否已存在
$existing = Brand::where('brand_code', $code)->first();
if (!$existing) {
return $code;
}
}
// 如果1000次尝试后仍然重复,使用时间戳确保唯一性
$timestamp = substr(time(), -6);
return $suffix . $timestamp;
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace app\admin\service;
use app\admin\validate\SysConfigValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\SysConfig;
use support\Redis;
use support\Request;
class SysConfigService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = SysConfig::class;
/**
* 验证器
*/
protected $validator = SysConfigValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/**
* 构建查询条件
*/
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'index':
// ====== ====== 排序 ====== ======
$config['order']['id'] = 'desc';
// ====== ====== 筛选 ====== ======
// 状态
if (isset($p['status']) && in_array($p['status'], [0,1])) {
$config['where']['status'] = $p['status'];
}
// 关键字
if (isset($p['keyword']) && $p['keyword']) {
$k = '%' . $p['keyword'] . '%';
foreach (['config_key','config_value','description'] as $col) {
$config['orWhere'][] = [$col, 'like', $k];
}
}
break;
case 'show':
$config['where']['id'] = $p['id'];
//$config['where']['status'] = 1;
break;
case 'store':
break;
case 'update':
$config['where']['id'] = $p['id'];
break;
case 'destroy':
$config['where']['id'] = $p['id'];
break;
}
return $config;
}
public function beforeServiceUpdate($data, Request $request)
{
Redis::del($data['config_key']);
}
}
+2
View File
@@ -100,6 +100,8 @@ class UserService
$config['with']['roles'][] = 'name';
$config['with']['systems'][] = 'id';
$config['with']['systems'][] = 'name';
$config['with']['reportToInfo'][] = 'id';
$config['with']['reportToInfo'][] = 'name';
break;
case 'show':
$config['where']['id'] = $p['id'];
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace app\admin\validate;
use think\Validate;
class SysConfigValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace app\command;
use app\common\model\User;
use app\common\model\UserSn;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
use app\common\utils\email\Emails;
/**
* 批量处理用户编号(也用于调试)
*/
class FinanceRecoCommand extends Command
{
protected static $defaultName = 'finance-reco';
protected static $defaultDescription = '';
protected function execute(InputInterface $input, OutputInterface $output): int
{
// $tmp = (new \app\common\service\OssService)->generatePrivateUrl('fdesk_test/20251104/20251104_财务.xlsx');;
$service = new \app\common\service\FinanceReconDubaiFilesService();
$result = $service->dImport('fdesk_test/20251104/20251104_迪拜.xlsx');
return self::SUCCESS;
}
}
@@ -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 . '>');
}
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace app\command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
use support\Redis;
use support\exception\BusinessException;
use Webman\RedisQueue\Redis as RedisQueueRedis;
/**
* 轮询分配 inquiry_log 里的询盘给业务员,并推送到 CRM 队列
*/
class InquiryAssignInquiryCommand extends Command
{
protected static $defaultName = 'iq-assign';
protected static $defaultDescription = '轮询分配询盘给业务员,并推送到 CRM 队列';
/**
* 参与轮询的业务员 ID 列表(请按你想要的顺序填写)
* 比如:销售1=186,销售2=233...
*/
protected const SALES_USER_IDS = [
63,
64,
67,
93,
108,
132,
143,
145,
157,
160,
168,
172,
173,
191,
193,
194,
195,
196,
197
];
/**
* 轮询分配的分组 key(同一分组共用一个轮询指针)
*/
protected const ROUND_ROBIN_GROUP_KEY = 'fb_inquiry';
/**
* 每次脚本最多分配多少条
*/
protected const BATCH_LIMIT = 300;
protected function execute(InputInterface $input, OutputInterface $output): int
{
// 业务员列表检查
$userIds = array_values(array_filter(
array_map('intval', static::SALES_USER_IDS),
fn($v) => $v > 0
));
if (empty($userIds)) {
throw new BusinessException('SALES_USER_IDS 业务员列表不能为空,请先在命令中配置.');
}
// 取出“待分配”的询盘(你可以根据自己系统调整条件)
// 当前策略:status = 0 且 recipient = 0 的视为未分配
$inquiries = Db::table('inquiry_log')
->where('enter_user_id', 186)
->where('channel', 10)
->where('status', 0)
->where('recipient', 0)
->where('id', '>', 34847)
->orderBy('id')
->limit(static::BATCH_LIMIT)
->get();
if ($inquiries->isEmpty()) {
$output->writeln('<comment>没有需要分配的询盘(status=0 且 recipient=0)</comment>');
return self::SUCCESS;
}
$now = time();
$count = 0;
foreach ($inquiries as $row) {
$inquiryId = (int)$row->id;
sleep(1);
// 轮询取出一个业务员 ID
$recipientUserId = $this->nextRecipientId($userIds, static::ROUND_ROBIN_GROUP_KEY);
// 对每一条记录单独事务,避免单条异常拖垮全部
Db::transaction(function () use ($inquiryId, $recipientUserId, $now, $output, &$count) {
// 1)更新 inquiry_log
$affected = Db::table('inquiry_log')
->where('id', $inquiryId)
->update([
'recipient' => $recipientUserId,
'status' => 2,
'r_time' => $now,
't_time' => $now,
'updated_at' => $now,
]);
if ($affected === 0) {
// 被其他进程抢先更新也有可能,这里只打印提示不中断整个批次
$output->writeln("<comment>询盘 {$inquiryId} 未更新(可能已被处理)</comment>");
return;
}
// 2)构造队列 payload(后续 CRM 消费时至少知道 ID)
$res = [
'id' => $inquiryId,
'recipient' => $recipientUserId,
'dispatched_at' => $now,
'source' => 'fb_leads_inquiry',
];
// 3)推送到 CRM 队列
RedisQueueRedis::send('inquiry-to-crm-new', $res);
$count++;
$output->writeln("<info>已分配询盘 {$inquiryId} 给业务员 {$recipientUserId},并推送到队列</info>");
});
}
$output->writeln("<info>本次共分配成功 {$count} 条询盘</info>");
return self::SUCCESS;
}
/**
* 轮询获取“下一位业务员 ID”
*
* @param int[] $userIds
* @param string $groupKey
* @return int
* @throws BusinessException
*/
protected function nextRecipientId(array $userIds, string $groupKey = 'default'): int
{
$userIds = array_values(array_filter(array_map('intval', $userIds), fn($v) => $v > 0));
if (empty($userIds)) {
throw new BusinessException('轮询分配失败:业务员列表为空');
}
$count = count($userIds);
$key = 'inquiry:assign:round_robin:' . $groupKey;
// Redis INCR 是原子操作,适合并发下使用
$val = Redis::incr($key); // 第一次会从 0 变成 1
// 换算为从 0 开始的下标
$index = ($val - 1) % $count;
return $userIds[$index];
}
}
+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;
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace app\command;
use app\inquiry\utils\PhoneCountryHelper;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
/**
* 将 fb_leads 数据清洗后导入 inquiry_log
*/
class InquiryFbLeadCommand extends Command
{
protected static $defaultName = 'iq-fb-to';
protected static $defaultDescription = '将 fb_leads 数据清洗后导入 inquiry_log';
// 这些常量根据你实际业务改
protected const DEFAULT_ENTER_USER_ID = 186;
protected const DEFAULT_CHANNEL = 10;
protected function execute(InputInterface $input, OutputInterface $output): int
{
// 只取未同步的记录
$leads = Db::table('fb_leads')
->where('sync_status', 0)
->get();
if ($leads->isEmpty()) {
$output->writeln('<comment>没有需要同步的 fb_leads(sync_status = 0)</comment>');
return self::SUCCESS;
}
$now = time();
Db::transaction(function () use ($leads, $now, $output) {
$insert = [];
$ids = [];
foreach ($leads as $lead) {
$leadArr = (array)$lead;
$insert[] = $this->mapLeadToInquiry($leadArr, $now);
// 假设 fb_leads 的主键字段名为 id
$ids[] = $leadArr['id'];
}
if ($insert) {
// 1) 写入 inquiry_log
Db::table('inquiry_log')->insert($insert);
// 2) 回写 fb_leads 的同步状态
Db::table('fb_leads')
->whereIn('id', $ids)
->update([
'sync_status' => 1,
'updated_at' => $now,
]);
$output->writeln('<info>已导入 ' . count($insert) . ' 条数据到 inquiry_log,并更新 fb_leads.sync_status = 1</info>');
}
});
return self::SUCCESS;
}
/**
* 将一条 fb_leads 记录映射为 inquiry_log 记录
*/
protected function mapLeadToInquiry(array $lead, int $now): array
{
// 联系方式
$email = (string)($lead['email'] ?? '');
$tel = (string)($lead['phone'] ?? '');
$wa = $tel; // 先用电话号码当 WhatsApp
// 根据电话或 WhatsApp 区号识别国家
$countryId = PhoneCountryHelper::detectCountryId($tel ?: $wa);
// 所有默认值按你的要求设置
$data = [
'order_no' => $this->orderNum(),
'enter_user_id' => static::DEFAULT_ENTER_USER_ID,
'recipient' => 0,
'quality' => 2,
'channel' => static::DEFAULT_CHANNEL,
'email' => $email,
'tel' => $tel,
'whatsapp' => $wa,
'country' => $countryId ?: 0,
'contacts' => (string)($lead['full_name'] ?? ''),
'content' => $this->buildContentHtml($lead),
'status' => 0,
'r_time' => 0,
't_time' => 0,
'sync_crm' => 0,
'created_at' => $now,
'updated_at' => $now,
];
return $data;
}
/**
* 生成订单号
*/
protected function orderNum(): string
{
return 'SN' . date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
}
/**
* 根据 fb_leads 的字段拼出富文本内容
*/
protected function buildContentHtml(array $lead): string
{
$e = function ($v) {
return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
};
$lines = [];
if (!empty($lead['car_model'])) {
$lines[] = '<p><strong>意向车型:</strong>' . $e($lead['car_model']) . '</p>';
}
if (!empty($lead['car_quantity']) || !empty($lead['car_quantity_text'])) {
$qty = $lead['car_quantity'] ?? $lead['car_quantity_text'] ?? '';
$lines[] = '<p><strong>意向数量:</strong>' . $e($qty) . '</p>';
}
if (!empty($lead['favorite_color'])) {
$lines[] = '<p><strong>意向颜色:</strong>' . $e($lead['favorite_color']) . '</p>';
}
if (!empty($lead['usage_type'])) {
$lines[] = '<p><strong>用途:</strong>' . $e($lead['usage_type']) . '</p>';
}
if (!empty($lead['seat_preference'])) {
$lines[] = '<p><strong>座位数偏好:</strong>' . $e($lead['seat_preference']) . '</p>';
}
$lines[] = '<hr />';
$lines[] = '<p><strong>广告与表单信息:</strong></p>';
$lines[] = '<p>平台:' . $e($lead['platform'] ?? '') . '</p>';
$lines[] = '<p>Campaign:' . $e($lead['campaign_name'] ?? '') . ' (#' . $e($lead['campaign_id'] ?? '') . ')</p>';
$lines[] = '<p>Adset:' . $e($lead['adset_name'] ?? '') . ' (#' . $e($lead['adset_id'] ?? '') . ')</p>';
$lines[] = '<p>Ad:' . $e($lead['ad_name'] ?? '') . ' (#' . $e($lead['ad_id'] ?? '') . ')</p>';
$lines[] = '<p>Form:' . $e($lead['form_name'] ?? '') . ' (#' . $e($lead['form_id'] ?? '') . ')</p>';
$lines[] = '<hr />';
$lines[] = '<p><strong>Meta 信息:</strong></p>';
$lines[] = '<p>Lead ID:' . $e($lead['fb_lead_id'] ?? '') . '</p>';
$lines[] = '<p>FB 创建时间原始值:' . $e($lead['fb_created_time'] ?? '') . '</p>';
if (!empty($lead['inbox_url'])) {
$url = $e($lead['inbox_url']);
$lines[] = '<p>Inbox 链接:<a href="' . $url . '" target="_blank">' . $url . '</a></p>';
}
return implode("\n", $lines);
}
}
+259
View File
@@ -0,0 +1,259 @@
<?php
namespace app\command;
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;
use PhpOffice\PhpSpreadsheet\IOFactory;
use app\inquiry\utils\FbLeadHeaderMapper;
use support\exception\BusinessException;
/**
* 从Excel文件导入fb_leads
*/
class InquiryImportFbCommand extends Command
{
protected static $defaultName = 'iq-fb';
protected static $defaultDescription = '导入 Facebook Leads Excel 数据';
protected function configure(): void
{
$this
->addOption(
'file',
null,
InputOption::VALUE_OPTIONAL,
'要导入的单个 Excel 文件名(相对 public/excel 的路径,或绝对路径)'
)
->addOption(
'dir',
null,
InputOption::VALUE_OPTIONAL,
'Excel 目录(相对 public/ 的路径)',
'excel' // 默认就是 public/excel
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$fileOption = $input->getOption('file');
$dirOption = $input->getOption('dir') ?? 'excel';
// 基准目录:public/{$dirOption}
$baseDir = rtrim(public_path($dirOption), DIRECTORY_SEPARATOR);
if ($fileOption) {
// 如果传的是绝对路径,直接用;否则认为是相对 baseDir 的文件名
if ($this->isAbsolutePath($fileOption)) {
$filePath = $fileOption;
} else {
$filePath = $baseDir . DIRECTORY_SEPARATOR . $fileOption;
}
$output->writeln("<info>开始导入单个文件:</info> {$filePath}");
$this->importOneFile($filePath, $output);
} else {
// 不指定 file 时,默认导入目录下所有 .xlsx
if (!is_dir($baseDir)) {
throw new BusinessException("目录不存在:{$baseDir}");
}
$pattern = $baseDir . DIRECTORY_SEPARATOR . '*.xlsx';
$files = glob($pattern);
if (empty($files)) {
$output->writeln("<comment>目录 {$baseDir} 下没有找到 .xlsx 文件</comment>");
return self::SUCCESS;
}
$output->writeln("<info>开始批量导入目录:</info> {$baseDir}");
foreach ($files as $filePath) {
$output->writeln(" -> 文件:{$filePath}");
try {
$this->importOneFile($filePath, $output);
$output->writeln(" <info>导入成功</info>");
} catch (\Throwable $e) {
// 某个文件失败,不影响后面的文件
$output->writeln(" <error>导入失败:{$e->getMessage()}</error>");
}
}
}
return self::SUCCESS;
}
/**
* 导入单个 Excel 文件
*/
protected function importOneFile(string $filePath, OutputInterface $output): void
{
if (!is_file($filePath)) {
throw new BusinessException("文件不存在:{$filePath}");
}
$spreadsheet = IOFactory::load($filePath);
$sheet = $spreadsheet->getActiveSheet();
$rows = $sheet->toArray(null, true, true, true);
if (empty($rows)) {
throw new BusinessException('Excel 内容为空:' . $filePath);
}
// 取第一行表头
$headerRow = array_shift($rows);
// 表头映射器
$mapper = new FbLeadHeaderMapper();
// 如果有未知表头,这里会直接抛异常中断当前文件
$colToField = $mapper->buildColumnFieldMap($headerRow);
$insertData = [];
$now = time();
foreach ($rows as $row) {
$item = [];
foreach ($colToField as $colIndex => $field) {
$raw = $row[$colIndex] ?? null;
switch ($field) {
// 1) is_organic: Excel 里是 true/false,这里转成 0/1
case 'is_organic':
$item['is_organic'] = $this->normalizeIsOrganic($raw);
break;
// 2) fb_created_time: 统一转成 Y-m-d H:i:s
case 'fb_created_time':
$item['fb_created_time'] = $this->normalizeFbCreatedTime($raw);
break;
default:
if (is_string($raw)) {
$raw = trim($raw);
}
$item[$field] = $raw;
break;
}
}
// 补充公共字段
$item['source_file'] = basename($filePath);
$item['created_at'] = $now;
$item['updated_at'] = $now;
// 整行都空就跳过
$allNull = true;
foreach ($item as $v) {
if ($v !== null && $v !== '') {
$allNull = false;
break;
}
}
if ($allNull) {
continue;
}
$insertData[] = $item;
}
if ($insertData) {
Db::table('fb_leads')->insert($insertData);
} else {
$output->writeln(" <comment>文件 {$filePath} 没有有效数据行</comment>");
}
}
/**
* 将 Excel 里的 is_organic 值转换为 0/1
*/
protected function normalizeIsOrganic($value): int
{
if ($value === null) {
return 0;
}
$v = strtolower(trim((string)$value));
// 认为这些是 “true”
$trueValues = ['1', 'true', 'yes', 'y'];
return in_array($v, $trueValues, true) ? 1 : 0;
}
/**
* 统一处理 fb_created_time
* Excel 目前是类似 '12/8/25',这里尽量转成 'Y-m-d H:i:s'
* 解析失败时返回 null
*/
protected function normalizeFbCreatedTime($value): ?string
{
if ($value === null || $value === '') {
return null;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
$v = trim((string)$value);
$formats = [
'Y-m-d H:i:s',
'Y-m-d',
'n/j/y', // 12/8/25
'n/j/Y',
'd/m/Y',
'd.m.Y',
];
foreach ($formats as $fmt) {
$dt = \DateTime::createFromFormat($fmt, $v);
if ($dt !== false) {
return $dt->format('Y-m-d H:i:s');
}
}
// 纯数字当时间戳试一下
if (is_numeric($v) && strlen($v) >= 9 && strlen($v) <= 11) {
$ts = (int)$v;
if ($ts > 0) {
return date('Y-m-d H:i:s', $ts);
}
}
return null;
}
/**
* 判断是否为绝对路径(简单兼容 Windows / Linux)
*/
protected function isAbsolutePath(string $path): bool
{
// Windows: X:\ 或 \\server\share
if (preg_match('/^[a-zA-Z]:\\\\/', $path) || str_starts_with($path, '\\\\')) {
return true;
}
// *nix: / 开头
return str_starts_with($path, '/');
}
}
/*
php webman tb-db
# 等价于 php webman tb-db --dir=excel
# 会遍历 public/excel/*.xlsx 逐个导入
# 相对路径(相对 public/excel)
php webman tb-db --file="BYD tang_Leads_2025-12-04_2025-12-09.xlsx"
# 或给绝对路径
php webman tb-db --file="/www/wwwroot/xxx/public/excel/BYD tang_Leads_2025-12-04_2025-12-09.xlsx"
*/
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace app\command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
use Webman\RedisQueue\Redis as RedisQueueRedis;
/**
* 询盘补发
*/
class InquiryReissueCommand extends Command
{
protected static $defaultName = 'iq-bf';
protected static $defaultDescription = '';
protected function execute(InputInterface $input, OutputInterface $output): int
{
$inquiries = Db::table('inquiry_log')
->where('sync_crm', 0)
->where('id', '>', 33785)
->orderBy('id')
->get();
foreach ($inquiries as $row) {
$inquiryId = (int)$row->id;
var_dump('补发ID:'.$inquiryId);
sleep(1);
$res = [
'id' => $inquiryId
];
RedisQueueRedis::send('inquiry-to-crm-new', $res);
}
return self::SUCCESS;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace app\command;
use app\common\model\User;
use app\common\model\UserSn;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
use app\common\utils\email\Emails;
/**
* 批量处理用户编号(也用于调试)
*/
class VehMoneyCommand extends Command
{
protected static $defaultName = 'veh-money';
protected static $defaultDescription = '';
protected function execute(InputInterface $input, OutputInterface $output): int
{
return self::SUCCESS;
}
}
@@ -48,7 +48,7 @@ class BrandSeriesController extends BaseController
}
/**
* 异步抓取系列车型
* 抓取系列车型
* @param Request $request
* @return \support\Response
*/
@@ -58,7 +58,7 @@ class BrandSeriesController extends BaseController
if($result){
return $this->success($result,'抓取成功');
}else{
return $this->error('抓取任务提交失败');
return $this->error('抓取任务失败');
}
} catch (\Exception $e) {
return $this->error($e->getMessage());
@@ -66,7 +66,7 @@ class BrandSeriesController extends BaseController
}
/**
* 异步抓取系列车型
* 保存抓取的系列车型
* @param Request $request
* @return \support\Response
*/
@@ -82,4 +82,76 @@ class BrandSeriesController extends BaseController
return $this->error($e->getMessage());
}
}
/**
* 关键词抓取品牌、车系、车型
* @param Request $request
* @return \support\Response
*/
public function crawlKeyword(Request $request){
try {
$result = $this->service->crawlKeyword($request);
if($result){
return $this->success($result,'抓取成功');
}else{
return $this->error('抓取任务失败');
}
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* URL抓取品牌、车系、车型
* @param Request $request
* @return \support\Response
*/
public function crawlUrl(Request $request){
try {
$result = $this->service->crawlUrl($request);
if($result){
return $this->success($result,'抓取成功');
}else{
return $this->error('抓取任务失败');
}
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 抓取品牌、车系、车型
* @param Request $request
* @return \support\Response
*/
public function crawl(Request $request){
try {
$result = $this->service->crawl($request);
if($result){
return $this->success($result,'抓取成功');
}else{
return $this->error('抓取任务失败');
}
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 保存抓取的系列车型
* @param Request $request
* @return \support\Response
*/
public function crawlKeywordCreate(Request $request){
try {
$result = $this->service->crawlKeywordCreate($request);
if($result){
return $this->success($result,'成功');
}else{
return $this->error('创建失败');
}
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
}
@@ -0,0 +1,181 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\model\Country;
use app\common\model\FinPaymentStock;
use app\common\model\StockOrders;
use app\common\model\SysAccount;
use app\common\model\SysCurrency;
use app\common\model\SysDictItem;
use app\common\model\User;
use app\common\service\OssService;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\FinPaymentApplyService;
class FinPaymentApplyController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new FinPaymentApplyService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
protected function indexFormat($data)
{
if (isset($data['doc_user_id'])) //内勤
$data['doc_user_name'] = User::where('id', $data['doc_user_id'])->value('name')??'';
if (isset($data['sales_user_id'])) //业务员
$data['sales_user_name'] = User::where('id', $data['doc_user_id'])->value('name')??'';
if (isset($data['corp_id'])) //公司(渠道)
$data['corp_name'] = SysDictItem::where('id', $data['corp_id'])->value('item_label')??'';
if (isset($data['account_id'])){ //收款账号
$data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??'';
$data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??'';
}
if (isset($data['country_id'])) //付款来源国家
$data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??'';
if (isset($data['currency_id'])) //付款币种
$data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??'';
$OssService = new OssService();
if (!empty($data['submit_files'])){
$data['submit_files'] = json_decode($data['submit_files'], true);
foreach ($data['submit_files'] as $key => $value) {
$data['submit_files'][$key] = [
'file_src' => $value['file_src'],
'file_full' => $OssService->generatePrivateUrl($value['file_src']),
'file_name' => $value['file_name'],
];
}
}else{
$data['submit_files'] = [];
}
if (!empty($data['voucher_file'])){
$data['voucher_file'] = json_decode($data['voucher_file'], true);
foreach ($data['voucher_file'] as $key => $value) {
$data['voucher_file'][$key] = [
'file_src' => $value['file_src'],
'file_full' => $OssService->generatePrivateUrl($value['file_src']),
'file_name' => $value['file_name'],
];
}
}else{
$data['voucher_file'] = [];
}
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
if (isset($data['claimed_at'])){
$data['enter_file_last'] = date('Y-m-d H:i:s', ($data['claimed_at']+86400)); //入账资料最后提交时间
$data['claimed_at'] = date('Y-m-d H:i:s', $data['claimed_at']); //认领时间
}
if (isset($data['id'])){
$finPaymentStock = FinPaymentStock::where('payment_apply_id', $data['id'])->get();
$stock = [];
$stock_order_no = [];
foreach ($finPaymentStock as $v) {
$stock[$v['pi_no']]['pi_no'] = $v['pi_no'];
$stock[$v['pi_no']]['stock_id'][] = [$v['stock_id']];
$stockOrders = StockOrders::where('id', $v['stock_id'])->first();
$stock_order_no[] = $stockOrders['order_no'];
}
$data['stock'] = array_values($stock);
$data['stock_order_no'] = $stock_order_no;
}
return $data;
}
/**
* 数据格式化
*
* @param array $data
* @return array
*/
public function showFormat($data)
{
if (isset($data['doc_user_id'])) //内勤
$data['doc_user_name'] = User::where('id', $data['doc_user_id'])->value('name')??'';
if (isset($data['sales_user_id'])) //业务员
$data['sales_user_name'] = User::where('id', $data['doc_user_id'])->value('name')??'';
if (isset($data['corp_id'])) //公司(渠道)
$data['corp_name'] = SysDictItem::where('id', $data['corp_id'])->value('item_label')??'';
if (isset($data['account_id'])){ //收款账号
$data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??'';
$data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??'';
}
if (isset($data['country_id'])) //付款来源国家
$data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??'';
if (isset($data['currency_id'])) //付款币种
$data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??'';
$OssService = new OssService();
if (!empty($data['submit_files'])){
$data['submit_files'] = json_decode($data['submit_files'], true);
foreach ($data['submit_files'] as $key => $value) {
$data['submit_files'][$key] = [
'file_src' => $value['file_src'],
'file_full' => $OssService->generatePrivateUrl($value['file_src']),
'file_name' => $value['file_name'],
];
}
}else{
$data['submit_files'] = [];
}
if (!empty($data['voucher_file'])){
$data['voucher_file'] = json_decode($data['voucher_file'], true);
foreach ($data['voucher_file'] as $key => $value) {
$data['voucher_file'][$key] = [
'file_src' => $value['file_src'],
'file_full' => $OssService->generatePrivateUrl($value['file_src']),
'file_name' => $value['file_name'],
];
}
}else{
$data['voucher_file'] = [];
}
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
$finPaymentStock = FinPaymentStock::where('payment_apply_id', $data['id'])->get();
$stock = [];
foreach ($finPaymentStock as $v) {
$stock[$v['pi_no']]['pi_no'] = $v['pi_no'];
$stock[$v['pi_no']]['stock_id'][] = [$v['stock_id']];
}
$data['stock'] = array_values($stock);
return $data;
}
/**
* 作废
* @param Request $request
* @return \support\Response
*/
public function delete(Request $request)
{
try {
$id = $request->input('id');
$raw = $this->service->delete($id);
return $this->success($raw);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
}
@@ -0,0 +1,195 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\model\Country;
use app\common\model\FinPaymentApply;
use app\common\model\FinPaymentStock;
use app\common\model\SysAccount;
use app\common\model\SysCurrency;
use app\common\model\SysDictItem;
use app\common\model\User;
use app\common\service\OssService;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\FinReceiptService;
use support\Redis;
use support\Request;
class FinReceiptController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new FinReceiptService();
}
/**
* 确认入账
* @param Request $request
* @return \support\Response
*/
public function credit(Request $request)
{
try {
$id = $request->input('id');
$exchange_rate = $request->input('exchange_rate');
if(empty($exchange_rate) || !is_numeric($exchange_rate)){
return $this->error('请输入汇率');
}
$raw = $this->service->credit($id,$exchange_rate);
return $this->success($raw);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
public function show(Request $request)
{
try {
$raw = $this->service->serviceShow($request);
$data = $this->transformData($raw, method_exists($this, 'showFormat')
? [$this, 'showFormat']
: null);
return $this->success($data);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 更新
*
* @param Request $request 请求对象
* @return mixed 返回更新成功的数据或信息
*/
public function payment(Request $request)
{
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'update');
$res = $this->service->serviceUpdatePayment($request, $data);
return $this->success([], '更新成功');
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
protected function indexFormat($data)
{
if (isset($data['created_at'])){
$data['claim_timeout'] = (($data['created_at'] < (time()-86400)) && empty($data['payment_apply_id'])) ?1:0;
$data['claim_timeout_time'] = date('Y-m-d H:i:s', $data['created_at']+86400);
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
}
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
if (isset($data['arrived_at']))
$data['arrived_at'] = date('Y-m-d H:i:s', $data['arrived_at']);
if (isset($data['received_at'])){
$data['received_at'] = date('Y-m-d H:i:s', $data['received_at']);
$voucher_no_timeout_hour = redis::get('voucher_no_timeout_hour');
if(!empty($voucher_no_timeout_hour)){
$data['voucher_no_timeout_time'] = date('Y-m-d H:i:s', time()+(60*60*$voucher_no_timeout_hour));
}
}
if (isset($data['account_id'])) { //收款账号
$data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??'';
$data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??'';
}
if (isset($data['country_id'])) //付款来源国家
$data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??'';
if (isset($data['currency_id'])) //付款币种
$data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??'';
$OssService = new OssService();
if (!empty($data['fx_receipt_file'])){
$data['fx_receipt_file'] = json_decode($data['fx_receipt_file'], true);
foreach ($data['fx_receipt_file'] as $key => $value) {
$data['fx_receipt_file'][$key] = [
'file_src' => $value['file_src'],
'file_full' => $OssService->generatePrivateUrl($value['file_src']),
'file_name' => $value['file_name'],
];
}
}else{
$data['fx_receipt_file'] = [];
}
return $data;
}
/**
* 数据格式化
*
* @param array $data
* @return array
*/
protected function showFormat($data)
{
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
if (isset($data['arrived_at']))
$data['arrived_at'] = date('Y-m-d H:i:s', $data['arrived_at']);
if (isset($data['received_at']))
$data['received_at'] = date('Y-m-d H:i:s', $data['received_at']);
if (isset($data['account_id'])) { //收款账号
$data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??'';
$data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??'';
}
if (isset($data['country_id'])) //付款来源国家
$data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??'';
if (isset($data['currency_id'])) //付款币种
$data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??'';
$OssService = new OssService();
if (!empty($data['fx_receipt_file'])){
$data['fx_receipt_file'] = json_decode($data['fx_receipt_file'], true);
foreach ($data['fx_receipt_file'] as $key => $value) {
$data['fx_receipt_file'][$key] = [
'file_src' => $value['file_src'],
'file_full' => $OssService->generatePrivateUrl($value['file_src']),
'file_name' => $value['file_name'],
];
}
}else{
$data['fx_receipt_file'] = [];
}
//查询关联的付款记录
if (!empty($data['payment_apply_id'])) {
$paymentApply = FinPaymentApply::where('id', $data['payment_apply_id'])->first();
if(!$paymentApply)
return $data;
$paymentApply = $paymentApply->toArray();
$paymentApply['created_at'] = strtotime($paymentApply['created_at']);
$paymentApply['updated_at'] = strtotime($paymentApply['updated_at']);
$paymentApply = (new FinPaymentApplyController())->showFormat($paymentApply);
$data['payment_apply'] = $paymentApply;
}
return $data;
}
/**
* 收款记录统计
* @param Request $request
* @return \support\Response
*/
public function statistics(Request $request)
{
try {
$raw = $this->service->statistics();
return $this->success($raw);
} catch (\Throwable $e) {
return $this->error($e->getMessage());
}
}
}
@@ -0,0 +1,98 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\model\Country;
use app\common\model\SysAccount;
use app\common\model\SysCurrency;
use app\common\model\User;
use app\common\service\OssService;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\FinReceiptHisService;
use support\Request;
class FinReceiptHisController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new FinReceiptHisService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
protected function indexFormat($data)
{
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
if (isset($data['arrived_at']))
$data['arrived_at'] = date('Y-m-d H:i:s', $data['arrived_at']);
if (isset($data['received_at']))
$data['received_at'] = date('Y-m-d H:i:s', $data['received_at']);
if (isset($data['account_id'])) { //收款账号
$data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??'';
$data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??'';
}
if (isset($data['country_id'])) //付款来源国家
$data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??'';
if (isset($data['currency_id'])) //付款币种
$data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??'';
if (!empty($data['fx_receipt_file'])){
$data['fx_receipt_file_full'] = (new OssService)->generatePrivateUrl($data['fx_receipt_file']);
}elseif (isset($data['fx_receipt_file'])){
$data['fx_receipt_file_full'] = '';
}
if (isset($data['created_by']))
$data['created_by'] = User::where('id', $data['created_by'])->value('name')??'';
if (isset($data['updated_by']))
$data['updated_by'] = User::where('id', $data['updated_by'])->value('name')??'';
return $data;
}
/**
* 数据格式化
*
* @param array $data
* @return array
*/
protected function showFormat($data)
{
if (isset($data['created_at']))
$data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
if (isset($data['updated_at']))
$data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
if (isset($data['arrived_at']))
$data['arrived_at'] = date('Y-m-d H:i:s', $data['arrived_at']);
if (isset($data['received_at']))
$data['received_at'] = date('Y-m-d H:i:s', $data['received_at']);
if (isset($data['account_id'])){ //收款账号
$data['account_name'] = SysAccount::where('id', $data['account_id'])->value('account_name')??'';
$data['account_short_desc'] = SysAccount::where('id', $data['account_id'])->value('short_desc')??'';
}
if (isset($data['country_id'])) //付款来源国家
$data['country_name'] = Country::where('id', $data['country_id'])->value('cn_addr')??'';
if (isset($data['currency_id'])) //付款币种
$data['currency_name'] = SysCurrency::where('id', $data['currency_id'])->value('currency_name')??'';
if (!empty($data['fx_receipt_file'])){
$data['fx_receipt_file_full'] = (new OssService)->generatePrivateUrl($data['fx_receipt_file']);
}elseif (isset($data['fx_receipt_file'])){
$data['fx_receipt_file_full'] = '';
}
if (isset($data['created_by']))
$data['created_by'] = User::where('id', $data['created_by'])->value('name')??'';
if (isset($data['updated_by']))
$data['updated_by'] = User::where('id', $data['updated_by'])->value('name')??'';
return $data;
}
}
@@ -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('查询失败,请稍后重试');
}
}
}
+4 -4
View File
@@ -91,8 +91,8 @@ class UploadController
$mimes = $ossservice->getMimeTypes(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'zip', 'rar']);
$result = Storage::adapter('oss')
->path('x_server/' . $sceneUrl . date('Ymd'))
->size(1024*1024*5)
->path(config('oss.oss_main_dir') . $sceneUrl . date('Ymd'))
->size(1024*1024*50)
->extYes($mimes)
->upload($file);
@@ -132,8 +132,8 @@ class UploadController
}
$result = Storage::adapter('oss')
->path('x_server/' . $sceneUrl . date('Ymd'))
->size(1024*1024*5)
->path(config('oss.oss_main_dir') . $sceneUrl . date('Ymd'))
->size(1024*1024*50)
->extYes(['image/jpeg','image/png'])
->extNo(['image/gif'])
->upload($file);
@@ -0,0 +1,33 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\UsedcarCrawlResultsService;
class UsedcarCrawlResultsController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new UsedcarCrawlResultsService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -0,0 +1,33 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\UsedcarCrawlResultsImagesService;
class UsedcarCrawlResultsImagesController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->service = new UsedcarCrawlResultsImagesService();
}
/**
* 列表页数据格式化
*
* @param array $data
* @return array
*/
// protected function indexFormat($data)
// {
// if (isset($data['created_at']))
// $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']);
// if (isset($data['updated_at']))
// $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']);
// return $data;
// }
}
@@ -47,6 +47,7 @@ class VehVehicleSkuController extends BaseController
public function store(Request $request)
{
return $this->error('请优先抓取车辆信息,如抓取不到相应车型请联系管理员,暂停手动录入车辆信息');
try {
$data = $this->validate($this->service->getValidator(), $request->post(), 'store');
$res = $this->service->serviceStore($request, $data);
+26 -5
View File
@@ -172,7 +172,7 @@ class AuthLogic
$payload = $this->buildJwtPayload($user, $system);
$token = $this->generateAccessToken($payload);
$refreshToken = $this->generateRefreshToken($user->id, $data['system_code']);
$homeUrl = $this->getHome($system->id);
$homeUrl = $this->getHome($system->id, $payload);
// 记录 token 到 Redis 和 MySQL
$this->recordUserToken($user->id, $token, $data['system_code']);
@@ -537,11 +537,32 @@ class AuthLogic
}
public function getHome($systemId)
public function getHome($systemId, $data)
{
$info = Resource::where('system_id', $systemId)
->where('is_home', 1)
->first();
// // 2025.10.30 临时处理 ====== start
// if ($systemId == 1) {
// if (in_array(1, $data['role_ids'])) {
// return '/dashboard/console';
// }
// if (count($data['role_ids']) == 1 && in_array(5, $data['role_ids'])) {
// return '/dashboard/console/backoffice';
// }
// if (count($data['role_ids']) == 1 && in_array(6, $data['role_ids'])) {
// return '/dashboard/console/finance';
// }
// }
// // 2025.10.30 临时处理 ====== end
//
// $info = Resource::where('system_id', $systemId)
// ->where('is_home', 1)
// ->first();
$resources_id = (new UserDashboardRuleLogic)->getUserDashboard($data['uid']);
if(!empty($resources_id)){
$info = Resource::where('system_id', $systemId)
->where('id', $resources_id)
->first();
}
if (!$info) {
return '/dashboard/console';
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\FinPaymentApply;
class FinPaymentApplyLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = FinPaymentApply::class;
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\FinReceiptHis;
class FinReceiptHisLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = FinReceiptHis::class;
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\FinReceipt;
class FinReceiptLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = FinReceipt::class;
public function credit($id, $data)
{
return FinReceipt::where('id', $id)->update($data);
}
}
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\FinanceReconDubaiFiles;
class FinanceReconDubaiFilesLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = FinanceReconDubaiFiles::class;
}
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\InquiryTransmitConfig;
class InquiryTransmitConfigLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = InquiryTransmitConfig::class;
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\InquiryTransmit;
class InquiryTransmitLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = InquiryTransmit::class;
}
+7
View File
@@ -23,6 +23,13 @@ class PermLogic
}
$all = collect();
//获取仪表盘
$resources_id = (new UserDashboardRuleLogic)->getUserDashboard($userId);
if(!empty($resources_id)){
$resources = Resource::where('id', $resources_id)->get();
$all = $all->merge($resources);
}
// 遍历用户的所有角色,合并每个角色所拥有的资源
foreach ($user->roles as $role) {
$all = $all->merge(
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace app\common\logic;
use app\common\model\SysConfig;
use app\common\model\SysCurrency;
use app\common\traits\crud\CrudLogicTrait;
class SysConfigLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = SysCurrency::class;
public function findByKey(string $key)
{
return SysConfig::where('status', 1)->where('config_key', $key)->get(['id', 'config_key', 'config_value']);
}
}
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\UsedcarCrawlResultsImages;
class UsedcarCrawlResultsImagesLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UsedcarCrawlResultsImages::class;
}
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\UsedcarCrawlResults;
class UsedcarCrawlResultsLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UsedcarCrawlResults::class;
}
@@ -0,0 +1,47 @@
<?php
namespace app\common\logic;
use app\common\model\User;
use app\common\model\UserGroup;
use app\common\model\UserGroupMembers;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\UserDashboardRule;
class UserDashboardRuleLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UserDashboardRule::class;
public function getUserDashboard($userId)
{
$user = User::with('reportToInfo')->find($userId);;
//获取用户所在分组
$ugm = UserGroupMembers::where('user_id', $userId)->where('status', 1)->get();
$group_id = isset($ugm->group_id)?$ugm->group_id:0;
$roleRule = UserDashboardRule::orWhere(function ($query) use ($user) {
$query->whereIn('related', $user->roles->pluck('id'))
->where('rule_type', 'role');
})
->orWhere(function ($query) use ($userId) {
$query->where('related', $userId)
->where('rule_type', 'user');
})
->orWhere(function ($query) use ($group_id) {
$query->where('related', $group_id)
->where('rule_type', 'group');
})
->orderBy('weight','desc')
->orderBy('rule_type','desc')
->first();
$resources_id = $roleRule?$roleRule->resources_id:null;
return $resources_id;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class EmailMailbox extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'email_mailbox';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'channel_id', 'corp_id', 'email', 'imap_host', 'imap_port', 'imap_flags', 'mailbox', 'username', 'password', 'status', 'last_uid', 'last_fetch_at', 'remark'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class EmailPull extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'email_pull';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'email', 'uid', 'subject', 'from', 'date', 'body', 'status', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class EmailPullParsed extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'email_pull_parsed';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
// protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'email_id', 'date', 'email', 'name', 'tel', 'company', 'country', 'form_url', 'message', 'ip', 'iplocation', 'ilang', 'mailto', 'navigator_language', 'navigator_platform', 'screen_width', 'screen_height', 'ucountry', 'status', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class EmailPullUids extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'email_pull_uids';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['uid', 'fetched_at', 'status', 'processed_at', 'email'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+76
View File
@@ -0,0 +1,76 @@
<?php
namespace app\common\model;
use Illuminate\Database\Eloquent\SoftDeletes;
use support\Model;
class FinPaymentApply extends Model
{
use SoftDeletes;
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'fin_payment_apply';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'apply_no', 'doc_user_id', 'sales_user_id', 'corp_id', 'account_id', 'payment_account', 'payment_entity', 'entity_type', 'currency_id', 'amount', 'swift', 'remit_bank', 'country_id', 'remitter', 'exchange_rate', 'total_amount_cny', 'voucher_file', 'voucher_name', 'submit_files','receipt_id','claimed_at','warn', 'received_at', 'created_at', 'updated_at', 'deleted_at', 'claimed_by', 'created_by',];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
/**
* 获取对应的收款记录(1-1,可为空)
*/
public function receipt()
{
return $this->hasOne(FinReceipt::class, 'payment_apply_id', 'id');
}
/**
* 关联的备货单(通过中间表)
*/
public function stocks()
{
return $this->belongsToMany(StockOrders::class, 'fin_payment_stock', 'payment_apply_id', 'stock_id');
}
/**
* 查询 submit_files 不为空的记录
*/
public function scopeWithNonEmptyFiles($query)
{
return $query->whereNotNull('submit_files')
->whereRaw('JSON_LENGTH(submit_files) > 0');
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace app\common\model;
use support\Model;
class FinPaymentStock extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'fin_payment_stock';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'pi_no', 'payment_apply_id', 'receipt_id', 'stock_id', 'allocated_amount', 'allocated_amount_cny', 'updated_at', 'created_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
// 付款申请
public function payment_apply()
{
return $this->belongsTo(FinPaymentApply::class, 'payment_apply_id', 'id');
}
// 收款记录
public function receipt()
{
return $this->belongsTo(FinReceipt::class, 'receipt_id', 'id');
}
// 备货单
public function stock()
{
return $this->belongsTo(StockOrders::class, 'stock_id', 'id');
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace app\common\model;
use support\Model;
class FinReceipt extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'fin_receipt';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'receipt_no', 'payment_apply_id', 'country_id', 'remitter', 'account_id', 'currency_id', 'amount', 'discount_amount', 'total_amount_cny', 'exchange_rate', 'arrived_at', 'received_at', 'voucher_no', 'fx_receipt_file', 'fx_receipt_file_name', 'is_domestic_return', 'submit_files', 'created_by', 'updated_by', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
/**
* 对应的付款申请(1-1,可为空)
*/
public function payment()
{
return $this->belongsTo(FinPaymentApply::class, 'payment_apply_id', 'id');
}
/**
* 关联的备货单(通过中间表)
*/
public function stocks()
{
return $this->belongsToMany(StockOrders::class, 'fin_payment_stock', 'receipt_id', 'stock_id');
}
/**
* 关联付款申请
*/
// public function finPaymentApply()
// {
// return $this->hasOne(FinPaymentApply::class, 'receipt_id', 'id');
// }
/**
* 关联付款申请表
*/
public function paymentApply()
{
return $this->belongsTo(FinPaymentApply::class, 'payment_apply_id', 'id');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class FinReceiptHis extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'fin_receipt_his';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'receipt_id', 'receipt_no', 'payment_apply_id', 'country_id', 'remitter', 'account_id', 'currency_id', 'amount', 'discount_amount', 'total_amount_cny', 'exchange_rate', 'arrived_at', 'received_at', 'voucher_no', 'fx_receipt_file', 'is_domestic_return', 'created_by', 'updated_by', 'claimed_by', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class FinanceReconDubaiFiles extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'finance_recon_dubai_files';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'caiwu_file_name', 'caiwu_file_url', 'dubai_file_name', 'dubai_file_url', 'created_by', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class InquiryTransmit extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'inquiry_transmit';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'date', 'email', 'name', 'tel', 'company', 'country', 'domain', 'form_url', 'message', 'ip', 'ip_location', 'ilang', 'mailto', 'navigator_language', 'navigator_platform', 'screen_width', 'screen_height', 'uCountry', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class InquiryTransmitConfig extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'inquiry_transmit_config';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'domain', 'mailto', 'lang', 'status', 'remark', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+8 -1
View File
@@ -45,7 +45,14 @@ class StockOrderItems extends Model
'id', 'order_id', 'product_code', 'sku_id', 'sku_snapshot',
'model_snapshot', 'quantity', 'color_interior_code', 'color_interior_snapshot',
'color_exterior_code', 'color_exterior_snapshot', 'remark', 'vin', 'created_at', 'updated_at',
'mileage', 'unique_id'
'mileage', 'unique_id', 'attachment'
];
/**
* 字段类型转换:attachment 以 JSON 数组存取(写自动 json_encode、读自动 decode)
*/
protected $casts = [
'attachment' => 'array',
];
/**
+16
View File
@@ -107,4 +107,20 @@ class StockOrders extends Model
{
return $this->hasOne(User::class, 'id', 'salesperson_id');
}
/**
* 客户的标签
*/
public function payment()
{
return $this->belongsToMany(FinPaymentApply::class, 'fin_payment_stock', 'stock_id', 'payment_apply_id');
}
/**
* 关联收款记录(多对多,经由中间表)
*/
public function receipts()
{
return $this->belongsToMany(FinReceipt::class, 'fin_payment_stock', 'stock_id', 'receipt_id');
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ class StockOrdersFiles extends Model
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'order_id', 'file_name', 'file_code', 'file_url', 'created_at', 'updated_at'];
protected $fillable = ['id', 'order_id', 'file_name', 'file_code', 'file_url', 'original_name', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class SysConfig extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'sys_config';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'category', 'config_key', 'config_value', 'config_type', 'description', 'status', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class UsedcarCrawlResults extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'usedcar_crawl_results';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'used_car_id', 'car_id', 'url', 'car_id_url', 'fuel_form', 'classify_energy', 'brand_name', 'series_name', 'car_name', 'year', 'title', 'price', 'new_price', 'price_difference', 'usage_mileage', 'condition_desc', 'images', 'used_detail', 'config_detail', 'format_shop_info', 'format_car_info', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class UsedcarCrawlResultsImages extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'usedcar_crawl_results_images';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'uc_id', 'used_car_id', 'original_name', 'file_path', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class UserDashboardRule extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'user_dashboard_rule';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = [];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
+78 -4
View File
@@ -2,6 +2,9 @@
namespace app\common\service;
use app\common\model\User;
use app\common\model\UserGroupMembers;
use support\Db;
use app\common\validate\ApprovalRecordsValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\ApprovalRecords;
@@ -56,20 +59,91 @@ class ApprovalRecordsService
case 'todo':
if ($p['scene'] === 'todo') {
if (!in_array(1, $request->user->role_ids)) {
$config['where']['approver_id'] = $request->user->uid;
// if (!in_array(1, $request->user->role_ids)) {
// $config['where']['approver_id'] = $request->user->uid;
// }
$roles_id = array_intersect([1,6], $request->user->role_ids);
if(count($roles_id) == 0){
if(in_array(4, $request->user->role_ids)){
$report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
$report = array_merge($report, [$request->user->uid]);
$config['where']['approver_id'] = ['in',$report];
} elseif (in_array(5, $request->user->role_ids)){ //内勤
$userGroupMember = UserGroupMembers::where('user_id',$request->user->uid)
->where('is_leader',1)
->first();
if($userGroupMember){
$report = UserGroupMembers::where('group_id',$userGroupMember->group_id)
->pluck('user_id')
->toArray();
$report = array_merge($report, [$request->user->uid]);
$config['where']['approver_id'] = ['in',$report];
}else{ //内勤没有负责的组时
$config['where']['approver_id'] = $request->user->uid;
}
} else {
$config['where']['approver_id'] = $request->user->uid;
}
}
$config['where']['status'] = 'pending';
$config['where']['instance.status'] = 'processing';
}
if ($p['scene'] === 'history') {
if (!in_array(1, $request->user->role_ids)) {
$config['where']['approver_id'] = $request->user->uid;
// if (!in_array(1, $request->user->role_ids) && !in_array(5, $request->user->role_ids)) {
// $config['where']['approver_id'] = $request->user->uid;
// }
$roles_id = array_intersect([1,6], $request->user->role_ids);
if(count($roles_id) == 0){
if(in_array(4, $request->user->role_ids)){
$report = User::where('report_to',$request->user->uid)->pluck('id')->toArray();
$report = array_merge($report, [$request->user->uid]);
$config['where']['approver_id'] = ['in',$report];
} elseif (in_array(5, $request->user->role_ids)){ //内勤
$userGroupMember = UserGroupMembers::where('user_id',$request->user->uid)
->where('is_leader',1)
->first();
if($userGroupMember){
$report = UserGroupMembers::where('group_id',$userGroupMember->group_id)
->pluck('user_id')
->toArray();
$report = array_merge($report, [$request->user->uid]);
$config['where']['approver_id'] = ['in',$report];
}else{ //内勤没有负责的组时
$config['where']['approver_id'] = $request->user->uid;
}
} else {
$config['where']['approver_id'] = $request->user->uid;
}
}
$config['where']['status'] = ['!=', 'pending'];
}
// 业务筛选:pi_no 精确到PI号;keyword 匹配 单号/PI号(历史页原有搜索框此前后端未实现)
// 两个条件同时传时取交集
$bizIdFilter = null;
if (!empty($p['pi_no'])) {
$ids = Db::table('stock_orders')
->where('pi_no', 'like', '%' . trim((string)$p['pi_no']) . '%')
->pluck('id')->toArray();
$bizIdFilter = $ids ?: [0];
}
if (!empty($p['keyword'])) {
$kw = '%' . trim((string)$p['keyword']) . '%';
$ids = Db::table('stock_orders')
->where(function ($q) use ($kw) {
$q->where('order_no', 'like', $kw)
->orWhere('pi_no', 'like', $kw)
->orWhere('customer_snapshot', 'like', $kw);
})
->pluck('id')->toArray();
$ids = $ids ?: [0];
$bizIdFilter = $bizIdFilter === null ? $ids : array_values(array_intersect($bizIdFilter, $ids));
}
if ($bizIdFilter !== null) {
$config['where']['instance.biz_id'] = ['in', $bizIdFilter];
}
$config['order']['id'] = 'desc';
$config['with'] = [
+15 -1
View File
@@ -9,7 +9,7 @@ class CrawlService
{
public function brandSeries($brand)
{
$url = "https://www.dongchedi.com/search?currTab=6&keyword=".$brand;
$url = "https://www.dongchedi.com/search?currTab=6&keyword=".$brand.'&from=search_tab_er&api_param={"en_qc":+0}';
$ql = QueryList::get($url );
$series = [];
$ql->find('div.card-name')->texts()->map(function($item) use(&$series) {
@@ -73,4 +73,18 @@ class CrawlService
});
return $href;
}
/**
* 关键词搜索的更多页面,通过面包屑抓取品牌与系列
* @param $series
* @return mixed|string
*/
public function crawlBrandSeries($series)
{
$url = "https://www.dongchedi.com/auto/series/" . $series . "?anchorId=carModels";
$ql = QueryList::get($url);
$result = $ql->find('div.breadcrumb_wrap__2GLeU span')->texts()->all();
return ['brand' => $result[2], 'series' => $result[3]];
}
}
@@ -0,0 +1,293 @@
<?php
namespace app\common\service;
use app\common\model\FinPaymentStock;
use app\common\model\FinReceipt;
use app\common\validate\FinPaymentApplyValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\FinPaymentApply;
use support\Redis;
use support\Request;
class FinPaymentApplyService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = FinPaymentApply::class;
/**
* 验证器
*/
protected $validator = FinPaymentApplyValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/**
* 构建查询条件
*/
protected function buildQueryConfig(Request $request, string $scene = 'index'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'index':
// ====== ====== 排序 ====== ======
$config['order']['id'] = 'desc';
// ====== ====== 筛选 ====== ======
// 汇款国家
if (isset($p['country_id']) && !empty($p['country_id'])) {
$config['where']['country_id'] = $p['country_id'];
}
// 收款账户筛选
if (isset($p['account_id']) && !empty($p['account_id'])) {
$config['where']['account_id'] = $p['account_id'];
}
// 入账资料超时
if (isset($p['scene']) && $p['scene'] == 'file_timeout') {
$config['where']['claimed_at'] = ['between', [1, time()-24*60*60]];
}
// 关键字
if (isset($p['keyword']) && $p['keyword']) {
$k = '%' . $p['keyword'] . '%';
$config['orWhere'][] = ['remitter', 'like', $k];
}
break;
case 'show':
$config['where']['id'] = $p['id'];
break;
case 'update':
case 'destroy':
$config['where']['id'] = $p['id'];
break;
case 'store':
break;
}
return $config;
}
public function beforeServiceStore($data, Request $request)
{
$data['apply_no'] = $this->generateUniqueApplyNo();
$data['doc_user_id'] = $request->user->uid;
$data['voucher_file'] = json_encode($data['voucher_file']);
$data['submit_files'] = json_encode($data['submit_files']);
redis::set('stock_' . $data['doc_user_id'], json_encode($request->post('stock')), 'EX', 30);
unset($data['stock']);
if($data['voucher_file'] == '[]' || $data['voucher_file'] == '{}' || $data['voucher_file'] == '""')
$data['voucher_file'] = '';
if($data['submit_files'] == '[]' || $data['submit_files'] == '{}' || $data['submit_files'] == '""')
$data['submit_files'] = '';
return $data;
}
public function afterServiceStore($res)
{
$data = [
'payment_apply_id' => $res['id'],
];
$piStock = json_decode(redis::get('stock_' . $res['doc_user_id']), true);
redis::del('stock' . $res['doc_user_id']);
foreach ($piStock as $stock) {
foreach ($stock['stock_id'] as $v) {
$data['stock_id'] = $v;
$data['pi_no'] = $stock['pi_on'];
FinPaymentStock::create($data);
}
}
}
public function beforeServiceUpdate($data, Request $request)
{
unset($data['stock']);
$data['submit_files'] = json_encode($data['submit_files']);
if($data['submit_files'] == '[]' || $data['submit_files'] == '{}' || $data['submit_files'] == '""')
$data['submit_files'] = '';
$data['voucher_file'] = json_encode($data['voucher_file']);
if($data['voucher_file'] == '[]' || $data['voucher_file'] == '{}' || $data['voucher_file'] == '""')
$data['voucher_file'] = '';
return $data;
}
public function afterServiceUpdate($res, Request $request)
{
if($res->submit_files != '' && $res->receipt_id != 0)
FinReceipt::where('id', $res->receipt_id)->update(['submit_files' => $res->submit_files]);
}
/**
* 生成唯一的付款申请编码
* 格式:PA + 日期 + 6位字母数字组合
*
* @return string 唯一编码
*/
public function generateUniqueApplyNo()
{
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$maxAttempts = 10; // 防止无限循环
$secondChar = 'PA' . date('Ymd');
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
// 生成后6位随机字符
$suffix = '';
for ($i = 0; $i < 6; $i++) {
$suffix .= $chars[rand(0, strlen($chars) - 1)];
}
$applyNo = $secondChar . $suffix;
// 检查是否已存在
$existing = FinPaymentApply::where('apply_no', $applyNo)->first();
if (!$existing) {
return $applyNo;
}
}
// 如果1000次尝试后仍然重复,使用时间戳确保唯一性
$timestamp = substr(time(), -6);
return $secondChar . $timestamp;
}
/**
* 根据相似度排序付款记录
* @param Request $request
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Pagination\LengthAwarePaginator
*/
public function complexQueryIndex(Request $request)
{
$limit = (int)$request->get('size', 15);
$page = (int)$request->get('current', 1);
$param = $request->all();
$where = [];
$orWhere = [];
// 汇款国家
if (isset($param['country_id']) && !empty($param['country_id'])) {
$where[] = ['country_id', $param['country_id']];
}
// 收款账户筛选
if (isset($param['account_id']) && !empty($param['account_id'])) {
$where[] = ['account_id', $param['account_id']];
}
// 入账资料超时
if (isset($param['scene']) && $param['scene'] == 'file_timeout') {
$where[] = ['submit_files', ''];
$where[] = ['claimed_at', '<', time()-24*60*60];
$where[] = ['claimed_at', '>', 1];
}
// 关键字
if (isset($param['keyword']) && $param['keyword']) {
$k = '%' . $param['keyword'] . '%';
$where[] = ['remitter', 'like', $k];
}
// 构建查询
$query = $this->modelClass::where($where);
// 是否已关联收款记录的筛选
$correlation = $param['correlation']??'';
if ($correlation === 'receipt') { //已关联
$query->whereHas('receipt');
} elseif ($correlation === 'unbind') { //未关联
$query->whereDoesntHave('receipt');
}
if(isset($param['scene']) && $param['scene'] == 'pending'){ //待入账列表
$query->where('receipt_id', '>' ,'0');
$query->where('submit_files','!=','');
$query->whereNull('received_at');
}
if($param['scene'] == 'delete'){ //查询作废数据
$query->onlyTrashed();
}elseif ($param['scene'] == 'all'){ //查询包含作废数据的全量数据
$query->withTrashed();
}
if ((isset($param['scene']) && ($param['scene'] == 'list' || $param['scene'] == 'pending'))|| empty($param['receipt_id']) ) {
return $query->orderBy('id','desc')->paginate($limit, ['*'], 'page', $page);
}
$receipt = FinReceipt::where('id', $param['receipt_id'])->first();
$account_id = $receipt->account_id;
$currency_id = $receipt->currency_id;
$remitter = $receipt->remitter;
$amount = $receipt->amount;
// 计算十天前的时间戳
$sevenDaysAgo = time() - (10 * 24 * 60 * 60);
// 构建相似度排序逻辑
$similarityOrder = [];
// 如果有传入参数,计算相似度
if (!empty($account_id)) {
$similarityOrder[] = "CASE WHEN account_id = {$account_id} THEN 0 ELSE 1 END";
}
if (!empty($currency_id)) {
$similarityOrder[] = "CASE WHEN currency_id = {$currency_id} THEN 0 ELSE 1 END";
}
if (!empty($remitter)) {
$similarityOrder[] = "CASE WHEN remitter = '{$remitter}' THEN 0 ELSE 1 END";
}
if (!empty($amount)) {
$amount = (float)$amount;
$similarityOrder[] = "ABS(amount - {$amount})";
$similarityOrder[] = "CASE WHEN (ABS(amount - {$amount}) / {$amount} * 100) <= 5 THEN 0 ELSE 1 END";
}
// 构建排序SQL
$orderByClause = "";
if (!empty($similarityOrder)) {
$orderByClause = "CASE
WHEN created_at >= {$sevenDaysAgo} THEN
(" . implode(' + ', $similarityOrder) . ")
ELSE 0
END ASC, ";
}
$orderByClause .= "CASE
WHEN created_at > {$sevenDaysAgo} THEN id
ELSE 999999
END DESC, ";
$orderByClause .= "id DESC";
$query->orderByRaw($orderByClause);
return $query->paginate($limit, ['*'], 'page', $page);
}
public function delete($id)
{
$res = FinPaymentApply::where('id', $id)->delete();
return $res;
}
}
@@ -0,0 +1,62 @@
<?php
namespace app\common\service;
use app\common\validate\FinReceiptHisValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\FinReceiptHis;
use support\exception\BusinessException;
use support\Request;
class FinReceiptHisService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = FinReceiptHis::class;
/**
* 验证器
*/
protected $validator = FinReceiptHisValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/**
* 构建查询条件
*/
protected function buildQueryConfig(Request $request, string $scene = 'index'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'index':
if(empty($p['receipt_id'])){
throw new BusinessException('receipt_id不能为空');
}
// ====== ====== 排序 ====== ======
$config['order']['id'] = 'desc';
// ====== ====== 筛选 ====== ======
$config['where']['receipt_id'] = $p['receipt_id'];
break;
case 'show':
$config['where']['id'] = $p['id'];
break;
}
return $config;
}
}
+335
View File
@@ -0,0 +1,335 @@
<?php
namespace app\common\service;
use app\common\logic\FinReceiptLogic;
use app\common\model\FinPaymentApply;
use app\common\model\FinPaymentStock;
use app\common\model\FinReceiptHis;
use app\common\validate\FinReceiptValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\FinReceipt;
use support\exception\BusinessException;
use support\Redis;
use support\Request;
class FinReceiptService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = FinReceipt::class;
/**
* 验证器
*/
protected $validator = FinReceiptValidate::class;
protected $logic;
public function __construct()
{
$this->logic = (new FinReceiptLogic());
}
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/**
* 构建查询条件
*/
protected function buildQueryConfig(Request $request, string $scene = 'index'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'index':
// ====== ====== 排序 ====== ======
$config['order']['id'] = 'desc';
// ====== ====== 筛选 ====== ======
// 汇款国家
if (isset($p['country_id']) && !empty($p['country_id'])) {
$config['where']['country_id'] = $p['country_id'];
}
// 没有付款记录的收款记录
if (isset($p['payment_apply']) && $p['payment_apply'] == 'not') { //未认领
$config['where']['payment_apply_id'] = 0;
} elseif (isset($p['payment_apply']) && $p['payment_apply'] == 'already') { //已认领
$config['where']['payment_apply_id'] = ['>', 0];
} elseif (isset($p['payment_apply']) && $p['payment_apply'] == 'timeout') { //超时未认领
$config['where']['payment_apply_id'] = 0;
$config['where']['created_at'] = ['<' ,time() - 24*60*60];
}
// 收款申请筛选
if (isset($p['payment_apply_id']) && !empty($p['payment_apply_id'])) {
$config['where']['payment_apply_id'] = $p['payment_apply_id'];
}
// 收款账户筛选
if (isset($p['account_id']) && !empty($p['account_id'])) {
$config['where']['account_id'] = $p['account_id'];
}
// 关键字
if (isset($p['keyword']) && $p['keyword']) {
$k = '%' . $p['keyword'] . '%';
$config['orWhere'][] = ['remitter', 'like', $k];
}
break;
case 'show':
$config['where']['id'] = $p['id'];
break;
case 'update':
case 'payment':
case 'destroy':
$config['where']['id'] = $p['id'];
break;
case 'store':
break;
}
return $config;
}
public function beforeServiceStore($data, Request $request)
{
if(isset($data['payment_apply_id']) && empty($data['payment_apply_id'])){
unset($data['payment_apply_id']);
}
$data['receipt_no'] = $this->generateUniqueReceipt();
$data['arrived_at'] = strtotime( $data['arrived_at']);
$data['created_by'] = $request->user->uid;
$data['updated_by'] = $request->user->uid;
$data['fx_receipt_file'] = json_encode($data['fx_receipt_file']);
if($data['fx_receipt_file'] == '[]' || $data['fx_receipt_file'] == '{}' || $data['fx_receipt_file'] == '""')
$data['fx_receipt_file'] = '';
return $data;
}
public function afterServiceStore($res)
{
$receipt = FinReceipt::where('id',$res->id)->first()->toArray();
$receipt = array_merge($receipt,['receipt_id' => $res->id]);
unset($receipt['id']);
FinReceiptHis::create($receipt);
}
public function beforeServiceUpdate($data, Request $request)
{
$data['updated_by'] = $request->user->uid;
if(isset($data['arrived_at']))
$data['arrived_at'] = strtotime($data['arrived_at']);
if(isset($data['received_at']))
$data['received_at'] = strtotime($data['received_at']);
$data['fx_receipt_file'] = json_encode($data['fx_receipt_file']);
if($data['fx_receipt_file'] == '[]' || $data['fx_receipt_file'] == '{}' || $data['fx_receipt_file'] == '""')
$data['fx_receipt_file'] = '';
return $data;
}
public function afterServiceUpdate($res)
{
$receipt = FinReceipt::where('id',$res['id'])->first()->toArray();
$receipt = array_merge($receipt,['receipt_id' => $res['id']]);
unset($receipt['id']);
FinReceiptHis::create($receipt);
}
/**
* 生成唯一的付款申请编码
* 格式:PA + 日期 + 6位字母数字组合
*
* @return string 唯一编码
*/
public function generateUniqueReceipt()
{
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$maxAttempts = 10; // 防止无限循环
$secondChar = 'RE' . date('Ymd');
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
// 生成后6位随机字符
$suffix = '';
for ($i = 0; $i < 6; $i++) {
$suffix .= $chars[rand(0, strlen($chars) - 1)];
}
$ReceiptNo = $secondChar . $suffix;
// 检查是否已存在
$existing = FinReceipt::where('receipt_no', $ReceiptNo)->first();
if (!$existing) {
return $ReceiptNo;
}
}
// 如果1000次尝试后仍然重复,使用时间戳确保唯一性
$timestamp = substr(time(), -6);
return $secondChar . $timestamp;
}
/**
* 根据相似度排序付款记录
* @param Request $request
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Pagination\LengthAwarePaginator
*/
public function complexQueryIndex(Request $request)
{
$limit = (int)$request->get('size', 15);
$page = (int)$request->get('current', 1);
$param = $request->all();
if(isset($param['voucher_no_timeout_hour'])){
redis::set('voucher_no_timeout_hour', $param['voucher_no_timeout_hour'], 'EX', 30);
}
$where = [];
// 汇款国家
if (isset($param['country_id']) && !empty($param['country_id'])) {
$where[] = ['country_id', $param['country_id']];
}
// 收款账户筛选
if (isset($param['account_id']) && !empty($param['account_id'])) {
$where[] = ['account_id', $param['account_id']];
}
// 收款申请筛选
if (isset($param['payment_apply_id']) && !empty($param['payment_apply_id'])) {
$where[]['payment_apply_id'] = $param['payment_apply_id'];
}
// 关键字
if (isset($param['keyword']) && $param['keyword']) {
$k = '%' . $param['keyword'] . '%';
$where[] = ['remitter', 'like', $k];
}
// 构建查询
$query = $this->modelClass::where($where);
// 收款记录绑定情况
if (isset($param['payment_apply']) && $param['payment_apply'] == 'not') {
$query->where('payment_apply_id', 0);
} elseif (isset($param['payment_apply']) && $param['payment_apply'] == 'already') {
$query->where('payment_apply_id', '>', 0);
} elseif (isset($param['payment_apply']) && $param['payment_apply'] == 'timeout') { //超时未认领
$query->where('payment_apply_id', 0);
$query->where('created_at', '<' ,time() - 24*60*60);
}
if(isset($param['scene']) && $param['scene'] == 'pending'){ //待入账列表
$query->where('payment_apply_id', '>' ,'0');
$query->where('submit_files','!=','');
}
if (isset($param['scene']) && ($param['scene'] == 'list' ||$param['scene'] == 'pending')) {
return $query->paginate($limit, ['*'], 'page', $page);
}
// 计算七天前的时间戳
$sevenDaysAgo = time() - (10 * 24 * 60 * 60);
// 构建相似度排序逻辑
$similarityOrder = [];
// 如果有传入参数,计算相似度
if (isset($param['account_id']) && !empty($param['account_id'])) {
$similarityOrder[] = "CASE WHEN account_id = {$param['account_id']} THEN 0 ELSE 1 END";
}
if (isset($param['country_id']) && !empty($param['country_id'])) {
$similarityOrder[] = "CASE WHEN country_id = {$param['country_id']} THEN 0 ELSE 1 END";
}
if (isset($param['keyword']) && $param['keyword']) {
$keyword = addslashes($param['keyword']);
$similarityOrder[] = "CASE WHEN remitter LIKE '%{$keyword}%' THEN 0 ELSE 1 END";
}
if (isset($param['amount']) && !empty($param['amount'])) {
$amount = (float)$param['amount'];
$similarityOrder[] = "ABS(amount - {$amount})";
}
// 构建排序SQL
$orderByClause = "";
if (!empty($similarityOrder)) {
$orderByClause = "CASE
WHEN created_at >= {$sevenDaysAgo} THEN
(" . implode(' + ', $similarityOrder) . ")
ELSE 999999
END ASC, ";
}
$orderByClause .= "CASE
WHEN created_at < {$sevenDaysAgo} THEN id
ELSE 0
END DESC";
$query->orderByRaw($orderByClause);
return $query->paginate($limit, ['*'], 'page', $page);
}
public function credit($id, $exchange_rate)
{
$finReceipt = FinReceipt::find($id);
if(!empty($finReceipt->received_at)){
throw new BusinessException('该记录已将确认入账');
}
$data = [
'received_at' => time(),
'exchange_rate' => $exchange_rate,
'total_amount_cny' => round($finReceipt['amount']*$exchange_rate,2),
];
$res = $this->logic->credit($id,$data);
FinPaymentApply::where('id', $finReceipt->payment_apply_id)->update(['received_at' => $data['received_at']]);
$this->afterServiceUpdate(['id'=>$id]);
return $res;
}
/**
* 更新:绑定收款记录,计算折让金额
* @param Request $request
* @param array $data
* @param string $scene
* @return mixed
*/
public function serviceUpdatePayment(Request $request, array $data = [], string $scene = 'payment')
{
$payment_apply_id = $request->input('payment_apply_id');
$id = $request->input('id');
$data = $this->preProcess(ucfirst($scene), $data, $request);
$config = $this->buildQueryConfig($request, $scene);
FinPaymentApply::where('id', $payment_apply_id)->update(['receipt_id' => $id, 'claimed_at' => time(), 'claimed_by' => $request->user->uid]);
FinPaymentStock::where('payment_apply_id', $payment_apply_id)->update(['receipt_id' => $id]);
//折让金额
$payment = FinPaymentApply::where('id', $payment_apply_id)->first();
$receipt = FinReceipt::find($id);
$data['discount_amount'] = $payment->amount - $receipt->amount ?? 0;
$data['submit_files'] = $payment->submit_files ?? '';
$updated = $this->logicUpdateBy($config, $data);
return $this->postProcess(ucfirst($scene), $data);
}
}
@@ -0,0 +1,290 @@
<?php
namespace app\common\service;
use app\common\validate\FinanceReconDubaiFilesValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\FinanceReconDubaiFiles;
use app\common\utils\excel\ExcelImportTrait;
use support\Request;
/**
* FinanceReconDubaiFilesService
*
* - 提供两类导入:
* 1) M项目对账明细(sheet: M项目对账明细)
* 2) 迪拜两表合并(sheets: Stock in Jebel Ali / Under shipping)
*
* - 公共导入流程复用 ExcelImportTrait
* - OSS 读取通过你的 OssService->generatePrivateUrl() 完成
*/
class FinanceReconDubaiFilesService
{
use CrudServiceTrait;
use ExcelImportTrait; // 复用通用导入方法
/** 指定当前要操作的模型(用于 CrudServiceTrait) */
protected string $modelClass = FinanceReconDubaiFiles::class;
/** 验证器 */
protected $validator = FinanceReconDubaiFilesValidate::class;
/** 获取验证器实例 */
public function getValidator()
{
return new $this->validator;
}
/**
* 写入前置:将上传组件的结构转为 *_url / *_name,并记录创建人
*/
public function beforeServiceStore($data, Request $request)
{
$data['created_by'] = $request->user->uid ?? 0;
$data['caiwu_file_url'] = $data['caiwu_file']['url'] ?? '';
$data['caiwu_file_name'] = $data['caiwu_file']['name'] ?? '';
$data['dubai_file_url'] = $data['dubai_file']['url'] ?? '';
$data['dubai_file_name'] = $data['dubai_file']['name'] ?? '';
return $data;
}
/* ============================================================
* 一、M项目对账明细 导入配置(sheet: M项目对账明细)
* ============================================================ */
/** 中文表头 => 目标字段名 */
protected array $headerMapM = [
'车架号' => 'vin',
'开票价' => 'invoice_price',
'实际采购价' => 'actual_purchase_price',
'开票价和实际采购价差额' => 'invoice_vs_purchase_diff',
'车源中介费' => 'source_broker_fee',
'配件' => 'parts_fee',
'停车费' => 'parking_fee',
'保险费' => 'insurance_fee',
'车辆手续费' => 'vehicle_service_fee',
'检测费' => 'inspection_fee',
'购置税' => 'purchase_tax',
'汽车其他费用' => 'other_vehicle_fee',
'转移待出口' => 'transfer_to_export',
'国际运费' => 'freight_international',
'国内运费1' => 'freight_domestic_1',
'国内运费2' => 'freight_domestic_2',
'报关费及港杂费' => 'customs_port_fee',
'注销费' => 'cancellation_fee',
'车船税' => 'vehicle_vessel_tax',
'其他财务费用' => 'other_finance_fee',
'汽车小额杂费' => 'minor_vehicle_misc',
'上牌费' => 'registration_fee',
'其他保险费' => 'other_insurance_fee',
'油补' => 'fuel_subsidy',
'快递费' => 'express_fee',
'国内成本合计' => 'domestic_cost_total',
'退税额' => 'tax_refund_amount',
'cif杰贝阿里成本价' => 'cif_jebel_ali_cost',
'范经理发车看板CIF' => 'fan_manager_cif',
'财务-范经理 差额' => 'finance_vs_fan_diff',
];
/** 需要转为 decimal 的目标字段 */
protected array $decimalFieldsM = [
'invoice_price','actual_purchase_price','invoice_vs_purchase_diff',
'source_broker_fee','parts_fee','parking_fee','insurance_fee','vehicle_service_fee','inspection_fee',
'purchase_tax','other_vehicle_fee','transfer_to_export',
'freight_international','freight_domestic_1','freight_domestic_2','customs_port_fee',
'cancellation_fee','vehicle_vessel_tax','other_finance_fee','minor_vehicle_misc',
'registration_fee','other_insurance_fee','fuel_subsidy','express_fee',
'domestic_cost_total','tax_refund_amount','cif_jebel_ali_cost','fan_manager_cif','finance_vs_fan_diff',
];
/**
* M项目对账明细导入
*
* @param string $file 可为“签名 URL”或“OSS 对象键”
* @param bool $isUrl 若传入的是已签名 URL,请设 true;若是对象键,设 false
* @param bool $insertToDb 是否立即入库
*
* 返回:['sheet','total_rows','skipped','errors','to_import','inserted','preview_3',('error'?)]
*/
public function mImport(string $file, bool $isUrl = true, bool $insertToDb = true): array
{
$sheet = 'M项目对账明细';
// 允许传对象键:当 isUrl=false 且非 http(s) 时,自动用 OssService 签名
if ($isUrl === false && !preg_match('#^https?://#i', $file)) {
$file = $this->ossSignedUrl($file);
$isUrl = true;
}
$rows = $this->readSheetRows($file, $sheet, $isUrl, headerRow: 1);
$mapped = [];
$skipped = 0;
$errors = 0;
foreach ($rows as $row) {
try {
if ($this->shouldSkipByVinAliases($row, ['车架号'])) { $skipped++; continue; }
$rec = $this->mapAndNormalizeRow($row, $this->headerMapM, $this->decimalFieldsM);
$this->stamp($rec);
$mapped[] = $rec;
} catch (\Throwable $e) {
// 单行异常不阻断全局导入
$errors++; continue;
}
}
$result = [
'sheet' => $sheet,
'total_rows' => count($rows),
'skipped' => $skipped,
'errors' => $errors,
'to_import' => count($mapped),
];
if ($insertToDb) {
// 注意表名:按你的业务这里写入 finance_recon_m_detail
$ret = $this->batchInsert('finance_recon_m_detail', $mapped);
$result['inserted'] = $ret['inserted'];
if ($ret['error']) $result['error'] = 'Database insert error: ' . $ret['error'];
} else {
$result['inserted'] = 0;
}
$result['preview_3'] = array_slice($mapped, 0, 3);
return $result;
}
/** M项目对账明细:从 OSS 对象键导入(语义化便捷入口) */
public function mImportFromOssKey(string $objectKey, bool $insertToDb = true): array
{
$signedUrl = $this->ossSignedUrl($objectKey);
return $this->mImport($signedUrl, true, $insertToDb);
}
/** M项目对账明细:从已签名 URL 导入(语义化便捷入口) */
public function mImportFromSignedUrl(string $signedUrl, bool $insertToDb = true): array
{
return $this->mImport($signedUrl, true, $insertToDb);
}
/* ============================================================
* 二、迪拜两表合并 导入配置(sheets: Stock in Jebel Ali / Under shipping)
* ============================================================ */
/** 表头映射(已兼容不一致列名:VIN NO/车架号、国际运费和杂费/SHIPPING COST) */
protected array $headerMapDubai = [
'VIN NO' => 'vin', '车架号' => 'vin',
'开票价' => 'invoice_price',
'中介费' => 'broker_fee',
'购置税' => 'purchase_tax',
'退税' => 'tax_refund',
'不含税车价' => 'price_ex_vat',
'费用' => 'fee_total',
'国内运费' => 'domestic_freight',
'FCA成本价' => 'fca_cost',
'FCA成本价汇率' => 'fca_fx',
'国际运费和杂费' => 'shipping_cost',
'SHIPPING COST' => 'shipping_cost',
'运费汇率' => 'shipping_fx',
'CIF 杰贝阿里成本价' => 'cif_jebel_ali_cost',
'CIF RMB' => 'cif_rmb', // Under shipping 没有该列,将在逻辑中推导
];
/** 需要转为 decimal 的目标字段(汇率字段使用 DECIMAL(18,6) 建表) */
protected array $decimalFieldsDubai = [
'invoice_price','broker_fee','purchase_tax','tax_refund','price_ex_vat',
'fee_total','domestic_freight','fca_cost','fca_fx',
'shipping_cost','shipping_fx','cif_jebel_ali_cost','cif_rmb',
];
/**
* 迪拜两表合并导入(Stock in Jebel Ali / Under shipping)
*
* 缺失值规则:
* - Under shipping 无 “CIF RMB”,尝试:cif_rmb = cif_jebel_ali_cost × shipping_fx
* 若运费汇率缺失则用 fca_fx 兜底,都没有则置 NULL。
*
* 返回:['sheets','total_rows','skipped','to_import','inserted','preview_5',('error'?)]
*/
public function dImport(string $file, bool $isUrl = false, bool $insertToDb = true): array
{
// 允许传对象键:当 isUrl=false 且非 http(s) 时,自动用 OssService 签名
if ($isUrl === false && !preg_match('#^https?://#i', $file)) {
$file = $this->ossSignedUrl($file);
$isUrl = true;
}
$sheets = ['Stock in Jebel Ali', 'Under shipping'];
$parsed = [];
// 逐 Sheet 读取
foreach ($sheets as $sheet) {
$parsed[$sheet] = $this->readSheetRows($file, $sheet, $isUrl, headerRow: 1);
}
// 行处理与合并
$rowsAll = [];
$skipped = 0;
foreach ($sheets as $sheet) {
foreach ($parsed[$sheet] as $row) {
if ($this->shouldSkipByVinAliases($row, ['VIN NO','车架号'])) { $skipped++; continue; }
// 映射 + 数值规范化
$rec = $this->mapAndNormalizeRow($row, $this->headerMapDubai, $this->decimalFieldsDubai);
// Under shipping 无 CIF RMB:尝试用汇率推导
if (empty($rec['cif_rmb'])) {
$cif = $rec['cif_jebel_ali_cost'] ?? null;
$fx = $rec['shipping_fx'] ?? null;
if ($cif !== null && $fx !== null) {
$rec['cif_rmb'] = round($cif * $fx, 2);
} elseif ($cif !== null && !empty($rec['fca_fx'])) {
$rec['cif_rmb'] = round($cif * (float)$rec['fca_fx'], 2);
} else {
$rec['cif_rmb'] = null;
}
}
// 统一 VIN 与来源
$rec['vin'] = $this->pickFirstNonEmpty($row, ['VIN NO','车架号']);
$rec['source_sheet'] = $sheet;
$this->stamp($rec);
$rowsAll[] = $rec;
}
}
// 结果与入库
$result = [
'sheets' => $sheets,
'total_rows' => array_sum(array_map('count', $parsed)),
'skipped' => $skipped,
'to_import' => count($rowsAll),
];
if ($insertToDb) {
// 注意表名:按你的业务这里写入 finance_recon_d_detail
$ret = $this->batchInsert('finance_recon_d_detail', $rowsAll);
$result['inserted'] = $ret['inserted'];
if ($ret['error']) $result['error'] = 'Database insert error: ' . $ret['error'];
} else {
$result['inserted'] = 0;
}
$result['preview_5'] = array_slice($rowsAll, 0, 5);
return $result;
}
/** 迪拜两表:从 OSS 对象键导入(语义化便捷入口) */
public function dImportFromOssKey(string $objectKey, bool $insertToDb = true): array
{
$signedUrl = $this->ossSignedUrl($objectKey);
return $this->dImport($signedUrl, true, $insertToDb);
}
/** 迪拜两表:从已签名 URL 导入(语义化便捷入口) */
public function dImportFromSignedUrl(string $signedUrl, bool $insertToDb = true): array
{
return $this->dImport($signedUrl, true, $insertToDb);
}
}
@@ -0,0 +1,67 @@
<?php
namespace app\common\service;
use app\common\validate\InquiryTransmitConfigValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\InquiryTransmitConfig;
use support\Request;
class InquiryTransmitConfigService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = InquiryTransmitConfig::class;
/**
* 验证器
*/
protected $validator = InquiryTransmitConfigValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/**
* 构造 CrudUtil 查询配置
*/
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'default':
$config['where']['status'] = 1;
$config['order']['id'] = 'desc';
break;
case 'index':
$config['where']['status'] = 1;
$config['order']['id'] = 'desc';
break;
case 'show':
$config['where']['id'] = $p['id'];
$config['where']['status'] = 1;
break;
case 'store':
break;
case 'update':
$config['where']['id'] = $p['id'];
break;
case 'destroy':
$config['where']['id'] = $p['id'];
break;
}
return $config;
}
}
@@ -0,0 +1,92 @@
<?php
namespace app\common\service;
use app\common\validate\InquiryTransmitValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\InquiryTransmit;
use support\Request;
class InquiryTransmitService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = InquiryTransmit::class;
/**
* 验证器
*/
protected $validator = InquiryTransmitValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/** 创建 */
public function serviceStore(Request $request, array $data = [], string $domain = '')
{
$data['email'] = $data['Email'];
$data['name'] = $data['Name'];
$data['tel'] = $data['Tel'];
$data['company'] = $data['Company'];
$data['country'] = $data['Country'];
$data['form_Url'] = $data['Form Url'];
$data['message'] = $data['Message'];
$data['ip_location'] = $data['IpLocation'];
$data['ip'] = $data['Ip'];
$data['domain'] = $domain;
return InquiryTransmit::create($data);
}
/**
* 构造 CrudUtil 查询配置
*/
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'default':
$config['order']['id'] = 'desc';
break;
case 'index':
// 域名
if (isset($p['domain']) && !empty($p['domain'])) {
$config['where']['domain'] = $p['domain'];
}
// 关键字
if (isset($p['keyword']) && $p['keyword']) {
$k = '%' . $p['keyword'] . '%';
foreach (['email', 'name', 'tel', 'country', 'message', 'form_url'] as $col) {
$config['orWhere'][] = [$col, 'like', $k];
}
}
$config['order']['id'] = 'desc';
break;
case 'show':
$config['where']['id'] = $p['id'];
break;
case 'store':
break;
case 'update':
$config['where']['id'] = $p['id'];
break;
case 'destroy':
$config['where']['id'] = $p['id'];
break;
}
return $config;
}
}
+36
View File
@@ -57,6 +57,42 @@ class OssService
return $this->ossClient->signUrl($this->bucket, $objectKey, $expireTime);
}
/**
* 附件数组的 url 重签为新的私有访问地址(响应期调用,防止历史签名过期)。
* 兼容三种存量格式:完整签名URL / 完整未签名URL / 纯对象键。
* @param array $attachments [{url, original_name}, ...]
* @param int $expireTime
* @return array
*/
public static function resignAttachmentUrls(array $attachments, int $expireTime = 604800): array
{
foreach ($attachments as &$a) {
if (empty($a['url']) || !is_string($a['url'])) {
continue;
}
try {
$url = $a['url'];
if (strpos($url, 'http') === 0) {
$path = (string)parse_url($url, PHP_URL_PATH);
if ($path === '') {
continue;
}
$key = ltrim(urldecode($path), '/');
} else {
$key = ltrim($url, '/'); // 已是对象键
}
if ($key === '') {
continue;
}
$a['url'] = (new self())->generatePrivateUrl($key, $expireTime);
} catch (\Throwable $e) {
// 重签失败保留原样
}
}
unset($a);
return $attachments;
}
/**
* 删除 OSS 上的文件
* @param string $objectKey OSS上存储的对象名称
+3
View File
@@ -47,6 +47,9 @@ class ResourceService
$config['order']['id'] = 'asc';
break;
case 'menuList':
if(isset($p['is_home']) && $p['is_home']==1){
$config['where']['is_home'] = 1;
}
$config['where']['status'] = 1;
$config['where']['type'] = ['!=', 'permission'];
$config['order']['sort'] = 'asc';
+229 -44
View File
@@ -73,6 +73,7 @@ class StockOrdersService
case 'print':
case 'approval':
$config['where']['id'] = $p['id'];
$config['with'][] = 'files'; // 添加文件附件关联
break;
case 'detail':
case 'show':
@@ -102,22 +103,14 @@ class StockOrdersService
public function beforeServiceStore($data, Request $request)
{
$userId = $request->user->uid;
$data['order_no'] = generateStockNo();
$data['biz_date'] = strtotime($data['biz_date']);
$data['eta_ship_date'] = strtotime($data['eta_ship_date']);
$data['latest_arrival_date'] = strtotime($data['latest_arrival_date']);
$data['salesperson_id'] = $userId;
$data['salesperson_snapshot'] = User::where('id', $userId)->value('name') ?? '';
$data['approval_status'] = $data['approval_status'] == 1 ? 1 : 0;
// if ($data['is_final'] == 1) {
$data['pick_up_date'] = $data['pick_up_date'] ? strtotime($data['pick_up_date']) : 0;
$data['collect_all_date'] = $data['collect_all_date'] ? strtotime($data['collect_all_date']) : 0;
// }
// 检查绑定
(new UserService())->checkBind($userId);
// 保存不检查文件上传
if ($data['approval_status'] != 0) {
$this->validateRequiredFiles($data);
}
// 终版备货单时,至少一个车辆填写了vin
if ($data['is_final'] == 1) {
// 检查是否至少有一个车辆填写了vin
$hasVin = false;
foreach ($data['veh_list'] as $k => $v) {
if ($v['vin'] != '') {
@@ -125,17 +118,105 @@ class StockOrdersService
break;
}
}
// 如果没有任何车辆填写vin,则抛出异常
if (!$hasVin) {
throw new BusinessException('至少需要为一辆车辆填写vin,才能提交审核');
}
}
// 检查绑定
(new UserService())->checkBind($userId);
$data['order_no'] = generateStockNo();
$data['biz_date'] = strtotime($data['biz_date']);
$data['eta_ship_date'] = strtotime($data['eta_ship_date']);
$data['latest_arrival_date'] = strtotime($data['latest_arrival_date']);
$data['salesperson_id'] = $userId;
$data['salesperson_snapshot'] = User::where('id', $userId)->value('name') ?? '';
$data['approval_status'] = $data['approval_status'] == 1 ? 1 : 0;
$data['pick_up_date'] = $data['pick_up_date'] ? strtotime($data['pick_up_date']) : 0;
$data['collect_all_date'] = $data['collect_all_date'] ? strtotime($data['collect_all_date']) : 0;
return $data;
}
/**
* 验证创建备货单时必需的文件是否已提供
*
* @param array $data 提交的数据
* @throws BusinessException 当必需的文件缺失或为空时抛出异常
*/
private function validateRequiredFiles(array $data): void
{
$requiredFiles = [
'contract' => '合同',
'transaction_slip' => '水单',
'pi_doc' => 'PI'
];
foreach ($requiredFiles as $fileType => $fileLabel) {
if (!isset($data[$fileType]) || !is_array($data[$fileType]) || empty($data[$fileType])) {
throw new BusinessException("创建备货单时{$fileLabel}文件不能为空");
}
// 检查文件数组中的每个条目是否都有有效的URL
$hasValidFile = false;
foreach ($data[$fileType] as $fileInfo) {
if (isset($fileInfo['url']) && !empty($fileInfo['url'])) {
$hasValidFile = true;
break;
}
}
if (!$hasValidFile) {
throw new BusinessException("创建备货单时{$fileLabel}文件不能为空");
}
}
}
/**
* 验证备货单审批时必需的文件是否存在
*
* @param StockOrders $item 备货单对象
* @throws BusinessException 当必需的文件缺失或为空时抛出异常
*/
private function validateRequiredFilesForApproval($item): void
{
// 确保已加载文件关联
if (!isset($item->files)) {
$item->load('files');
}
$requiredFiles = [
\app\common\model\StockOrdersFiles::CONTRACT => '合同',
\app\common\model\StockOrdersFiles::TRANSACTION_SLIP => '水单',
\app\common\model\StockOrdersFiles::PI_DOC => 'PI'
];
// 按文件类型分组现有文件
$existingFiles = [];
foreach ($item->files as $file) {
if (!isset($existingFiles[$file->file_code])) {
$existingFiles[$file->file_code] = [];
}
$existingFiles[$file->file_code][] = $file;
}
// 检查每种必需文件是否存在
foreach ($requiredFiles as $fileCode => $fileLabel) {
if (!isset($existingFiles[$fileCode]) || empty($existingFiles[$fileCode])) {
throw new BusinessException("提交审批前必须上传{$fileLabel}文件");
}
// 检查文件中是否有有效的URL
$hasValidFile = false;
foreach ($existingFiles[$fileCode] as $file) {
if (!empty($file->file_url)) {
$hasValidFile = true;
break;
}
}
if (!$hasValidFile) {
throw new BusinessException("提交审批前必须上传有效的{$fileLabel}文件");
}
}
}
protected function afterServiceStore($res)
{
// 处理原单
@@ -146,6 +227,8 @@ class StockOrdersService
if (!$order->save()) {
flg('备货单【'.$order->order_no.'】创建终版成功,但更新原单失败');
}
StockOrders::where('object_id', $res->object_id)->where('is_final', 1)->where('approval_status', 3)->delete();
}
// 启动审批
if ($res->approval_status == 1) {
@@ -161,7 +244,8 @@ class StockOrdersService
{
// 启动审批
$appro = new \app\common\utils\approval\service\ApprovalFlowService();
$appro->start('stock_orders', 'stock_orders', $item->id, [], $item->salesperson_id);
$formData = $this->checkNewOrUsedVehicles($item->id);
$appro->start('stock_orders', 'stock_orders', $item->id, $formData, $item->salesperson_id);
// 异步生成pdf文件
\Webman\RedisQueue\Redis::send('stock-order-pdf', ['id'=>$item->id]);
}
@@ -270,10 +354,18 @@ class StockOrdersService
public function serviceApproval(Request $request)
{
$config = $this->buildQueryConfig($request, 'approval');
$item = $this->logicShow($config);
if (!$item || $item->approval_status != 0) {
throw new BusinessException('该单据已提交审批或单子不存在');
}
// 检查备货单是否包含必需的文件
$this->validateRequiredFilesForApproval($item);
$this->logicUpdateBy($config, [
'approval_status' => 1
]);
$item = $this->logicShow($config);
$this->startApproval($item);
return $this->postProcess('Approval', $item);
}
@@ -295,7 +387,8 @@ class StockOrdersService
public function approvalNullify($item)
{
$appro = new \app\common\utils\approval\service\ApprovalFlowService();
$appro->startNullify('stock_orders_nullify', 'stock_orders', $item->id, [], $item->salesperson_id);
$formData = $this->checkNewOrUsedVehicles($item->id);
$appro->startNullify('stock_orders_nullify', 'stock_orders', $item->id, $formData, $item->salesperson_id);
}
/**
@@ -364,6 +457,15 @@ class StockOrdersService
*/
public function beforeServiceUpdate($data, Request $request)
{
// 检查必填文件是否已上传
// $this->validateRequiredFiles($data);
$param = $request->all();
$info = StockOrders::find($param['id']);
if (!$info || $info->approval_status != 0) {
throw new BusinessException('该单不存在或状态异常,请关闭或刷新页面后重试~');
}
$data['biz_date'] = strtotime($data['biz_date']);
$data['eta_ship_date'] = strtotime($data['eta_ship_date']);
$data['latest_arrival_date'] = strtotime($data['latest_arrival_date']);
@@ -479,14 +581,19 @@ class StockOrdersService
// 检查数据中是否存在该字段且为数组
if (!empty($data[$field]) && is_array($data[$field])) {
// 处理该字段下的每个文件
foreach ($data[$field] as $fileUrl) {
foreach ($data[$field] as $fileInfo) {
$fileData = [
'order_id' => $order->id,
'file_code' => $fileCode,
'file_url' => $fileUrl,
'file_url' => $fileInfo['url'] ?? '',
'file_name' => StockOrdersFiles::FILE_CODE[$fileCode],
'original_name' => $fileInfo['original_name'] ?? StockOrdersFiles::FILE_CODE[$fileCode],
];
StockOrdersFiles::create($fileData);
// 只有当file_url不为空时才创建记录
if (!empty($fileData['file_url'])) {
StockOrdersFiles::create($fileData);
}
}
}
}
@@ -537,6 +644,8 @@ class StockOrdersService
foreach ($vehList as $veh) {
$veh['order_id'] = $order->id;
$veh['unique_id'] = $this->generateVehicleUniqueId($veh);
// 车辆附件:归一化数组(由模型 attachment=>'array' cast 自动序列化为 JSON 落库)
$veh['attachment'] = is_array($veh['attachment'] ?? null) ? array_values($veh['attachment']) : [];
$item = StockOrderItems::create($veh);
@@ -560,7 +669,7 @@ class StockOrdersService
*/
private function generateVehicleUniqueId(array $veh): string
{
$keysToRemove = ['vin', 'remark', 'modify_list', 'order_id'];
$keysToRemove = ['remark', 'modify_list', 'order_id', 'attachment'];
$array = array_diff_key($veh, array_flip($keysToRemove));
$uniqueIdStr = json_encode($array);
return md5($uniqueIdStr);
@@ -775,6 +884,18 @@ class StockOrdersService
$vehList[$v->unique_id]['remark'] = $v->remark;
}
}
// 拼接附件(并集去重,按 url+文件名)
$currAttach = is_array($v->attachment) ? $v->attachment : [];
if (!empty($currAttach)) {
$union = [];
foreach (array_merge($vehList[$v->unique_id]['attachment'], $currAttach) as $a) {
$key = ($a['url'] ?? '') . '|' . ($a['original_name'] ?? '');
if (!isset($union[$key])) {
$union[$key] = $a;
}
}
$vehList[$v->unique_id]['attachment'] = array_values($union);
}
continue;
}
$index++;
@@ -789,8 +910,17 @@ class StockOrdersService
'num' => 1,
'mileage' => $v->mileage,
'remark' => $v->remark,
'vin' => $v->vin,
'attachment' => is_array($v->attachment) ? $v->attachment : [],
] + $moneyValues;
}
// 附件 url 响应期重签(历史存的是上传时的签名URL,会过期)
foreach ($vehList as &$row) {
if (!empty($row['attachment'])) {
$row['attachment'] = \app\common\service\OssService::resignAttachmentUrls($row['attachment']);
}
}
unset($row);
return ['list' => $vehList];
}
@@ -830,25 +960,26 @@ class StockOrdersService
{
$vehicleData['num'] += 1;
$fieldList = [
'fob_transport_fee',
'car_price',
'ocean_freight',
'commission',
'retention_price',
'port_misc_fee',
'total_price',
'clearance_price',
'profit'
];
// ** 11.11 remove 价格*数量
// $fieldList = [
// 'fob_transport_fee',
// 'car_price',
// 'ocean_freight',
// 'commission',
// 'retention_price',
// 'port_misc_fee',
// 'total_price',
// 'clearance_price',
// 'profit'
// ];
foreach ($vehicleData as $field => $value) {
if (in_array($field, $fieldList)) {
$amount = $vehicleData[$field]['amount'] * $vehicleData['num'];
$currencySymbol = SysCurrency::where('currency_code', $vehicleData[$field]['currency'])->value('currency_symbol');
$vehicleData[$field]['currency_info'] = $currencySymbol . number_format($amount, 2, '.', ',');
}
}
// foreach ($vehicleData as $field => $value) {
// if (in_array($field, $fieldList)) {
// $amount = $vehicleData[$field]['amount'] * $vehicleData['num'];
// $currencySymbol = SysCurrency::where('currency_code', $vehicleData[$field]['currency'])->value('currency_symbol');
// $vehicleData[$field]['currency_info'] = $currencySymbol . number_format($amount, 2, '.', ',');
// }
// }
}
/**
@@ -905,6 +1036,7 @@ class StockOrdersService
'id' => $file->id,
'file_url' => $file->file_url,
'file_full_url' => $this->getFileUrl($file->file_url),
'original_name' => $file->original_name,
];
}
@@ -978,4 +1110,57 @@ class StockOrdersService
}
return (new \app\common\service\OssService)->generatePrivateUrl($fileUrl);
}
/**
* 检查备货单中的车辆是新车还是二手车
* @param int $stockOrderId 备货单ID
* @return array 返回包含has_new和has_used的数组
*/
public function checkNewOrUsedVehicles(int $stockOrderId): array
{
// 获取备货单关联的所有车辆项
$vehicles = StockOrderItems::where('order_id', $stockOrderId)->get();
$hasNew = false;
$hasUsed = false;
$submitNote = '';
foreach ($vehicles as $vehicle) {
// 判断是否为二手车(mileage > 0)
if ($vehicle->mileage > 0) {
$hasUsed = true;
$submitNote = '本单只有二手车';
} else {
$hasNew = true;
$submitNote = '本单只有新车';
}
// 如果两种类型都已经存在,可以提前退出循环
if ($hasNew && $hasUsed) {
$submitNote = '本单包含新车和二手车';
break;
}
}
return [
'has_new' => $hasNew ? 1 : 0,
'has_used' => $hasUsed ? 1 : 0,
'submit_note' => $submitNote
];
}
public function serviceDestroy(Request $request, string $scene = 'destroy')
{
$config = $this->buildQueryConfig($request, $scene);
$item = $this->logicShow($config);
if (!$item) {
throw new BusinessException('数据不存在');
}
if ($item->approval_status == 1 || $item->approval_status == 2) {
throw new BusinessException('审核中或者已审核的订单不能删除');
}
$deleted = $this->logicDeleteBy($config);
return $this->postProcess(ucfirst($scene), $deleted);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace app\common\service;
use app\common\logic\SysConfigLogic;
use app\common\logic\SysCurrencyLogic;
use app\common\logic\SystemsLogic;
use support\Redis;
class SysConfigService
{
protected $logic;
public function __construct()
{
$this->logic = (new SysConfigLogic());
}
/**
* 获取系统list
*/
public static function getConfig(string $config_key)
{
$value = Redis::get($config_key);
if (empty($value)) {
$config = (new SysConfigLogic())->findByKey($config_key);
if (empty($config)) {
return null;
}
$value = $config->config_value;
Redis::set($config->config_key, $value, 'EX', 7200);
}
return $value;
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ class UploadService
$size = filesize($filePath) ?: 0;
$sceneUrl = $scene ? ($scene . '/') : '';
$dir = 'x_server/' . $sceneUrl . date('Ymd') . '/';
$dir = config('oss.oss_main_dir') . $sceneUrl . date('Ymd') . '/';
// 使用传入的 original_name 命名(缺少扩展名时补上推断的扩展名),并做简单安全处理
$safeBaseName = preg_replace('/[\\\\\/]+/', '_', $originalName);
if ($extension && strtolower(pathinfo($safeBaseName, PATHINFO_EXTENSION)) !== strtolower($extension)) {
@@ -0,0 +1,31 @@
<?php
namespace app\common\service;
use app\common\validate\UsedcarCrawlResultsImagesValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\UsedcarCrawlResultsImages;
class UsedcarCrawlResultsImagesService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UsedcarCrawlResultsImages::class;
/**
* 验证器
*/
protected $validator = UsedcarCrawlResultsImagesValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
@@ -0,0 +1,31 @@
<?php
namespace app\common\service;
use app\common\validate\UsedcarCrawlResultsValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\UsedcarCrawlResults;
class UsedcarCrawlResultsService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UsedcarCrawlResults::class;
/**
* 验证器
*/
protected $validator = UsedcarCrawlResultsValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
@@ -0,0 +1,78 @@
<?php
namespace app\common\service;
use app\common\validate\UserDashboardRuleValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\UserDashboardRule;
use support\Request;
class UserDashboardRuleService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UserDashboardRule::class;
/**
* 验证器
*/
protected $validator = UserDashboardRuleValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
/**
* 构造 CrudUtil 查询配置
*/
protected function buildQueryConfig(Request $request, string $scene = 'default'): array
{
$p = $request->all();
$config = [];
$config['model'] = $this->modelClass;
switch ($scene) {
case 'default':
$config['where']['status'] = 1;
$config['order']['id'] = 'desc';
break;
case 'index':
$config['where']['status'] = 1;
$config['order']['id'] = 'desc';
break;
case 'show':
$config['where']['id'] = $p['id'];
$config['where']['status'] = 1;
break;
case 'store':
break;
case 'update':
$config['where']['id'] = $p['id'];
break;
case 'destroy':
$config['where']['id'] = $p['id'];
break;
}
return $config;
}
/**
* 添加前置处理
*/
public function beforeServiceStore($data, Request $request)
{
if(!isset($data['weight']) || ($data['weight'] == null || $data['weight'] == '')){
throw new \Exception("权重不能为空");
}
return $data;
}
}
+36 -11
View File
@@ -41,6 +41,27 @@ class UserService
// $menuTree = $permService->getMenuTree($uid, $systemCode);
// $permissionCodes = $permService->getPermissionCodes($uid, $systemCode);
$corp_name = Corp::where('abbr', $user->corp_id)->value('name');
// 查找直属上级 10.13 add
$reportToInfo = [];
if ($user->reportToInfo) {
$reportToInfo = [
'id' => $user->reportToInfo->id,
'name' => $user->reportToInfo->name
];
}
// 查找绑定内勤 10.13 add
$officeClerkInfo = [];
$ugm = UserGroupMembers::where('user_id', $uid)->where('status', 1)->first();
if ($ugm) {
$lugm = UserGroupMembers::where('group_id', $ugm->group_id)->where('is_leader', 1)->first();
if ($lugm) {
$officeClerkInfo['id'] = $lugm->user_id;
$officeClerkInfo['name'] = User::where('id', $lugm->user_id)->value('name');
}
}
return [
'user' => [
'id' => $user->id,
@@ -50,6 +71,7 @@ class UserService
'email' => $user->email,
'status' => $user->status,
'roles' => $user->roles->pluck('name'),
'roles_id' => $user->roles->pluck('id'),
'sex' => $user->sex,
'sn' => $user->sn,
'corp_id' => $user->corp_id,
@@ -57,6 +79,8 @@ class UserService
'report_to' => $user->report_to,
'sign_img' => $user->sign_img,
'sign_img_url' => (!empty($user->sign_img))?(new \app\common\service\OssService)->generatePrivateUrl($user->sign_img):'',
'report_to_info' => $reportToInfo,
'office_clerk_info' => $officeClerkInfo
],
// 'menu' => $menuTree,
// 'permissions' => $permissionCodes
@@ -159,14 +183,10 @@ class UserService
/** 分页列表 */
public function serviceTeam(Request $request)
{
if (method_exists($this, 'complexQuery')) {
$result = $this->complexQuery($request->get());
} else {
$config = $this->buildQueryConfig($request, 'team');
$limit = (int)$request->get('size', 15);
$page = (int)$request->get('current', 1);
$result = $this->logicList($config, $limit, $page);
}
$config = $this->buildQueryConfig($request, 'team');
$limit = (int)$request->get('size', 15);
$page = (int)$request->get('current', 1);
$result = $this->logicList($config, $limit, $page);
foreach ($result as &$data){
$data['customer_count'] = Customer::where('user_id', $data['id'])->count();
@@ -219,9 +239,14 @@ class UserService
$config['where']['status'] = $p['status'];
}
$config['dataLimit']['type'] = 'relation';
$config['dataLimit']['userId'] = $request->user->uid;
$config['dataLimit']['userField'] = 'report_to';
$config['with']= ['reportToInfo'];
if (!in_array(1, $request->user->role_ids)) {
$config['where']['reportToInfo.id'] = $request->user->uid;
}
// $config['dataLimit']['type'] = 'relation';
// $config['dataLimit']['userId'] = $request->user->uid;
// $config['dataLimit']['userField'] = 'report_to';
break;
}
+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];
}
}
@@ -68,7 +68,7 @@ class ApprovalFlowService
}
$this->sendNotification($approverIds, $instance, 'pending', false, $extra);
$this->notifyCCs($firstStep, $instance, 'pending');
// $this->notifyCCs($firstStep, $instance, 'pending');
$this->pushTodoChanged($approverIds);
@@ -193,6 +193,12 @@ class ApprovalFlowService
'updated_at' => $now,
]);
$this->onFlowFinalized($instance, $finalStatus);
// 新增:在流程最终完成时才发本步骤的抄送
if (!empty($step['cc'])) {
$this->notifyCCs($step, $instance, $notifyAction);
}
$this->sendNotification([$instance->initiator_id], $instance, $notifyAction, false);
$this->pushTodoChanged([$approverId]);
return ['status' => 'completed', 'msg' => ($finalStatus === 'voided' ? '已作废' : '同意')];
@@ -215,7 +221,7 @@ class ApprovalFlowService
$this->insertApprovalRecord($instance, $nextIndex, $uid);
}
$this->sendNotification($nextApprovers, $instance, 'pending', false);
$this->notifyCCs($nextStep, $instance, 'pending');
// $this->notifyCCs($nextStep, $instance, 'pending');
$this->pushTodoChanged(array_merge([$approverId], $nextApprovers));
return ['status' => 'next_step', 'msg' => '进行下一步:'.$nextStep['name'] ?? ('Step '.$nextIndex)];
});
@@ -0,0 +1,857 @@
<?php
namespace app\common\utils\crawl\service;
use app\common\model\UsedcarCrawlResultsImages;
use app\common\service\UploadService;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use QL\QueryList;
use support\Redis;
use support\Db;
use support\exception\BusinessException;
use Throwable;
class UsedCarCrawl
{
// 默认请求头
private const DEFAULT_HEADERS = [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language' => 'zh-CN,zh;q=0.9',
'Referer' => 'https://www.dongchedi.com/',
];
// Redis队列键名
private const REDIS_QUEUE_KEY = 'usedcar:urls';
// 能源类型常量
public const ENERGY_TYPE_RENEWABLE = 'RE';
public const ENERGY_TYPE_CONVENTIONAL = 'CE';
public const ENERGY_TYPE_UNKNOWN = 'UNKNOWN';
/**
* 插入队列url
*
* @param string $url URL地址
* @param string $scene 场景:usedcar(二手信息) config(配置信息)
* @param int $id 记录ID
* @return int
*/
public function pushUrl(string $url = '', string $scene = 'usedcar', int $id = 0): int
{
$data = [
'scene' => $scene,
'url' => $url,
'id' => $id,
];
return Redis::lpush(
self::REDIS_QUEUE_KEY,
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
}
/**
* 获取队列url
*
* @return array|null
* @throws BusinessException
*/
public function popUrl(): ?array
{
$data = Redis::rPop(self::REDIS_QUEUE_KEY);
if (!$data) {
return null;
}
$task = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($task)) {
throw new BusinessException('任务数据解析失败:' . json_last_error_msg());
}
// 基本字段校验
if (empty($task['url']) || empty($task['scene'])) {
throw new BusinessException('队列任务数据缺少必要字段:url 或 scene');
}
return $task;
}
/**
* 执行抓取任务
*
* @return bool
* @throws BusinessException
*/
public function execute(): bool
{
try {
$task = $this->popUrl();
if (!$task) {
// 队列为空,任务结束
return false;
}
$url = (string)$task['url'];
$scene = (string)$task['scene'];
$id = (int)($task['id'] ?? 0);
$html = $this->fetchHtml($url);
if ($html === '') {
throw new BusinessException("页面内容为空: {$url}");
}
switch ($scene) {
case 'usedcar':
$info = $this->parseUsedCarDetail($html, $url);
$insertId = $this->saveUsedCarBasicInfo($info);
// 下载图片 + 上传 + 入库(图片失败不影响主记录)
$imagesArray = json_decode($info['images'] ?? '[]', true);
if (json_last_error() !== JSON_ERROR_NONE) {
$imagesArray = [];
}
$saveDir = public_path('uploads/usedcar/' . $insertId);
$this->uploadAndRemove($imagesArray, $saveDir, $info['used_car_id'], $insertId);
// 继续派发配置页任务
if (!empty($info['car_id_url'])) {
$this->pushUrl($info['car_id_url'], 'config', $insertId);
}
break;
case 'config':
if ($id <= 0) {
throw new BusinessException("配置任务缺少主记录ID");
}
$data = $this->parseConfigDetail($html);
$this->saveUsedCarConfigInfo($data, $id);
break;
default:
throw new BusinessException("未知的任务场景: " . $scene);
}
return true;
} catch (GuzzleException $e) {
throw new BusinessException("网络请求失败: " . $e->getMessage());
} catch (BusinessException $e) {
// 业务异常原样抛出,方便上层知道是逻辑问题
throw $e;
} catch (Throwable $th) {
// 兜底异常,避免 worker 直接挂掉
throw new BusinessException(
"执行任务出错: " . $th->getMessage()
. ' | 行号: ' . $th->getLine()
. ' | 文件: ' . $th->getFile()
);
}
}
/**
* 获取网页HTML内容
*
* @param string $url
* @return string
* @throws GuzzleException
*/
public function fetchHtml(string $url): string
{
$client = new Client();
$response = $client->request('GET', $url, [
'headers' => self::DEFAULT_HEADERS,
'timeout' => 10,
]);
return (string)$response->getBody();
}
/**
* 解析二手车详情页信息
*
* @param string $html
* @param string $url
* @return array
* @throws BusinessException
*/
public function parseUsedCarDetail(string $html = '', string $url = ''): array
{
try {
$ql = QueryList::html($html);
} catch (Throwable $e) {
throw new BusinessException('解析 HTML 失败(详情页): ' . $e->getMessage());
}
$title = trim($ql->find('h1')->text());
$price = trim($ql->find('.head-info_price-wrap__Y4bxi > p')->text());
$pageText = $ql->find('body')->text();
$jsonText = $ql->find('#__NEXT_DATA__')->text();
if ($jsonText === '') {
throw new BusinessException('详情页未找到 __NEXT_DATA__ JSON');
}
$jsonArray = $this->decodeJson($jsonText, '详情页 __NEXT_DATA__');
// 车辆相册
$images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? [];
// 车辆ID / 年份
$carInfo = $jsonArray['props']['pageProps']['skuDetail']['car_info'] ?? [];
$carId = $carInfo['car_id'] ?? 0;
$year = (int)($carInfo['year'] ?? 0);
// 车辆ID URL
$carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : '';
// 能源类型(两个字段择一)
$overview = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview'] ?? [];
$fuelForm = $overview['power']['fuel_form'] ?? '';
$newFuelForm = $overview['new_energy_power']['fuel_form'] ?? '';
$resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm;
// 使用里程
$importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? '';
$importantTextArray = $importantText ? explode(' | ', $importantText) : [];
$usageMileage = '';
if (isset($importantTextArray[1])) {
$usageMileage = str_replace('万公里', '', $importantTextArray[1]);
}
// 车辆名称 品牌、车系、车型
$brandName = $carInfo['brand_name'] ?? '';
$seriesName = $carInfo['series_name'] ?? '';
$carName = $carInfo['car_name'] ?? '';
// 商家信息
$shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? [];
$formatShopInfo = [
'shop_id' => $shopInfo['shop_id'] ?? 0,
'shop_name' => $shopInfo['shop_short_name'] ?? '',
'shop_address' => $shopInfo['shop_address'] ?? '',
];
// 简介参数
$otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? [];
$displacement = $this->getParamValue($otherParams, '排量');
$gearbox = $this->getParamValue($otherParams, '变速箱');
$formatCarInfo = [
'displacement' => $displacement,
'gearbox' => $gearbox,
];
// 提取新车指导价 / 比新车省金额
$newPrice = $this->extractPatternValue($pageText, '/新车指导价[::]\s*([^\r\n万]*万)/u');
$priceDifference = $this->extractPatternValue($pageText, '/比新车省[::]\s*([^\r\n万]*万)/u');
// 提取车况介绍
$conditionDesc = '';
if ($pageText && preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) {
$conditionDesc = trim($m[1]);
}
return [
'used_car_id' => $this->extractIdFromUrl($url),
'car_id' => $carId,
'url' => $url,
'title' => $title,
'price' => str_replace('万', '', $this->decodePrice($price)),
'new_price' => str_replace('万', '', $newPrice),
'price_difference' => str_replace('万', '', $priceDifference),
'condition_desc' => $conditionDesc,
'images' => json_encode($images, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'car_id_url' => $carIdUrl,
'used_detail' => $jsonText,
'format_shop_info' => json_encode($formatShopInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'fuel_form' => $resultFuelForm,
'classify_energy' => $this->detectEnergyType($resultFuelForm),
'usage_mileage' => $usageMileage,
'brand_name' => $brandName,
'series_name' => $seriesName,
'car_name' => $carName,
'year' => $year,
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
];
}
/**
* 解析配置详情页信息
*
* @param string $html
* @return array
* @throws BusinessException
*/
public function parseConfigDetail(string $html = ''): array
{
try {
$ql = QueryList::html($html);
} catch (Throwable $e) {
throw new BusinessException('解析 HTML 失败(配置页): ' . $e->getMessage());
}
$jsonText = $ql->find('#__NEXT_DATA__')->text();
if ($jsonText === '') {
throw new BusinessException('配置页未找到 __NEXT_DATA__ JSON');
}
$raw = $this->decodeJson($jsonText, '配置页 __NEXT_DATA__');
$properties = $raw['props']['pageProps']['rawData']['properties'] ?? [];
$carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? [];
$valuesMap = $this->buildValuesMap($carInfo);
$groups = $this->buildCarSpecGroups($properties, $valuesMap);
return [
'format_car_info' => [
'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间
'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式
'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数
'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机
'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率
'electric_description' => $this->getFieldValue($groups, 'electric_description'), // 电动机
'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), // 电池容量
'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型
'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage'), // 续航里程
],
'groups' => $groups,
];
}
/**
* 从车辆配置中获取能源类型
*
* @param array $data JSON数据
* @return string|null 能源类型
*/
public function getFuelFormByConfig(array $data): ?string
{
if (empty($data[0]['items']) || !is_array($data[0]['items'])) {
return null;
}
foreach ($data[0]['items'] as $item) {
if (isset($item['key']) && $item['key'] === 'fuel_form') {
return $item['value'] ?? null;
}
}
return null;
}
/**
* 从数据中根据字段key获取值
*
* @param mixed $data 数据
* @param string $field 字段名
* @return string|null
*/
public function getFieldValue($data, string $field): ?string
{
if (is_string($data)) {
$data = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return null;
}
}
if (!is_array($data)) {
return null;
}
foreach ($data as $group) {
if (isset($group['items']) && is_array($group['items'])) {
foreach ($group['items'] as $item) {
if (($item['key'] ?? null) === $field) {
return $item['value'] ?? null;
}
if (!empty($item['children']) && is_array($item['children'])) {
foreach ($item['children'] as $child) {
if (($child['key'] ?? null) === $field) {
return $child['value'] ?? null;
}
}
}
}
}
}
return null;
}
/**
* 从参数列表中获取指定名称的值
*/
public function getParamValue(array $params, string $name): ?string
{
foreach ($params as $item) {
if (($item['name'] ?? null) === $name) {
return $item['value'] ?? null;
}
}
return null;
}
/**
* 判断车辆能源类型
*/
public function detectEnergyType(string $text): string
{
if (function_exists('mb_internal_encoding')) {
mb_internal_encoding('UTF-8');
}
// 明确定义的新能源关键字
$newEnergyKeywords = [
'纯电', '纯电动', '电动车', 'BEV',
'插电混动', '插电式混合', '插电式', '插电', 'PHEV',
'增程式', '增程', 'REEV',
'氢能', '氢燃料', '燃料电池', 'FCEV',
'太阳能',
];
// 传统能源关键字
$traditionalKeywords = [
'汽油',
'柴油',
'天然气', 'LNG', 'CNG',
];
// 普通混动关键字(归为传统能源)
$hybridKeywords = [
'油电混合', '油电混动', '油电',
'混动',
'双擎',
'HEV',
];
$contains = function (string $haystack, string $needle): bool {
if ($needle === '') {
return false;
}
if (function_exists('mb_stripos')) {
return mb_stripos($haystack, $needle) !== false;
}
return stripos($haystack, $needle) !== false;
};
foreach ($newEnergyKeywords as $kw) {
if ($contains($text, $kw)) {
return self::ENERGY_TYPE_RENEWABLE;
}
}
foreach ($hybridKeywords as $kw) {
if ($contains($text, $kw)) {
return self::ENERGY_TYPE_CONVENTIONAL;
}
}
foreach ($traditionalKeywords as $kw) {
if ($contains($text, $kw)) {
return self::ENERGY_TYPE_CONVENTIONAL;
}
}
return self::ENERGY_TYPE_UNKNOWN;
}
/**
* 保存二手车基本信息到数据库
*
* @param array $data 数据
* @return int 插入记录ID
* @throws BusinessException
*/
public function saveUsedCarBasicInfo(array $data): int
{
try {
$time = time();
$data['created_at'] = $data['created_at'] ?? $time;
$data['updated_at'] = $time;
return (int)Db::table('usedcar_crawl_results')->insertGetId($data);
} catch (Throwable $e) {
throw new BusinessException('保存二手车基础信息失败:' . $e->getMessage());
}
}
/**
* 更新配置信息到数据库
*
* @param array $data 配置数据
* @param int $id 记录ID
* @return bool|int
* @throws BusinessException
*/
public function saveUsedCarConfigInfo(array $data, int $id)
{
try {
$record = Db::table('usedcar_crawl_results')->find($id);
if (!$record) {
// 找不到记录就算了,不抛致命错误,视业务情况也可以抛异常
return false;
}
$groups = $data['groups'] ?? [];
$oldFormat = [];
if (!empty($record->format_car_info)) {
$decoded = json_decode($record->format_car_info, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
$oldFormat = $decoded;
}
}
$formatCarInfo = array_merge(
$oldFormat,
$data['format_car_info'] ?? []
);
return Db::table('usedcar_crawl_results')
->where('id', $id)
->update([
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => time(),
]);
} catch (Throwable $e) {
throw new BusinessException('保存车辆配置信息失败:' . $e->getMessage());
}
}
/**
* 构建值映射表
*/
public function buildValuesMap(array $carInfo): array
{
$valuesMap = [];
// 顶层字段
foreach ($carInfo as $k => $v) {
if ($k === 'info' || is_array($v)) {
continue;
}
$valuesMap[$k] = ['value' => $v];
}
// info中的配置项
if (!empty($carInfo['info']) && is_array($carInfo['info'])) {
foreach ($carInfo['info'] as $k => $v) {
if (is_array($v) && array_key_exists('value', $v)) {
$valuesMap[$k] = $v;
}
}
}
return $valuesMap;
}
/**
* 构建车辆规格分组
*/
public function buildCarSpecGroups(array $properties, array $valuesMap): array
{
$groups = [];
$currentKey = null;
foreach ($properties as $item) {
$type = (int)($item['type'] ?? 1);
$key = $item['key'] ?? '';
$text = $item['text'] ?? '';
$subList = $item['sub_list'] ?? null;
// 分类字段:type = 0 / 2
if (in_array($type, [0, 2], true)) {
$currentKey = $key ?: ($text ?: ('group_' . count($groups)));
if (!isset($groups[$currentKey])) {
$groups[$currentKey] = [
'group_key' => $currentKey,
'group_text' => $text,
'group_type' => $type,
'items' => [],
];
}
continue;
}
// 普通字段:挂到最近的分类下
if ($currentKey === null) {
$currentKey = '_default';
$groups[$currentKey] = [
'group_key' => '_default',
'group_text' => '其他',
'group_type' => -1,
'items' => [],
];
}
$value = '';
if ($key && isset($valuesMap[$key])) {
$value = trim((string)($valuesMap[$key]['value'] ?? ''));
}
$node = [
'key' => $key,
'text' => $text,
'value' => $value,
];
if (is_array($subList) && !empty($subList)) {
$children = [];
foreach ($subList as $sub) {
$subKey = $sub['key'] ?? '';
$subText = $sub['text'] ?? '';
$subValue = '';
if ($subKey && isset($valuesMap[$subKey])) {
$subValue = trim((string)($valuesMap[$subKey]['value'] ?? ''));
}
$children[] = [
'key' => $subKey,
'text' => $subText,
'value' => $subValue,
];
}
$node['children'] = $children;
}
$groups[$currentKey]['items'][] = $node;
}
return array_values($groups);
}
/**
* 解码懂车帝价格
*/
protected function decodePrice(string $str): string
{
$map = [
"\u{E53D}" => '1',
"\u{E3F0}" => '2',
"\u{E422}" => '3',
"\u{E42C}" => '4',
"\u{E49C}" => '5',
"\u{E42B}" => '6',
"\u{E4FE}" => '7',
"\u{E548}" => '8',
"\u{E4C8}" => '9',
"\u{E453}" => '0',
"\u{E45F}" => '万',
];
return strtr($str, $map);
}
/**
* 从URL中提取ID
*/
protected function extractIdFromUrl(string $url)
{
if (preg_match('/\/(\d+)$/', $url, $matches)) {
return (int)$matches[1];
}
return false;
}
/**
* 提取符合正则表达式的值(只保留数字和小数点)
*/
protected function extractPatternValue(string $text, string $pattern): string
{
$value = '';
if ($text && preg_match($pattern, $text, $m)) {
$value = trim(preg_replace('/[^0-9.]/', '', $m[1]));
}
return $value;
}
/**
* 下载单张图片
*/
public function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string
{
if (!is_dir($saveDir)) {
if (!@mkdir($saveDir, 0777, true) && !is_dir($saveDir)) {
// echo "[Image] 创建目录失败:{$saveDir}\n";
return null;
}
}
if ($filename === null) {
$path = parse_url($url, PHP_URL_PATH);
$ext = pathinfo($path ?? '', PATHINFO_EXTENSION);
if ($ext === '') {
$ext = 'jpg';
}
$filename = md5($url) . '.' . $ext;
}
$savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename;
$defaultHeaders = [
'User-Agent: ' . self::DEFAULT_HEADERS['User-Agent'],
'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Referer: ' . self::DEFAULT_HEADERS['Referer'],
];
foreach ($headers as $h) {
$defaultHeaders[] = $h;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => $defaultHeaders,
]);
$data = curl_exec($ch);
$errNo = curl_errno($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errNo !== 0 || $code !== 200 || $data === false || $data === '') {
return null;
}
$bytes = @file_put_contents($savePath, $data);
if ($bytes === false) {
return null;
}
return $savePath;
}
/**
* 批量下载相册
*
* @param array $urls 图片URL数组
* @param string $saveDir 保存目录
* @return array [原始URL => 保存路径或null]
*/
public function downloadImageAlbum(array $urls, string $saveDir): array
{
$result = [];
foreach ($urls as $url) {
$url = trim((string)$url);
if ($url === '') {
continue;
}
if (strpos($url, '//') === 0) {
$url = 'https:' . $url;
}
try {
$path = $this->downloadImage($url, $saveDir);
} catch (Throwable $e) {
$path = null;
}
$result[$url] = $path;
}
return $result;
}
/**
* 下载 -> 上传 -> 删除本地
*
* @param array $images 原始相册URL列表
* @param string $path 本地保存目录
* @param int $usedCarId 懂车帝 used_car_id
* @param int $id 本地 crawl 记录id
* @return bool
*/
public function uploadAndRemove(array $images, string $path, $usedCarId, $id): bool
{
if (empty($images)) {
return false;
}
// 下载图片
$filePatch = $this->downloadImageAlbum($images, $path);
if (empty($filePatch)) {
return false;
}
$uploadService = new UploadService();
foreach ($filePatch as $url => $filePath) {
if ($filePath === null) {
continue;
}
try {
// 上传云端
$param = [
'path' => $filePath,
'scene' => 'usedcar_crawl',
'category' => 'usedcar',
];
$result = $uploadService->uploadLocalFile($param);
if (empty($result['file_name'])) {
// 上传失败则跳过,但不抛异常影响主流程
continue;
}
// 保存图片信息
UsedcarCrawlResultsImages::create([
'uc_id' => $id,
'used_car_id' => $usedCarId,
'original_name'=> $result['original_name'] ?? '',
'file_path' => $result['file_name'],
]);
} catch (Throwable $e) {
// 单张图失败不影响其他
// 可以按需写日志
} finally {
// 删除本地文件
if (is_file($filePath)) {
@unlink($filePath);
}
}
}
// 删除目录
rmdir($path);
return true;
}
/**
* 安全解 JSON
*
* @param string $json
* @param string $context
* @return array
* @throws BusinessException
*/
protected function decodeJson(string $json, string $context = 'JSON'): array
{
$data = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
throw new BusinessException($context . ' 解析失败:' . json_last_error_msg());
}
return $data;
}
}
@@ -0,0 +1,681 @@
<?php
namespace app\common\utils\crawl\service;
use GuzzleHttp\Client;
use QL\QueryList;
use support\Redis;
use support\Db;
use support\exception\BusinessException;
class UsedCarCrawl_bak
{
/**
* 插入队列url
*
* $scene 二手信息 usedcar 配置信息 config
*/
public function iUrl(string $url = '', string $scene = 'usedcar', int $id = 0)
{
return Redis::lpush('usedcar:urls', json_encode([
'scene' => $scene,
'url' => $url,
'id' => $id
], JSON_UNESCAPED_UNICODE));
}
/**
* 获取队列url
*
* $scene 二手信息 usedcar 配置信息 config
*/
public function rUrl()
{
return json_decode(Redis::rPop('usedcar:urls'), true);
}
/**
* 执行抓取
*/
public function doData()
{
try {
$raw = $this->rUrl();
if (!$raw) {
return false;
}
$html = $this->getHtml($raw['url']);
switch ($raw['scene']) {
case 'usedcar':
$info = $this->getUsedDetail($html, $raw['url']);
$id = $this->saveUsedcarBasicToDb($info);
$this->iUrl($info['car_id_url'], 'config', $id);
break;
case 'config':
$data = $this->getConfigDetail($html);
$this->saveUsedcarConfigToDb($data, $raw['id']);
break;
}
return true;
} catch (\Throwable $th) {
throw new BusinessException($th->getMessage().'|'.$th->getLine().'|'.$th->getFile());
}
}
/**
* 获取html
*/
public function getHtml($url)
{
$client = new Client();
$res = $client->request('GET', $url, [
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
. 'AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/120.0.0.0 Safari/537.36',
'Accept-Language' => 'zh-CN,zh;q=0.9',
'Referer' => 'https://www.dongchedi.com/'
]
]);
return (string)$res->getBody();
}
/**
* 获取二手信息
*/
public function getUsedDetail(string $html = '', string $url = '')
{
$ql = QueryList::html($html);
$title = $ql->find('h1')->text();
$price = $ql->find('.head-info_price-wrap__Y4bxi > p')->text();
$pageText = $ql->find('body')->text();
$jsonText = $ql->find('#__NEXT_DATA__')->text();
// 新车指导价
$newPrice = '';
if ($pageText) {
if (preg_match('/新车指导价[::]\s*([^\r\n万]*万)/u', $pageText, $m)) {
$newPrice = trim(preg_replace('/[^0-9.]/', '', $m[1]));
}
}
// 比新车省
$priceDifference = '';
if ($pageText) {
if (preg_match('/比新车省[::]\s*([^\r\n万]*万)/u', $pageText, $m)) {
$priceDifference = trim(preg_replace('/[^0-9.]/', '', $m[1]));
}
}
// 车况介绍
$conditionDesc = '';
if ($pageText) {
if (preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) {
$conditionDesc = trim($m[1]);
}
}
$jsonArray = json_decode($jsonText, true);
// 车辆相册
$images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? [];
// 车辆ID
$carId = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_id'] ?? 0;
// 车辆年份
$year = $jsonArray['props']['pageProps']['skuDetail']['car_info']['year'] ?? 0;
// 车辆ID URL
$carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : '';
// 能源类型
$fuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['power']['fuel_form'] ?? '';
$newFuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['new_energy_power']['fuel_form'] ?? '';
$resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm;
// 使用里程
$importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? '';
$importantTextArray = explode(' | ', $importantText);
$usageMileage = str_replace('万公里', '', isset($importantTextArray[1]) ? $importantTextArray[1] : '');
// 车辆名称 品牌、车系、车型
$brandName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['brand_name'] ?? '';
$seriesName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['series_name'] ?? '';
$carName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_name'] ?? '';
// 商家信息
$shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? [];
$formatShopInfo = [
'shop_id' => $shopInfo['shop_id'] ?? 0,
'shop_name' => $shopInfo['shop_short_name'] ?? '',
'shop_address' => $shopInfo['shop_address'] ?? ''
];
// 简介参数
$otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? [];
// 排量
$displacement = $this->getOtherParamValue($otherParams, '排量');
// 变速箱
$gearbox = $this->getOtherParamValue($otherParams, '变速箱');
// 格式化车辆信息
$formatCarInfo = [
'displacement' => $displacement,
'gearbox' => $gearbox
];
// 整理车辆信息
$info['used_car_id'] = $this->getLastIdFromUrl($url);
$info['car_id'] = $carId;
$info['url'] = $url;
$info['title'] = $title;
$info['price'] = str_replace('万', '', $this->decodeDongchediPrice($price));
$info['new_price'] = str_replace('万', '', $newPrice);
$info['price_difference'] = str_replace('万', '', $priceDifference);
$info['condition_desc'] = $conditionDesc;
$info['images'] = json_encode($images);
$info['car_id_url'] = $carIdUrl;
$info['used_detail'] = $jsonText;
$info['format_shop_info'] = json_encode($formatShopInfo);
$info['fuel_form'] = $resultFuelForm;
$info['classify_energy'] = $this->detectEnergyType($resultFuelForm);
$info['usage_mileage'] = $usageMileage;
$info['brand_name'] = $brandName;
$info['series_name'] = $seriesName;
$info['car_name'] = $carName;
$info['year'] = $year;
$info['format_car_info'] = json_encode($formatCarInfo);
return $info;
}
/**
* 获取配置信息
*/
public function getConfigDetail(string $html = '')
{
$ql = QueryList::html($html);
$jsonText = $ql->find('#__NEXT_DATA__')->text();
$raw = json_decode($jsonText, true);
$properties = $raw['props']['pageProps']['rawData']['properties'] ?? [];
$carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? [];
$valuesMap = $this->buildValuesMapFromCarInfo($carInfo);
$groups = $this->buildCarSpecGroups($properties, $valuesMap);
$data['format_car_info'] = [
'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间
'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式
'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数
'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机
'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率
'electric_description' => $this->getFieldValue($groups, 'electric_description'), //电动机
'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), //电池容量
'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型
'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage') // 续航里程
];
$data['groups'] = $groups;
return $data;
}
/**
* 从车辆配置 JSON 数据中获取能源类型
*
* @param array $data JSON 数据
* @return string|null 能源类型,如 '油电混合'、'纯电动'、
*/
public function getFuelFormByConfigInfo(array $data)
{
$fuelForm = null;
foreach ($data[0]['items'] as $item) {
if ($item['key'] === 'fuel_form') {
$fuelForm = $item['value'];
break;
}
}
return $fuelForm;
}
/**
* 从车辆配置 JSON 数据中根据字段 key 获取对应的 value
*
* @param string|array $data JSON 字符串 或 已解析的数组
* @param string $field 要查找的字段 key(如 'fuel_form')
* @return string|null 找到则返回 value,否则返回 null
*/
public function getFieldValue($data, string $field)
{
// 如果传入的是 JSON 字符串,先解码
if (is_string($data)) {
$data = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return null; // JSON 格式错误
}
}
// 遍历第一层(假设结构是 [ { "items": [...] } ])
foreach ($data as $group) {
if (isset($group['items']) && is_array($group['items'])) {
foreach ($group['items'] as $item) {
if (isset($item['key']) && $item['key'] === $field) {
return $item['value'] ?? null;
}
}
}
}
return null; // 未找到
}
/**
* 从 other_params 数组中根据 name 获取对应的 value
*
* @param array $otherParams 格式为 [ ['name' => 'xxx', 'value' => 'yyy'], ... ]
* @param string $name 要查找的字段名称(如 '上牌地')
* @return string|null 找到则返回 value,否则返回 null
*/
public function getOtherParamValue(array $otherParams, string $name): ?string
{
foreach ($otherParams as $item) {
if (isset($item['name']) && $item['name'] === $name) {
return $item['value'] ?? null;
}
}
return null;
}
/**
* 根据给定字符串判断车辆能源类型
*
* 返回:
* - RE 新能源(纯电、插电混动、增程、氢燃料等)
* - CE 传统能源(汽油、柴油、天然气、非插电混动等)
* - UNKNOWN 无法判断
*
* @param string $text 例如:'2022款 某某牌 2.0L 油电混合 双擎'
* @return string
*/
public function detectEnergyType(string $text): string
{
if (function_exists('mb_internal_encoding')) {
mb_internal_encoding('UTF-8');
}
// 明确定义的新能源关键字(纯电 / 插电 / 增程 / 氢 / 太阳能 等)
$newEnergyKeywords = [
// 纯电
'纯电', '纯电动', '电动车', 'BEV',
// 插电式混合
'插电混动', '插电式混合', '插电式', '插电', 'PHEV',
// 增程式
'增程式', '增程', 'REEV',
// 氢能 / 燃料电池
'氢能', '氢燃料', '燃料电池', 'FCEV',
// 其它
'太阳能',
];
// 传统能源关键字(包括燃油/燃气)
$traditionalKeywords = [
'汽油',
'柴油',
'天然气', 'LNG', 'CNG',
];
// 普通混动关键字(按你的要求,统一归为传统能源)
$hybridKeywords = [
'油电混合', '油电混动', '油电',
'混动', // 不带“插电”的混动
'双擎', // 丰田常见表达
'HEV',
];
// 辅助:不区分大小写的包含判断,优先 mb_stripos
$contains = function (string $haystack, string $needle): bool {
if ($needle === '') {
return false;
}
if (function_exists('mb_stripos')) {
return mb_stripos($haystack, $needle) !== false;
}
return stripos($haystack, $needle) !== false;
};
// 1. 先判断“明确新能源”关键词(插电、增程、氢、纯电等)
foreach ($newEnergyKeywords as $kw) {
if ($contains($text, $kw)) {
return 'RE';
}
}
// 2. 再判断“普通油电混动”——按照你的业务,一律归为传统能源
foreach ($hybridKeywords as $kw) {
if ($contains($text, $kw)) {
return 'CE';
}
}
// 3. 再判断传统能源(纯燃油/气)
foreach ($traditionalKeywords as $kw) {
if ($contains($text, $kw)) {
return 'CE';
}
}
// 4. 都没匹配上
return 'UNKNOWN';
}
/**
* 二手信息入库
*/
public function saveUsedcarBasicToDb(array $data = [])
{
$data['created_at'] = time();
$data['updated_at'] = time();
return Db::table('usedcar_crawl_results')->insertGetId($data);
}
/**
* 更新信息入库
*/
public function saveUsedcarConfigToDb(array $data = [], int $id = 0)
{
$info = Db::table('usedcar_crawl_results')->find($id);
if (!$info) {
return false;
}
$groups = $data['groups'];
$formatCarInfo = array_merge($data['format_car_info'], json_decode($info->format_car_info, true));
return Db::table('usedcar_crawl_results')
->where('id', $id)
->update([
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE),
'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE),
'updated_at' => time()
]);
}
/**
* 把 car_info 扁平成一个 [key => ['value' => xxx]] 的 map
*
* @param array $carInfo rawData['car_info'][0]
* @return array
*/
public function buildValuesMapFromCarInfo(array $carInfo): array
{
$valuesMap = [];
// 1) car_info 顶层的一些字段(official_price、series_name 等)
foreach ($carInfo as $k => $v) {
if ($k === 'info' || is_array($v)) {
continue;
}
$valuesMap[$k] = ['value' => $v];
}
// 2) info 里的配置项
if (!empty($carInfo['info']) && is_array($carInfo['info'])) {
foreach ($carInfo['info'] as $k => $v) {
if (is_array($v) && array_key_exists('value', $v)) {
$valuesMap[$k] = $v;
}
}
}
return $valuesMap;
}
/**
* 根据 type 重新分组:
* - type=0,2 为分组(左侧分类)
* - 其它 type 都视为该分组下的字段
*
* @param array $properties rawData['properties']
* @param array $valuesMap 来自 buildValuesMapFromCarInfo()
* @return array
*/
public function buildCarSpecGroups(array $properties, array $valuesMap): array
{
$groups = [];
$currentKey = null; // 当前分组 key
foreach ($properties as $item) {
$type = (int)($item['type'] ?? 1);
$key = $item['key'] ?? '';
$text = $item['text'] ?? '';
$subList = $item['sub_list'] ?? null;
// ---------- 1) 分类字段:type = 0 / 2 ----------
if (in_array($type, [0, 2], true)) {
$currentKey = $key ?: ($text ?: ('group_' . count($groups)));
if (!isset($groups[$currentKey])) {
$groups[$currentKey] = [
'group_key' => $currentKey,
'group_text' => $text,
'group_type' => $type,
'items' => [],
];
}
continue;
}
// ---------- 2) 普通字段:挂到最近的分类下 ----------
if ($currentKey === null) {
// 如果一开始就没有遇到分类,给一个默认分组
$currentKey = '_default';
$groups[$currentKey] = [
'group_key' => '_default',
'group_text' => '其他',
'group_type' => -1,
'items' => [],
];
}
// 取 value
$value = '';
if ($key && isset($valuesMap[$key])) {
$value = trim((string)($valuesMap[$key]['value'] ?? ''));
}
$node = [
'key' => $key,
'text' => $text,
'value' => $value,
];
// ---------- 3) sub_list 子项(有就当成 children) ----------
if (is_array($subList) && !empty($subList)) {
$children = [];
foreach ($subList as $sub) {
$subKey = $sub['key'] ?? '';
$subText = $sub['text'] ?? '';
$subValue = '';
if ($subKey && isset($valuesMap[$subKey])) {
$subValue = trim((string)($valuesMap[$subKey]['value'] ?? ''));
}
$children[] = [
'key' => $subKey,
'text' => $subText,
'value' => $subValue,
];
}
$node['children'] = $children;
}
$groups[$currentKey]['items'][] = $node;
}
// 你可以按需返回关联数组或索引数组,这里转成索引数组方便前端遍历
return array_values($groups);
}
/**
* 转义价格
*/
protected function decodeDongchediPrice(string $str): string
{
// 映射表:PUA 字符 => 数字 / 万
$map = [
"\u{E53D}" => '1',
"\u{E3F0}" => '2',
"\u{E422}" => '3',
"\u{E42C}" => '4',
"\u{E49C}" => '5',
"\u{E42B}" => '6',
"\u{E4FE}" => '7',
"\u{E548}" => '8',
"\u{E4C8}" => '9',
"\u{E453}" => '0',
"\u{E45F}" => '万',
];
return strtr($str, $map);
}
/**
* 获取URL最后ID
*/
protected function getLastIdFromUrl($url)
{
// 方法1:使用正则表达式提取最后的数字ID
if (preg_match('/\/(\d+)$/', $url, $matches)) {
return $matches[1];
}
return false;
}
/**
* 下载单张图片
*
* @param string $url 图片完整 URL
* @param string $saveDir 保存目录(如 /www/usedcar_images/2025-11-20)
* @param string|null $filename 指定文件名(可选,不含路径),为空则自动生成
* @param array $headers 额外 HTTP 头(可选)
*
* @return string|null 成功返回保存路径,失败返回 null
*/
protected function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string
{
// 1. 目录检查 & 创建
if (!is_dir($saveDir)) {
if (!mkdir($saveDir, 0777, true) && !is_dir($saveDir)) {
echo "[Image] 创建目录失败:{$saveDir}\n";
return null;
}
}
// 2. 自动生成文件名(基于 URL md5 + 原扩展名)
if ($filename === null) {
$path = parse_url($url, PHP_URL_PATH); // /xxx/xxx.jpg
$ext = pathinfo($path, PATHINFO_EXTENSION); // jpg 或 webp 之类
if ($ext === '') {
$ext = 'jpg'; // 没有扩展名时兜底
}
$filename = md5($url) . '.' . $ext;
}
$savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename;
// 3. 组装 HTTP 头
$defaultHeaders = [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
. 'AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/120.0.0.0 Safari/537.36',
'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Referer: https://www.dongchedi.com/', // 很多图床需要 Referer
];
foreach ($headers as $h) {
$defaultHeaders[] = $h;
}
// 4. 使用 cURL 下载
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => $defaultHeaders,
]);
$data = curl_exec($ch);
$errNo = curl_errno($ch);
$errMsg = curl_error($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errNo !== 0) {
echo "[Image] 下载失败(cURL):{$url} | {$errNo} {$errMsg}\n";
return null;
}
if ($code !== 200 || $data === false || $data === '') {
echo "[Image] 下载失败(HTTP {$code}):{$url}\n";
return null;
}
// 5. 写入本地文件
$bytes = file_put_contents($savePath, $data);
if ($bytes === false) {
echo "[Image] 保存失败:{$savePath}\n";
return null;
}
echo "[Image] 下载成功:{$url} => {$savePath}\n";
return $savePath;
}
/**
* 批量下载相册
*
* @param array $urls 图片 URL 数组
* @param string $saveDir 保存目录
*
* @return array [原始 URL => 保存路径或 null]
*/
protected function downloadImageAlbum(array $urls, string $saveDir): array
{
$result = [];
foreach ($urls as $url) {
$url = trim($url);
if ($url === '') {
continue;
}
// 如果存在 // 开头的相对协议,补全
if (strpos($url, '//') === 0) {
$url = 'https:' . $url;
}
$path = $this->downloadImage($url, $saveDir);
$result[$url] = $path;
}
return $result;
}
}
@@ -0,0 +1,798 @@
<?php
namespace app\common\utils\crawl\service;
use app\common\model\UsedcarCrawlResultsImages;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use QL\QueryList;
use support\Redis;
use support\Db;
use support\exception\BusinessException;
use Throwable;
class UsedCarCrawl_bak2
{
// 默认请求头
private const DEFAULT_HEADERS = [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language' => 'zh-CN,zh;q=0.9',
'Referer' => 'https://www.dongchedi.com/'
];
// Redis队列键名
private const REDIS_QUEUE_KEY = 'usedcar:urls';
// 能源类型常量
public const ENERGY_TYPE_RENEWABLE = 'RE';
public const ENERGY_TYPE_CONVENTIONAL = 'CE';
public const ENERGY_TYPE_UNKNOWN = 'UNKNOWN';
/**
* 插入队列url
*
* @param string $url URL地址
* @param string $scene 场景:usedcar(二手信息) config(配置信息)
* @param int $id 记录ID
* @return int
*/
public function pushUrl(string $url = '', string $scene = 'usedcar', int $id = 0): int
{
$data = [
'scene' => $scene,
'url' => $url,
'id' => $id
];
return Redis::lpush(self::REDIS_QUEUE_KEY, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
/**
* 获取队列url
*
* @return array|null
*/
public function popUrl(): ?array
{
$data = Redis::rPop(self::REDIS_QUEUE_KEY);
if (!$data) {
return null;
}
return json_decode($data, true);
}
/**
* 执行抓取任务
*
* @return bool
* @throws BusinessException
*/
public function execute(): bool
{
try {
$task = $this->popUrl();
if (!$task) {
return false;
}
$html = $this->fetchHtml($task['url']);
switch ($task['scene']) {
case 'usedcar':
$info = $this->parseUsedCarDetail($html, $task['url']);
$id = $this->saveUsedCarBasicInfo($info);
$this->uploadAndRemove(json_decode($info['images'], true), public_path('uploads/usedcar/' . $id), $info['used_car_id'], $id);
$this->pushUrl($info['car_id_url'], 'config', $id);
break;
case 'config':
$data = $this->parseConfigDetail($html);
$this->saveUsedCarConfigInfo($data, $task['id']);
break;
default:
throw new BusinessException("未知的任务场景: " . $task['scene']);
}
return true;
} catch (GuzzleException $e) {
throw new BusinessException("网络请求失败: " . $e->getMessage());
} catch (Throwable $th) {
throw new BusinessException("执行任务出错: " . $th->getMessage() . ' | 行号: ' . $th->getLine() . ' | 文件: ' . $th->getFile());
}
}
/**
* 获取网页HTML内容
*
* @param string $url
* @return string
* @throws GuzzleException
*/
public function fetchHtml(string $url): string
{
$client = new Client();
$response = $client->request('GET', $url, [
'headers' => self::DEFAULT_HEADERS
]);
return (string)$response->getBody();
}
/**
* 解析二手车详情页信息
*
* @param string $html
* @param string $url
* @return array
*/
public function parseUsedCarDetail(string $html = '', string $url = ''): array
{
$ql = QueryList::html($html);
$title = $ql->find('h1')->text();
$price = $ql->find('.head-info_price-wrap__Y4bxi > p')->text();
$pageText = $ql->find('body')->text();
$jsonText = $ql->find('#__NEXT_DATA__')->text();
// 解析JSON数据
$jsonArray = json_decode($jsonText, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new BusinessException("JSON解析失败: " . json_last_error_msg());
}
// 车辆相册
$images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? [];
// 车辆ID
$carId = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_id'] ?? 0;
// 车辆年份
$year = $jsonArray['props']['pageProps']['skuDetail']['car_info']['year'] ?? 0;
// 车辆ID URL
$carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : '';
// 能源类型
$fuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['power']['fuel_form'] ?? '';
$newFuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['new_energy_power']['fuel_form'] ?? '';
$resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm;
// 使用里程
$importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? '';
$importantTextArray = explode(' | ', $importantText);
$usageMileage = str_replace('万公里', '', isset($importantTextArray[1]) ? $importantTextArray[1] : '');
// 车辆名称 品牌、车系、车型
$brandName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['brand_name'] ?? '';
$seriesName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['series_name'] ?? '';
$carName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_name'] ?? '';
// 商家信息
$shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? [];
$formatShopInfo = [
'shop_id' => $shopInfo['shop_id'] ?? 0,
'shop_name' => $shopInfo['shop_short_name'] ?? '',
'shop_address' => $shopInfo['shop_address'] ?? ''
];
// 简介参数
$otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? [];
// 排量
$displacement = $this->getParamValue($otherParams, '排量');
// 变速箱
$gearbox = $this->getParamValue($otherParams, '变速箱');
// 格式化车辆信息
$formatCarInfo = [
'displacement' => $displacement,
'gearbox' => $gearbox
];
// 提取新车指导价
$newPrice = $this->extractPatternValue($pageText, '/新车指导价[::]\s*([^\r\n万]*万)/u');
// 提取比新车省金额
$priceDifference = $this->extractPatternValue($pageText, '/比新车省[::]\s*([^\r\n万]*万)/u');
// 提取车况介绍
$conditionDesc = '';
if ($pageText) {
if (preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) {
$conditionDesc = trim($m[1]);
}
}
// 整理车辆信息
return [
'used_car_id' => $this->extractIdFromUrl($url),
'car_id' => $carId,
'url' => $url,
'title' => $title,
'price' => str_replace('万', '', $this->decodePrice($price)),
'new_price' => str_replace('万', '', $newPrice),
'price_difference' => str_replace('万', '', $priceDifference),
'condition_desc' => $conditionDesc,
'images' => json_encode($images, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'car_id_url' => $carIdUrl,
'used_detail' => $jsonText,
'format_shop_info' => json_encode($formatShopInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'fuel_form' => $resultFuelForm,
'classify_energy' => $this->detectEnergyType($resultFuelForm),
'usage_mileage' => $usageMileage,
'brand_name' => $brandName,
'series_name' => $seriesName,
'car_name' => $carName,
'year' => $year,
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
];
}
/**
* 解析配置详情页信息
*
* @param string $html
* @return array
*/
public function parseConfigDetail(string $html = ''): array
{
$ql = QueryList::html($html);
$jsonText = $ql->find('#__NEXT_DATA__')->text();
$raw = json_decode($jsonText, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new BusinessException("配置页面JSON解析失败: " . json_last_error_msg());
}
$properties = $raw['props']['pageProps']['rawData']['properties'] ?? [];
$carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? [];
$valuesMap = $this->buildValuesMap($carInfo);
$groups = $this->buildCarSpecGroups($properties, $valuesMap);
return [
'format_car_info' => [
'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间
'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式
'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数
'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机
'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率
'electric_description' => $this->getFieldValue($groups, 'electric_description'), //电动机
'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), //电池容量
'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型
'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage') // 续航里程
],
'groups' => $groups
];
}
/**
* 从车辆配置中获取能源类型
*
* @param array $data JSON数据
* @return string|null 能源类型
*/
public function getFuelFormByConfig(array $data): ?string
{
if (empty($data[0]['items'])) {
return null;
}
foreach ($data[0]['items'] as $item) {
if (isset($item['key']) && $item['key'] === 'fuel_form') {
return $item['value'] ?? null;
}
}
return null;
}
/**
* 从数据中根据字段key获取值
*
* @param mixed $data 数据
* @param string $field 字段名
* @return string|null
*/
public function getFieldValue($data, string $field): ?string
{
// 如果传入的是 JSON 字符串,先解码
if (is_string($data)) {
$data = json_decode($data, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return null;
}
}
// 如果不是数组,直接返回null
if (!is_array($data)) {
return null;
}
// 遍历寻找对应字段
foreach ($data as $group) {
if (isset($group['items']) && is_array($group['items'])) {
foreach ($group['items'] as $item) {
if (isset($item['key']) && $item['key'] === $field) {
return $item['value'] ?? null;
}
// 检查子元素
if (isset($item['children']) && is_array($item['children'])) {
foreach ($item['children'] as $child) {
if (isset($child['key']) && $child['key'] === $field) {
return $child['value'] ?? null;
}
}
}
}
}
}
return null;
}
/**
* 从参数列表中获取指定名称的值
*
* @param array $params 参数列表
* @param string $name 名称
* @return string|null
*/
public function getParamValue(array $params, string $name): ?string
{
foreach ($params as $item) {
if (isset($item['name']) && $item['name'] === $name) {
return $item['value'] ?? null;
}
}
return null;
}
/**
* 判断车辆能源类型
*
* @param string $text 描述文本
* @return string 能源类型标识
*/
public function detectEnergyType(string $text): string
{
if (function_exists('mb_internal_encoding')) {
mb_internal_encoding('UTF-8');
}
// 明确定义的新能源关键字
$newEnergyKeywords = [
// 纯电
'纯电', '纯电动', '电动车', 'BEV',
// 插电式混合
'插电混动', '插电式混合', '插电式', '插电', 'PHEV',
// 增程式
'增程式', '增程', 'REEV',
// 氢能 / 燃料电池
'氢能', '氢燃料', '燃料电池', 'FCEV',
// 其它
'太阳能',
];
// 传统能源关键字
$traditionalKeywords = [
'汽油',
'柴油',
'天然气', 'LNG', 'CNG',
];
// 普通混动关键字(归为传统能源)
$hybridKeywords = [
'油电混合', '油电混动', '油电',
'混动', // 不带"插电"的混动
'双擎', // 丰田常见表达
'HEV',
];
// 辅助函数:不区分大小写的包含判断
$contains = function (string $haystack, string $needle): bool {
if ($needle === '') {
return false;
}
if (function_exists('mb_stripos')) {
return mb_stripos($haystack, $needle) !== false;
}
return stripos($haystack, $needle) !== false;
};
// 1. 先判断"明确新能源"关键词
foreach ($newEnergyKeywords as $kw) {
if ($contains($text, $kw)) {
return self::ENERGY_TYPE_RENEWABLE;
}
}
// 2. 再判断"普通油电混动"
foreach ($hybridKeywords as $kw) {
if ($contains($text, $kw)) {
return self::ENERGY_TYPE_CONVENTIONAL;
}
}
// 3. 再判断传统能源
foreach ($traditionalKeywords as $kw) {
if ($contains($text, $kw)) {
return self::ENERGY_TYPE_CONVENTIONAL;
}
}
// 4. 都没匹配上
return self::ENERGY_TYPE_UNKNOWN;
}
/**
* 保存二手车基本信息到数据库
*
* @param array $data 数据
* @return int 插入记录ID
*/
public function saveUsedCarBasicInfo(array $data): int
{
$data['created_at'] = time();
$data['updated_at'] = time();
return Db::table('usedcar_crawl_results')->insertGetId($data);
}
/**
* 更新配置信息到数据库
*
* @param array $data 配置数据
* @param int $id 记录ID
* @return bool|int
*/
public function saveUsedCarConfigInfo(array $data, int $id)
{
$record = Db::table('usedcar_crawl_results')->find($id);
if (!$record) {
return false;
}
$groups = $data['groups'];
$formatCarInfo = array_merge(
$data['format_car_info'],
json_decode($record->format_car_info, true)
);
return Db::table('usedcar_crawl_results')
->where('id', $id)
->update([
'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => time()
]);
}
/**
* 构建值映射表
*
* @param array $carInfo 车辆信息
* @return array
*/
public function buildValuesMap(array $carInfo): array
{
$valuesMap = [];
// 处理顶层字段
foreach ($carInfo as $k => $v) {
if ($k === 'info' || is_array($v)) {
continue;
}
$valuesMap[$k] = ['value' => $v];
}
// 处理info中的配置项
if (!empty($carInfo['info']) && is_array($carInfo['info'])) {
foreach ($carInfo['info'] as $k => $v) {
if (is_array($v) && array_key_exists('value', $v)) {
$valuesMap[$k] = $v;
}
}
}
return $valuesMap;
}
/**
* 构建车辆规格分组
*
* @param array $properties 属性列表
* @param array $valuesMap 值映射表
* @return array
*/
public function buildCarSpecGroups(array $properties, array $valuesMap): array
{
$groups = [];
$currentKey = null;
foreach ($properties as $item) {
$type = (int)($item['type'] ?? 1);
$key = $item['key'] ?? '';
$text = $item['text'] ?? '';
$subList = $item['sub_list'] ?? null;
// 分类字段:type = 0 / 2
if (in_array($type, [0, 2], true)) {
$currentKey = $key ?: ($text ?: ('group_' . count($groups)));
if (!isset($groups[$currentKey])) {
$groups[$currentKey] = [
'group_key' => $currentKey,
'group_text' => $text,
'group_type' => $type,
'items' => [],
];
}
continue;
}
// 普通字段:挂到最近的分类下
if ($currentKey === null) {
// 如果一开始就没有遇到分类,给一个默认分组
$currentKey = '_default';
$groups[$currentKey] = [
'group_key' => '_default',
'group_text' => '其他',
'group_type' => -1,
'items' => [],
];
}
// 取 value
$value = '';
if ($key && isset($valuesMap[$key])) {
$value = trim((string)($valuesMap[$key]['value'] ?? ''));
}
$node = [
'key' => $key,
'text' => $text,
'value' => $value,
];
// 处理子项
if (is_array($subList) && !empty($subList)) {
$children = [];
foreach ($subList as $sub) {
$subKey = $sub['key'] ?? '';
$subText = $sub['text'] ?? '';
$subValue = '';
if ($subKey && isset($valuesMap[$subKey])) {
$subValue = trim((string)($valuesMap[$subKey]['value'] ?? ''));
}
$children[] = [
'key' => $subKey,
'text' => $subText,
'value' => $subValue,
];
}
$node['children'] = $children;
}
$groups[$currentKey]['items'][] = $node;
}
// 转成索引数组方便前端遍历
return array_values($groups);
}
/**
* 解码懂车帝价格
*
* @param string $str 加密字符串
* @return string 解码后的字符串
*/
protected function decodePrice(string $str): string
{
// 映射表:PUA 字符 => 数字 / 万
$map = [
"\u{E53D}" => '1',
"\u{E3F0}" => '2',
"\u{E422}" => '3',
"\u{E42C}" => '4',
"\u{E49C}" => '5',
"\u{E42B}" => '6',
"\u{E4FE}" => '7',
"\u{E548}" => '8',
"\u{E4C8}" => '9',
"\u{E453}" => '0',
"\u{E45F}" => '万',
];
return strtr($str, $map);
}
/**
* 从URL中提取ID
*
* @param string $url URL
* @return int|false
*/
protected function extractIdFromUrl(string $url)
{
if (preg_match('/\/(\d+)$/', $url, $matches)) {
return (int)$matches[1];
}
return false;
}
/**
* 提取符合正则表达式的值
*
* @param string $text 文本
* @param string $pattern 正则表达式
* @return string
*/
protected function extractPatternValue(string $text, string $pattern): string
{
$value = '';
if ($text) {
if (preg_match($pattern, $text, $m)) {
$value = trim(preg_replace('/[^0-9.]/', '', $m[1]));
}
}
return $value;
}
/**
* 下载单张图片
*
* @param string $url 图片完整URL
* @param string $saveDir 保存目录
* @param string|null $filename 指定文件名
* @param array $headers 额外HTTP头
* @return string|null 成功返回保存路径,失败返回null
*/
public function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string
{
// 目录检查 & 创建
if (!is_dir($saveDir)) {
if (!mkdir($saveDir, 0777, true) && !is_dir($saveDir)) {
echo "[Image] 创建目录失败:{$saveDir}\n";
return null;
}
}
// 自动生成文件名
if ($filename === null) {
$path = parse_url($url, PHP_URL_PATH);
$ext = pathinfo($path, PATHINFO_EXTENSION);
if ($ext === '') {
$ext = 'jpg';
}
$filename = md5($url) . '.' . $ext;
}
$savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename;
// 组装HTTP头
$defaultHeaders = [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Referer: https://www.dongchedi.com/',
];
foreach ($headers as $h) {
$defaultHeaders[] = $h;
}
// 使用cURL下载
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => $defaultHeaders,
]);
$data = curl_exec($ch);
$errNo = curl_errno($ch);
$errMsg = curl_error($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errNo !== 0) {
// echo "[Image] 下载失败(cURL):{$url} | {$errNo} {$errMsg}\n";
return null;
}
if ($code !== 200 || $data === false || $data === '') {
// echo "[Image] 下载失败(HTTP {$code}):{$url}\n";
return null;
}
// 写入本地文件
$bytes = file_put_contents($savePath, $data);
if ($bytes === false) {
// echo "[Image] 保存失败:{$savePath}\n";
return null;
}
// echo "[Image] 下载成功:{$url} => {$savePath}\n";
return $savePath;
}
/**
* 批量下载相册
*
* @param array $urls 图片URL数组
* @param string $saveDir 保存目录
* @return array [原始URL => 保存路径或null]
*/
public function downloadImageAlbum(array $urls, string $saveDir): array
{
$result = [];
foreach ($urls as $url) {
$url = trim($url);
if ($url === '') {
continue;
}
// 补全协议
if (strpos($url, '//') === 0) {
$url = 'https:' . $url;
}
$path = $this->downloadImage($url, $saveDir);
$result[$url] = $path;
}
return $result;
}
public function uploadAndRemove($images, $path, $usedCarId, $id)
{
// 下载图片
$filePatch = $this->downloadImageAlbum($images, $path);
// 检查图片
if (empty($filePatch)) {
return false;
}
// 上传图片
foreach ($filePatch as $url => $path) {
if ($path === null) {
continue;
}
// 上传云端
$param = [
'path' => $path,
'scene' => 'usedcar_crawl',
'category' => 'usedcar'
];
$up = new \app\common\service\UploadService();
$result = $up->uploadLocalFile($param);
// 保存图片信息
UsedcarCrawlResultsImages::create([
'uc_id' => $id,
'used_car_id' => $usedCarId,
'original_name' => $result['original_name'],
'file_path' => $result['file_name'],
]);
// 删除本地文件
@unlink($path);
}
}
}
+7 -1
View File
@@ -434,9 +434,15 @@ class Emails
$channel = 12;
$channelInfo = InquiryChannel::where('id', $channel)->first();
// 从库实时获取
$users = \app\common\model\User::where('corp_id', 3)->where('status', 1)->orderBy('id')->pluck('id')->toArray();
// 移除不参与分配人员
$users = array_values(array_diff($users, [190]));
// 初始化 UID 轮询器
$uidRotator = new \app\common\utils\RedisUidRotator('email_parsed_result_20250804');
$uidRotator->setUids([64, 63, 93, 108, 132, 145, 150, 143, 156, 157, 158], false);
$uidRotator->setUids($users, false);
EmailParsedResult::where('status', 0)
->chunkById($batchSize, function ($records) use ($delaySeconds, $throttleAfter, $throttleSeconds, $enterUser, $channelInfo, $uidRotator) {
@@ -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>';
}
}
+922
View File
@@ -0,0 +1,922 @@
<?php
namespace app\common\utils\email;
use app\common\model\Country;
use app\common\model\CrmCustomer;
use app\common\model\EmailMailbox;
use app\common\model\EmailPull;
use app\common\model\EmailPullParsed;
use app\common\model\EmailPullUids;
use app\common\model\InquiryChannel;
use app\common\model\InquiryLog;
use app\common\model\User;
use DOMDocument;
use DOMXPath;
use support\Db;
use support\exception\BusinessException;
class Mailbox
{
/**
* 多邮箱处理:拉取uid
*/
function mailbox()
{
$mailboxModel = EmailMailbox::where('last_fetch_at', '<=', time())
->where('status',1)->first();
//$mailboxModel['last_fetch_at'] = time();
//$mailboxModel->save();
$mailbox = $this->mailboxSplice($mailboxModel);
// $INBOX= $mailboxModel['imap_host'].':'.$mailboxModel['imap_port'].$mailboxModel['imap_flags'].$mailboxModel['mailbox'];
// $mailbox = '{'.$INBOX.'}'.'INBOX';
//$username = $mailbox['username'];
//$password = $mailbox['password'];
$this->pullUid($mailboxModel, $mailbox);
}
/**
* 拼接INBOX
* @param $mailboxModel
* @return string
*/
function mailboxSplice($mailboxModel){
$INBOX= $mailboxModel['imap_host'].':'.$mailboxModel['imap_port'].$mailboxModel['imap_flags'];
$mailbox = '{'.$INBOX.'}'.'INBOX';
return $mailbox;
}
/**
* 拉取邮件uid
*/
function pullUid($mailboxModel, $mailbox)
{
try {
$username = $mailboxModel['username'];
$password = $mailboxModel['password'];
$lastUid = $mailboxModel['last_uid'];
$retryTimes = 3;
$retryDelay = 10;
$inbox = false;
$attempt = 0;
while ($attempt < $retryTimes) {
$attempt++;
flg("尝试第 {$attempt} 次连接 IMAP...", false,'mailbox');
$inbox = @imap_open($mailbox, $username, $password);
if ($inbox) {
break;
}
$error = imap_last_error();
flg("<comment>{$username}第 {$attempt} 次失败:{$error}</comment>", false,'mailbox');
// 若出现 too many login failures 立即暂停一段时间
if (stripos($error, 'Too many login failures') !== false) {
flg("<error>检测到登录失败次数过多,暂停 60 秒避免封禁</error>", false,'mailbox');
sleep(60);
} else {
sleep($retryDelay);
}
}
if (!$inbox) {
flg("{$username}<error>最终仍然连接失败:" . imap_last_error() . "</error>", false,'mailbox');
return false;
}
// 拉取 UID 并入库
flg("开始拉取 UID 并入库 …", false,'mailbox');
$uids = imap_search($inbox, 'ALL', SE_UID) ?: [];
$uids = array_filter($uids, function($uid) use ($lastUid) {
return $uid > $lastUid;
});
sort($uids);
$now = time();
$count = 0;
foreach ($uids as $uid) {
try {
Db::table('email_pull_uids')->insert([
'uid' => $uid,
'fetched_at' => $now,
'email' => $username
]);
$count++;
flg("{$username} UID:".$uid." 已写入数据库", false,'mailbox');
} catch (\Throwable $e) {
flg("{$username} <comment>{$e->getMessage()}</comment>", false,'mailbox');
}
}
imap_close($inbox);
flg("✔ {$username}获取到新询盘邮件 {$count} 条 ...", false,'mailbox');
if(!empty($uids)){
$mailboxModel->last_uid = max($uids);
}
$mailboxModel->last_fetch_at = time();
$mailboxModel->save();
return true;
} catch (\Throwable $th) {
flg(dpr($th), false,'mailbox');
return false;
}
}
/**
* 多邮箱处理:拉取详情
*/
function pullDetails()
{
$emails = EmailPullUids::where('status', 0)
->select('email')
->distinct()
->get();
foreach ($emails as $email) {
$mailboxModel = EmailMailbox::where('username', $email->email)->first();
$mailbox = $this->mailboxSplice($mailboxModel);
$this->details($mailboxModel, $mailbox);
}
}
/**
* 获取邮件详情
*/
public function details($mailboxModel, $mailbox)
{
try {
$username = $mailboxModel['username'];
$password = $mailboxModel['password'];
$inbox = @imap_open($mailbox, $username, $password);
if (!$inbox) {
flg("<error>{$username} IMAP 连接失败:". imap_last_error() ."</error>", false,'mailbox');
return false;
}
// 依次拉取详情并写入 emails 表
flg("依次拉取详情并写入 email_pull 表 …", false,'mailbox');
while (true) {
$pending = Db::table('email_pull_uids')
->where('email', $username)
->where('status', 0)
->orderBy('uid', 'desc')
->limit(2)
->get();
if (count($pending) === 0) {
flg("邮件UID的详情,已拉取完毕。", false,'mailbox');
break;
}
flg("处理下一批 " . count($pending) . " 条 …", false,'mailbox');
foreach ($pending as $row) {
$uid = $row->uid;
// 包裹整行处理在 try/catch 中,任何异常都不会中断循环
try {
flg("---- 开始处理 UID={$uid} ----", false,'mailbox');
// 1. fetch overview
$ov = @imap_fetch_overview($inbox, $uid, FT_UID)[0] ?? null;
if (!$ov) {
$err = imap_last_error() ?: '未知错误';
flg("UID={$uid} overview 失败:{$err}", false,'mailbox');
continue;
}
$subject = mb_convert_encoding($ov->subject ?? '', 'UTF-8', 'GBK,UTF-8');
$from = $ov->from ?? '';
$date = $ov->date ?? '';
flg("UID={$uid} overview 成功,subject={$subject}", false,'mailbox');
// 2. fetch body
$body = $this->getMailBody($inbox, $uid, true);
flg("UID={$uid} body 长度:" . strlen($body), false,'mailbox');
// 3. sanitize & insert
$body = $this->sanitizeUtf8($body);
$now = time();
try {
Db::table('email_pull')->insert([
'email' => $username,
'uid' => $uid,
'subject' => $subject,
'from' => $from,
'date' => $date,
'body' => $body,
'created_at' => $now,
'updated_at' => $now,
]);
flg($username." UID={$uid} 写入 emails 成功", false,'mailbox');
} catch (\Throwable $e) {
flg($username." UID={$uid} 写入失败:{$e->getMessage()}", false,'mailbox');
continue;
}
// 4. 标记已处理
try {
Db::table('email_pull_uids')
->where('uid', $uid)
->update([
'status' => 1,
'processed_at' => $now,
]);
flg($username." UID={$uid} 标记为已处理", false,'mailbox');
} catch (\Throwable $e) {
flg($username." UID={$uid} 标记状态失败:{$e->getMessage()}", false,'mailbox');
// 即使更新 status 失败,也继续下一条
}
} catch (\Throwable $e) {
// 捕获任何非预期错误
flg($username." UID={$uid} 处理异常:{$e->getMessage()}", false,'mailbox');
continue;
}
}
// 批次结束后稍作休眠,防止服务器过载
sleep(1);
}
imap_close($inbox);
flg($username." ✔ 新邮件详情拉取完成 ...", false,'mailbox');
return true;
} catch (\Throwable $th) {
flg(dpr($th), false,'mailbox');
return false;
}
}
/**
* 将邮件按编码解码并保留 "\r\n"
*/
protected function getMailBody($inbox, int $id, bool $isUid = false): string
{
$flags = $isUid ? FT_UID : 0;
$structure = imap_fetchstructure($inbox, $id, $flags);
if (!empty($structure->parts)) {
// HTML 部分
foreach ($structure->parts as $i => $part) {
if (isset($part->subtype) && strtolower($part->subtype) === 'html') {
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
return $this->decodeBody($data, $part->encoding);
}
}
// 文本部分
foreach ($structure->parts as $i => $part) {
if (isset($part->subtype) && strtolower($part->subtype) === 'plain') {
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
return $this->decodeBody($data, $part->encoding);
}
}
// 原始 body
return imap_body($inbox, $id, $flags);
}
return imap_body($inbox, $id, $flags);
}
/**
* 将任意字符串清洗为有效 UTF-8,移除或替换无效字节
*/
protected function sanitizeUtf8(string $text): string
{
return iconv('UTF-8', 'UTF-8//IGNORE', $text);
}
/**
* 按编码解码
*/
protected function decodeBody(string $body, int $encoding): string
{
switch ($encoding) {
case 3: return imap_base64($body);
case 4: return imap_qprint($body);
default: return $body;
}
}
/**
* 多邮箱处理:清洗数据
*/
function processMailbox()
{
$emails = EmailPull::where('status', 0)
->select('email')
->distinct()
->get();
foreach ($emails as $email) {
$mailboxModel = EmailMailbox::where('username', $email->email)->first();
$this->process($mailboxModel);
}
}
function process($mailboxModel)
{
switch ($mailboxModel['username']) {
case 'paika@paikacar.com':
$this->paika($mailboxModel);
break;
case 'hesu@hesucar.com':
$this->hesu($mailboxModel);
break;
}
}
/**
* 清洗询盘邮件
* @param $mailbox
* @return void
*/
function paika($mailbox)
{
$batchSize = 100; // 每次处理 100 条
$page = 0;
// 明确定义 parsed_contacts 表里要插入的所有列(顺序无所谓,但要全部包含)
$fields = [
'email_id',
'date',
'email',
'name',
'tel',
'company',
'country',
'form_url',
'message',
'ip',
'iplocation',
'ilang',
'mailto',
'navigator_language',
'navigator_platform',
'screen_width',
'screen_height',
'ucountry',
];
while (true) {
$rows = EmailPull::where('email', $mailbox['username'])
->where('status', 0)
->orderBy('id', 'asc')
->limit($batchSize)
->offset($page * $batchSize)
->get();
if ($rows->isEmpty()) {
break;
}
foreach ($rows as $row) {
// $parsed = $this->parseHtml($row->body);
$parsed = $this->parsePaikaInquiry($row->body, $row->date);
if ($parsed === null) {
$row->status = 2; //其他邮件
$row->save();
flg("<error>{$mailbox['username']} {$row->id} skip: not a contact-form HTML</error>", false,'mailbox');
continue;
}
$row->status = 1; //已处理
$row->save();
// 严格按 $fields 顺序填充每条记录,缺失的字段补 null
$record = [];
foreach ($fields as $col) {
if ($col === 'email_id') {
$record[$col] = $row->id;
} else {
$record[$col] = $parsed[$col] ?? null;
}
}
$toInsert[] = $record;
}
if (!empty($toInsert)) {
$emailId = array_column($toInsert, 'email_id');
$mailto = array_column($toInsert, 'mailto')[0];
$email = Db::table('email_pull_parsed')->whereIn('email_id', $emailId)
// ->where('mailto', $mailto)
->first();
if(!empty($email)){
continue;
}
Db::table('email_pull_parsed')->insert($toInsert);
}
$page++;
}
}
/**
* 解析https://paikacar.com/wp-admin询盘邮件
* DOM 解析为数组
*/
public function parsePaikaInquiry(string $html, $date)
{
libxml_use_internal_errors(true);
$dom = new DOMDocument();
//$dom->loadHTML($html);
$dom->loadHTML('<?xml encoding="utf-8"?>' . $html);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
if (
!str_contains($html, 'Product Inquiry') ||
!str_contains($html, 'Product Name') ||
!str_contains($html, 'Your Name')
) {
// 认为是询盘
return null;
}
$data = [];
// 找到所有 tr
$rows = $xpath->query('//table//tr');
foreach ($rows as $row) {
$th = $xpath->query('.//th//p', $row)->item(0);
$td = $xpath->query('.//td//p', $row)->item(0);
if (!$th || !$td) {
continue;
}
$key = trim(str_replace(':', '', $th->textContent));
$value = trim($td->textContent);
// 特殊字段:Product Name 是链接
if ($key === 'Product Name') {
$a = $xpath->query('.//a', $td)->item(0);
if ($a) {
$data['product_name'] = trim($a->textContent);
$data['product_url'] = $a->getAttribute('href');
continue;
}
}
$data[$key] = $value;
}
$data['date'] = $date;
return $this->mapInquiryFields($data);
}
/**
* paika.@paikacar.com字段处理
*/
function mapInquiryFields(array $raw): array
{
$result = [
'product_name' => $raw['product_name'] ?? '',
'form_url' => $raw['product_url'] ?? '',
'quantity' => $this->normalizeValue($raw['Quantity'] ?? ''), //数量
'name' => $raw['Your Name'] ?? '',
'user_type' => $raw['User type'] ?? '',
'email' => $raw['E-mail'] ?? '',
'tel' => $this->normalizePhone($raw['Phone'] ?? ''),
'whatsapp' => $raw['WhatsApp'] ?? '',
// 'skype' => $raw['Skype'] ?? '',
'company' => $raw['Company'] ?? '',
'country' => $this->normalizeCountry($raw['Country'] ?? ''),
'ip' => $this->extractIp($raw['IP'] ?? ''),
'content' => $raw['Content'] ?? '',
'date' => date('Y-m-d H:i:s',strtotime($raw['date'])),
];
//只有ipv4分析ip所属地区
if (filter_var($result['ip'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$result['iplocation'] = empty($result['ip'])?ip2region($result['ip']).'(仅供参考)':'';
}
// $message = trim(
// (!empty($result['product_name']) ? "意向车型:{$result['product_name']}" : '') .
// (!empty($result['quantity']) ? (!empty($result['product_name']) ? ', ' : '') . "数量:{$result['quantity']}" : '')
// ) ?: '';
$message[] = !empty($result['product_name']) ? "意向车型:{$result['product_name']}" : '';
$message[] = !empty($result['quantity']) ? "数量:{$result['quantity']}" : '';
$message[] = !empty($result['user_type']) ? "用户类型:{$result['user_type']}" : '';
$message[] = "<p>询盘来源:" . "paikacar.com</p>";
$message[] = "<p>FB 创建时间原始值:".$raw['date']."</p>";
$result['message'] = implode(',', $message);
$result['ilang'] = 'en';
$result['mailto'] = 'paika.@paikacar.com';
unset($result['product_name']);
unset($result['quantity']);
return $result;
}
function normalizeValue(string $value): string
{
if (str_contains($value, '[your-')) {
return '';
}
return $value;
}
function normalizePhone(string $phone): string
{
return preg_replace('/[^\d+]/', '', $phone);
}
function extractIp(string $text): string
{
if (preg_match('/\b\d{1,3}(\.\d{1,3}){3}\b/', $text, $m)) {
return $m[0];
}
return '';
}
function normalizeCountry(string $country): string
{
if (str_contains($country, 'Please select')) {
return '';
}
return $country;
}
/**
* 清洗合塑询盘邮件
* @param $mailbox
* @return void
*/
function hesu($mailbox)
{
$batchSize = 100; // 每次处理 100 条
$page = 0;
// 明确定义 parsed_contacts 表里要插入的所有列(顺序无所谓,但要全部包含)
$fields = [
'email_id',
'date',
'email',
'name',
'tel',
'company',
'country',
'form_url',
'message',
'ip',
'iplocation',
'ilang',
'mailto',
'navigator_language',
'navigator_platform',
'screen_width',
'screen_height',
'ucountry',
];
while (true) {
$rows = EmailPull::where('email', $mailbox['username'])
->where('status', 0)
->orderBy('id', 'asc')
->limit($batchSize)
->offset($page * $batchSize)
->get();
if ($rows->isEmpty()) {
break;
}
$toInsert = [];
foreach ($rows as $row) {
// $parsed = $this->parseHtml($row->body);
$parsed = $this->parseHesucarInquiry($row->body, $row->date);
if ($parsed === null) {
$row->status = 2; //其他邮件
$row->save();
flg($mailbox['username']." <error>{$mailbox['username']} {$row->id} skip: not a contact-form HTML</error>", false,'mailbox');
continue;
}
$row->status = 1; //已处理
$row->save();
// 严格按 $fields 顺序填充每条记录,缺失的字段补 null
$record = [];
foreach ($fields as $col) {
if ($col === 'email_id') {
$record[$col] = $row->id;
} else {
$record[$col] = $parsed[$col] ?? null;
}
}
$toInsert[] = $record;
}
if (!empty($toInsert)) {
$emailId = array_column($toInsert, 'email_id');
$mailto = array_column($toInsert, 'mailto')[0];
$email = Db::table('email_pull_parsed')->whereIn('email_id', $emailId)
//->where('mailto', $mailto)
->first();
if(!empty($email)){
continue;
}
Db::table('email_pull_parsed')->insert($toInsert);
}
$page++;
}
}
/**
* 解析https://hesucar.com询盘邮件
* DOM 解析为数组
*/
public function parseHesucarInquiry(string $html, $date)
{
libxml_use_internal_errors(true);
$dom = new DOMDocument();
//$dom->loadHTML($html);
$dom->loadHTML('<?xml encoding="utf-8"?>' . $html);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
if (
!str_contains($html, 'HESUCAR')
|| !str_contains($html, 'Tel/Whatsapp')
|| !str_contains($html, 'Sent from')
) {
// 认为是询盘
return null;
}
$data = [
'product_url' => '',
'name' => '',
'phone' => '',
'message' => '',
'source' => 'hesucar_inquiry',
'date' => $date,
];
/**
* 1️⃣ 产品链接(正文最前面的 a)
*/
$productLink = $xpath->query(
'//td[contains(@class,"mcnTextContent")]//a[starts-with(@href,"http")]'
)->item(0);
if ($productLink) {
$data['product_url'] = trim($productLink->getAttribute('href'));
}
/**
* 2️⃣ Name / Tel / Message
* 结构特点:
* <strong>Label</strong>
* 下一行 td 就是 value
*/
$labels = $xpath->query('//strong');
foreach ($labels as $labelNode) {
$label = trim($labelNode->textContent);
// 当前 strong 所在 td
$td = $labelNode->parentNode;
// 下一个 tr 的 td 才是值
$valueTd = $td->parentNode->nextSibling;
while ($valueTd && $valueTd->nodeName !== 'tr') {
$valueTd = $valueTd->nextSibling;
}
if (!$valueTd) {
continue;
}
$value = trim($valueTd->textContent);
switch ($label) {
case 'Name':
$data['name'] = $value;
break;
case 'Tel/Whatsapp':
$data['phone'] = $this->normalizePhone($value);
break;
case 'Message':
$data['message'] = $this->cleanMessage($value);
break;
}
}
// return $data;
return $this->hesucarInquiryFields($data);
}
/**
* hesucar.@hesucar.com字段处理
*/
function hesucarInquiryFields(array $raw): array
{
$result = [
'form_url' => $raw['product_url'] ?? '',
'name' => $raw['name'] ?? '',
'tel' => $this->normalizePhone($raw['phone'] ?? ''),
'whatsapp' => $raw['phone'] ?? '',
'phone' => $raw['phone'] ?? '',
'country' => '未知',
'message' => $raw['message'] ?? '',
'date' => date('Y-m-d H:i:s',strtotime($raw['date'])),
];
$message = [];
$message[] = "<p>询盘来源:" . $raw['product_url']."</p>";
$message[] = "<p>Tel/Whatsapp:" . $raw['phone']."</p>";
$message[] = "<p>FB 创建时间原始值:".$raw['date']."</p>";
$result['message'] = $result['message'].implode(',', $message);
$result['ilang'] = 'en';
$result['mailto'] = 'hesu.@hesucar.com';
return $result;
}
function cleanMessage(string $message): string
{
return trim(preg_replace("/\s+/", " ", $message));
}
/**
* 多邮箱处理:分配客户
*/
function fenpaiMailbox()
{
$emails = EmailPullParsed::where('status', 0)
->select('mailto')
->distinct()
->get();
foreach ($emails as $email) {
$mailboxModel = EmailMailbox::where('username', $email['mailto'])->first();
$this->fenpai($mailboxModel);
}
}
/**
* 分配清洗好的数据
* @param $mailbox
* @return false|void
*/
public function fenpai($mailbox)
{
try {
// 配置参数
$batchSize = 100; // 每批处理量
$delaySeconds = 2; // 每封邮件间隔秒数
$throttleAfter = 50; // 每发送多少封后增加休息
$throttleSeconds = 5; // 批量休息秒数
// 固定录单人
$enterUserId = 186;
$enterUser = User::where('id', $enterUserId)->first();
// 固定渠道
//$channel = 12;
$channel = $mailbox['channel_id'];
$channelInfo = InquiryChannel::where('id', $channel)->first();
// 从库实时获取
$corp_id = $mailbox['corp_id'];
// $users = \app\common\model\User::where('corp_id', $corp_id)->where('status', 1)->orderBy('id')->pluck('id')->toArray();
if($corp_id == 1){ //合塑
$users = \app\common\model\UserGroupMembers::where('group_id', 10)
->orderBy('user_id')->pluck('user_id')->toArray();
}else if($corp_id == 3){
$users = \app\common\model\UserGroupMembers::where('group_id', 9)
->orderBy('user_id')->pluck('user_id')->toArray();
}
// 移除不参与分配人员
$users = array_values(array_diff($users, [186]));
// 初始化 UID 轮询器
$uidRotator = new \app\common\utils\RedisUidRotator('email_parsed_result_20250804');
$uidRotator->setUids($users, false);
EmailPullParsed::where('status', 0)
->chunkById($batchSize, function ($records) use ($delaySeconds, $throttleAfter, $throttleSeconds, $enterUser, $channelInfo, $uidRotator) {
$count = 0;
foreach ($records as $record) {
try {
// 获取国家信息
$countryName = $record->iplocation ? removeParentheses($record->iplocation) : '未知';
$countryInfo = Country::where('cn_addr', $countryName)->first();
// 获取下一个分配的UID
$assignedUid = $uidRotator->next();
// 获取分配的用户信息
$recipientInfo = User::where('id', $assignedUid)->where('status', 1)->first();
if (!$recipientInfo) {
flg("用户ID=" . $assignedUid . " 已离职 跳过自动分配,需要手动移除 UID", false,'mailbox');
$assignedUid = $uidRotator->next();
$recipientInfo = User::where('id', $assignedUid)->where('status', 1)->first();
}
// 生成录单
$log = [
'order_no' => orderNum(),
'enter_user_id' => $enterUser->id,
'recipient' => $assignedUid,
'quality' => 2,
'channel' => $channelInfo->id,
'email' => $record->email,
'tel' => $record->tel,
'whatsapp' => '',
'country' => $countryInfo->id,
'contacts' => $record->name,
'content' => $record->message,
'r_time' => time(),
't_time' => time(),
'status' => 2,
'sync_crm' => 1
];
$inquiryLogResult = InquiryLog::create($log);
if (!$inquiryLogResult) {
throw new BusinessException('录单失败~');
}
// 同步客户
$crmCustomer = new CrmCustomer();
$crmCustomer->sn = $inquiryLogResult->order_no;
$crmCustomer->corp_id = $recipientInfo->corp_id;
$crmCustomer->user_id = $assignedUid;
$crmCustomer->type = 1;
$crmCustomer->channel_id = $inquiryLogResult->channel;
$crmCustomer->country_id = $inquiryLogResult->country;
$crmCustomer->magnitude = $inquiryLogResult->quality;
$crmCustomer->email = $inquiryLogResult->email;
$crmCustomer->phone = $inquiryLogResult->tel;
$crmCustomer->WhatsApp = $inquiryLogResult->whatsapp;
$crmCustomer->contacts = $inquiryLogResult->contacts;
$crmCustomer->desc_collect = $inquiryLogResult->content;
$crmCustomer->enter_user_id = $inquiryLogResult->enter_user_id;
if (!$crmCustomer->save()) {
throw new BusinessException('同步失败~');
}
// 9.2 修改消息发送
$data = [
'id' => $inquiryLogResult->id
];
\Webman\RedisQueue\Redis::send('inquiry-to-crm-new', $data);
// 更新状态
$record->update(['status' => 1]);
flg('====== 编号:'.$inquiryLogResult->order_no.' 分配给:'.$recipientInfo->name.' email:'.$recipientInfo->email, false,'mailbox');
// 每封邮件后延迟
if ($delaySeconds > 0) {
sleep($delaySeconds);
}
// 每发送一定数量后额外休息
if (++$count % $throttleAfter === 0) {
sleep($throttleSeconds);
flg("已发送 {$count} 封邮件,休息 {$throttleSeconds} 秒", false,'mailbox');
}
} catch (\Exception $e) {
flg("邮件发送失败 ID:{$record->id} - " . $e->getMessage(), false,'mailbox');
// 更新状态
$record->update(['status' => 3]);
// 失败后也加入延迟,避免快速连续失败
sleep($delaySeconds);
}
}
});
} catch (\Throwable $th) {
flg(dpr($th), false,'mailbox');
return false;
}
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace app\common\utils\excel;
use support\Db;
/**
* ExcelImportTrait
*
* 把“读取 Sheet → 映射字段 → 规范化数据 → 打时间戳 → 批量入库”
* 这条通用导入链路抽成 Trait,便于在多个 Service 里复用。
*
* 依赖:
* - ExcelReader:提供 readSheet / readSheetFromUrl / remap / toDecimal / toBool / toDate
* - 你的 OssService:通过 generatePrivateUrl($objectKey) 生成 OSS 私有签名 URL
*/
trait ExcelImportTrait
{
/**
* 读取某个 Sheet 的“行数组”
* - $file 可为本地路径或 URL(由 $isUrl 控制)
* - headerRow:表头所在行(Excel 行号,从 1 开始)
* - startRow:数据起始行(不传则为 headerRow+1)
*/
protected function readSheetRows(string $file, string $sheet, bool $isUrl, int $headerRow = 1, ?int $startRow = null): array
{
$ret = $isUrl
? ExcelReader::readSheetFromUrl($file, $sheet, $headerRow, $startRow)
: ExcelReader::readSheet($file, $sheet, $headerRow, $startRow);
return $ret['rows'] ?? [];
}
/**
* 读取某个 Sheet(传入 OSS 对象键),内部会用你的 OssService 生成签名 URL 再读取
*/
protected function readSheetRowsFromOssKey(string $objectKey, string $sheet, int $headerRow = 1, ?int $startRow = null): array
{
$signedUrl = $this->ossSignedUrl($objectKey);
$ret = ExcelReader::readSheetFromUrl($signedUrl, $sheet, $headerRow, $startRow);
return $ret['rows'] ?? [];
}
/**
* 使用你的 OssService 生成私有签名 URL
* - 兼容你的现有写法:(new \app\common\service\OssService)->generatePrivateUrl($objectKey)
*/
protected function ossSignedUrl(string $objectKey): string
{
return (new \app\common\service\OssService())->generatePrivateUrl($objectKey);
}
/**
* 将一行做字段映射 + 基础类型规范化(decimal/bool/date)
* - $headerMap:表头映射(源列名 => 目标字段名)
* - $decimalFields/$boolFields/$dateFields:需要做类型规范化的目标字段名列表
*/
protected function mapAndNormalizeRow(
array $row,
array $headerMap,
array $decimalFields = [],
array $boolFields = [],
array $dateFields = []
): array {
$rec = ExcelReader::remap($row, $headerMap);
foreach ($decimalFields as $f) {
if (array_key_exists($f, $rec)) {
$rec[$f] = ExcelReader::toDecimal($rec[$f]); // 建议已升级为“去货币符号/千分位/括号负数”的版本
}
}
foreach ($boolFields as $f) {
if (array_key_exists($f, $rec)) {
$rec[$f] = ExcelReader::toBool($rec[$f]);
}
}
foreach ($dateFields as $f) {
if (array_key_exists($f, $rec)) {
$rec[$f] = ExcelReader::toDate($rec[$f]);
}
}
return $rec;
}
/**
* 基于 VIN 的跳过规则:VIN 为空、或是“合计/总计”行
* - $vinAliases:VIN 列的候选表头(如 ['车架号','VIN NO'])
*/
protected function shouldSkipByVinAliases(array $row, array $vinAliases = ['车架号', 'VIN NO']): bool
{
$vin = $this->pickFirstNonEmpty($row, $vinAliases);
if ($vin === null) return true;
return (bool)preg_match('/^(合计|总计)/u', $vin);
}
/**
* 在多个候选键里取第一个非空字符串
*/
protected function pickFirstNonEmpty(array $row, array $keys): ?string
{
foreach ($keys as $k) {
if (isset($row[$k])) {
$v = trim((string)$row[$k]);
if ($v !== '') return $v;
}
}
return null;
}
/**
* 统一补齐 created_at / updated_at 时间戳(int(11))
*/
protected function stamp(array &$rec): void
{
$now = time();
$rec['created_at'] = $now;
$rec['updated_at'] = $now;
}
/**
* 批量入库
* - 返回 ['inserted'=>N, 'error'=>null|string]
* - 发生异常时不抛出,返回 error 字符串,便于上层统一处理
*/
protected function batchInsert(string $table, array $rows): array
{
if (!$rows) return ['inserted' => 0, 'error' => null];
try {
Db::table($table)->insert($rows);
return ['inserted' => count($rows), 'error' => null];
} catch (\Throwable $e) {
return ['inserted' => 0, 'error' => $e->getMessage()];
}
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
namespace app\common\utils\excel;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
class ExcelReader
{
/**
* (可选)环境自检:避免因缺少扩展导致读取失败
* - simplexml_load_string 存在性(ext-simplexml)
* - ext-zip
*/
public static function assertRequirements(): void
{
if (!function_exists('simplexml_load_string')) {
throw new \RuntimeException('缺少 PHP 扩展:ext-simplexml。请安装/启用后再试。');
}
if (!extension_loaded('zip')) {
throw new \RuntimeException('缺少 PHP 扩展:ext-zip。请安装/启用后再试。');
}
// 可按需继续检查 ext-mbstring/ext-dom 等
}
/**
* 读取指定 sheet,按某一行作为表头
* 返回:['headers'=>[], 'rows'=>[]]
* - $headerRow:从 1 开始计数(Excel 行号)
* - $startRow:数据起始行(默认 headerRow+1)
* - 注意:$filePath 必须是“本地文件路径”;若为 URL,请改用 readSheetFromUrl()
*/
public static function readSheet(string $filePath, string $sheetName, int $headerRow = 1, ?int $startRow = null): array
{
self::assertRequirements();
if (!is_file($filePath)) {
throw new \RuntimeException("File not found: {$filePath}");
}
$reader = IOFactory::createReaderForFile($filePath);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($filePath);
$sheet = $spreadsheet->getSheetByName($sheetName);
if (!$sheet) {
$names = implode(' / ', $spreadsheet->getSheetNames());
$spreadsheet->disconnectWorksheets();
throw new \RuntimeException("Sheet '{$sheetName}' not found. Available: {$names}");
}
$highestRow = $sheet->getHighestDataRow();
$highestCol = $sheet->getHighestDataColumn();
$highestColIndex = Coordinate::columnIndexFromString($highestCol);
// 读取表头(去除可能的 UTF-8 BOM)
$headers = [];
for ($col = 1; $col <= $highestColIndex; $col++) {
$coord = Coordinate::stringFromColumnIndex($col) . $headerRow;
$cell = $sheet->getCell($coord);
$raw = $cell ? $cell->getCalculatedValue() : null;
$text = is_null($raw) ? '' : trim((string)$raw);
// 去掉 BOM
if ($col === 1) {
$text = preg_replace('/^\xEF\xBB\xBF/u', '', $text);
}
$headers[$col] = $text === '' ? "COL_{$col}" : $text;
}
// 读取数据
$rows = [];
$firstDataRow = $startRow ?? ($headerRow + 1);
for ($row = $firstDataRow; $row <= $highestRow; $row++) {
$record = [];
$isEmpty = true;
for ($col = 1; $col <= $highestColIndex; $col++) {
$coord = Coordinate::stringFromColumnIndex($col) . $row;
$cell = $sheet->getCell($coord);
$value = $cell ? $cell->getCalculatedValue() : null;
// 日期单元格转换(使用 PhpSpreadsheet 的日期检测)
if ($cell && \PhpOffice\PhpSpreadsheet\Shared\Date::isDateTime($cell) && is_numeric($value)) {
$dt = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($value);
$value = $dt ? $dt->format('Y-m-d') : $value;
}
if (is_string($value)) {
$value = trim($value);
}
if ($value !== null && $value !== '') {
$isEmpty = false;
}
$record[$headers[$col]] = $value;
}
if (!$isEmpty) {
$rows[] = $record;
}
}
$spreadsheet->disconnectWorksheets();
return ['headers' => array_values($headers), 'rows' => $rows];
}
/**
* 从 URL 读取指定 sheet(内部先下载到临时文件)
*/
public static function readSheetFromUrl(string $url, string $sheetName, int $headerRow = 1, ?int $startRow = null): array
{
$tmp = self::downloadToTemp($url);
try {
return self::readSheet($tmp, $sheetName, $headerRow, $startRow);
} finally {
@unlink($tmp);
}
}
/**
* 按映射表将“源表头”=>“目标字段名”(snake_case)
* $map 形如:['车架号' => 'vin', '实际采购价' => 'actual_purchase_price', ...]
*/
public static function remap(array $row, array $map): array
{
$out = [];
foreach ($map as $from => $to) {
$out[$to] = $row[$from] ?? null;
}
return $out;
}
/** 将一批行做 remap */
public static function remapRows(array $rows, array $map): array
{
$out = [];
foreach ($rows as $row) {
$out[] = self::remap($row, $map);
}
return $out;
}
/**
* 将“金额字符串”解析成 float(适配多币种/多地区写法)
* - 支持:¥ ¥ $ € £ …、AED/Dirham:dh / dhs / aed、阿语 د.إ
* - 支持:中文/全角标点、阿拉伯小数/千分位、细空格、撇号千分位
* - 支持:括号负数 (xxx) 与显式负号
* - 返回 null 表示无法解析
*/
public static function toDecimal($v): ?float
{
if ($v === null || $v === '') return null;
if (is_numeric($v)) return (float)$v;
$s = trim((string)$v);
// 1) 统一常见 unicode 标点(全角/阿拉伯)与空格
$s = strtr($s, [
',' => ',', '.' => '.',
'٫' => '.', '٬' => ',',
"\xC2\xA0" => ' ', // NBSP
"\xE2\x80\xAF" => ' ', // NNBSP(窄不换行空格)
]);
// 2) 括号负数,如 (dh1,234.56)
$negative = false;
if (preg_match('/^\s*\((.*)\)\s*$/u', $s, $m)) {
$negative = true;
$s = $m[1];
}
// 3) 去币种/符号:AED/Dirham(dh/dhs/aed/د.إ)& 其它常见符号/字母
$s = preg_replace('/\b(dhs?|aed)\b/i', '', $s); // dh / dhs / aed
$s = str_replace(['د.إ', 'د', 'إ'], '', $s); // 常见阿语形式简化处理
$s = preg_replace('/[A-Za-z¥¥$€£₽₺₩₹﷼]+/u', '', $s); // 其它字母/货币符号
// 4) 只保留 数字/点/逗号/减号/空格/撇号,然后去掉空格与撇号(千分位)
$s = preg_replace('/[^\d\.,\-\s\']+/u', '', $s);
$s = str_replace([' ', "'"], '', $s);
// 5) 统一小数分隔符(判断最后出现的分隔符)
$lastComma = strrpos($s, ',');
$lastDot = strrpos($s, '.');
if ($lastComma !== false && $lastDot !== false) {
// 同时存在,最后出现者视为小数点
if ($lastComma > $lastDot) {
// 欧式 1.234,56
$s = str_replace('.', '', $s); // 去千分点
$s = substr_replace($s, '.', $lastComma, 1); // 最后一个逗号改为小数点
$s = str_replace(',', '', $s); // 其余逗号清空
} else {
// 美式 1,234.56
$s = str_replace(',', '', $s);
}
} elseif ($lastComma !== false) {
// 只有逗号
$digitsAfter = strlen($s) - $lastComma - 1;
if (substr_count($s, ',') > 1) {
// 多个逗号基本都是千分位
$s = str_replace(',', '', $s);
} else {
// 一个逗号:若逗号后 1~2 位,视为小数点,否则千分位
$s = ($digitsAfter >= 1 && $digitsAfter <= 2)
? str_replace(',', '.', $s)
: str_replace(',', '', $s);
}
} elseif ($lastDot !== false && substr_count($s, '.') > 1) {
// 多个点:保留最后一个为小数点,其他去掉(当作千分位)
$digitsAfter = strlen($s) - $lastDot - 1;
if ($digitsAfter >= 1 && $digitsAfter <= 2) {
$s = preg_replace('/\.(?=.*\.)/', '', $s); // 移除除最后一个外的点
} else {
$s = str_replace('.', '', $s);
}
}
// 6) 处理显式负号
if (strpos($s, '-') !== false) {
$negative = true;
$s = str_replace('-', '', $s);
}
// 7) 最终校验并转 float
if ($s === '' || !preg_match('/^\d+(\.\d+)?$/', $s)) {
return null; // 无法解析
}
$num = (float)$s;
return $negative ? -$num : $num;
}
/** 规范化布尔:'是/否'、'Y/N'、'1/0'、'true/false' */
public static function toBool($v): ?int
{
if ($v === null || $v === '') return null;
$s = mb_strtolower(trim((string)$v));
if (in_array($s, ['是','y','yes','true','1','√','✅'], true)) return 1;
if (in_array($s, ['否','n','no','false','0','×','❌'], true)) return 0;
return null;
}
/** 规范化日期(readSheet 已对 Excel 日期做过处理,这里再兜底) */
public static function toDate($v): ?string
{
if ($v === null || $v === '') return null;
if (is_numeric($v)) {
// Excel 序列号兜底
$ts = ExcelDate::excelToTimestamp($v);
return $ts ? date('Y-m-d', $ts) : null;
}
// 简单清洗
$s = trim((string)$v);
// 常见格式直接返回(如 2025-11-04 / 2025/11/04)
if (preg_match('/^\d{4}[-\/]\d{1,2}[-\/]\d{1,2}$/', $s)) {
return str_replace('/', '-', $s);
}
return $s; // 留给上层决定是否再严格校验
}
/**
* 把远程 URL 流式下载到临时文件;返回临时路径
*/
protected static function downloadToTemp(string $url, ?string $ext = null): string
{
$ext = $ext ? ('.' . ltrim($ext, '.')) : '';
$tmp = sys_get_temp_dir() . '/excel_' . uniqid() . $ext;
$fp = fopen($tmp, 'w');
if (!$fp) {
throw new \RuntimeException("Cannot open temp file: {$tmp}");
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 300,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_USERAGENT => 'Webman-ExcelReader/1.0',
]);
$ok = curl_exec($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
fclose($fp);
if ($ok === false || $http >= 400) {
@unlink($tmp);
throw new \RuntimeException("Download failed: HTTP {$http} {$err}");
}
return $tmp;
}
}
+15 -7
View File
@@ -89,7 +89,7 @@
<div class="container">
<!-- 头部 -->
<div class="header">
<h1 class="title">合塑实业-汽车事业部-备货单(客户下定)</h1>
<h1 class="title">合塑实业-汽车事业部-备货单<?php if ($data['is_final'] == 0) { echo '(客户下定)'; } else { echo '(终版)'; } ?></h1>
<div>
<span class="sub-title">备货单号: <?php echo htmlspecialchars($data['order_no'] ?? 'N/A'); ?></span>
<span class="sub-title">业务员: <?php echo htmlspecialchars($data['salesperson_snapshot'] ?? ' - '); ?></span>
@@ -126,7 +126,7 @@
<th>车型</th>
<th>数量</th>
<th>颜色</th>
<th colspan="7">费用明细</th>
<th colspan="8">费用明细</th>
</tr>
</thead>
@@ -142,9 +142,12 @@
<!-- 车型 跨4行(不含备注行) -->
<td rowspan="4">
<?php echo htmlspecialchars($vehicle['model_snapshot'] ?? ' - '); ?>
<?php echo htmlspecialchars($vehicle['model_snapshot'] ?? ' - '); ?><br/><br/>
<?php if (!empty($vehicle['mileage']) && $vehicle['mileage'] > 0): ?>
<span class="nowrap">(<?php echo htmlspecialchars($vehicle['mileage']); ?>km)</span>
<span class="nowrap">里程:<?php echo htmlspecialchars($vehicle['mileage']); ?>km</span>
<?php endif; ?><br/>
<?php if ($vehicle['vin']): ?>
<span class="nowrap">VIN:<?php echo htmlspecialchars($vehicle['vin']); ?></span>
<?php endif; ?>
</td>
@@ -160,6 +163,7 @@
</td>
<!-- 费用明细(第1组)标题 -->
<th>手续费</th>
<th>改装费</th>
<th>配件费</th>
<th>收款价</th>
@@ -171,6 +175,7 @@
<!-- 行2:费用明细(第1组)数据 -->
<tr>
<td class="nowrap"><?php echo htmlspecialchars($vehicle['handling_fee']['currency_info'] ?? ' - '); ?></td>
<td class="nowrap"><?php echo htmlspecialchars($vehicle['retrofit_fee']['currency_info'] ?? ' - '); ?></td>
<td class="nowrap"><?php echo htmlspecialchars($vehicle['accessory_price']['currency_info'] ?? ' - '); ?></td>
<td class="nowrap"><?php echo htmlspecialchars($vehicle['unit_price']['currency_info'] ?? ' - '); ?></td>
@@ -191,6 +196,7 @@
<!-- 行3:费用明细(第2组)标题 -->
<tr>
<th>加价</th>
<th>报关价</th>
<th>FOB运费</th>
<th>车价</th>
@@ -202,6 +208,7 @@
<!-- 行4:费用明细(第2组)数据 -->
<tr>
<td class="nowrap"><?php echo htmlspecialchars($vehicle['increase_price']['currency_info'] ?? ' - '); ?></td>
<td class="nowrap"><?php echo htmlspecialchars($vehicle['clearance_price']['currency_info'] ?? ' - '); ?></td>
<td class="nowrap"><?php echo htmlspecialchars($vehicle['fob_transport_fee']['currency_info'] ?? ' - '); ?></td>
<td class="nowrap"><?php echo htmlspecialchars($vehicle['car_price']['currency_info'] ?? ' - '); ?></td>
@@ -214,7 +221,7 @@
<!-- 行5:备注(因为“编号”已跨到本行,所以这里合并剩余10列) -->
<tr>
<td colspan="1"><strong>备注</strong></td>
<td colspan="9" style="text-align:left;">
<td colspan="10" style="text-align:left;">
<span class="info-value">
<?php echo nl2br($vehicle['remark'] ?? ''); ?>
</span>
@@ -231,7 +238,7 @@
<span class="info-label">合同汇率:</span>
<span class="info-value"><?php echo htmlspecialchars($data['contract_exchange_rate'] ?? ' - '); ?></span>
</td>
<td colspan="7" style="text-align:left;">
<td colspan="8" style="text-align:left;">
<span class="info-label">合计到岸价:</span>
<span class="info-value"><?php echo htmlspecialchars($data['cif_total']['currency_info'] ?? ' - '); ?></span>
</td>
@@ -261,7 +268,8 @@
<td><span class="info-label">出运方式:</span> <span class="info-value"><?php echo htmlspecialchars($data['shipping_method_snapshot'] ?? ' - '); ?></span></td>
<td><span class="info-label">国内收货港:</span> <span class="info-value"><?php echo htmlspecialchars($data['domestic_port_snapshot'] ?? ' - '); ?></span></td>
<td><span class="info-label">贸易条款/清关目的港:</span> <span class="info-value"><?php echo htmlspecialchars($data['trade_term_snapshot'] ?? ' - '); ?>/<?php echo htmlspecialchars($data['clearance_port_snapshot'] ?? ' - '); ?></span></td>
<td><span class="info-label">贸易条款:</span> <span class="info-value"><?php echo htmlspecialchars($data['trade_term_snapshot'] ?? ' - '); ?></span></td>
<td><span class="info-label">清关目的港:</span> <span class="info-value"><?php echo htmlspecialchars($data['clearance_port_snapshot'] ?? ' - '); ?></span></td>
<?php if ($data['is_final'] == 1): ?>
<td><span class="info-label">提车日期:</span> <span class="info-value nowrap"><?php echo htmlspecialchars($data['pick_up_date'] ?? ' - '); ?></span></td>
@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>邮箱提醒</title>
<style type="text/css">
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
}
.header {
padding: 20px;
background-color: #0056b3;
color: white;
text-align: center;
}
.content {
padding: 20px;
}
.button {
display: inline-block;
padding: 10px 20px;
background-color: #0056b3;
color: white !important;
text-decoration: none;
border-radius: 4px;
font-weight: bold;
margin: 10px 0;
}
.footer {
padding: 15px;
text-align: center;
font-size: 12px;
color: #777777;
background-color: #f0f0f0;
}
@media only screen and (max-width: 480px) {
.email-container {
width: 100%;
}
.button {
display: block;
text-align: center;
}
}
</style>
</head>
<body>
<!--[if mso]>
<style type="text/css">
.button {
background-color: #0056b3 !important;
color: white !important;
}
</style>
<![endif]-->
<table class="email-container" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
<tr>
<td class="header">
<h1>付款单提醒</h1>
</td>
</tr>
<tr>
<td class="content">
<p>您好,</p>
<p>您有新的付款单待处理。请及时查看:</p>
<ul>
<li>付款单编号:{{code}}</li>
<li>付款主体:{{payment_entity}}</li>
<li>问题描述:{{content}}</li>
{{reply}}
</ul>
<p>请点击下方按钮立即查看详情:</p>
<div style="text-align: center;">
<a href="https://x.hnxinyouhe.com" class="button" style="color: white; text-decoration: none;">立即查看</a>
</div>
<!--<p>如果您未请求此操作,请忽略此邮件或联系我们的客服团队。</p>-->
</td>
</tr>
<tr>
<td class="footer">
<p>© 2025 河南新友合科技有限公司. 保留所有权利。</p>
<p>这是系统自动发送的邮件,请勿直接回复。</p>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class FinPaymentApplyValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class FinReceiptHisValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
@@ -0,0 +1,56 @@
<?php
namespace app\common\validate;
use think\Validate;
class FinReceiptValidate extends Validate
{
// 规则
protected $rule = [
// 'payment_apply_id' => 'integer|gt:0',
// 'country_id' => 'require|integer|gt:0',
// 'remitter' => 'require',
// 'account_id' => 'require|integer|gt:0',
// 'currency_id' => 'require|integer|gt:0',
// 'amount' => 'require|gt:0',
// 'exchange_rate' => 'require|gt:0',
// 'arrived_at' => 'require',
// 'is_domestic_return' => 'require|integer',
];
// 提示
protected $message = [
// 'payment_apply_id.integer' => '付款申请ID必须是整数',
// 'payment_apply_id.gt' => '付款申请ID必须大于0',
// 'country_id.require' => '付款来源国家ID不能为空',
// 'country_id.integer' => '付款来源国家ID必须是整数',
// 'country_id.gt' => '付款来源国家ID必须大于0',
// 'remitter.require' => '汇款人不能为空',
// 'account_id.require' => '收款账号ID不能为空',
// 'account_id.integer' => '收款账号ID必须是整数',
// 'account_id.gt' => '收款账号ID必须大于0',
// 'currency_id.require' => '收款币种ID不能为空',
// 'currency_id.integer' => '收款币种ID必须是整数',
// 'currency_id.gt' => '收款币种ID必须大于0',
// 'amount.require' => '收款金额不能为空',
// 'amount.gt' => '收款金额必须大于0',
// 'exchange_rate.require' => '实际汇率不能为空',
// 'exchange_rate.gt' => '实际汇率必须大于0',
// 'arrived_at.require' => '到账时间不能为空',
// 'arrived_at.gt' => '到账时间必须大于0',
// 'is_domestic_return.require' => '是否回转内地不能为空',
// 'is_domestic_return.integer' => '是否回转内地必须是整数',
];
// 场景
protected $scene = [
'store' => [
// 'payment_apply_id', 'country_id', 'remitter', 'account_id', 'currency_id', 'amount', 'exchange_rate', 'arrived_at'
],
'update' => [
// 'payment_apply_id'
]
];
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class FinanceReconDubaiFilesValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class InquiryTransmitConfigValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class InquiryTransmitValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}

Some files were not shown because too many files have changed in this diff Show More