Compare commits

..

175 Commits

Author SHA1 Message Date
127287a289 fix: 拆分违约赔付与普通冲减金额 (#481) 2026-10-23 21:42:36 +08:00
f7197b4976 财务分析新增开票/不开票费用,并补齐违约赔付预览 (#480)
## 任务占用与边界

- 任务名称:财务分析开票费用与业绩明细违约赔付预览
- 实际开发者:yueyang
- 任务来源/团队分工:负责人直接指定
- 目标结果:财务经营分析表按科目映射表拆分开票费用/不开票费用;业绩明细增加违约赔付(冲减收入)列并按指定顺序展示。
- 预计修改路径:业务 Pages Controller、财务分析视图、账单明细数据服务、业绩明细配置与相关测试。
- 将写入的公共契约:财务分析费用聚合、默认补偿预览命令、业绩明细列配置。
- 仅复用、明确不修改的公共契约:store_receipt_order_match_rows、ec_account_mapping_rules。
- 数据源、表/视图和字段:店铺收款账单镜像、store_receipt_order_match_rows、store_receipt_product_cost_rows、ec_account_mapping_rules。
- 明确不修改的范围:账单抓取与上游同步链路。

## 开工碰撞检查

- [x] Codex 已执行 scripts/codex-task-preflight.ps1
- [x] 已检查最新 main 和远程任务分支
- [x] 已搜索主线既有页面、接口和数据服务
- [x] 已将写入和复用契约分开
- [x] 预检列出的残留分支均无开放 PR;当前 2 个开放 PR 与本任务无关,确认无活跃写入冲突

## 实际改动

- 财务经营分析表增加“开票费用”“不开票费用”及合计;财务类型=费用,票据口径 0.06/0.09 归入开票,不开票归入不开票,金额按支出减收入净额。
- 费用聚合并入现有月度缓存,缓存键包含科目映射哈希,映射变化后自动失效。
- 业绩明细增加违约赔付(冲减收入)列,放在冲减金额后。
- CSV 导出列同步更新。

## 验证证据

- 正常流程:2026-07/08/09 实库数据计算成功;2026-09 开票费用 3,896,538.65,不开票费用 81,377.62。
- 实际页面:2026-09 财务经营分析表 438 行,两列与合计正常展示。
- 性能:单月费用聚合 7.0–13.6 秒,PHP 峰值约 65.5 MB;页面结果缓存 5 分钟。
- 自动测试:NavigationTest 目标 2 条通过(35 assertions),StoreReceiptMappingPreviewOverrideTest 2 条通过(5 assertions)。

## 风险检查

- [x] 不涉及数据库迁移或生产数据写入
- [x] 涉及金额口径,已用实库页面和固定总额核对
- [x] 未提交 .env、密码、Token、真实数据或构建产物
- [x] 前端符合 DESIGN.md 黑金表格样式

## 合并前复查

- [x] 已基于最新 main 53bd1dc4 变基
- [x] 完整 diff 自审,无冲突标记
- [x] 目标测试通过
- [x] 负责人已授权 Squash merge 和发布

Reviewed-on: #480
2026-10-23 19:05:35 +08:00
53bd1dc44a 商品成本:按种菜标签更新账单侧取数 (#479) 2026-10-23 17:41:01 +08:00
f1ecd4a978 feat: 业绩明细种菜报销与未报销合并为标签 (#478)
合并业绩明细种菜标签字段变更
2026-10-23 17:31:58 +08:00
e3bcffd0f7 商品成本:排除占位行并固定月加权同步顺序 (#477) 2026-10-23 17:27:31 +08:00
1ad248dda6 商品价格:自动生成月加权平均单价并输出对账简报 (#476) 2026-10-23 16:21:15 +08:00
2fa66ef1d1 进销存:保留供应商汇总中的相同明细行 (#475) 2026-10-23 15:55:06 +08:00
1991b7d319 进销存:增加供应商汇总表月度增量导入 (#474) 2026-10-23 15:47:39 +08:00
c9ecf033d5 进销存:入库明细支持页面导入 (#473) 2026-10-23 15:02:03 +08:00
f61fbb5028 商品利润镜像:长单号只查线上订单号,内部订单号补索引 (#472) 2026-10-23 14:52:38 +08:00
6908546d44 商品利润镜像:单号查询改走索引,修复页面 504 (#471) 2026-10-23 13:55:15 +08:00
238bff99e9 商品成本:线上单号保持业绩明细原始格式 (#470) 2026-10-23 13:49:50 +08:00
3bdc3382db 商品成本:全局排除简家乡间组合装占位编码 (#469) 2026-10-23 12:04:27 +08:00
1d33cc2654 商品成本:全局排除配件占位编码 DF-PJZY0001 (#468) 2026-10-23 11:41:54 +08:00
ee9205dca7 商品成本:支持 MKH 组合装拆分 (#466) 2026-10-23 11:14:29 +08:00
acab9b2905 fix(财务台账): 账户简称表头筛选支持手动输入 (#467)
账户简称表头筛选改为可输入、可点选的搜索框,并保留原有筛选逻辑。
2026-10-23 11:08:01 +08:00
cfb56c98a7 fix: 业绩明细复用既有精确差额备注 (#465) 2026-10-23 10:51:42 +08:00
0893f3d8d3 商品成本:订单内有带仓库行时丢弃空仓库汇总行 (#464) 2026-10-22 22:49:05 +08:00
9711ec4f57 fix(电子签章): 双方骑缝章错开位置,不再叠在一起 (#463) 2026-10-22 20:54:02 +08:00
812b633146 feat(财务台账): 动账明细账户简称支持输入联想 (#462)
账户简称筛选支持输入即时联想,并保留点击、键盘选择和无匹配提示。
2026-10-22 18:45:54 +08:00
562973438d feat(财务台账): 优化动账列宽筛选与行高 (#461)
动账明细支持列宽拖动与保存,表头日期/账户/来源筛选可选,行高统一。
2026-10-22 18:03:55 +08:00
3fe7833f87 商品成本:套餐/服务占位编码增加 AZF 排除 (#460) 2026-10-22 17:07:53 +08:00
4206f2f9ba 构建:Debian apt 源改用清华镜像 (#459) 2026-10-22 16:20:04 +08:00
58e0c990e2 商品成本:拆单只取叶子订单行,剔除主单与中间汇总层 (#458) 2026-10-22 16:01:38 +08:00
fa469fafec 安全更新:升级 Node、regctl 与 BuildKit 构建链 (#457) 2026-10-22 12:35:29 +08:00
2c1bea34e0 feat(财务台账): 支持逐行修改动账账户关联 (#456)
动账明细账户简称支持输入搜索和逐行修改,映射、流水及余额同步重算。
2026-10-22 11:49:52 +08:00
2852c1332f feat(财务台账): 优化手工明细日期与账户选择 (#455)
手工明细日期支持日历按钮,账户简称支持输入筛选并限制为账户总表数据。
2026-10-22 11:36:27 +08:00
00511c4577 feat(财务台账): 分页支持选择页码 (#454)
五个财务台账页签统一支持选择页码,并修正分页后的当前页签参数。
2026-10-22 11:21:20 +08:00
260014a4a0 售后工单按类型日期索引收紧查询 (#453)
补充生产首屏验收:将候选工单限制在最新售后类型中,保留原有筛选与金额规则。
2026-10-22 11:08:56 +08:00
920fbcb650 优化售后首屏索引、工单查询及翻页 (#452)
保留原始日期与金额,增加可重建镜像索引并通过相关回归和百万行验证。
2026-10-22 10:55:40 +08:00
446da08445 feat(财务台账): 支持手工动账并联动每日流水 (#451)
支持动账明细手工录入、账户总表简称关联、每日流水自动重算及同步保留。
2026-10-22 10:54:21 +08:00
7280d86de9 安全更新:升级 PHP 8.3.36 修复 PHP2Shell (#450)
Pin production and local development to official PHP 8.3.36 Apache Bookworm, preserving resource limits. Verified 47 tests / 319 assertions, real Apache runtime and 115000-row read-only import.
2026-10-22 09:46:44 +08:00
edc27345ee 商品利润镜像:订单时间按字符串日期解析后再筛选排序 (#449) 2026-10-21 22:22:51 +08:00
9976ca6b0a 商品利润镜像:字段映射只保留原表实际存在的列 (#448) 2026-10-21 22:18:52 +08:00
ec3cd39413 feat(财务台账): 增加可筛选排序的表头 (#447) 2026-10-21 21:13:49 +08:00
9ab4e1e261 feat(财务台账): 移入经营报表快捷分组 (#446) 2026-10-21 20:50:24 +08:00
9028f6aff4 快递应付:改用上一页、下一页分页按钮 (#445) 2026-10-21 20:07:50 +08:00
cc0cdbeed7 财务台账:新增不保留账户处理待办 (#444) 2026-10-21 17:59:29 +08:00
66c5864455 修复售后退货退款来源遗漏与历史明细校准 (#443) 2026-10-21 17:45:37 +08:00
873d306277 财务台账:补齐账户后4位并统一支付宝占位 2026-10-21 17:37:25 +08:00
04034948e3 财务台账:限额列内容居中 2026-10-21 17:13:10 +08:00
ea1bff1d5a 财务台账:金额按万元展示并增加余额筛选 2026-10-21 17:03:42 +08:00
c2a11b3064 财务台账:增加当前余额并按账户状态同步金额 2026-10-21 16:47:16 +08:00
a6f4ca8ccc feat: 接入供应商钉钉门户权限 2026-10-21 16:24:15 +08:00
0942f31d7a 财务台账:修复账户异常待办列宽恢复基线 2026-10-21 16:21:29 +08:00
18297f1845 财务台账:新增账户异常待办工作台与汇总卡片优化 (#434)
新增账户异常独立待办、筛选排序、完成状态联动、吸顶表头、列宽调整和无刷新分页,并优化汇总卡片。
2026-10-21 16:11:55 +08:00
9a7ecf879c 财务台账:支持自定义账户总表列宽 (#433)
支持拖动账户总表表头调整列宽,并按用户保存列宽偏好。
2026-10-21 14:57:36 +08:00
0ab30d5bb9 商品成本:订单侧排除状态为 Cancelled 的明细行 (#432) 2026-10-21 14:54:36 +08:00
eaeae97752 财务台账:公司规划与保留状态增加下拉编辑 (#431) 2026-10-21 14:37:41 +08:00
36af0e211a 修复生产内存保护:网页限额与重型导入隔离 (#430)
网页设置 2GiB 内存边界;生产重型导入统一进入 1CPU/1GiB 独立容器,保留只读文件、互斥、超时清理和发布保护。相关回归与资源隔离测试通过。
2026-10-21 14:30:06 +08:00
639ff3d0b3 财务台账:允许管理员编辑台账 (#429) 2026-10-21 14:29:03 +08:00
023342c6a8 财务台账:增加编辑模式开关 (#428) 2026-10-21 14:14:47 +08:00
de770efce7 商品利润镜像:超宽表非键列存 TEXT,并在首次全量完成前不对外使用 (#427) 2026-10-21 14:08:22 +08:00
20c11719e0 docs: 增加性能与资源验收强制标准 2026-10-21 14:06:49 +08:00
a1f921be2f 订单售后费用明细:改读聚水潭商品利润原表 jst_goods_profit_kpi_items_afs (#425) 2026-10-21 14:03:18 +08:00
1a7a647907 订单售后费用导入:只有完整覆盖所选日期时才使用导入数据 (#424) 2026-10-21 13:53:38 +08:00
0e0ab438c0 订单售后费用导入:按整行内容生成行键 (#423) 2026-10-21 13:50:40 +08:00
8ba241a868 订单售后费用导入:改为流式解析 xlsx,避免整本加载拖垮服务器 (#422) 2026-10-21 13:47:01 +08:00
e829ab2094 订单售后费用明细:接入聚水潭「订单售后费用数据」导入明细 (#421) 2026-10-21 13:03:24 +08:00
2186e9085a 财务台账:增加账户是否保留列 (#420) 2026-10-21 11:59:04 +08:00
a57a8116bf feat(财务台账): 移入财务经营报表并兼容旧链接
财务台账移入财务经营报表,兼容旧地址和旧权限键。
2026-10-21 11:39:31 +08:00
0b2bd343c5 fix: 恢复可用的 Composer 基础镜像源 2026-10-21 11:37:46 +08:00
345790175b 部署:切换可用的 Composer 基础镜像源 (#417) 2026-10-21 11:30:11 +08:00
36b8b5c616 财务台账:修复页签筛选提交丢失条件 (#416) 2026-10-21 11:26:45 +08:00
176357871b feat: 供应商管理新增是否包邮厂家
在供应商名称后新增「是否包邮厂家」,支持行内编辑、模板导入和导出;空白按不是包邮厂家处理。
2026-10-21 11:20:58 +08:00
6feea82305 商品成本:订单侧排除三个仓,常州简尚保留 DF- 商品行 (#414) 2026-10-21 11:14:49 +08:00
cd25916d9d fix: 补齐 Windows 发布 Composer 镜像源 (#413)
Windows 与 macOS 发布脚本统一支持 Composer 镜像源。
2026-10-21 11:12:01 +08:00
20dc695549 feat: 完善财务台账账户维护与分页体验 (#412)
合并账户维护、筛选、列设置、无刷新分页、录入模板及批量映射工具。
2026-10-21 11:00:56 +08:00
93a27725b6 feat(财务台账): 接入 AI 轻量视图并改为分批增量快照 (#411)
本地验证:7 天和 30 天增量合并后,12,037 笔明细、收入 184,308,335.23、支出 155,917,513.13 保持不变。
2026-10-21 10:37:59 +08:00
5633ed2430 安全整合存量报价计费规则与独立成本价观测 (#410) 2026-10-20 19:08:47 +08:00
d68548646d 修复多人发布并发覆盖及旧客户端绕过保护 (#409) 2026-10-20 18:13:01 +08:00
chenxi
0ec6f703fe 商品成本:移除两个筛选复选框,默认全部数据展示
- 去掉「只看未匹配商品单价」「包含-不看仓」两个复选框
- 页面与导出默认展示全部行:已匹配/未匹配单价、含「-不看」仓全部纳入
- 汇总卡片按全部口径统计,移除「已隐藏的-不看仓行数」
2026-10-20 17:46:34 +08:00
a317ab9dea 供应商对账:比对汇总改按我方商品编码匹配 (#408)
对照我方货款对账的商品编码匹配厂家条码,页面、CSV导出和钉钉通知统一切换。
2026-10-20 17:45:55 +08:00
db08b2c264 供应商对账:新增财务比对汇总导出 (#407)
财务端比对明细新增CSV导出,包含筛选汇总和完整比对结果。
2026-10-20 17:29:46 +08:00
chenxi
32cd0d0a15 商品成本:常州简尚家居有限公司的仓库简称对应映射表「配件单仓」行
业务确认:仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 简称=配件单仓-不看。
仓库简称匹配增加这一档:主仓行 → 同名仓库 → 配件单仓行(仅在没有前两者时使用)。
2026-10-20 17:15:49 +08:00
8162afa969 供应商对账:财务比对筛选与分页优化 (#406)
财务端增加供应商精确搜索、比对明细吸顶表头和每页15条分页。
2026-10-20 17:03:27 +08:00
chenxi
bcb29aac1c 商品成本:仓库简称只认映射表里「仓储方 + 主仓」那条
发货仓库是聚水潭仓储方级别的名称,映射表里同一仓储方下还有次品仓/销退仓/配件单仓等行,
之前无主仓行时会兜底取到这些行的简称(如常州简尚家居有限公司→退货报废-不看),
把主仓发货误判成不看仓。改为:仓储方匹配仓库名含「主仓」的行的简称;
没有主仓行时只接受「仓库名=仓储方名」的同名仓库,不再兜底次品/销退行。
2026-10-20 15:19:32 +08:00
d1b751c79c 业绩明细:补齐拼多多退款支付立减备注并延长联动缓存 2026-10-20 15:14:55 +08:00
chenxi
fce31f0c09 商品成本:种菜金额有数据的账单行整条排除
账单侧按业绩明细取数时,除「收入金额有数据且不为 0」外,再排除 planting_amount > 0 的账单行
(种菜订单整条不参与商品成本匹配),页面口径说明同步补充。
2026-10-20 14:12:19 +08:00
acad1f4577 修复业绩明细日期前缀串单并增加防碰撞匹配 2026-10-20 14:01:26 +08:00
ad8da1264d 供应商对账:按采购单号日期条码核对数量金额 (#405)
厂家账单改为按采购单号+日期+条码匹配,逐项比较双方数量、单价和金额,页面与钉钉通知明确展示厂家多算或少算。
2026-10-20 13:44:00 +08:00
6470346392 修复账单覆盖校验日期边界 2026-10-20 13:26:20 +08:00
f08c600961 合并京东钱包每日账单同步修复 2026-10-20 13:19:37 +08:00
144937bfa3 补齐京东钱包每日账单同步并增加覆盖校验 2026-10-20 12:20:13 +08:00
chenxi
5fe92bd05c 商品成本:订单侧不取套餐/服务占位编码 TCXJ、FUWU
订单原始数据里的 TCXJ(套餐)、FUWU(服务)不是实物商品,商品成本同步时直接剔除
(SQL 层过滤 + 组合装拆出的组件里同样剔除),页面口径说明同步补充。
2026-10-20 11:53:03 +08:00
81bf2bd779 供应商对账:厂家端隐藏上传记录并调整内部位置 (#404)
厂家门户隐藏最近上传摘要;内部厂家端上传记录保留并移动到页面底部。
2026-10-20 11:39:20 +08:00
cad8369fdb 业绩明细:新增1688红包补贴备注并优化筛选统计 2026-10-20 11:28:53 +08:00
2a2df7451e 供应商对账:退货表格改为十条并固定表头 (#403)
退货表格调整为每页十条,并增加固定高度滚动区和吸顶表头。
2026-10-20 11:21:22 +08:00
chenxi
5e05a8728e 商品成本:未匹配单价统计排除「数量与单价口径不一致」行,避免两处重复计数 2026-10-20 11:19:53 +08:00
chenxi
6b044e727b 商品成本:单价增加「普通商品资料成本价」第三档兜底
取价顺序改为:商品月加权平均单价(仓库简称+商品编码)→ 线上代发价格(采购单价/钉钉代发单价)
→ 普通商品资料(operations_replica.rpt.jst_sku)里的商品成本价(sku_type=normal 且成本价>0)。
页面汇总新增「其中用商品资料成本价兜底」,同步命令日志同步输出三档兜底行数。

7 月未匹配单价行数 3,599→2,098(资料成本价补 1,071 行),8 月 2,713→2,765(含数据重跑差异)。
2026-10-20 11:16:56 +08:00
145a62c397 供应商对账:增加发货日期范围筛选 (#402)
内部厂家端和厂家门户增加日期面板与近7天、本月、上月、全部日期快捷筛选。
2026-10-20 11:00:43 +08:00
21b29d06eb 种菜未报销排除种菜明细已报销订单 2026-10-20 11:03:06 +08:00
ffa4bbf15f 业绩明细新增种菜未报销列 2026-10-20 10:55:41 +08:00
75aac2e5aa 供应商对账:厂家端退货汇总改为件数与金额 (#401)
内部厂家端和厂家门户改为大号退货件数、退货金额及确认状态卡片,汇总不再显示行数。
2026-10-20 10:50:08 +08:00
0104b9b18e 供应商对账:内部可搜索厂家并隔离厂家名称 (#400)
内部厂家端改为可搜索且精确匹配供应商名称;厂家账号仅能上传和查看自己绑定的供应商数据。
2026-10-20 10:35:54 +08:00
4511f6c71a 业绩明细新增种菜报销列 2026-10-20 10:36:24 +08:00
cdabffb3a6 业绩明细删除费用项列及相关归集逻辑 2026-10-20 10:20:55 +08:00
4e174963e8 fix(种菜损耗): 透明挂钩/虚拟条码整单不扣款;拍A发B 从种菜报销直接挑出发运营
财务确认(2026-10-20):种菜损耗是查种菜报销的单有没有真实发出有价值的货——
- 发货数据中交易单号下出现信誉订单虚拟条码或透明挂钩(含条形码 GG 开头)即整单不扣款;
- 任务备注/卖家备注含拍A发B/拍A发A 的不扣款,且无论是否匹配到发货都从种菜报销中挑出发运营;
- “导出拍A发B(发运营)”直接按「种菜报销」页字段输出;其余真实发货的单按成本价×数量+运费扣款。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-20 10:10:29 +08:00
9b6c3d2477 业绩明细排除京东网银钱包账户数据 2026-10-20 10:00:55 +08:00
366dcc1da7 业绩明细新增费用项并按科目映射费用备注归集 2026-10-20 10:00:53 +08:00
56909bd669 货款对账:每页固定展示15条数据 (#399) 2026-10-20 09:48:25 +08:00
19bb6a8a75 退货对账:每页固定展示15条数据 (#398) 2026-10-20 09:33:20 +08:00
dd9cab671b 合并业绩明细万元显示与口径说明整理 2026-10-20 09:32:52 +08:00
1acd97b9e0 业绩明细:金额改为万元并整理指标口径说明
金额构成统一按万元显示两位小数并逐项标注万;长口径说明拆为匹配口径、金额字段、差额判定、匹配原因四组,完整规则默认折叠。
2026-10-20 09:32:42 +08:00
c0cc8c7e60 供应商对账:同步退货转物流记账归属 (#397) 2026-10-20 09:21:55 +08:00
bf290d5353 fix(种菜损耗): 交易单号下含信誉订单虚拟条码的整单不扣款
10 月生产数据里,部分种菜单的交易单号在发货数据中与大量合并发货的别的订单商品挂在一起(同一交易单号下几十个实物+信誉订单虚拟条码),
被错算为扣款。财务确认信誉订单虚拟条码不扣款:该单号下出现信誉订单虚拟条码(X00006/X00002)即整单归入不扣款;
“晒图有奖励(无实物)”等无实物商品也不计扣款。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-20 08:53:17 +08:00
31f6c72bf3 优化订单与账单镜像分批读取及内存释放 2026-10-19 21:04:42 +08:00
bbb967e110 降低业绩匹配任务峰值内存 2026-10-19 19:08:39 +08:00
a6e0b2ac7d 业绩明细汇总拆开复杂差额规则
月度汇总先按默认差额聚合,只有实收/应付兜底行再计算完整差额原因。7月验证结果一致,汇总耗时由约3.49秒降到0.71秒。
2026-10-19 18:24:13 +08:00
8e4bf99483 优化京东账单首次汇总与候选查询的覆盖索引 2026-10-19 18:14:37 +08:00
d735de7864 修复发布冷缓存时店铺筛选退回大范围扫描 2026-10-19 18:07:12 +08:00
e4d82dab2a chore(部署): 发布前拦截会覆盖线上已发布提交的版本
今天连续发生多次热修复/旧版本发布把别人刚上线的改动冲掉(种菜损耗运费口径被撤回两次)。
发布脚本读取线上 .deployed-sha 后:本地没有该提交先 git fetch 再判断;线上提交不是本次发布版本的祖先时直接拒绝(服务器不做任何变更);
新增 -AllowRollback / --allow-rollback 仅供负责人决定撤回时使用。AGENTS.md 第 8 节补充热修复须基于线上版本并立即合入 main。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-19 18:00:07 +08:00
17429d65ab perf(财务): 减少大表扫描与重复重算,串行受限导出 2026-10-19 17:53:14 +08:00
a124e9906b 业绩明细收入统一按科目映射表判桶
账单明细页已优先使用科目映射表,但业绩同步的收入仍优先取旧总账备注表,导致两套口径冲突。改为收入、优惠、冲减、退款统一使用当前科目映射表,旧表仅在当前映射缺失时兜底。
2026-10-19 17:28:31 +08:00
4c617fd063 fix(种菜损耗): 扣款金额改为 成本价×数量 + 该单发货运费
财务更正口径:种菜损耗扣款 = Σ 发货条码在普通商品资料的成本价 × 数量 + 该订单在发货数据中的运费合计,
不再按成本价×1.2;运费为空的订单标"运费缺失",缺失部分不计入扣款金额。

涉及:金额计算展示(只读);不涉及迁移、权限和上游写入。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-19 16:46:46 +08:00
528701fdec 附加费结算导入核验:只填单号+类型,自动匹配快递并按报价核算 (#391) 2026-10-19 16:44:50 +08:00
chenxi
e0abc6732b 商品成本:拆单订单有子单时只取子单,避免主单与子单重复计
聚水潭拆单后主单保留原始下单行、子单才是实际发货行;之前按「商品+数量+金额+仓库」去重,
主单 10 件与子单各 1 件键不同,会重复累加。改为:有子单时只取子单(按 o_id+oi_id 去重),
整单没有子单才用主单;再按「发货仓库 + 商品编码」汇总数量。

回归样例 5122123095012119232:31202C 计 10 件(10 个子单各 1 件)、DNZ032916N887V01 计 10 件。
2026-10-19 16:31:16 +08:00
aaron
c44d267902 chore(部署): 增加财务台账每日同步 cron 2026-10-19 16:07:32 +08:00
aaron
02d21ce9ac feat(财务台账): 新增财务台账模块与 rpt 取数 2026-10-19 16:06:55 +08:00
eec820bfc5 修复1688多店铺前缀收入漏取
同一平台多个店铺名前缀应使用 OR 命中;原实现把阿里巴巴、1688、新佰广三个 LIKE 用 AND 连接,导致1688店铺的订单收入账单行被排除。新增多前缀回归测试。
2026-10-19 15:35:30 +08:00
424311a441 财务经营风险管控:「流程统计」页面及菜单改名为「店铺流程」 2026-10-19 15:18:28 +08:00
79f6937e75 字段查询页去掉重复的页面标题栏和多余的列设置条
- 移除页面内重复的「页面名 + 查询与导出说明」标题块(顶部已有页面标题和已打开页面标签)
- 这类页面筛选条件里本来就有「筛选字段」,隐藏脚本额外注入的「列设置」条
2026-10-19 15:06:40 +08:00
1f4f6f48d4 优化财务资源:减少重复扫描、隔离重型任务、约束系统日志 (#390) 2026-10-19 14:56:54 +08:00
eb0f246765 财务经营风险管控:网页端不再重复显示目录页签
- 目录内页签(店铺违规信息预警/预览统计/流程统计)只在钉钉单页入口(?focus=1 或钉钉 UA)显示
- 网页端已有左侧菜单和已打开页面标签栏,页面内只保留内容本身
- 单页模式判断抽到 FocusMode,与布局共用
2026-10-19 14:56:08 +08:00
9e199243b6 合并已上线的 hotfix:科目映射表保存后自动重跑业绩明细(cbbf32bb) 2026-10-19 14:47:36 +08:00
74906ac5f6 侧边栏常用网址:新增工作平台、预策、由壹点「脉象」 2026-10-19 14:45:37 +08:00
ea479733ab feat(种菜数据汇总): 接入聚工单种菜报销,新增种菜损耗扣款页面
- 种菜报销:复用已登记镜像 jiansun.dbo.jst_workOrderInfo,工单类型“种菜报销”、状态已完成,
  按完成时间归属报销月份(M 月 = [M-02 00:00, M+1-02 00:00)),自 2026-09-02 起;同一工单只取最新一版
- 种菜损耗:当月种菜报销的线上订单号匹配上月+本月发货数据(jst_oms_shipping)交易单号;
  任务备注/卖家备注含“拍A发B/拍A发A”的不扣款(单独导出发运营),只发信誉订单虚拟条码/透明挂钩的不扣款,
  其余按发货条码在普通商品资料(ops_jst_sku)的成本价 × 数量 × 1.2 扣款,成本价缺失单独提示
- 两页均支持筛选、列设置、CSV 导出;按月缓存 10 分钟
- 测试:窗口口径、免扣字样、虚拟条码/挂钩、成本价取数与缺失、页面筛选和导出

涉及:金额计算展示(只读);不涉及数据库迁移、权限模型和上游写入。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VANWYsNUbu2piMtCKymoyd
2026-10-19 14:37:09 +08:00
566a3ef4db 登录后固定进入首页,不再回跳上次打开的业务页面
- 账号密码登录与钉钉免登都忽略 url.intended,直接进 landing_page(首页/财务驾驶舱)
- 补充登录落地测试,修正首页改版后仍按旧行为断言的测试
2026-10-19 14:26:10 +08:00
9f4f4f0cbb 科目映射表:新增费用备注列并回填费用备注数据 2026-10-19 14:24:42 +08:00
cbbf32bb5c 科目映射表保存后自动重跑业绩明细
映射表保存、删除、恢复、导入和重置后,自动把受影响平台已存在的月份排入后台队列;后台按平台×月份逐个独立进程重跑,数据库锁防重复,生产 cron 每分钟兜底。保存响应直接提示排队任务数。
2026-10-19 14:22:43 +08:00
664fc05faf 首页改为财务驾驶舱骨架,并加入常用网址入口
- 首页不再展示业务目录:改为财务驾驶舱(本月收入/支出/毛利/毛利率/应收/应付 + 趋势 + 风险预警位置),指标待接入
- 侧边栏常用网址:新增聚水潭,搜索框右侧书签入口展示
- 更新首页相关测试
2026-10-19 14:15:27 +08:00
chenxi
9b2edfe1de 商品成本:组合装按子订单拆成组件行
聚水潭组合装 sku 形如 ZH-<条码>-<数量>-<条码>-<数量>,之前整包按一个商品行列出,
月加权/代发价都匹配不到。现在按组件拆分:一个订单有几个发货仓库、商品编码、数量就分开列,
组件数量 = 明细数量 × 组件倍数;解析不了的(条码被截断等)保持原样。
组件行的明细金额按整包处理,不参与「数量×单价 vs 明细金额」校验。
2026-10-19 14:06:56 +08:00
0cb970eeec 业绩明细备注:京喜「订单实付取供货价」证据按活动代过滤——修复多代账单把直营服务费算两倍,导致 9 月 4.4 万行误落差额待核实(修复后 9 月 44897→1245 行) 2026-10-19 13:45:50 +08:00
chenxi
5fdd3bba17 科目映射规则加进程内缓存,修复业绩明细逐行重查规则表
业绩明细同步里每一行账单都会调用 AccountMappingMatcher,而 match() 每次都走
AccountMappingStore::all() 重新 SELECT 整张规则表;9 月得物 3.6 万行要跑近半小时。
改为进程内静态缓存(写入 create/update/importRows 时清空),行为不变,单平台同步回到 1-2 分钟。
2026-10-19 13:19:25 +08:00
87788d87ff fix(发货数据): 快递名称以出库单/发货明细当前值为准,发货数据视图仅兜底
- 聚水潭换单、改快递公司后,出库单、发货明细、订单的快递公司都会更新(如 崔桥顺丰干配→常州德邦、德邦快递→申通E物流),
  工作平台随之显示新值;发货数据视图的 expressName 却停在旧值。此前优先取视图,9 月 111 个包裹快递名称与工作平台不一致。
- 改为:快递名称取出库单/发货明细当前值,视图只在两者都没有时兜底;仓库仍按视图 wareHoursName。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
2026-10-19 11:56:56 +08:00
13d54d0c76 fix(厂家记账): 配置表中没有的供应商按可扣款处理
财务更正口径:厂家记账配置表中没有的供应商(含配置不在生效期内)视同正常记账,作为厂家扣款项;
取消“待配置”判定。工单未带供应商、或多供应商判定不一致的仍为“待确认”,不计入扣款。

涉及:扣款金额汇总口径;无数据库变更。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JouFR6TJ7vGscRJanYScEo
2026-10-19 11:19:27 +08:00
b22eb61541 侧边栏:搜索框右侧增加常用网址入口
- 搜索框右侧新增书签按钮,点开显示 config/sidebar_links.php 中配置的网址,新标签页打开
- 未配置时给出去哪里加的提示
- 补充导航测试
2026-10-19 11:13:49 +08:00
efb6d9c992 业绩明细备注:京喜拆单/合并单货款含直营服务费正差与分位尾差归因 2026-10-19 11:07:29 +08:00
a3b36b1fb6 店铺维度报表销量改为收入+优惠+冲减+退款合计 (#389) 2026-10-19 10:47:29 +08:00
9d8133cc73 登录后进入首页导航,不再直接打开第一个业务页面
- 新增首页导航页:常用分组沿用侧边栏分组,下面是全部业务模块(可展开)
- Pages@home 由跳转首个业务页改为渲染首页;侧边栏分组定义抽到 SidebarMenu 复用
- 侧边栏顶部标识可点回首页
- 更新钉钉登录落地断言,补充首页导航测试
2026-10-19 10:42:16 +08:00
09b4ffcb8a 供应商应付-厂家记账:新增厂家记账配置并按配置判定厂家扣款
- 新增配置表 business_supplier_bookkeeping_configs(+ 操作日志表),迁移时导入财务提供的 209 家存量配置
- 记账方式:正常记账 / 仅代发仓发错货记账 / 不记账,支持生效期;配置页支持新增、编辑、删除恢复、Excel 导入导出
- 扣款判定:厂家记账按供应商配置,仅正常记账扣款;仓库记账仅订单发货仓库为代发仓且二级原因含“仓库发错货”时作为厂家扣款项,其余不扣
- 未配置/生效期外显示待配置,多供应商判定不一致显示待确认,均不计入扣款金额
- 明细页新增厂家扣款判定筛选、扣款工单数与扣款金额指标、导出列

涉及:数据库迁移(新建配置表并写入存量配置)、金额汇总展示;不涉及上游写入和权限模型。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JouFR6TJ7vGscRJanYScEo
2026-10-19 10:38:36 +08:00
68b16a95c0 业绩明细备注:京喜「直营补贴款」覆盖扩至全部京喜自营工厂店(按实付≈商品金额/供货价特征自动识别,10 家已核验店沿用名单;待核实负差消除) 2026-10-19 09:34:57 +08:00
bcbb9e72af fix(发货数据): 每晚重拉最近 40 天,并清除上游已作废的出库/采购退货包裹
- 聚水潭常在发货后数天到数周内换单/改快递公司(如崔桥顺丰干配→常州德邦、德邦→申通E物流)、作废采购退货出库;
  原先每天只重拉最近 3 天,上月数据停在旧值:9 月 111 个包裹快递名称与工作平台不一致,14 个已作废的采购退货单仍在。
- 01:28 定时同步改为最近 40 天(覆盖上月结算期),07:28 仍补拉最近 3 天。
- 窗口重跑时,出库单兜底/采购退货出库包裹若本次完整扫描中已不存在(作废/换单),直接删除;扫描为空时不删。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
2026-10-19 09:31:17 +08:00
ed58e8ef43 供应商应付:新增厂家记账,接入聚工单记账工单(2026-09-01 起)
- 复用已登记镜像 jiansun.dbo.jst_workOrderInfo,不新增上游链路
- 口径与聚工单「记账」视图一致:工单类型“记账”,状态待审核/已拒绝/待处理/处理中/已完成,
  记账原因为厂家记账 12 项 + 仓库记账 5 项二级原因,发起时间 >= 2026-09-01;同一工单只取最新一版
- 新页面 供应商应付 → 厂家记账:筛选、记账金额合计、列设置、CSV 导出;首次同步未完成时明确提示
- 镜像索引迁移 (woTypeId, created, Id)

涉及:金额展示(只读)、镜像索引迁移;不涉及权限模型和上游写入。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5KQeRJaLrbJY9iKNC5veV
2026-10-18 22:27:16 +08:00
b2dd1e5e6b 报价管理:修复列设置后筛选翻页表头与内容错位 (#388) 2026-10-18 14:25:25 +08:00
e428cf5de2 报价历史:支持勾选并批量审核通过 (#387) 2026-10-18 14:05:40 +08:00
2d30aea624 报价管理:移除列表黄色分组说明行 (#386) 2026-10-18 13:29:18 +08:00
409da4cae8 feat: 提现管理余额日期改为下拉选择 2026-10-18 12:09:42 +08:00
da794c3ddb feat: 提现管理支持按余额日期筛选并长期保留每日余额 2026-10-18 11:52:02 +08:00
b49d23d090 供应商配件运费:外采快递不扣款 (#385) 2026-10-17 21:49:09 +08:00
23887059f1 多时期补录保留选定快递网点编号 (#384) 2026-10-17 21:09:11 +08:00
10d9c22e3e 报价执行期间冲突显示明确错误提示 (#383) 2026-10-17 21:02:31 +08:00
bd39a7fdda 存量报价支持多时期导入、快递分组及按日期查看 (#382) 2026-10-17 20:58:04 +08:00
077701d24a 供应商应付:新增新河北仓、曹县仓配件运费扣除明细 (#381) 2026-10-17 20:54:13 +08:00
d24eefb687 补全工作平台供应商管理字段同步和展示 (#380) 2026-10-17 13:12:52 +08:00
3638a8cee9 货款对账:差异支持勾选多条推送 (#379) 2026-10-17 13:05:10 +08:00
41f6946a2b 货款对账:差异支持按厂家钉钉机器人推送 (#378) 2026-10-17 12:54:30 +08:00
3fedeef59a 修复存量报价审核生效、原件预览与配件补发可扣款清单 (#377) 2026-10-17 12:49:39 +08:00
a0a6e9285a 供应商对账:新增厂家账号门户与独立退货视图 (#376) 2026-10-17 12:01:30 +08:00
18d03fe092 供应商对账:厂家退货支持查询导出并精简字段 (#375) 2026-10-17 11:13:18 +08:00
b0d7096241 供应商对账:厂家端退货表表头与退货明细完全一致 (#374)
厂家端退货表列头与退货明细一致(补齐月份/条形码/商品名称等),类型/操作在最后。
2026-10-17 10:53:13 +08:00
c69796b0d3 供应商对账:退货明细按导入顺序展示 (#373)
退货扣减确认取消金额排序,按导入顺序分页展示。
2026-10-17 10:24:25 +08:00
a8ffb6fc23 供应商对账:退货扣减确认展示全部退货行 (#372)
退货行按扣减金额从高到低分页展示(每页 15 行 + 页码),不再只显示前 15 行。
2026-10-17 10:05:08 +08:00
670faf2b80 供应商对账:表格改为卡片内横向滚动 (#371)
宽表不再撑开页面:卡片/滚动容器 min-width:0 + max-width:100%,表格在卡片内横向滚动。
2026-10-17 09:54:38 +08:00
feea2ed8b6 供应商对账:厂家上传的对账数据改为每页 15 行 + 页码翻页 (#370)
上传的对账数据表改为每页 15 行短表,底部总行数+页码(含上一页/下一页),翻页保留所选厂家。
2026-10-17 09:47:59 +08:00
2848c81a5e 供应商对账:退货明细改为「退货扣减确认」面板 (#369)
退货扣减确认:顶部汇总、逐行确认/异议、全部确认(不覆盖异议)、金额空按预估金额扣减,展示金额最高 15 行。
2026-10-17 09:40:54 +08:00
8aa7676853 供应商对账:查看比对改为独立明细视图 (#368)
财务端点「查看比对」进入只显示该厂家明细的视图(汇总+行级差异),带返回列表按钮并保留筛选。
2026-10-17 09:23:52 +08:00
3b4db0e273 供应商对账:单价/金额留空自动补齐 (#367)
单价留空按金额÷数量补齐、金额留空按数量×单价补齐、都空按 0(数量必填),未定价行厂家可直接上传。
2026-10-17 09:16:51 +08:00
2ffb49ca92 供应商对账:条码匹配去掉厂家编号/版本后缀,表头不符提示缺列 (#366)
比对键先去掉厂家编号+版本后缀再匹配(实测雪翔家居文件),页面显示厂家原始条码;表头校验失败列出缺少/多出的列名。
2026-10-17 09:07:16 +08:00
2a80f380e6 供应商对账:财务端数据在月份后新增「对账人」筛选 (#365)
对账人筛选取自终版货款对账的对账人列,按月过滤后生成选项;选择后供应商列表与比对明细同步过滤。
2026-10-17 08:57:14 +08:00
87eaf48c2c 供应商对账:应付款项下新增对账页面(财务端数据 / 厂家端数据) (#364)
新增供应商对账目录:财务端实时上传情况与行级差异(条码+单价匹配,四类口径),厂家端严格按模板上传(表头不一致/必填缺失/非数字/串厂一律失败);复用终版存储与流式读取。
2026-10-17 08:49:17 +08:00
82c79dadc3 feat: 店铺维度报表销量改取业绩明细的订单实付/实收金额 (#363) 2026-10-17 00:19:09 +08:00
bcf8ea8fb7 应付款终版导入:流式读取改用 zip:// 管道,解析内存 274MB→76MB (#362)
sharedStrings 与工作表 XML 改为 XMLReader 经 zip:// 流式解析;200k 行基准读取从 6.2s/274MB 降到 2.3s/76MB。
2026-10-17 00:05:05 +08:00
656c655c91 应付款终版导入:支持 20 万行(流式读取 + 分片导入 + 进度显示) (#361)
新增流式 xlsx/csv 读取器与分片导入:上传建行缓存,按片处理(2 万行/25 秒预算),批量 upsert + 批量审计事件,前端进度条自动续跑;上限 20 万行,上传 50MB。
2026-10-16 23:54:03 +08:00
7742163632 应付款终版导入:分块读取工作簿,避免大文件把进程撑爆导致 500 (#359)
按 2000 行分块读取(大文件不再整表载入内存),读满 1 万行立即提示拆分文件;php.ini 补 log_errors/error_log,致命错误可查。
2026-10-16 23:15:33 +08:00
370 changed files with 41128 additions and 3550 deletions

View File

@ -10,6 +10,7 @@ runtime
*.md
JetBrainsMono-字体
node_modules
**/__pycache__
vendor
bootstrap/cache/*
.caiwu-build

View File

@ -38,8 +38,14 @@ jobs:
- name: Copy .env
run: cp .env.testing .env
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install NPM
run: npm install
run: npm ci --legacy-peer-deps --no-audit --no-fund
- name: Compile assets
run: npm run production

1
.gitignore vendored
View File

@ -113,6 +113,7 @@ modules/*
.workbuddy/
runtime/.env
/.private/kaifa-progress-sync/
__pycache__/
public/net-layer-demo.html
public/perf-diff-plan.html
public/plan-a-changes-202608.csv

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
24.22.1

View File

@ -38,6 +38,15 @@
- 数据页面按本次影响验证代表性数据量下的首页、翻页和筛选耗时;明显长时间等待不能以“返回正确”判定通过。低风险改动不增加重复或仅复述实现的测试。
- 明确区分:本地已验证、已提交/推送、已合并、已上线和仍需生产验收。
### 性能与资源验收(强制)
- 涉及查询、报表、导入导出、同步或后台任务时,开发前先确认实际数据规模、单次数据量、文件大小和运行频率,并据此选择实现与验收数据;不能只验证几十行样例。
- Excel/CSV 必须流式读取;表扫描、关联和排序使用合适索引,页面数据使用分页。分批写库只控制写入批次,不能据此声称读取过程内存受控。
- 重型任务复用项目现有独立批处理容器,完成命令登记,并明确内存上限、超时、互斥和失败恢复;不得占用网页服务资源执行全量处理。
- 禁止通过取消 `memory_limit`、无限重试、放大数据库参数或其他绕过资源边界的办法掩盖性能问题。
- 使用代表性数据测量耗时、进程峰值内存和数据库开销,并在交付说明中写明测量范围、数据量及未验证项。生产环境不运行压测,不随意重跑全量任务。
- 性能优化不得改变金额口径、筛选范围或现有业务结果;相关金额和功能回归必须同时通过。
## 5. 前端与视觉
- 只有前端任务需要先阅读根目录 `DESIGN.md`;`.impeccable/design.json` 是同源机器规范。
@ -64,12 +73,14 @@
## 8. 构建与生产部署
- 发布脚本会拒绝覆盖“不在本次版本里”的线上提交(防止互相冲掉已上线的改动)。热修复必须基于线上当前版本构建,发布后立即合入 `main`;遇到拦截先 `git fetch` 并合入最新 `main` 或线上热修复分支,不得随手加 `-AllowRollback`(`--allow-rollback`),撤回线上发布需负责人明确决定。
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,macOS 开发机使用 `bash scripts/deploy-from-mac.sh`;两者流程一致(本机构建前端与镜像、迁移拦截、增量推送服务器本机 Registry、激活与健康检查、双版本保留)。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。
- 当前生产入口为 `https://caiwu.jiansuntuzhi.cn:8442`;公网仍只使用 `8442`,日常发布不得改成无端口地址。
- 发布脚本检测到新增数据库迁移时,由 Codex 核对全部待执行迁移。仅涉及本任务共享镜像建表、字段、索引和同步登记的迁移,完成备份后自主使用 `-AllowMigrations`(macOS 为 `--allow-migrations`);其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
- 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。
- 生产重型 CLI 导入统一使用 `sh /opt/caiwuxitong/run-finance-batch.sh <命令> <原参数>`,不得在网页容器内直接执行或取消 PHP 内存限制。新增导入登记 `deploy/finance-batch-imports.json`;入口提供独立容器、互斥、资源与时间上限,主应用保留自己的内存边界。
- 程序静态资源随应用镜像同源发布;七牛只存用户上传的业务图片、票据和附件。
- 只有本地初始化、服务器维护、生产发布或数据库迁移任务需要读取 `docs/开发与部署SOP.md` 的对应章节;普通功能开发无需加载整份 SOP。

View File

@ -0,0 +1,237 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
use DateTimeImmutable;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
class InventoryInboundDetailImporter
{
private const TABLE = 'inventory_inbound_detail_rows';
/**
* Replace the imported inbound detail snapshot only after the workbook has
* been opened successfully. The delete and inserts share one transaction,
* so a malformed file cannot leave the page with a partially imported table.
*
* @return array{imported:int,mapped:int,skipped:int,total:int}
*/
public function import(string $file, string $forcedMonth = ''): array
{
$path = realpath($file);
if ($path === false || ! is_file($path) || ! is_readable($path)) {
throw new RuntimeException('入库明细文件不存在或不可读取。');
}
$forcedMonth = $this->normalizeForcedMonth($forcedMonth);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$now = now();
$maps = $this->shortNameMap();
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($path);
$sheet = $spreadsheet->getSheet(0);
try {
return DB::connection($connection)->transaction(function () use (
$connection,
$sheet,
$maps,
$forcedMonth,
$now,
): array {
DB::connection($connection)->table(self::TABLE)->delete();
$batch = [];
$imported = 0;
$mapped = 0;
$skipped = 0;
foreach ($sheet->getRowIterator(2) as $row) {
$rowNumber = $row->getRowIndex();
$cells = [];
foreach ($row->getCellIterator('A', 'U') as $cell) {
$cells[] = $cell->getFormattedValue();
}
$warehouse = trim((string) ($cells[14] ?? ''));
$createdRaw = trim((string) ($cells[16] ?? ''));
if ($warehouse === '' && $createdRaw === '') {
$skipped++;
continue;
}
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
if ($month === '') {
$skipped++;
continue;
}
// 代发仓按仓储方匹配;其它仓库按仓库名称做唯一匹配。
$shortName = '';
if ($warehouse !== '') {
$shortName = str_contains($warehouse, '代发仓')
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
: ($maps['warehouse'][$warehouse] ?? '');
}
if ($shortName !== '') {
$mapped++;
}
$batch[] = [
'report_month' => $month,
'row_key' => sha1(substr($month, 0, 7) . '|' . $rowNumber),
'source_no' => $this->text($cells[0] ?? ''),
'document_no' => $this->text($cells[1] ?? ''),
'created_serial' => $this->text($cells[2] ?? ''),
'supplier' => $this->text($cells[3] ?? ''),
'item_no' => $this->text($cells[4] ?? ''),
'barcode' => $this->text($cells[5] ?? ''),
'spec_name' => $this->text($cells[6] ?? ''),
'quantity' => $this->number($cells[7] ?? ''),
'unit_price' => $this->number($cells[8] ?? ''),
'amount' => $this->number($cells[9] ?? ''),
'estimated_unit_price' => $this->number($cells[10] ?? ''),
'estimated_amount' => $this->number($cells[11] ?? ''),
'supplier_barcode' => $this->text($cells[12] ?? ''),
'remark' => $this->text($cells[13] ?? ''),
'warehouse' => $warehouse !== '' ? $warehouse : null,
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
'created_at_raw' => $this->moment($createdRaw),
'express_name' => $this->text($cells[17] ?? ''),
'express_no' => $this->text($cells[18] ?? ''),
'not_sent' => $this->text($cells[19] ?? ''),
'owner_name' => $this->text($cells[20] ?? ''),
'imported_at' => $now,
];
$imported++;
if (count($batch) >= 1000) {
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
$batch = [];
}
}
if ($batch !== []) {
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
}
if ($imported === 0) {
throw new RuntimeException('入库明细文件中没有可导入的数据。');
}
return [
'imported' => $imported,
'mapped' => $mapped,
'skipped' => $skipped,
'total' => DB::connection($connection)->table(self::TABLE)->count(),
];
});
} finally {
$spreadsheet->disconnectWorksheets();
}
}
private function normalizeForcedMonth(string $month): string
{
$month = trim($month);
if ($month === '') {
return '';
}
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
return $month . '-01';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $month) === 1) {
return $month;
}
throw new RuntimeException('指定月份格式应为 YYYY-MM。');
}
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
private function shortNameMap(): array
{
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
$grouped = [];
foreach ($rows as $row) {
$warehouse = trim((string) $row->warehouse);
if ($warehouse === '') {
continue;
}
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
}
$byWarehouse = [];
foreach ($grouped as $warehouse => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byWarehouse[$warehouse] = $unique[0];
}
}
$byProvider = [];
foreach ($rows as $row) {
$provider = trim((string) $row->warehouse_provider);
if ($provider !== '') {
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
}
}
foreach ($byProvider as $provider => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byProvider[$provider] = $unique[0];
}
}
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
}
private function monthOf(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
$parsed = DateTimeImmutable::createFromFormat($format, $value);
if ($parsed instanceof DateTimeImmutable) {
return $parsed->format('Y-m') . '-01';
}
}
$timestamp = strtotime($value);
return $timestamp === false ? '' : date('Y-m-01', $timestamp);
}
private function moment(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
private function text(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function number(mixed $value): ?float
{
$text = str_replace(',', '', trim((string) $value));
if ($text === '' || ! is_numeric($text)) {
return null;
}
return (float) $text;
}
}

View File

@ -63,6 +63,7 @@ class JstOrderLabelImporter
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->delete();
app(PlantingOrderLabelLookup::class)->forget($companyId);
}
$now = now();
@ -71,9 +72,10 @@ class JstOrderLabelImporter
$noLabelOrders = 0;
$buffer = [];
$flush = function () use (&$buffer, &$rows, $target): void {
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
$target->table('jst_order_labels')->insert($chunk);
app(PlantingOrderLabelLookup::class)->forget($companyId);
$rows += count($chunk);
}
$buffer = [];
@ -191,6 +193,7 @@ class JstOrderLabelImporter
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->delete();
app(PlantingOrderLabelLookup::class)->forget($companyId);
};
$cleared = ! $replace;
@ -203,9 +206,10 @@ class JstOrderLabelImporter
$orderSeen = [];
$buffer = [];
$flush = function () use (&$buffer, &$rows, $target): void {
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
$target->table('jst_order_labels')->insert($chunk);
app(PlantingOrderLabelLookup::class)->forget($companyId);
$rows += count($chunk);
}
$buffer = [];

View File

@ -82,6 +82,7 @@ class JstOrderLabelPlantingRules
public function forget(int $companyId): void
{
Cache::forget($this->key($companyId));
app(PlantingOrderLabelLookup::class)->forget($companyId);
}
private function key(int $companyId): string

View File

@ -0,0 +1,68 @@
<?php
namespace App\Business;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/** Reuse label classification across monthly/platform jobs; writers invalidate it. */
class PlantingOrderLabelLookup
{
/** @param list<string> $positiveLabels @return list<string> */
public function orderNumbers(int $companyId, array $positiveLabels): array
{
sort($positiveLabels, SORT_STRING);
$rules = hash('sha256', json_encode($positiveLabels, JSON_THROW_ON_ERROR));
$key = $this->key($companyId);
$read = function () use ($key, $rules, $companyId): ?array {
$cached = Cache::get($key);
return is_array($cached) && ($cached['rules'] ?? null) === $rules
&& ($cached['revision'] ?? null) === $this->revision($companyId)
? $cached['orders'] : null;
};
if (($orders = $read()) !== null) {
return $orders;
}
return Cache::lock($key . ':build', 300)->block(305, function () use ($companyId, $positiveLabels, $key, $rules, $read): array {
if (($orders = $read()) !== null) {
return $orders;
}
$revision = $this->revision($companyId);
$orders = [];
foreach (DB::table('jst_order_labels')->where('company_id', $companyId)
->where(function ($query) use ($positiveLabels): void {
if ($positiveLabels !== []) {
$query->whereIn('label', $positiveLabels);
}
$query->orWhereRaw('FIND_IN_SET(?, item_code) > 0', [JstOrderLabelPlantingRules::PLANTING_ITEM_CODE]);
})->select('order_no')->distinct()->cursor() as $row) {
$orders[] = (string) $row->order_no;
}
// An import/rule change during the scan must not publish an old result.
if ($this->revision($companyId) === $revision) {
Cache::put($key, ['rules' => $rules, 'revision' => $revision, 'orders' => $orders], now()->addHours(24));
}
return $orders;
});
}
public function forget(int $companyId): void
{
$key = $this->key($companyId);
Cache::store('upstream_shared')->forever($key . ':revision', bin2hex(random_bytes(16)));
Cache::forget($key);
}
public function revision(int $companyId): ?string
{
return Cache::store('upstream_shared')->get($this->key($companyId) . ':revision');
}
private function key(int $companyId): string
{
return 'planting-order-labels:v1:' . $companyId;
}
}

View File

@ -0,0 +1,261 @@
<?php
namespace App\Business;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
/**
* 汇总商品月加权平均单价结果,并将入库明细差异按原因分类。
*/
class ProductMonthlyWeightedAverageReporter
{
// 异常阈值未由业务另行指定:取至少 100 行,且不超过总行数的 0.1%。
private const ANOMALY_THRESHOLD_MIN_ROWS = 100;
private const ANOMALY_THRESHOLD_RATE = 0.001;
private const ROW_TABLE = 'product_monthly_weighted_average_rows';
private const STATE_TABLE = 'product_monthly_weighted_average_states';
private const INBOUND_TABLE = 'inventory_inbound_detail_rows';
private const INVENTORY_MONTH_TABLE = 'inventory_report_authoritative_months';
private const INVENTORY_ROW_TABLE = 'inventory_report_authoritative_rows';
/** @return array<string,mixed> */
public function build(string $connection, CarbonImmutable $month): array
{
$month = $month->startOfMonth();
$db = DB::connection($connection);
$baseQuery = static fn () => $db->table(self::ROW_TABLE)
->where('report_month', $month->toDateString());
$totals = $baseQuery()
->selectRaw('count(*) as total_rows')
->selectRaw('coalesce(sum(opening_quantity), 0) as opening_quantity')
->selectRaw('coalesce(sum(opening_amount), 0) as opening_amount')
->selectRaw('coalesce(sum(purchase_quantity), 0) as purchase_quantity')
->selectRaw('coalesce(sum(purchase_amount), 0) as purchase_amount')
->selectRaw('sum(case when weighted_average_unit_price is not null then 1 else 0 end) as weighted_price_rows')
->selectRaw('sum(case when weighted_average_unit_price is null then 1 else 0 end) as missing_weighted_rows')
->selectRaw('sum(case when pricing_status = ? then 1 else 0 end) as manual_review_rows', ['待人工核查'])
->first();
if ($totals === null || (int) $totals->total_rows === 0) {
return [
'status' => 'missing',
'report_month' => $month->toDateString(),
'message' => '该月份尚未生成商品月加权平均单价。',
'generated_at' => now()->toDateTimeString(),
];
}
$weightedKeys = [];
foreach ($baseQuery()->select('product_code', 'warehouse_short_name')->cursor() as $row) {
$weightedKeys[$this->key($row->product_code, $row->warehouse_short_name)] = true;
}
$inbound = [
'source_rows' => 0,
'source_quantity' => 0.0,
'source_amount' => 0.0,
'matched_rows' => 0,
'matched_quantity' => 0.0,
'matched_amount' => 0.0,
'unmatched_rows' => 0,
'unmatched_quantity' => 0.0,
'unmatched_amount' => 0.0,
'unmatched_groups' => [],
];
foreach ($db->table(self::INBOUND_TABLE)
->where('report_month', $month->toDateString())
->selectRaw('upper(barcode) as code, warehouse_short_name, count(*) as rows_count')
->selectRaw('coalesce(sum(quantity), 0) as quantity')
->selectRaw('coalesce(sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)), 0) as amount')
->groupByRaw('upper(barcode), warehouse_short_name')
->cursor() as $row) {
$rows = (int) $row->rows_count;
$quantity = (float) $row->quantity;
$amount = (float) $row->amount;
$inbound['source_rows'] += $rows;
$inbound['source_quantity'] += $quantity;
$inbound['source_amount'] += $amount;
if (isset($weightedKeys[$this->key($row->code, $row->warehouse_short_name)])) {
$inbound['matched_rows'] += $rows;
$inbound['matched_quantity'] += $quantity;
$inbound['matched_amount'] += $amount;
continue;
}
$inbound['unmatched_rows'] += $rows;
$inbound['unmatched_quantity'] += $quantity;
$inbound['unmatched_amount'] += $amount;
$inbound['unmatched_groups'][] = [
'product_code' => trim((string) $row->code),
'warehouse_short_name' => trim((string) $row->warehouse_short_name),
'source_rows' => $rows,
'quantity' => $this->number($quantity),
'amount' => $this->number($amount),
'reason' => '入库明细未匹配到商品编码 + 仓库简称行',
];
}
usort(
$inbound['unmatched_groups'],
static fn (array $left, array $right): int =>
abs((float) $right['amount']) <=> abs((float) $left['amount']),
);
$inbound = $this->roundNestedNumbers($inbound);
$monthState = $db->table(self::INVENTORY_MONTH_TABLE)
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('source_month', $month->toDateString())
->first();
$generation = $monthState?->active_generation;
$actualSourceRows = $generation === null
? 0
: $db->table(self::INVENTORY_ROW_TABLE)
->where('generation', (string) $generation)
->count();
$expectedSourceRows = (int) ($monthState?->source_row_count ?? 0);
$weightedAnomalyRows = $baseQuery()
->where(function ($query): void {
$query->whereNull('weighted_average_unit_price')
->orWhere('pricing_status', '待人工核查');
})
->count();
$reasons = [];
if ((int) $totals->missing_weighted_rows > 0) {
$reasons[] = [
'code' => 'missing_weighted_price',
'label' => '加权平均单价为空',
'rows' => (int) $totals->missing_weighted_rows,
];
}
if ((int) $totals->manual_review_rows > 0) {
$reasons[] = [
'code' => 'manual_review',
'label' => '待人工核查',
'rows' => (int) $totals->manual_review_rows,
];
}
if ((int) $inbound['unmatched_rows'] > 0) {
$reasons[] = [
'code' => 'inbound_without_inventory_dimension',
'label' => '入库明细未匹配到商品编码 + 仓库简称行',
'rows' => (int) $inbound['unmatched_rows'],
'groups' => count($inbound['unmatched_groups']),
];
}
if ($actualSourceRows !== $expectedSourceRows) {
$reasons[] = [
'code' => 'upstream_source_row_mismatch',
'label' => '上游进销存源表行数与已激活明细不一致',
'rows' => abs($actualSourceRows - $expectedSourceRows),
];
}
$anomalyRows = $weightedAnomalyRows + (int) $inbound['unmatched_rows'];
$threshold = max(
self::ANOMALY_THRESHOLD_MIN_ROWS,
(int) ceil((int) $totals->total_rows * self::ANOMALY_THRESHOLD_RATE),
);
return [
'status' => $reasons === [] ? 'computed' : 'review_required',
'report_month' => $month->toDateString(),
'totals' => [
'rows' => (int) $totals->total_rows,
'opening_quantity' => $this->number((float) $totals->opening_quantity),
'opening_amount' => $this->number((float) $totals->opening_amount),
'purchase_quantity' => $this->number((float) $totals->purchase_quantity),
'purchase_amount' => $this->number((float) $totals->purchase_amount),
'weighted_price_rows' => (int) $totals->weighted_price_rows,
'missing_weighted_rows' => (int) $totals->missing_weighted_rows,
'manual_review_rows' => (int) $totals->manual_review_rows,
],
'inbound' => $inbound,
'source' => [
'expected_rows' => $expectedSourceRows,
'active_rows' => $actualSourceRows,
'row_difference' => $actualSourceRows - $expectedSourceRows,
'status' => (string) ($monthState?->status ?? 'missing'),
'active_generation' => $generation,
'source_filename' => $monthState?->source_filename,
],
'anomalies' => [
'affected_rows' => $anomalyRows,
'threshold_rows' => $threshold,
'threshold_exceeded' => $anomalyRows > $threshold,
'reasons' => $reasons,
],
'state' => $db->table(self::STATE_TABLE)
->where('report_month', $month->toDateString())
->first(),
'generated_at' => now()->toDateTimeString(),
];
}
/** @param array<string,mixed> $report */
public function brief(array $report): string
{
if (($report['status'] ?? null) === 'missing') {
return (string) ($report['message'] ?? '商品月加权平均单价尚未生成');
}
$totals = $report['totals'] ?? [];
$inbound = $report['inbound'] ?? [];
$anomalies = $report['anomalies'] ?? [];
$reasonLabels = array_map(
static fn (array $reason): string =>
(string) ($reason['label'] ?? $reason['code'] ?? '未知异常'),
$anomalies['reasons'] ?? [],
);
return sprintf(
'%s:总行数 %s;期初 %s 件 / %s;入库 %s 件 / %s;加权单价 %s 行;异常 %s 行%s',
substr((string) ($report['report_month'] ?? ''), 0, 7),
number_format((int) ($totals['rows'] ?? 0)),
number_format((float) ($totals['opening_quantity'] ?? 0), 2),
number_format((float) ($totals['opening_amount'] ?? 0), 2),
number_format((float) ($inbound['matched_quantity'] ?? 0), 2),
number_format((float) ($inbound['matched_amount'] ?? 0), 2),
number_format((int) ($totals['weighted_price_rows'] ?? 0)),
number_format((int) ($anomalies['affected_rows'] ?? 0)),
$reasonLabels === [] ? '' : '(' . implode('、', $reasonLabels) . ')',
);
}
private function key(mixed $productCode, mixed $warehouseShortName): string
{
return mb_strtoupper(trim((string) $productCode))
. "\0"
. trim((string) $warehouseShortName);
}
private function number(float $value): float
{
return round($value, 6);
}
/**
* @param array<string,mixed> $value
* @return array<string,mixed>
*/
private function roundNestedNumbers(array $value): array
{
foreach ($value as $key => $item) {
if (is_array($item)) {
$value[$key] = $this->roundNestedNumbers($item);
} elseif (is_float($item)) {
$value[$key] = round($item, 6);
}
}
return $value;
}
}

View File

@ -0,0 +1,939 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
use DateTimeImmutable;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use Throwable;
use XMLReader;
use ZipArchive;
/**
* 供应商汇总表月度增量导入。
*
* 仅追加目标月份的新行,不删除其他月份。内容相同的源明细按出现次序分配
* 稳定标识后全部保留;重复执行时只跳过已经写入的同一源行。
* 单行字段异常会进入失败清单,不阻断其他有效行。
*/
class SupplierSummaryInboundImporter
{
private const TABLE = 'inventory_inbound_detail_rows';
private const DETAIL_SHEET = '明细';
/** @var array<string,list<string>> */
private const REQUIRED_HEADERS = [
'source_no' => ['来源单号'],
'document_no' => ['单据编号(采购单号)', '单据编号(采购单号)', '单据编号'],
'supplier' => ['供应商'],
'item_no' => ['货号'],
'barcode' => ['条码'],
'spec_name' => ['规格名'],
'quantity' => ['库存变动量'],
'unit_price' => ['单价'],
'amount' => ['金额'],
'estimated_unit_price' => ['预估单价'],
'estimated_amount' => ['预估金额'],
'supplier_barcode' => ['供应商条码'],
'remark' => ['备注'],
'warehouse' => ['仓库'],
];
/** @var array<string,list<string>> */
private const OPTIONAL_HEADERS = [
'express_name' => ['快递名称'],
'express_no' => ['快递单号'],
'not_sent' => ['未发出'],
'owner_name' => ['负责人'],
];
/**
* @return array{
* status:string,
* report_month:string,
* source_filename:string,
* success:int,
* duplicates:int,
* failed:int,
* mapped:int,
* total_in_table:int,
* failure_reason_count:int,
* failure_reasons:list<array{row:int,reason:string}>
* }
*/
public function import(string $file, string $reportMonth): array
{
$path = realpath($file);
if ($path === false || ! is_file($path) || ! is_readable($path)) {
throw new RuntimeException('供应商汇总文件不存在或不可读取。');
}
$month = $this->normalizeMonth($reportMonth);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$maps = $this->shortNameMap();
[$reader, $sharedStrings] = $this->streamDetailSheet($path);
try {
$success = 0;
$duplicates = 0;
$mapped = 0;
$failed = 0;
$reasons = [];
$occurrences = [];
$batch = [];
DB::connection($connection)->transaction(function () use (
$connection,
$reader,
$sharedStrings,
$maps,
$month,
&$success,
&$duplicates,
&$mapped,
&$failed,
&$reasons,
&$occurrences,
&$batch,
): void {
$flush = function () use (
$connection,
$month,
&$success,
&$duplicates,
&$batch,
): void {
if ($batch === []) {
return;
}
$keys = array_column($batch, 'row_key');
$existing = DB::connection($connection)
->table(self::TABLE)
->where('report_month', $month)
->whereIn('row_key', $keys)
->pluck('row_key')
->map(static fn (mixed $key): string => (string) $key)
->all();
$existing = array_fill_keys($existing, true);
$insertable = [];
foreach ($batch as $record) {
if (isset($existing[$record['row_key']])) {
$duplicates++;
continue;
}
$insertable[] = $record;
}
if ($insertable !== []) {
$inserted = DB::connection($connection)
->table(self::TABLE)
->insertOrIgnore($insertable);
$success += (int) $inserted;
$duplicates += count($insertable) - (int) $inserted;
}
$batch = [];
};
$headerRow = null;
$columns = [];
$headerCandidates = [];
while ($reader->read()) {
if (
$reader->nodeType !== XMLReader::ELEMENT
|| $reader->name !== 'row'
) {
continue;
}
$rowNumber = (int) $reader->getAttribute('r');
$values = $this->readRow($reader, $sharedStrings);
if ($headerRow === null) {
$headerCandidates[$rowNumber] = $values;
$header = $this->headerColumns($headerCandidates);
if ($header !== null) {
[$headerRow, $columns] = $header;
$headerCandidates = [];
continue;
}
if ($rowNumber >= 20) {
throw new RuntimeException(
'「明细」子表前 20 行未找到供应商汇总标准表头。',
);
}
continue;
}
if ($rowNumber <= $headerRow) {
continue;
}
if ($this->emptyRow($values)) {
continue;
}
try {
$record = $this->record(
$values,
$columns,
$maps,
$month,
$rowNumber,
);
} catch (Throwable $exception) {
$failed++;
$reasons[] = [
'row' => $rowNumber,
'reason' => $exception->getMessage(),
];
continue;
}
$baseKey = (string) $record['row_key'];
$occurrence = ($occurrences[$baseKey] ?? 0) + 1;
$occurrences[$baseKey] = $occurrence;
if ($occurrence > 1) {
$record['row_key'] = hash(
'sha256',
$baseKey . '|occurrence:' . $occurrence,
);
}
$mapped++;
$batch[] = $record;
if (count($batch) >= 500) {
$flush();
}
}
if ($headerRow === null) {
throw new RuntimeException(
'「明细」子表前 20 行未找到供应商汇总标准表头。',
);
}
$flush();
});
$status = $failed === 0
? 'success'
: ($success > 0 ? 'partial' : 'failed');
return [
'status' => $status,
'report_month' => $month,
'source_filename' => basename($path),
'success' => $success,
'duplicates' => $duplicates,
'failed' => $failed,
'mapped' => $mapped,
'total_in_table' => DB::connection($connection)
->table(self::TABLE)
->where('report_month', $month)
->count(),
'failure_reason_count' => count($reasons),
'failure_reasons' => $reasons,
];
} finally {
$reader->close();
}
}
private function normalizeMonth(string $month): string
{
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
throw new RuntimeException('月份格式应为 YYYY-MM。');
}
return $month . '-01';
}
/**
* @param array<int,array<int,mixed>> $rows
* @return array{0:int,1:array<string,int>}|null
*/
private function headerColumns(array $rows): ?array
{
ksort($rows);
foreach ($rows as $row => $values) {
$positions = [];
foreach ($values as $index => $value) {
$heading = $this->normalizeHeader(
(string) $this->plainValue($value),
);
if ($heading !== '') {
$positions[$heading][] = $index;
}
}
$columns = [];
foreach (self::REQUIRED_HEADERS as $field => $aliases) {
$position = $this->firstHeaderPosition($positions, $aliases);
if ($position !== null) {
$columns[$field] = $position;
}
}
$createdPositions = $positions[$this->normalizeHeader('创建时间')] ?? [];
if (count($createdPositions) >= 2) {
$columns['created_serial'] = $createdPositions[0];
$columns['created_at_raw'] = $createdPositions[1];
}
if (count($columns) < count(self::REQUIRED_HEADERS) + 2) {
continue;
}
foreach (self::OPTIONAL_HEADERS as $field => $aliases) {
$position = $this->firstHeaderPosition($positions, $aliases);
if ($position !== null) {
$columns[$field] = $position;
}
}
foreach (['created_serial', 'created_at_raw'] as $field) {
if (! isset($columns[$field])) {
throw new RuntimeException('「明细」表头缺少第二个「创建时间」列。');
}
}
return [$row, $columns];
}
return null;
}
/** @param array<string,list<int>> $positions @param list<string> $aliases */
private function firstHeaderPosition(array $positions, array $aliases): ?int
{
foreach ($aliases as $alias) {
$heading = $this->normalizeHeader($alias);
if (isset($positions[$heading][0])) {
return $positions[$heading][0];
}
}
return null;
}
private function normalizeHeader(string $heading): string
{
return preg_replace('/[\s\x{3000}]+/u', '', trim($heading)) ?? trim($heading);
}
/**
* @param array<int,mixed> $values
* @param array<string,int> $columns
* @param array{warehouse:array<string,string>,provider:array<string,string>} $maps
* @return array<string,mixed>
*/
private function record(
array $values,
array $columns,
array $maps,
string $month,
int $rowNumber,
): array {
$text = fn (string $field): ?string => $this->text(
$values[$columns[$field] ?? -1] ?? null,
);
$number = function (string $label, string $field) use (
$values,
$columns,
$rowNumber,
): ?float {
$value = $values[$columns[$field] ?? -1] ?? null;
if (is_array($value)) {
$formula = trim((string) ($value['__formula'] ?? ''));
if ($formula !== '') {
return $this->formulaValue(
$label,
$field,
$formula,
$rowNumber,
$values,
$columns,
);
}
$value = $value['__cached'] ?? null;
}
$text = str_replace(
[',', ','],
'',
trim((string) $this->plainValue($value)),
);
if ($text === '') {
return null;
}
if (! is_numeric($text)) {
throw new RuntimeException("字段「{$label}」不是有效数字:" . $text);
}
return (float) $text;
};
$barcode = $text('barcode');
$warehouse = $text('warehouse');
if ($barcode === null) {
throw new RuntimeException('商品编码(条码)为空');
}
if ($warehouse === null) {
throw new RuntimeException('仓库为空');
}
$shortName = $maps['warehouse'][$warehouse]
?? $maps['provider'][$warehouse]
?? null;
if ($shortName === null || trim($shortName) === '') {
throw new RuntimeException('仓库未匹配到唯一仓库简称:' . $warehouse);
}
$createdRaw = $this->moment($values[$columns['created_at_raw']] ?? null);
if ($createdRaw === null) {
throw new RuntimeException('第二个「创建时间」为空或格式无效');
}
$record = [
'report_month' => $month,
'source_no' => $text('source_no'),
'document_no' => $text('document_no'),
'created_serial' => $text('created_serial'),
'supplier' => $text('supplier'),
'item_no' => $text('item_no'),
'barcode' => mb_strtoupper($barcode),
'spec_name' => $text('spec_name'),
'quantity' => $number('库存变动量', 'quantity'),
'unit_price' => $number('单价', 'unit_price'),
'amount' => $number('金额', 'amount'),
'estimated_unit_price' => $number('预估单价', 'estimated_unit_price'),
'estimated_amount' => $number('预估金额', 'estimated_amount'),
'supplier_barcode' => $text('supplier_barcode'),
'remark' => $text('remark'),
'warehouse' => $warehouse,
'warehouse_short_name' => $shortName,
'created_at_raw' => $createdRaw,
'express_name' => $text('express_name'),
'express_no' => $text('express_no'),
'not_sent' => $text('not_sent'),
'owner_name' => $text('owner_name'),
];
unset($record['warehouse_short_name']);
$record['row_key'] = hash(
'sha256',
json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
);
$record['warehouse_short_name'] = $shortName;
$record['imported_at'] = now();
return $record;
}
/**
* 仅接受金额类字段当前行内的乘积公式,避免执行来源文件中的任意公式。
*
* @param array<int,mixed> $values
* @param array<string,int> $columns
*/
private function formulaValue(
string $label,
string $field,
string $formula,
int $rowNumber,
array $values,
array $columns,
): float {
$expectedFields = match ($field) {
'amount' => ['quantity', 'unit_price'],
'estimated_amount' => ['estimated_unit_price', 'quantity'],
default => [],
};
if ($expectedFields === []) {
throw new RuntimeException(
"字段「{$label}」不允许使用公式:" . $formula,
);
}
if (
preg_match(
'/^=?\s*\$?([A-Z]{1,3})\$?(\d+)\s*\*\s*\$?([A-Z]{1,3})\$?(\d+)\s*$/i',
$formula,
$matches,
) !== 1
) {
throw new RuntimeException(
"字段「{$label}」公式无法安全识别:" . $formula,
);
}
if ((int) $matches[2] !== $rowNumber || (int) $matches[4] !== $rowNumber) {
throw new RuntimeException(
"字段「{$label}」公式引用了其他行:" . $formula,
);
}
$actualColumns = [
$this->columnIndex($matches[1]),
$this->columnIndex($matches[3]),
];
$expectedColumns = [
$columns[$expectedFields[0]] + 1,
$columns[$expectedFields[1]] + 1,
];
sort($actualColumns);
sort($expectedColumns);
if ($actualColumns !== $expectedColumns) {
throw new RuntimeException(
"字段「{$label}」公式与取数口径不一致:" . $formula,
);
}
$left = $this->numericReference(
$values[$actualColumns[0] - 1] ?? null,
$label,
);
$right = $this->numericReference(
$values[$actualColumns[1] - 1] ?? null,
$label,
);
return $left * $right;
}
private function numericReference(mixed $value, string $label): float
{
if (is_array($value)) {
if (($value['__formula'] ?? '') !== '') {
throw new RuntimeException(
"字段「{$label}」的公式引用了另一个公式单元格",
);
}
$value = $value['__cached'] ?? null;
}
$text = str_replace(
[',', ','],
'',
trim((string) $this->plainValue($value)),
);
if (! is_numeric($text)) {
throw new RuntimeException(
"字段「{$label}」的公式引用值不是有效数字:" . $text,
);
}
return (float) $text;
}
/** @param array<int,mixed> $values */
private function emptyRow(array $values): bool
{
foreach ($values as $value) {
if (trim((string) $this->plainValue($value)) !== '') {
return false;
}
}
return true;
}
private function text(mixed $value): ?string
{
$text = trim((string) $this->plainValue($value));
return $text === '' ? null : $text;
}
private function plainValue(mixed $value): mixed
{
if (is_array($value)) {
return $value['__cached'] ?? null;
}
return $value;
}
private function moment(mixed $value): ?string
{
$value = $this->plainValue($value);
if ($value === null || trim((string) $value) === '') {
return null;
}
if (is_numeric($value)) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
$value = trim((string) $value);
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
$date = DateTimeImmutable::createFromFormat($format, $value);
if ($date instanceof DateTimeImmutable) {
return $date->format('Y-m-d H:i:s');
}
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
private function shortNameMap(): array
{
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
$grouped = [];
foreach ($rows as $row) {
$warehouse = trim((string) $row->warehouse);
if ($warehouse !== '') {
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
}
}
$byWarehouse = [];
foreach ($grouped as $warehouse => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byWarehouse[$warehouse] = $unique[0];
}
}
$providerGrouped = [];
foreach ($rows as $row) {
$provider = trim((string) $row->warehouse_provider);
if ($provider !== '') {
$providerGrouped[$provider][] = [
'warehouse' => trim((string) $row->warehouse),
'short_name' => trim((string) $row->warehouse_short_name),
];
}
}
$byProvider = [];
foreach ($providerGrouped as $provider => $providerRows) {
$unique = array_values(array_unique(array_column(
$providerRows,
'short_name',
)));
if (count($unique) === 1 && $unique[0] !== '') {
$byProvider[$provider] = $unique[0];
continue;
}
$mainShortNames = [];
foreach ($providerRows as $providerRow) {
if (str_contains($providerRow['warehouse'], '主仓')) {
$mainShortNames[] = $providerRow['short_name'];
}
}
$mainShortNames = array_values(array_unique($mainShortNames));
if (count($mainShortNames) === 1 && $mainShortNames[0] !== '') {
$byProvider[$provider] = $mainShortNames[0];
}
}
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
}
/** @return array{0:XMLReader,1:list<string>} */
private function streamDetailSheet(string $path): array
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('供应商汇总文件不是有效的 XLSX 工作簿。');
}
try {
$sheetPath = $this->detailSheetPath($zip);
$sharedStrings = $zip->locateName('xl/sharedStrings.xml') !== false
? $this->readSharedStrings($path)
: [];
} finally {
$zip->close();
}
return [
$this->openZipEntry($path, $sheetPath),
$sharedStrings,
];
}
private function detailSheetPath(ZipArchive $zip): string
{
$workbookXml = $zip->getFromName('xl/workbook.xml');
$relationshipsXml = $zip->getFromName('xl/_rels/workbook.xml.rels');
if ($workbookXml === false || $relationshipsXml === false) {
throw new RuntimeException('供应商汇总工作簿缺少工作簿结构文件。');
}
$workbook = new DOMDocument();
$relationships = new DOMDocument();
if (
! $workbook->loadXML($workbookXml, LIBXML_NONET)
|| ! $relationships->loadXML($relationshipsXml, LIBXML_NONET)
) {
throw new RuntimeException('供应商汇总工作簿结构无法读取。');
}
$relationshipId = null;
$xpath = new DOMXPath($workbook);
$xpath->registerNamespace(
'main',
'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
);
$xpath->registerNamespace(
'office',
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
);
foreach ($xpath->query('/main:workbook/main:sheets/main:sheet') ?: [] as $sheet) {
if ($sheet instanceof \DOMElement
&& $sheet->getAttribute('name') === self::DETAIL_SHEET
) {
$relationshipId = $sheet->getAttributeNS(
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'id',
);
break;
}
}
if ($relationshipId === null || $relationshipId === '') {
throw new RuntimeException('供应商汇总表缺少「明细」子表。');
}
$target = null;
foreach ($relationships->getElementsByTagName('Relationship') as $relationship) {
if ($relationship->getAttribute('Id') === $relationshipId) {
$target = $relationship->getAttribute('Target');
break;
}
}
if ($target === null || $target === '') {
throw new RuntimeException('「明细」子表的工作簿关系不存在。');
}
return $this->normalizeZipPath(
str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/' . $target,
);
}
private function normalizeZipPath(string $path): string
{
$segments = [];
foreach (explode('/', str_replace('\\', '/', $path)) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
array_pop($segments);
continue;
}
$segments[] = $segment;
}
return implode('/', $segments);
}
/** @return list<string> */
private function readSharedStrings(string $path): array
{
$reader = $this->openZipEntry($path, 'xl/sharedStrings.xml');
$strings = [];
$current = null;
$stringDepth = null;
$phoneticDepth = null;
try {
while ($reader->read()) {
if (
$reader->nodeType === XMLReader::ELEMENT
&& $reader->name === 'si'
) {
$current = '';
$stringDepth = $reader->depth;
$phoneticDepth = null;
continue;
}
if ($current === null) {
continue;
}
if (
$reader->nodeType === XMLReader::ELEMENT
&& $reader->name === 'rPh'
) {
$phoneticDepth = $reader->depth;
continue;
}
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'rPh'
&& $reader->depth === $phoneticDepth
) {
$phoneticDepth = null;
continue;
}
if (
$reader->nodeType === XMLReader::ELEMENT
&& $reader->name === 't'
&& $phoneticDepth === null
) {
$current .= $reader->readString();
continue;
}
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'si'
&& $reader->depth === $stringDepth
) {
$strings[] = $current;
$current = null;
}
}
} finally {
$reader->close();
}
return $strings;
}
private function openZipEntry(string $path, string $entry): XMLReader
{
$reader = new XMLReader();
$flags = LIBXML_NONET | LIBXML_COMPACT;
if (! $reader->open("zip://{$path}#{$entry}", null, $flags)) {
throw new RuntimeException("无法读取工作簿内容:{$entry}");
}
return $reader;
}
/**
* @param list<string> $sharedStrings
* @return array<int,mixed>
*/
private function readRow(XMLReader $reader, array $sharedStrings): array
{
$values = [];
$rowDepth = $reader->depth;
while ($reader->read()) {
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'row'
&& $reader->depth === $rowDepth
) {
break;
}
if (
$reader->nodeType !== XMLReader::ELEMENT
|| $reader->name !== 'c'
) {
continue;
}
$reference = $reader->getAttribute('r') ?? '';
if (
preg_match('/^([A-Z]{1,3})\d+$/i', $reference, $matches) !== 1
) {
continue;
}
$values[$this->columnIndex($matches[1]) - 1] = $this->readCell(
$reader,
$sharedStrings,
);
}
return $values;
}
/** @param list<string> $sharedStrings */
private function readCell(XMLReader $reader, array $sharedStrings): mixed
{
if ($reader->isEmptyElement) {
return null;
}
$type = $reader->getAttribute('t') ?? '';
$cellDepth = $reader->depth;
$cached = null;
$formula = null;
$hasFormula = false;
$inlineParts = [];
$phoneticDepth = null;
while ($reader->read()) {
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'c'
&& $reader->depth === $cellDepth
) {
break;
}
if ($reader->nodeType === XMLReader::ELEMENT) {
if ($reader->name === 'f') {
$hasFormula = true;
$formula = $reader->readString();
continue;
}
if ($reader->name === 'v') {
$cached = $reader->readString();
continue;
}
if ($reader->name === 'rPh') {
$phoneticDepth = $reader->depth;
continue;
}
if (
$reader->name === 't'
&& $phoneticDepth === null
) {
$inlineParts[] = $reader->readString();
}
}
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'rPh'
&& $reader->depth === $phoneticDepth
) {
$phoneticDepth = null;
}
}
$value = match ($type) {
's' => is_numeric($cached)
? ($sharedStrings[(int) $cached] ?? null)
: null,
'inlineStr' => implode('', $inlineParts),
'b' => $cached === null ? null : ($cached === '1' ? '1' : '0'),
default => $cached ?? implode('', $inlineParts),
};
if ($hasFormula) {
return [
'__formula' => (string) $formula,
'__cached' => $value,
];
}
return $value;
}
private function columnIndex(string $column): int
{
$index = 0;
foreach (str_split(strtoupper($column)) as $letter) {
$index = ($index * 26) + (ord($letter) - 64);
}
return $index;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
@ -30,12 +31,12 @@ class WithdrawalManagementReport
/**
* @return array{
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string},
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string},
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
* summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
* balanceColumnLabel:string,balanceDate:string,sort:array{field:string,direction:string}
* balanceColumnLabel:string,balanceDate:string,balanceDates:list<string>,sort:array{field:string,direction:string}
* }
*/
public function results(Request $request): array
@ -61,12 +62,16 @@ class WithdrawalManagementReport
}
$today = now()->startOfDay();
// 余额快照按导入日期保留 5 个自然日:优先显示最近 5 天内导入的最新余额日期;
// 当天尚未导入新数据时,继续沿用最近一次可用余额,避免页面直接变成待同步。
$balanceDate = $this->shopBalances->latestDateInRetention($today)
?? $today->copy()->subDay()->toDateString();
// 余额快照按日期长期保留:默认显示最新余额日期,也可指定历史日期回看。
$latestBalanceDate = $this->shopBalances->latestBalanceDate($today);
$balanceDate = $filters['balanceDate'] !== ''
? $filters['balanceDate']
: ($latestBalanceDate ?? $today->copy()->subDay()->toDateString());
$labelDate = $filters['balanceDate'] !== ''
? CarbonImmutable::parse($balanceDate)
: $today;
$rows = $query->orderByDesc('id')->get();
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate, $today);
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
$balanceCounts = collect($balances)->countBy(
static fn (array $match): string => $match['status'],
);
@ -114,27 +119,34 @@ class WithdrawalManagementReport
'withdrawalAmounts' => $withdrawalAmounts,
'compliance' => $compliance,
'summary' => [
'source' => $this->shopBalances->countForDate($balanceDate, $today),
'source' => $this->shopBalances->countForDate($balanceDate),
'matched' => (int) $balanceCounts->get('matched', 0),
'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
],
'balanceColumnLabel' => $today->format('n月j日') . '店铺余额',
'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额',
'balanceDate' => $balanceDate,
'balanceDates' => $this->shopBalances->availableDates($today),
'sort' => $sort,
];
}
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string} */
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string} */
public function filters(Request $request): array
{
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
$balanceStatus = $clean($request->query('balance_status'));
$balanceDate = $clean($request->query('balance_date'), 10);
if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
$balanceStatus = '';
}
$validDate = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $balanceDate, $parts) === 1
&& checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]);
if (! $validDate || $balanceDate > now()->toDateString()) {
$balanceDate = '';
}
return [
'platform' => $clean($request->query('platform')),
@ -142,6 +154,7 @@ class WithdrawalManagementReport
'storeEntity' => $clean($request->query('store_entity'), 191),
'keyword' => $clean($request->query('keyword'), 191),
'balanceStatus' => $balanceStatus,
'balanceDate' => $balanceDate,
];
}

View File

@ -436,7 +436,7 @@ class WithdrawalManagementSpreadsheet
{
$today = now()->startOfDay();
return $this->shopBalances->latestDateInRetention($today)
return $this->shopBalances->latestBalanceDate($today)
?? $today->copy()->subDay()->toDateString();
}

View File

@ -0,0 +1,246 @@
<?php
namespace App\Console\Commands;
use App\Business\ProductMonthlyWeightedAverageReporter;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* 检查上游完整性后,自动生成尚未计算的最新月份商品月加权平均单价。
*/
class AutoComputeProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:auto
{--month= : 指定月份;默认取最新已成功的进销存月份}
{--result-json= : 将执行结果写入指定 JSON 文件}';
protected $description = '上游完整后自动计算商品月加权平均单价,并保护已计算历史月份';
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$month = $this->targetMonth($connection);
if ($month === null) {
return $this->finish('blocked', null, [
'message' => '未找到已成功导入的进销存月份。',
]);
}
$lock = Cache::lock('product-monthly-weighted-average-auto:' . $month->format('Y-m'), 7200);
if (! $lock->get()) {
return $this->finish('blocked', $month, [
'message' => '同月自动计算任务正在执行。',
]);
}
try {
$state = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('report_month', $month->toDateString())
->first();
if (($state?->status ?? null) === 'computed') {
$report = $reporter->build($connection, $month);
$report['calculation_status'] = $report['status'];
$report['status'] = 'already_computed';
return $this->finish('already_computed', $month, $report, $reporter);
}
$checks = $this->checks($connection, $month);
if (in_array(false, array_column($checks, 'passed'), true)) {
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
['report_month' => $month->toDateString()],
[
'status' => 'blocked',
'last_error' => '上游数据未通过完整性检查',
'updated_at' => now(),
'created_at' => now(),
],
);
return $this->finish('blocked', $month, [
'message' => '上游数据未更新或不完整,未执行计算。',
'checks' => $checks,
]);
}
$baseExit = $this->call('product:monthly-weighted-average:base', [
'month' => $month->format('Y-m'),
'--from-previous' => true,
// 自动任务只会在尚未 computed 的月份走到这里,可安全清理失败重试留下
// 的同月基表;已 computed 的历史月份在前面的门禁直接跳过。
'--force' => true,
]);
if ($baseExit !== self::SUCCESS) {
return $this->finish('failed', $month, [
'message' => '生成期初基数失败。',
]);
}
$computeExit = $this->call('product:monthly-weighted-average:compute', [
'month' => $month->format('Y-m'),
]);
if ($computeExit !== self::SUCCESS) {
return $this->finish('failed', $month, [
'message' => '加权平均单价计算失败。',
]);
}
$report = $reporter->build($connection, $month);
$report['checks'] = $checks;
return $this->finish(
($report['status'] ?? null) === 'review_required' ? 'review_required' : 'success',
$month,
$report,
$reporter,
);
} finally {
$lock->release();
}
}
private function targetMonth(string $connection): ?CarbonImmutable
{
$requested = trim((string) $this->option('month'));
if ($requested !== '') {
return CarbonImmutable::parse($requested)->startOfMonth();
}
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('status', 'succeeded')
->whereNotNull('active_generation')
->orderByDesc('source_month')
->value('source_month');
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
}
/** @return list<array{name:string,passed:bool,actual:mixed,expected:mixed}> */
private function checks(string $connection, CarbonImmutable $month): array
{
$db = DB::connection($connection);
$monthState = $db->table('inventory_report_authoritative_months')
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('source_month', $month->toDateString())
->first();
$generation = $monthState?->active_generation;
$activeRows = $generation === null
? 0
: $db->table('inventory_report_authoritative_rows')
->where('generation', (string) $generation)
->count();
$expectedRows = (int) ($monthState?->source_row_count ?? 0);
$previous = $month->subMonthNoOverflow();
$previousState = $db->table('product_monthly_weighted_average_states')
->where('report_month', $previous->toDateString())
->value('status');
$previousRows = $db->table('product_monthly_weighted_average_rows')
->where('report_month', $previous->toDateString())
->count();
$inboundRows = $db->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->count();
$inboundMissingDimensions = $db->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->where(function ($query): void {
$query->whereNull('barcode')->orWhere('barcode', '')
->orWhereNull('warehouse_short_name')->orWhere('warehouse_short_name', '');
})
->count();
$inboundMissingAmounts = $db->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->whereNull('amount')
->whereNull('estimated_amount')
->count();
$productCatalogRows = $db->table('ops_jst_sku')->whereNotNull('sku_id')->count();
return [
[
'name' => '进销存月份状态成功',
'passed' => ($monthState?->status ?? null) === 'succeeded',
'actual' => $monthState?->status,
'expected' => 'succeeded',
],
[
'name' => '进销存激活明细行数完整',
'passed' => $expectedRows > 0 && $activeRows === $expectedRows,
'actual' => $activeRows,
'expected' => $expectedRows,
],
[
'name' => '上月加权平均结果已完成',
'passed' => $previousState === 'computed' && $previousRows > 0,
'actual' => ['status' => $previousState, 'rows' => $previousRows],
'expected' => ['status' => 'computed', 'rows' => '> 0'],
],
[
'name' => '入库明细已更新',
'passed' => $inboundRows > 0,
'actual' => $inboundRows,
'expected' => '> 0',
],
[
'name' => '入库商品编码与仓库简称无缺失',
'passed' => $inboundMissingDimensions === 0,
'actual' => $inboundMissingDimensions,
'expected' => 0,
],
[
'name' => '入库金额至少取金额或预估金额',
'passed' => $inboundMissingAmounts === 0,
'actual' => $inboundMissingAmounts,
'expected' => 0,
],
[
'name' => '普通商品资料已更新',
'passed' => $productCatalogRows > 0,
'actual' => $productCatalogRows,
'expected' => '> 0',
],
];
}
/**
* @param array<string,mixed> $payload
*/
private function finish(
string $status,
?CarbonImmutable $month,
array $payload,
?ProductMonthlyWeightedAverageReporter $reporter = null,
): int {
$result = ['status' => $status] + $payload;
if (! isset($result['report_month']) && $month !== null) {
$result['report_month'] = $month->toDateString();
}
$path = trim((string) $this->option('result-json'));
if ($path !== '') {
File::ensureDirectoryExists(dirname($path));
File::put(
$path,
json_encode(
$result,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
if ($reporter !== null && isset($result['totals'])) {
$this->line($reporter->brief($result));
} elseif (isset($result['message'])) {
$this->error((string) $result['message']);
}
return in_array($status, ['blocked', 'failed'], true)
? self::FAILURE
: self::SUCCESS;
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace App\Console\Commands;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* 固定月度顺序:先生成商品月加权平均单价,再同步商品成本表。
*/
class AutoMonthlyWeightedAverageAndProductCost extends Command
{
protected $signature = 'finance:monthly-weighted-product-cost:auto
{--month= : 指定月份;默认取最新已成功的进销存月份}
{--result-json= : 将两阶段结果写入指定 JSON 文件}';
protected $description = '先计算商品月加权平均单价,再自动同步商品成本表';
public function handle(): int
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$month = $this->targetMonth($connection);
if ($month === null) {
return $this->writeResult([
'status' => 'blocked',
'message' => '未找到已成功导入的进销存月份。',
]);
}
$weightedPath = tempnam(sys_get_temp_dir(), 'weighted-result-') . '.json';
$costPath = tempnam(sys_get_temp_dir(), 'product-cost-result-') . '.json';
try {
$weightedExit = $this->call('product:monthly-weighted-average:auto', [
'--month' => $month->format('Y-m'),
'--result-json' => $weightedPath,
]);
$weighted = $this->readJson($weightedPath);
if (
$weightedExit !== self::SUCCESS
|| in_array($weighted['status'] ?? 'failed', ['blocked', 'failed'], true)
) {
return $this->writeResult([
'status' => 'failed',
'report_month' => $month->toDateString(),
'message' => '商品月加权平均单价未完成,商品成本未执行。',
'weighted_average' => $weighted,
'product_cost' => null,
]);
}
$costExit = $this->call('store-receipt:product-cost:sync', [
'month' => $month->format('Y-m'),
'--result-json' => $costPath,
]);
$cost = $this->readJson($costPath);
$status = $costExit !== self::SUCCESS
? 'failed'
: ((int) ($cost['pending_cost_rows'] ?? 0) > 0 ? 'review_required' : 'success');
return $this->writeResult([
'status' => $status,
'report_month' => $month->toDateString(),
'message' => match ($status) {
'success' => '月加权平均单价与商品成本已按顺序完成。',
'review_required' => '月加权平均单价与商品成本已完成,存在待核验成本。',
default => '商品成本同步失败。',
},
'weighted_average' => $weighted,
'product_cost' => $cost,
]);
} finally {
@unlink($weightedPath);
@unlink($costPath);
}
}
private function targetMonth(string $connection): ?CarbonImmutable
{
$requested = trim((string) $this->option('month'));
if ($requested !== '') {
return CarbonImmutable::parse($requested)->startOfMonth();
}
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('status', 'succeeded')
->whereNotNull('active_generation')
->orderByDesc('source_month')
->value('source_month');
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
}
/** @return array<string,mixed> */
private function readJson(string $path): array
{
if (! is_file($path)) {
return ['status' => 'failed', 'message' => '子任务未生成结果文件。'];
}
$decoded = json_decode((string) file_get_contents($path), true);
return is_array($decoded)
? $decoded
: ['status' => 'failed', 'message' => '子任务结果文件无法解析。'];
}
/** @param array<string,mixed> $result */
private function writeResult(array $result): int
{
$path = trim((string) $this->option('result-json'));
if ($path !== '') {
File::ensureDirectoryExists(dirname($path));
File::put(
$path,
json_encode(
$result,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
if (isset($result['message'])) {
$this->line((string) $result['message']);
}
return in_array($result['status'] ?? 'failed', ['success', 'review_required'], true)
? self::SUCCESS
: self::FAILURE;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\WorkOrderWaybillLookup;
class BackfillWorkOrderWaybillLookup extends Command
{
protected $signature = 'business-mirror:index-work-order-waybills {--batch=500} {--max-batches=2} {--pause-ms=50}';
protected $description = '按稳定游标建立查件运单小索引;生产须使用 run-finance-batch.sh 执行';
public function handle(WorkOrderWaybillLookup $lookup): int
{
$lock = Cache::store('upstream_shared')->lock('work-order-waybill-lookup:backfill', 3600);
if (! $lock->get()) {
$this->info('已有运单索引任务运行,本轮跳过。');
return self::SUCCESS;
}
try {
$result = $lookup->backfill((int) $this->option('batch'), max(0, (int) $this->option('max-batches')), max(0, (int) $this->option('pause-ms')));
$this->info(json_encode($result));
return self::SUCCESS;
} finally {
$lock->release();
}
}
}

View File

@ -2,26 +2,45 @@
namespace App\Console\Commands;
use App\Business\ProductMonthlyWeightedAverageReporter;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* 按取数逻辑计算某个月的更改价格、定价状态、采购单价、期初金额、入库金额和加权平均单价。
*/
class ComputeProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:compute {month : 报表月份,例如 2026-07}';
protected $signature = 'product:monthly-weighted-average:compute
{month : 报表月份,例如 2026-07}
{--force : 覆盖已经计算完成的同月结果}
{--result-json= : 将结果简报写入指定 JSON 文件}';
protected $description = '按取数逻辑计算商品月加权平均单价';
public function handle(): int
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
{
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = 'product_monthly_weighted_average_rows';
$deadline = $month->addMonthNoOverflow()->day(2)->startOfDay();
$stateStatus = DB::connection($connection)
->table('product_monthly_weighted_average_states')
->where('report_month', $month->toDateString())
->value('status');
$computedRows = DB::connection($connection)->table($table)
->where('report_month', $month->toDateString())
->whereNotNull('computed_at')
->count();
if (! $this->option('force') && ($stateStatus === 'computed' || $computedRows > 0)) {
$this->error($month->format('Y-m') . ' 已经计算完成,历史月份不自动覆盖;如需人工纠正请显式使用 --force。');
return self::FAILURE;
}
$query = DB::connection($connection)->table($table)->where('report_month', $month->toDateString());
$codes = (clone $query)->distinct()->pluck('product_code')->map(fn ($code) => trim((string) $code))->unique()->values()->all();
$this->info('待计算 SKU:' . count($codes));
@ -87,7 +106,21 @@ class ComputeProductMonthlyWeightedAverage extends Command
['status' => 'computed', 'computed_rows' => $computed, 'last_computed_at' => $now, 'updated_at' => $now, 'created_at' => $now],
);
$report = $reporter->build($connection, $month);
$json = json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if (is_string($json)) {
$resultPath = trim((string) $this->option('result-json'));
if ($resultPath !== '') {
File::ensureDirectoryExists(dirname($resultPath));
File::put($resultPath, $json . PHP_EOL);
}
}
$this->info($month->format('Y-m') . " 计算完成:{$computed} 行");
$this->line($reporter->brief($report));
if (($report['status'] ?? null) === 'review_required') {
$this->warn('存在待复核差异;结果已生成,但不会视为完全无异常。');
}
return self::SUCCESS;
}

View File

@ -0,0 +1,326 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
use XMLReader;
use ZipArchive;
/**
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
*
* 该报表已按商品编码(组合装拆成组件)给出每行和成本,导入后订单售后费用明细页面
* 在覆盖的店铺/日期范围内直接按这些明细行展示。
*/
class ImportAfterSalesCostRows extends Command
{
protected $signature = 'order-data:import-after-sales-cost {file : Excel 文件路径} {--chunk=500 : 每批写入行数} {--dry-run : 只解析不入库}';
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
public function handle(): int
{
$path = (string) $this->argument('file');
if (! is_file($path)) {
$this->error('文件不存在:' . $path);
return self::FAILURE;
}
$headers = (array) config('order_after_sales_cost.headers', []);
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
if ($headers === []) {
$this->error('缺少表头映射:config/order_after_sales_cost.php');
return self::FAILURE;
}
$this->info('流式读取文件:' . $path);
$sheetPath = $this->firstSheetPath($path);
if ($sheetPath === null) {
$this->error('不是有效的 xlsx(缺少工作表)。');
return self::FAILURE;
}
try {
$sharedStrings = $this->readSharedStrings($path);
} catch (\Throwable $exception) {
$this->error('读取共享字符串失败:' . $exception->getMessage());
return self::FAILURE;
}
$columns = [];
$first = true;
$chunkSize = max((int) $this->option('chunk'), 100);
$dryRun = (bool) $this->option('dry-run');
$buffer = [];
$total = 0;
$written = 0;
$skipped = 0;
$shops = [];
$minDate = null;
$maxDate = null;
$importedAt = now()->toDateTimeString();
$reader = new XMLReader();
if (! $reader->open('zip://' . $path . '#' . $sheetPath)) {
$this->error('无法打开工作表:' . $sheetPath);
return self::FAILURE;
}
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
continue;
}
$cells = $this->readRow($reader, $sharedStrings);
if ($first) {
$first = false;
foreach ($cells as $index => $header) {
$header = self::normalizeHeader((string) $header);
if ($header !== '' && isset($headers[$header])) {
$columns[$index] = $headers[$header];
}
}
if ($columns === []) {
$reader->close();
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
return self::FAILURE;
}
continue;
}
$total++;
$payload = [];
foreach ($columns as $index => $key) {
$value = $cells[$index] ?? null;
if (is_string($value)) {
$value = trim($value);
}
if ($value === '' || $value === null) {
continue;
}
if (isset($numberKeys[$key])) {
$value = is_numeric($value) ? (float) $value : 0.0;
} elseif (self::isDateKey($key)) {
$value = self::dateValue($value);
}
$payload[$key] = $value;
}
$shop = trim((string) ($payload['shop_name'] ?? ''));
if ($shop === '' || empty($payload['product_code'])) {
$skipped++;
continue;
}
$buffer[] = AfterSalesCostRowRepository::toRecord($payload, $importedAt);
$shops[$shop] = true;
$orderTime = $payload['order_time'] ?? null;
if (is_string($orderTime) && $orderTime !== '') {
$minDate = $minDate === null || $orderTime < $minDate ? $orderTime : $minDate;
$maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate;
}
if (! $dryRun && count($buffer) >= $chunkSize) {
$written += $this->flush($buffer);
$buffer = [];
$this->output->write("\r写入 " . $written . ' 行');
}
if ($dryRun && $buffer !== []) {
$buffer = [];
}
}
$reader->close();
if (! $dryRun && $buffer !== []) {
$written += $this->flush($buffer);
}
$this->newLine(2);
$this->info(sprintf(
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;订单时间 %s ~ %s;峰值内存 %.1f MB',
$total,
$written,
$skipped,
count($shops),
$minDate ?? '-',
$maxDate ?? '-',
memory_get_peak_usage(true) / 1048576,
));
return self::SUCCESS;
}
private function firstSheetPath(string $path): ?string
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
return null;
}
$sheet = null;
if ($zip->locateName('xl/worksheets/sheet1.xml') !== false) {
$sheet = 'xl/worksheets/sheet1.xml';
} else {
for ($i = 1; $i <= 30; $i++) {
$name = 'xl/worksheets/sheet' . $i . '.xml';
if ($zip->locateName($name) !== false) {
$sheet = $name;
break;
}
}
}
$zip->close();
return $sheet;
}
/** @return array<int,string> */
private function readSharedStrings(string $path): array
{
$strings = [];
$reader = new XMLReader();
if (! $reader->open('zip://' . $path . '#xl/sharedStrings.xml')) {
return $strings;
}
while ($reader->read()) {
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'si') {
$xml = $reader->readOuterXml();
$strings[] = html_entity_decode(strip_tags($xml), ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}
$reader->close();
return $strings;
}
/** @param array<int,string> $shared @return array<int,mixed> */
private function readRow(XMLReader $reader, array $shared): array
{
$cells = [];
$xml = $reader->readOuterXml();
if ($xml === '') {
return $cells;
}
$row = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($row === false) {
return $cells;
}
foreach ($row->c as $cell) {
$index = self::columnIndex((string) $cell['r']);
$type = (string) $cell['t'];
if ($type === 's') {
$value = $shared[(int) $cell->v] ?? '';
} elseif ($type === 'inlineStr') {
$value = (string) $cell->is->t;
} else {
$value = (string) $cell->v;
}
$cells[$index] = $value;
}
return $cells;
}
private static function columnIndex(string $reference): int
{
$letters = preg_replace('/[^A-Z]/', '', strtoupper($reference)) ?? '';
$index = 0;
foreach (str_split($letters) as $letter) {
$index = $index * 26 + (ord($letter) - 64);
}
return $index - 1;
}
/** @param list<array<string,mixed>> $rows */
private function flush(array $rows): int
{
DB::table('ops_after_sales_cost_rows')->upsert(
$rows,
['row_key'],
[
'shop_name', 'shop_code', 'online_order_no', 'internal_order_no', 'product_code',
'order_time', 'product_quantity', 'product_amount', 'product_cost', 'cost_source',
'combine_type', 'payload', 'imported_at', 'updated_at',
],
);
return count($rows);
}
private static function normalizeHeader(string $header): string
{
$header = str_replace(["\u{3000}", "\u{00A0}"], ' ', $header);
$header = preg_replace('/\s+/u', ' ', $header) ?? $header;
return trim($header);
}
private static function isDateKey(string $key): bool
{
return in_array($key, [
'pay_time', 'order_complete_time', 'send_time', 'order_time', 'registration_time',
'confirmation_time', 'warehouse_entry_time',
], true);
}
private static function dateValue(mixed $value): ?string
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) {
// Excel 序列日期(1899-12-30 起算),按本地时间格式化。
$timestamp = ((float) $value - 25569) * 86400;
return gmdate('Y-m-d H:i:s', (int) $timestamp);
}
$timestamp = strtotime((string) $value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
}

View File

@ -0,0 +1,280 @@
<?php
namespace App\Console\Commands;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Common\Company;
use App\Support\CourierQuoteStructure;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* 把整理好的结构化计费规则(标准计费规则_导入用.json)写入现有报价版本,供运费核算使用。
* 只写 billing_structure / billing_rows 两列,原始报价单元格、执行日期、状态都不动。
*/
class ImportCourierQuoteBillingRules extends Command
{
protected $signature = 'courier-quotes:import-billing-rules {file : 标准计费规则 JSON}
{--company= : 报价所属公司ID}
{--dry-run : 只校验并输出结果,不写入}
{--force : 覆盖已有且内容不同的计费规则}';
protected $description = '把存量报价的结构化计费规则批量写入报价版本(按版本号匹配,整批事务)';
private const MODES = ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then', CourierQuoteStructure::SEGMENTS];
public function handle(): int
{
$companyId = (int) $this->option('company');
if ($companyId <= 0 || ! Company::query()->where('id', $companyId)->exists()) {
$this->error('请指定有效的 --company。');
return self::FAILURE;
}
$payload = json_decode((string) @file_get_contents((string) $this->argument('file')), true);
$items = is_array($payload) ? ($payload['versions'] ?? null) : null;
if (! is_array($items) || $items === []) {
$this->error('文件不是有效的计费规则 JSON(缺少 versions)。');
return self::FAILURE;
}
$plan = [];
$report = [];
$seen = [];
foreach ($items as $item) {
if (! is_array($item)) {
$report[] = ['', '', '错误', '版本条目必须是对象'];
continue;
}
$code = (string) ($item['version_id'] ?? '');
if (isset($seen[$code])) {
$report[] = [$code, '', '错误', '同一文件中的版本号不能重复'];
continue;
}
$seen[$code] = true;
[$status, $note, $data] = $this->prepare($companyId, $item);
$report[] = [$code, (string) ($item['company_name'] ?? ''), $status, $note];
if ($data !== null) {
$plan[] = $data;
}
}
$this->table(['版本', '快递公司', '结果', '说明'], $report);
$counts = array_count_values(array_column($report, 2));
$summary = collect($counts)->map(fn ($n, $k) => "{$k} {$n}")->implode(',');
if (isset($counts['错误'])) {
$this->error('存在错误,整批未写入:' . $summary);
return self::FAILURE;
}
if ($this->option('dry-run')) {
$this->info('校验通过,未写入(dry-run):' . $summary);
return self::SUCCESS;
}
try {
DB::transaction(function () use ($plan): void {
foreach ($plan as [$version, $structure, $rows]) {
$current = DB::table($version->getTable())->where('id', $version->id)->lockForUpdate()->first();
if (! $current || $current->company_name !== $version->company_name
|| json_decode($current->billing_structure ?? 'null', true) != $version->billing_structure
|| json_decode($current->billing_rows ?? 'null', true) != $version->billing_rows) {
throw new RuntimeException('报价在校验后被其他操作修改,整批未写入,请重新校验。');
}
$version->forceFill(['billing_structure' => $structure, 'billing_rows' => $rows])->save();
}
});
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info('导入完成:' . $summary);
return self::SUCCESS;
}
/**
* @param array<string,mixed> $item
* @return array{0:string,1:string,2:?array}
*/
private function prepare(int $companyId, array $item): array
{
$code = (string) ($item['version_id'] ?? '');
$version = CourierQuoteVersion::query()->allCompanies()->where('company_id', $companyId)->where('version_code', $code)->first();
if ($version === null) {
return ['错误', '系统中找不到该版本号', null];
}
if (trim((string) $version->company_name) !== trim((string) ($item['company_name'] ?? ''))) {
return ['错误', '快递名称不一致:系统为「' . $version->company_name . '」', null];
}
if (($item['conversion'] ?? '') === 'manual') {
return ['跳过', '特殊计费(' . ($item['manual_reason'] ?? '按体积/条码') . '),需人工核算', null];
}
if (($error = $this->inputError($item)) !== null) {
return ['错误', $error, null];
}
$source = (array) ($item['structure'] ?? []);
$segments = ($item['conversion'] ?? '') === 'needs_segment_mode';
$mode = $segments ? CourierQuoteStructure::SEGMENTS : (string) ($source['mode'] ?? '');
if (! in_array($mode, self::MODES, true)) {
return ['错误', '不支持的计费方式:' . $mode, null];
}
$params = (array) ($item['params'] ?? []);
$perTicket = 0.0;
foreach ((array) ($params['surcharges'] ?? []) as $surcharge) {
if (($surcharge['condition'] ?? '') === '每票' && is_numeric($surcharge['amount'] ?? null)) {
$perTicket += (float) $surcharge['amount'];
}
}
$structure = [
'mode' => $mode,
'flat_bands' => $segments ? [] : $this->numbers($source['flat_bands'] ?? []),
'then_mode' => $segments ? '' : (string) ($source['then_mode'] ?? ''),
'first_weight' => ! $segments && is_numeric($source['first_weight'] ?? null) ? (float) $source['first_weight'] : null,
'rate_tiers' => $segments ? [] : $this->numbers($source['rate_tiers'] ?? []),
'rounding' => in_array($source['rounding'] ?? 'none', array_keys(CourierQuoteStructure::ROUNDING), true) ? (string) ($source['rounding'] ?? 'none') : 'none',
'volumetric_divisor' => is_numeric($params['volumetric_divisor'] ?? null) ? (int) $params['volumetric_divisor'] : null,
'weight_limit' => is_numeric($params['weight_limit_kg'] ?? null) ? (float) $params['weight_limit_kg'] : null,
'min_weight' => is_numeric($source['min_weight'] ?? $params['min_weight_kg'] ?? null) ? (float) ($source['min_weight'] ?? $params['min_weight_kg']) : null,
'per_ticket_surcharge' => $perTicket > 0 ? $perTicket : null,
'notes' => array_values(array_map('strval', (array) ($params['notes'] ?? []))),
'imported_from' => 'stock-quote-billing-import',
];
$rows = [];
foreach ((array) ($segments ? ($item['segments_rows'] ?? []) : ($item['rows'] ?? [])) as $row) {
$region = trim((string) ($row['region'] ?? ''));
if ($region === '') {
return ['错误', '存在没有地区名的行', null];
}
if ($segments) {
$list = [];
foreach ((array) ($row['segments'] ?? []) as $s) {
$list[] = array_filter([
'lo' => (float) ($s['lo'] ?? 0),
'hi' => is_numeric($s['hi'] ?? null) ? (float) $s['hi'] : null,
'type' => (string) ($s['type'] ?? ''),
'price' => $this->number($s['price'] ?? null),
'base' => $this->number($s['base'] ?? null),
'rate' => $this->number($s['rate'] ?? null),
'fw' => $this->number($s['fw'] ?? null),
], static fn ($v, $k) => $v !== null || $k === 'hi', ARRAY_FILTER_USE_BOTH);
}
$rows[] = ['region' => $region, 'segments' => $list];
} else {
$rows[] = [
'region' => $region,
'flat' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['flat'] ?? []))),
'base' => $this->number($row['base'] ?? null),
'rates' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['rates'] ?? []))),
];
}
}
if ($rows === []) {
return ['错误', '没有任何地区价格', null];
}
$existing = $version->billing_structure;
$note = CourierQuoteStructure::describe($structure) . ',' . count($rows) . ' 个地区';
if (is_array($existing) && $existing !== []) {
if ($existing == $structure && $version->billing_rows == $rows) {
return ['跳过', '已导入且内容相同', null];
}
if (! $this->option('force')) {
return ['错误', '已有不同的计费规则,如需覆盖请加 --force', null];
}
return ['覆盖', $note, [$version, $structure, $rows]];
}
return ['写入', $note, [$version, $structure, $rows]];
}
private function number(mixed $value): ?float
{
return is_numeric($value) ? round((float) $value, 4) : null;
}
/** 金额导入拒绝负数、歧义分段和不支持的附加条件,不静默改写计费含义。 */
private function inputError(array $item): ?string
{
$structure = $item['structure'] ?? null;
$params = $item['params'] ?? [];
if (! is_array($structure) || ! is_array($params)) {
return 'structure 和 params 必须是对象';
}
if (! array_key_exists($structure['rounding'] ?? 'none', CourierQuoteStructure::ROUNDING)) {
return '不支持的重量取整方式';
}
$valid = static fn ($v): bool => is_numeric($v) && is_finite((float) $v) && (float) $v >= 0;
foreach ([$params['volumetric_divisor'] ?? null, $params['weight_limit_kg'] ?? null,
$structure['min_weight'] ?? $params['min_weight_kg'] ?? null, $structure['first_weight'] ?? null] as $v) {
if ($v !== null && ! $valid($v)) return '重量、体积系数必须是非负有限数字';
}
if (isset($params['volumetric_divisor']) && (float) $params['volumetric_divisor'] <= 0) return '体积系数必须大于0';
$minimum = (float) ($structure['min_weight'] ?? $params['min_weight_kg'] ?? 0);
if (($params['weight_limit_kg'] ?? 0) > 0 && $minimum > $params['weight_limit_kg']) return '起算重量不能大于限重';
foreach (['flat_bands', 'rate_tiers'] as $field) {
$previous = 0;
if (! is_array($structure[$field] ?? [])) return '重量分段必须是数组';
foreach ($structure[$field] ?? [] as $edge) {
if (! $valid($edge) || (float) $edge <= $previous) return '重量节点必须为严格递增的正数';
$previous = (float) $edge;
}
}
$mode = ($item['conversion'] ?? '') === 'needs_segment_mode' ? CourierQuoteStructure::SEGMENTS : ($structure['mode'] ?? '');
if (in_array($mode, ['flat_bands', 'flat_then'], true) && empty($structure['flat_bands'])) return '缺少一口价重量节点';
if ($mode === 'flat_then' && ! in_array($structure['then_mode'] ?? '', ['waybill_rate', 'first_additional'], true)) return '不支持的超重计费方式';
if (CourierQuoteStructure::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! (($structure['first_weight'] ?? 0) > 0)) return '首重必须大于0';
if ($mode === 'flat_then' && ! empty($structure['rate_tiers']) && min($structure['rate_tiers']) <= max($structure['flat_bands'])) return '超重费率节点必须大于一口价最后节点';
if (! is_array($params['surcharges'] ?? [])) return '附加费必须是数组';
foreach ($params['surcharges'] ?? [] as $fee) {
if (! is_array($fee) || ($fee['condition'] ?? '') !== '每票' || ! $valid($fee['amount'] ?? null)) return '附加费条件不支持自动核算,需人工核对';
}
$rows = $item[$mode === CourierQuoteStructure::SEGMENTS ? 'segments_rows' : 'rows'] ?? null;
if (! is_array($rows) || $rows === []) return '没有任何地区价格';
$regions = [];
foreach ($rows as $row) {
if (! is_array($row) || ! is_string($row['region'] ?? null) || trim($row['region']) === '') return '地区行格式不正确';
$region = mb_strtolower(preg_replace('/\s+/u', '', $row['region']));
if (isset($regions[$region])) return '存在重复地区行';
$regions[$region] = true;
if ($mode === CourierQuoteStructure::SEGMENTS) {
if (! is_array($row['segments'] ?? null) || $row['segments'] === []) return '缺少地区计费分段';
$previous = 0.0;
foreach ($row['segments'] as $segment) {
if (! is_array($segment) || ! $valid($segment['lo'] ?? null)
|| (float) $segment['lo'] < $previous || ! in_array($segment['type'] ?? '', ['flat', 'waybill', 'first_add'], true)) return '分段类型不支持或存在重叠';
$hi = $segment['hi'] ?? null;
if ($hi !== null && (! $valid($hi) || (float) $hi <= (float) $segment['lo'])) return '分段上限必须大于下限';
$previous = $hi === null ? INF : (float) $hi;
foreach (['price', 'base', 'rate', 'fw'] as $key) {
if (isset($segment[$key]) && ! $valid($segment[$key])) return '分段金额和重量必须是非负有限数字';
}
if ($segment['type'] === 'first_add' && ! $valid($segment['fw'] ?? null)) return '分段首重缺失';
}
} else {
if (! is_array($row['flat'] ?? []) || ! is_array($row['rates'] ?? [])) return '价格必须是数组';
if (count($row['flat'] ?? []) !== count($structure['flat_bands'] ?? [])
|| (CourierQuoteStructure::usesRates($mode) && count($row['rates'] ?? []) !== count($structure['rate_tiers'] ?? []) + 1)) return '价格列数与重量节点不匹配';
foreach (array_merge($row['flat'] ?? [], $row['rates'] ?? [], [$row['base'] ?? null]) as $v) {
if ($v !== null && ! $valid($v)) return '价格必须是非负有限数字或null(不发)';
}
}
}
return null;
}
/** @return list<float> */
private function numbers(mixed $values): array
{
return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric')));
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerBaseImporter;
use Throwable;
class ImportFinanceLedgerBase extends Command
{
protected $signature = 'finance-ledger:import-base
{path : 《银行当前余额-*.xlsx》路径}
{--opening-date= : 期初日期,默认读取配置}
{--recalculate : 导入后立即重算每日流水}';
protected $description = '导入财务台账的账户、店铺、公司、期初余额(全量覆盖)';
public function handle(FinanceLedgerBaseImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('path'),
$this->option('opening-date') ?: null,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '数量/金额'], [
['账户', $result['accounts']],
['店铺', $result['shops']],
['公司', $result['companies']],
['期初账户', $result['opening_rows']],
['期初合计', number_format($result['opening_sum'], 2)],
['账户余额合计', number_format($result['account_balance_sum'], 2)],
['期初日期', $result['opening_date']],
]);
if ($this->option('recalculate')) {
$this->call('finance-ledger:recalculate');
}
$this->info('财务台账基础数据导入完成。');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerSnapshotImporter;
use Throwable;
class ImportFinanceLedgerSnapshot extends Command
{
protected $signature = 'finance-ledger:import-snapshot
{path : 已核对的 ledger_raw.json 路径}
{--no-recalculate : 只导入明细,不重算流水}';
protected $description = '导入财务台账动账明细快照(本地开发或首次建账使用)';
public function handle(FinanceLedgerSnapshotImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('path'),
! $this->option('no-recalculate'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['明细行数', $result['rows']],
['收入合计', number_format($result['income'], 2)],
['支出合计', number_format($result['expense'], 2)],
['日期范围', $result['from'] . ' ~ ' . $result['to']],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
]);
$this->info('财务台账动账明细导入完成。');
return self::SUCCESS;
}
}

View File

@ -2,11 +2,9 @@
namespace App\Console\Commands;
use App\Models\Business\InventoryWarehouseNameMapping;
use Carbon\CarbonImmutable;
use App\Business\InventoryInboundDetailImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
/**
* 进销存报表 → 入库明细数据:按原始 Excel 原样导入,仓库简称用仓库名称映射表按「仓库」唯一匹配。
@ -17,194 +15,28 @@ class ImportInventoryInboundDetail extends Command
protected $description = '导入进销存报表的入库明细数据';
public function handle(): int
public function handle(InventoryInboundDetailImporter $importer): int
{
$file = (string) $this->argument('file');
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = 'inventory_inbound_detail_rows';
$forcedMonth = trim((string) ($this->option('month') ?? ''));
$now = now();
$maps = $this->shortNameMap();
$this->info('仓库名称映射:仓库名 ' . count($maps['warehouse']) . ' 个、仓储方 ' . count($maps['provider']) . ' 个可唯一匹配');
try {
$result = $importer->import($file, $forcedMonth);
$this->info(sprintf(
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
$result['imported'],
$result['mapped'],
$result['skipped'],
$result['total'],
));
// 原样导入:以本次文件为准整体重建,不做去重、不做加工。
DB::connection($connection)->table($table)->delete();
return self::SUCCESS;
} catch (Throwable $exception) {
report($exception);
$this->error('入库明细导入失败:' . $exception->getMessage());
$this->warn('已保留上一版有效数据。');
$reader = IOFactory::createReaderForFile($file);
$reader->setReadDataOnly(true);
$sheet = $reader->load($file)->getSheet(0);
$batch = [];
$imported = 0;
$mapped = 0;
$skipped = 0;
foreach ($sheet->getRowIterator(2) as $row) {
$rowNumber = $row->getRowIndex();
$cells = [];
foreach ($row->getCellIterator('A', 'U') as $cell) {
$cells[] = $cell->getFormattedValue();
}
$warehouse = trim((string) ($cells[14] ?? ''));
$createdRaw = trim((string) ($cells[16] ?? ''));
if ($warehouse === '' && $createdRaw === '') {
$skipped++;
continue;
}
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
if ($month === '') {
$skipped++;
continue;
}
// 一般用"仓库"匹配仓库简称;含"代发仓"字样的按"仓储方"匹配。
$shortName = '';
if ($warehouse !== '') {
$shortName = str_contains($warehouse, '代发仓')
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
: ($maps['warehouse'][$warehouse] ?? '');
}
if ($shortName !== '') {
$mapped++;
}
$batch[] = [
'report_month' => $month . '-01',
// 唯一键按 Excel 行号生成,保留原表每一行(含重复行)。
'row_key' => sha1($month . '|' . $rowNumber),
'source_no' => $this->text($cells[0] ?? ''),
'document_no' => $this->text($cells[1] ?? ''),
'created_serial' => $this->text($cells[2] ?? ''),
'supplier' => $this->text($cells[3] ?? ''),
'item_no' => $this->text($cells[4] ?? ''),
'barcode' => $this->text($cells[5] ?? ''),
'spec_name' => $this->text($cells[6] ?? ''),
'quantity' => $this->number($cells[7] ?? ''),
'unit_price' => $this->number($cells[8] ?? ''),
'amount' => $this->number($cells[9] ?? ''),
'estimated_unit_price' => $this->number($cells[10] ?? ''),
'estimated_amount' => $this->number($cells[11] ?? ''),
'supplier_barcode' => $this->text($cells[12] ?? ''),
'remark' => $this->text($cells[13] ?? ''),
'warehouse' => $warehouse !== '' ? $warehouse : null,
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
'created_at_raw' => $this->moment($createdRaw),
'express_name' => $this->text($cells[17] ?? ''),
'express_no' => $this->text($cells[18] ?? ''),
'not_sent' => $this->text($cells[19] ?? ''),
'owner_name' => $this->text($cells[20] ?? ''),
'imported_at' => $now,
];
$imported++;
if (count($batch) >= 1000) {
DB::connection($connection)->table($table)->insertOrIgnore($batch);
$this->info('已写入 ' . $imported . ' 行');
$batch = [];
}
return self::FAILURE;
}
if ($batch !== []) {
DB::connection($connection)->table($table)->insertOrIgnore($batch);
}
$this->info(sprintf(
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
$imported,
$mapped,
$skipped,
DB::connection($connection)->table($table)->count(),
));
return self::SUCCESS;
}
/**
* @return array{warehouse:array<string,string>,provider:array<string,string>}
* 仅保留能唯一匹配的键
*/
private function shortNameMap(): array
{
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
$grouped = [];
foreach ($rows as $row) {
$warehouse = trim((string) $row->warehouse);
if ($warehouse === '') {
continue;
}
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
}
$byWarehouse = [];
foreach ($grouped as $warehouse => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byWarehouse[$warehouse] = $unique[0];
}
}
$byProvider = [];
foreach ($rows as $row) {
$provider = trim((string) $row->warehouse_provider);
if ($provider === '') {
continue;
}
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
}
foreach ($byProvider as $provider => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byProvider[$provider] = $unique[0];
}
}
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
}
private function monthOf(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
$parsed = \DateTimeImmutable::createFromFormat($format, $value);
if ($parsed instanceof \DateTimeImmutable) {
return $parsed->format('Y-m');
}
}
$timestamp = strtotime($value);
return $timestamp === false ? '' : date('Y-m', $timestamp);
}
private function moment(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
private function text(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function number(mixed $value): ?float
{
$text = str_replace(',', '', trim((string) $value));
if ($text === '' || ! is_numeric($text)) {
return null;
}
return (float) $text;
}
}

View File

@ -32,12 +32,11 @@ class ImportJushuitanShopBalances extends Command
}
$this->info(sprintf(
'%s:%s 共 %d 条,替换原有 %d 条,清理 5 天前 %d 条。',
'%s:%s 共 %d 条,替换原有 %d 条。',
$result['dry_run'] ? '校验完成' : '导入完成',
$result['balance_date'],
$result['total'],
$result['replaced'],
$result['pruned'],
));
return self::SUCCESS;

View File

@ -21,7 +21,8 @@ class ImportProductMonthlyWeightedAverageBase extends Command
protected $signature = 'product:monthly-weighted-average:base
{month : 报表月份,例如 2026-07}
{--file= : 期初基数 Excel(表1),首月使用}
{--from-previous : 用上月的加权平均单价作为本月原始价格}';
{--from-previous : 用上月的加权平均单价作为本月原始价格}
{--force : 覆盖已经存在的同月基数}';
protected $description = '导入/生成商品月加权平均单价的期初基数';
@ -32,6 +33,18 @@ class ImportProductMonthlyWeightedAverageBase extends Command
$rowsTable = 'product_monthly_weighted_average_rows';
$now = now();
$existingRows = DB::connection($connection)->table($rowsTable)
->where('report_month', $month->toDateString())
->count();
$existingState = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('report_month', $month->toDateString())
->value('status');
if (! $this->option('force') && ($existingRows > 0 || $existingState !== null)) {
$this->error($month->format('Y-m') . ' 已存在基数或计算状态,不自动覆盖;如需人工纠正请显式使用 --force。');
return self::FAILURE;
}
$inventory = $this->inventoryAggregate($connection, $month);
$jiangxiPurchase = $this->jiangxiDeliveryPurchase($connection, $month);
$nanchangOpening = $this->nanchangOpeningQuantity($connection, $month);
@ -198,9 +211,13 @@ class ImportProductMonthlyWeightedAverageBase extends Command
throw new \RuntimeException($month->format('Y-m') . ' 的进销存总表尚未导入。');
}
$mappingTable = (new InventoryWarehouseNameMapping)->getConnection()->getDatabaseName()
. '.' . DB::connection((new InventoryWarehouseNameMapping)->getConnectionName())->getTablePrefix()
. (new InventoryWarehouseNameMapping)->getTable();
$mappingModel = new InventoryWarehouseNameMapping();
$mappingConnection = DB::connection($mappingModel->getConnectionName());
$mappingTable = $mappingConnection->getDriverName() === 'sqlite'
? $mappingModel->getTable()
: $mappingModel->getConnection()->getDatabaseName()
. '.' . $mappingConnection->getTablePrefix()
. $mappingModel->getTable();
$sql = "select m.warehouse_short_name as short_name, r.product_code as product_code,
sum(case when r.warehouse like '%主仓%' then r.opening_quantity else 0 end) as opening_quantity,

View File

@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
class ImportSupplierBookkeepingConfigs extends Command
{
protected $signature = 'supplier-bookkeeping:import-configs {file} {--dry-run : 仅检查文件并输出条数,不写入}';
protected $description = '导入财务提供的厂家记账配置 Excel(供应商、厂家是否记账);新增/更新/跳过,任一行无法识别整批不写入';
public function handle(SupplierBookkeepingConfigImporter $importer, SupplierBookkeepingConfigStore $store): int
{
$path = (string) $this->argument('file');
$rows = $importer->read($path);
if ($this->option('dry-run')) {
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
return self::SUCCESS;
}
$result = $store->import($rows, '命令行导入', hash_file('sha256', $path));
$this->info("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace App\Console\Commands;
use App\Business\SupplierSummaryInboundImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Throwable;
/**
* 供应商汇总表月度增量导入:读取已解密工作簿的「明细」子表,仅追加目标月份数据。
*/
class ImportSupplierSummaryInbound extends Command
{
protected $signature = 'inventory:supplier-summary:import
{file : 已解密的供应商汇总 XLSX 文件}
{--month= : 目标月份 YYYY-MM,默认上一个月}
{--result-json= : 将执行结果写入指定 JSON 文件}';
protected $description = '将供应商汇总表「明细」增量导入入库明细数据';
public function handle(SupplierSummaryInboundImporter $importer): int
{
$month = trim((string) $this->option('month'));
if ($month === '') {
$month = now('Asia/Shanghai')->subMonthNoOverflow()->format('Y-m');
}
$lock = Cache::lock('inventory-supplier-summary-inbound-import', 21600);
if (! $lock->get()) {
return $this->finish([
'status' => 'failed',
'report_month' => $month,
'source_filename' => basename((string) $this->argument('file')),
'success' => 0,
'duplicates' => 0,
'failed' => 1,
'mapped' => 0,
'total_in_table' => 0,
'failure_reason_count' => 1,
'failure_reasons' => [[
'row' => 0,
'reason' => '已有供应商汇总导入任务正在执行',
]],
]);
}
try {
$result = $importer->import((string) $this->argument('file'), $month);
} catch (Throwable $exception) {
report($exception);
$result = [
'status' => 'failed',
'report_month' => $month,
'source_filename' => basename((string) $this->argument('file')),
'success' => 0,
'duplicates' => 0,
'failed' => 1,
'mapped' => 0,
'total_in_table' => 0,
'failure_reason_count' => 1,
'failure_reasons' => [[
'row' => 0,
'reason' => $exception->getMessage(),
]],
];
} finally {
$lock->release();
}
return $this->finish($result);
}
/** @param array<string,mixed> $result */
private function finish(array $result): int
{
$reasons = is_array($result['failure_reasons'] ?? null)
? $result['failure_reasons']
: [];
$result['failure_reason_count'] = count($reasons);
$json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (! is_string($json)) {
$json = '{"status":"failed","failure_reasons":[{"row":0,"reason":"结果 JSON 编码失败"}]}';
}
$resultPath = trim((string) $this->option('result-json'));
if ($resultPath !== '') {
File::ensureDirectoryExists(dirname($resultPath));
File::put($resultPath, $json . PHP_EOL);
}
$this->line(sprintf(
'%s 导入状态=%s,成功=%s,重复=%s,失败=%s,目标月份总行数=%s',
(string) ($result['report_month'] ?? ''),
(string) ($result['status'] ?? 'failed'),
number_format((int) ($result['success'] ?? 0)),
number_format((int) ($result['duplicates'] ?? 0)),
number_format((int) ($result['failed'] ?? 0)),
number_format((int) ($result['total_in_table'] ?? 0)),
));
foreach (array_slice($reasons, 0, 10) as $reason) {
$this->warn(sprintf(
'第 %s 行:%s',
(string) ($reason['row'] ?? ''),
(string) ($reason['reason'] ?? '未知错误'),
));
}
if (count($reasons) > 10) {
$this->line(sprintf(
'另有 %s 条失败原因,完整清单见结果 JSON。',
number_format(count($reasons) - 10),
));
}
return ($result['status'] ?? 'failed') === 'success'
? self::SUCCESS
: self::FAILURE;
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReceiptDefaultCompensationSnapshotBuilder;
use Throwable;
class PreviewStoreReceiptDefaultCompensation extends Command
{
protected $signature = 'store-receipt:default-compensation:preview
{month : 账期 YYYY-MM}
{mapping : 最新科目映射表 xlsx 路径}
{--output= : 输出 JSON 路径;默认写入 storage/app/local/performance-details/default-compensation}';
protected $description = '人工核对:按指定科目映射表生成「违约赔付(冲减收入)」金额快照,不写数据库';
public function handle(StoreReceiptDefaultCompensationSnapshotBuilder $builder): int
{
$startedAt = hrtime(true);
$month = trim((string) $this->argument('month'));
$mapping = (string) $this->argument('mapping');
try {
$snapshot = $builder->build($month, $mapping);
$path = (string) ($this->option('output') ?: storage_path(
'app/local/performance-details/default-compensation/' . $month . '.json'
));
$directory = dirname($path);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
throw new \RuntimeException('无法创建快照目录:' . $directory);
}
$temporary = $path . '.tmp';
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false || file_put_contents($temporary, $json, LOCK_EX) === false) {
throw new \RuntimeException('无法写入本地快照。');
}
rename($temporary, $path);
$this->info('本地预览快照已生成:' . $path);
$this->line('映射条数:' . $snapshot['mapping_rows']);
$this->line('关联行数:' . count($snapshot['amounts']));
$this->line('金额合计:' . number_format((float) $snapshot['total'], 2, '.', ''));
$this->line('耗时:' . number_format((hrtime(true) - $startedAt) / 1e6, 0) . ' ms');
$this->line('PHP 峰值内存:' . number_format(memory_get_peak_usage(true) / 1024 / 1024, 1) . ' MB');
foreach ($snapshot['source_stats'] as $platform => $stats) {
$this->line(sprintf(
' %s:账单行 %d,金额 %s',
$platform,
$stats['rows'],
number_format((float) $stats['amount'], 2, '.', ''),
));
}
return self::SUCCESS;
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
use Throwable;
class RecalculateFinanceLedger extends Command
{
protected $signature = 'finance-ledger:recalculate';
protected $description = '重算财务台账的每日资金流水、账户余额和店铺余额';
public function handle(FinanceLedgerCalculator $calculator): int
{
try {
$result = $calculator->recalculate();
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['账户数', $result['accounts']],
['流水行数', $result['flows']],
['期末余额合计', number_format($result['balance_sum'], 2)],
['期初日期', $result['opening_date']],
]);
$this->info('财务台账流水重算完成。');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
class RefreshStoreReportPaidSales extends Command
{
protected $signature = 'business-mirror:refresh-paid-sales {--full : 强制全量校准派生汇总}';
protected $description = '只刷新业绩明细派生的销量汇总,不修改原始财务明细';
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
{
$result = $synchronizer->syncPaidSales((bool) $this->option('full'));
$this->info(json_encode($result));
return $result['status'] === 'succeeded' ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use App\Business\ProductMonthlyWeightedAverageReporter;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class ReportProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:report
{month : 报表月份,例如 2026-09}
{--result-json= : 将结果写入指定 JSON 文件}';
protected $description = '输出商品月加权平均单价结果简报与差异分类';
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
{
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
$report = $reporter->build(
(string) config('upstream_mirrors.connection', 'business_mirror'),
$month,
);
$path = trim((string) $this->option('result-json'));
if ($path !== '') {
File::ensureDirectoryExists(dirname($path));
File::put(
$path,
json_encode(
$report,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
$this->line($reporter->brief($report));
if (($report['status'] ?? null) === 'review_required') {
$this->warn('存在待人工复核差异,完整清单已写入结果 JSON。');
}
return ($report['status'] ?? null) === 'missing'
? self::FAILURE
: self::SUCCESS;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
class RunAccountBillsExportQueue extends Command
{
protected $signature = 'account-bills:exports:run {--max-jobs=1}';
protected $description = '依次消费账单导出任务,生产从受限批容器启动';
public function handle(AccountBillsExportTasks $tasks): int
{
$limit = max(0, (int) $this->option('max-jobs'));
for ($processed = 0; $limit === 0 || $processed < $limit; $processed++) {
$task = $tasks->nextQueued();
if ($task === null) break;
$code = $this->call('account-bills:export-run', ['token' => $task['token']]);
if ($code === 75) break;
}
return self::SUCCESS;
}
}

View File

@ -28,6 +28,17 @@ class RunAccountBillsExportTask extends Command
protected $description = '执行店铺收款账户账单导出任务(后台进程调用)';
public function handle(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
{
$lock = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('account-bills:export-worker', 10800);
if (! $lock->get()) return 75;
try {
return $this->runTask($tasks, $remarkMap);
} finally {
$lock->release();
}
}
private function runTask(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
{
$token = (string) $this->argument('token');
$task = $this->findTask($tasks, $token);
@ -36,6 +47,7 @@ class RunAccountBillsExportTask extends Command
return self::FAILURE;
}
if (($task['status'] ?? '') !== 'queued') return self::SUCCESS;
$task = $tasks->patch($task, ['status' => 'running', 'started_at' => AccountBillsExportTasks::beijingNow()]);
@ -110,7 +122,7 @@ class RunAccountBillsExportTask extends Command
private function findTask(AccountBillsExportTasks $tasks, string $token): ?array
{
$root = storage_path('app/account-bills-exports');
$root = $tasks->rootDir();
foreach (glob($root . '/*/' . $token . '.json') ?: [] as $path) {
$task = json_decode((string) file_get_contents($path), true);
if (is_array($task) && ($task['token'] ?? null) === $token) {

View File

@ -0,0 +1,112 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
use Symfony\Component\Process\Process;
use Throwable;
/**
* 科目映射表变更后的业绩明细自动重跑队列。
*
* 每轮按预算处理队列里的「平台 × 月份」;实际同步放在独立 PHP 子进程中,
* 避免大平台重跑时的内存累积,并让其它同步有机会取得全局锁。
*/
class RunAccountMappingResync extends Command
{
protected $signature = 'store-receipt:auto-resync {--max-jobs=1 : 每轮最多任务数,0为不限} {--max-seconds=300 : 单个任务结束后检查本轮时间预算}';
protected $description = '处理科目映射表变更触发的业绩明细重跑队列';
public function handle(AccountMappingResyncDispatcher $dispatcher): int
{
// 页面(Apache)与每分钟 cron 都以 www-data 运行,使用共享数据库锁,
// 不受 storage/framework/cache 文件属主影响,也能避免多容器重复消费。
$lock = Cache::store('upstream_shared')->lock('store-receipt:auto-resync', 86400);
if (! $lock->get()) {
// 已有进程在跑;本次新入队的任务会在当前进程下一轮循环或每分钟兜底时执行。
return self::SUCCESS;
}
try {
// 进程崩溃遗留的 running 任务超过 60 分钟视为可重试。
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('status', 'running')
->where('updated_at', '<', now()->subMinutes(60))
->update(['status' => 'pending', 'updated_at' => now()]);
$processed = 0;
$maxJobs = max(0, (int) $this->option('max-jobs'));
$deadline = microtime(true) + max(1, (int) $this->option('max-seconds'));
while (($maxJobs === 0 || $processed < $maxJobs) && microtime(true) < $deadline) {
$job = DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('status', 'pending')
->orderBy('id')
->first();
if ($job === null) {
break;
}
$processed++;
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
->where('status', 'pending')
->update([
'status' => 'running',
'attempts' => DB::raw('attempts + 1'),
'updated_at' => now(),
]);
try {
[$succeeded, $output] = $this->runSync($job);
// 同步期间又保存了映射时,dispatchFor 已把状态改回 pending;
// 这里只在仍为 running 时写 done,不能吞掉新请求。
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
->where('status', 'running')
->update([
'status' => $succeeded ? 'done' : 'failed',
'message' => mb_substr(
$output !== '' ? $output : ($succeeded ? 'OK' : '同步命令执行失败'),
0,
1000
),
'updated_at' => now(),
]);
} catch (Throwable $exception) {
Log::error('科目映射表自动重跑失败:' . $exception->getMessage());
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
->where('status', 'running')
->update([
'status' => 'failed',
'message' => mb_substr($exception->getMessage(), 0, 1000),
'updated_at' => now(),
]);
}
}
} finally {
$lock->release();
}
return self::SUCCESS;
}
/** @return array{bool,string} */
protected function runSync(object $job): array
{
$process = new Process([
PHP_BINARY ?: 'php', 'artisan', 'store-receipt:order-match:sync',
(string) $job->month, '--platform=' . $job->platform,
], base_path());
$process->setTimeout(7200);
$process->run();
return [$process->isSuccessful(), trim($process->getOutput() . "\n" . $process->getErrorOutput())];
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerRptSynchronizer;
use Throwable;
class SyncFinanceLedgerRpt extends Command
{
protected $signature = 'finance-ledger:sync-rpt
{--from= : 业务日期起,默认读取配置}
{--to= : 业务日期止,默认读取配置或当天}
{--days= : 只拉取最近 N 天并合并到现有明细}
{--merge : 合并到现有明细,不删除历史}
{--no-recalculate : 只同步明细,不重算流水}';
protected $description = '从 AI 库 rpt 同步财务台账动账明细,并重算每日流水';
public function handle(FinanceLedgerRptSynchronizer $synchronizer): int
{
$days = (int) ($this->option('days') ?: 0);
$from = $this->option('from') ?: null;
$merge = (bool) $this->option('merge');
if ($days > 0) {
$from = now()->subDays(max(1, $days) - 1)->toDateString();
$merge = true;
}
try {
$result = $synchronizer->sync(
! $this->option('no-recalculate'),
$from,
$this->option('to') ?: null,
$merge,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['明细行数', $result['rows']],
['收入合计', number_format($result['income'], 2)],
['支出合计', number_format($result['expense'], 2)],
['日期范围', $result['from'] . ' ~ ' . $result['to']],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
]);
$this->line('来源分布:');
foreach (($result['source_counts'] ?? []) as $source => $count) {
$this->line(' ' . $source . ': ' . $count);
}
$this->info('财务台账 rpt 同步完成。');
return self::SUCCESS;
}
}

View File

@ -142,7 +142,12 @@ class SyncJstOrderLabelItemCodes extends Command
$params = array_merge($bindings, [$companyId], $orderNos, [$start, $end]);
return DB::connection()->update($sql, $params);
$updated = DB::connection()->update($sql, $params);
if ($updated > 0) {
app(\App\Business\PlantingOrderLabelLookup::class)->forget($companyId);
}
return $updated;
}
/** @return array{0:string,1:string} 起止日期(Y-m-d) */

View File

@ -6,12 +6,14 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
use Throwable;
class SyncJushuitanCostPriceHistory extends Command
{
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
{--full : 强制启动或继续首次全量同步}
{--source=open_api : open_api 为正式操作历史;snapshot 为独立的镜像价格观测}
{--page-size= : 聚水潭接口每页读取条数,最大 50}
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
@ -25,6 +27,25 @@ class SyncJushuitanCostPriceHistory extends Command
return self::SUCCESS;
}
if ($this->option('source') === 'snapshot') {
try {
$result = app(JushuitanCostPriceSnapshotDiffer::class)->sync(
$this->option('page-size') === null ? 500 : (int) $this->option('page-size'),
$this->option('max-pages') === null ? 20 : (int) $this->option('max-pages'),
);
$this->info(sprintf('镜像价格观测:%s,本轮读取 %d 条,发现 %d 次价格变化;不代表完整操作历史。', $result['status'], $result['rows'], $result['changes']));
return self::SUCCESS;
} catch (Throwable $exception) {
Log::error('镜像价格观测失败。', ['exception' => $exception::class]);
$this->error('镜像价格观测失败,已保留基线、记录和续传游标。');
return self::FAILURE;
}
}
if ($this->option('source') !== 'open_api') {
$this->error('source 仅支持 open_api 或 snapshot。');
return self::FAILURE;
}
try {
$state = $mirror->state();
$full = $this->shouldRunFull($state);

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use InvalidArgumentException;
use Throwable;
@ -19,7 +20,8 @@ class SyncStoreReceiptProductCost extends Command
protected $signature = 'store-receipt:product-cost:sync
{month : 账期月份,格式 YYYY-MM}
{--platform= : 只重建某个平台页签,留空表示全部}
{--chunk=1000 : 临时表批量写入行数}';
{--chunk=1000 : 临时表批量写入行数}
{--result-json= : 将结果统计和待核验清单写入指定 JSON 文件}';
protected $description = '展开业绩明细对应的订单商品行,计算商品成本(发货仓库 → 仓库简称 → 商品单价 → 商品成本)';
@ -34,6 +36,28 @@ class SyncStoreReceiptProductCost extends Command
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
private const VIRTUAL_SKU_PREFIX = 'X0000';
/** 套餐 / 服务及指定配件占位编码:不是实际成本商品,商品成本全局不取数。 */
private const EXCLUDED_SKUS = [
'TCXJ',
'FUWU',
'AZF',
'DF-PJZY0001',
'JIAXIANGJIANCAIJIAJUZIZHUANG964A',
];
/** 组合装条码前缀;条码结构为「前缀-组件条码-数量-组件条码-数量」。 */
private const COMBO_PREFIXES = ['ZH-', 'MKH-'];
/**
* 发货仓库黑名单:这些仓不是实际销售发货仓,整仓不参与商品成本;
* 例外:常州简尚家居有限公司里商品编码以 DF- 开头的行保留。
*/
private const EXCLUDED_WAREHOUSES = [
'常州简尚家居有限公司',
'简尚-苏州福利品仓',
'简尚-自动发货仓',
];
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
private const REMARK_NO_PRICE = '未匹配商品单价';
@ -54,6 +78,9 @@ class SyncStoreReceiptProductCost extends Command
private const PRICE_SOURCE_DING = '线上代发价格-代发单价';
/** 最后兜底:普通商品资料(operations_replica.rpt.jst_sku)里的成本价。 */
private const PRICE_SOURCE_CATALOG = '普通商品资料成本价';
/** 线上代发价格的采购单来源(与「线上代发价格」页面同一口径)。 */
private const PURCHASE_PRICE_TABLE = 'product_purchase_price_mirror_rows';
@ -67,11 +94,13 @@ class SyncStoreReceiptProductCost extends Command
*/
private const SOURCES = [
[
'key' => 'qmcust',
'orders' => 'ops_jst_qmcust_orders',
'items' => 'ops_jst_qmcust_order_items',
'warehouse' => 'o.wms_co_id',
],
[
'key' => 'jst',
'orders' => 'ops_jst_orders',
'items' => 'ops_jst_order_item',
'warehouse' => "COALESCE(NULLIF(i.send_warehouse, ''), o.wms_co_id)",
@ -112,18 +141,9 @@ class SyncStoreReceiptProductCost extends Command
$mirror = DB::connection($connection);
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
// 只取「收入金额有数据且不为 0」的账单行:收入为 0 的行是补出来的优惠/冲减/无收款账单行,
// 没有对应的收款商品,不参与商品成本。
$billQuery = $mirror->table(self::SOURCE_TABLE)
->where('report_month', $reportMonth)
->whereNotNull('income_amount')
->where('income_amount', '!=', 0);
if ($platform !== '') {
$billQuery->where('platform', $platform);
}
$bills = $billQuery->get([
'platform', 'store_name', 'order_core_no', 'online_order_no', 'occurred_date',
]);
// 收入为 0 的补行没有对应的收款商品,不参与商品成本。
// 种菜金额非空,或新标签为「种菜报销 / 种菜未报销」的账单行整条排除。
$bills = $this->billRows($mirror, $reportMonth, $platform);
$this->info(sprintf(
'账期 %s%s:业绩明细账单行 %d 条',
$month,
@ -166,20 +186,26 @@ class SyncStoreReceiptProductCost extends Command
}
$this->info('订单侧候选单号:' . count($candidates));
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string}>> $lines */
/** @var array<string,list<array{source:string,sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string,shop_i_id:string,src_combine_sku_id:string}>> $lines */
$lines = [];
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
foreach (self::SOURCES as $source) {
try {
$rows = $mirror->select(
"SELECT o.so_id as so_id, "
"SELECT '{$source['key']}' as source_key, "
. "o.so_id as so_id, "
. $source['warehouse'] . " as warehouse_code, "
. " COALESCE(wh.name, '') as warehouse_name, "
. " i.sku_id as sku_id, i.qty as qty, i.amount as amount
. " o.o_id as o_id, o.link_o_id as parent_o_id, "
. " CONCAT(o.o_id, '-', i.oi_id) as row_key, "
. " i.sku_id as sku_id, i.qty as qty, i.amount as amount, o.status as status, "
. " i.shop_i_id as shop_i_id, i.src_combine_sku_id as src_combine_sku_id
FROM tmp_cost_order_nos t
STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no
STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id
LEFT JOIN ops_jst_warehouse_info wh ON wh.wms_co_id = o.wms_co_id
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'"
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'
AND UPPER(TRIM(COALESCE(i.sku_id, ''))) NOT IN ({$excludedSkuSql})"
);
} catch (Throwable $exception) {
$this->error('订单明细查询失败(' . $source['items'] . '):' . $exception->getMessage());
@ -210,12 +236,32 @@ class SyncStoreReceiptProductCost extends Command
$warehouse = '';
}
}
// 发货仓库过滤(在商品编码全局排除之后执行):
// 默认排除常州简尚家居有限公司 / 简尚-苏州福利品仓 / 简尚-自动发货仓;
// 例外:常州简尚家居有限公司里「DF-」开头的商品行保留。
if (in_array($warehouse, self::EXCLUDED_WAREHOUSES, true)) {
$isDfException = $warehouse === '常州简尚家居有限公司'
&& str_starts_with(mb_strtoupper($sku), 'DF-');
if (! $isDfException) {
continue;
}
}
$lines[$core][] = [
'source' => (string) $row->source_key,
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
'sku' => mb_strtoupper($sku),
'qty' => $qty,
'amount' => $amount,
'warehouse' => $warehouse,
// 拆单层级:o_id 与上级 link_o_id,用于只保留末级发货行(叶子行)。
'o_id' => (string) $row->o_id,
'parent_o_id' => (string) ($row->parent_o_id ?? ''),
'status' => (string) ($row->status ?? ''),
'row_key' => (string) $row->row_key,
'shop_i_id' => trim((string) ($row->shop_i_id ?? '')),
'src_combine_sku_id' => mb_strtoupper(trim((string) ($row->src_combine_sku_id ?? ''))),
];
}
}
@ -225,11 +271,14 @@ class SyncStoreReceiptProductCost extends Command
$prices = $this->weightedAverageUnitPrices($mirror, $reportMonth);
// 月加权平均单价匹配不到时,用「线上代发价格」(采购单价 / 钉钉代发单价)兜底。
$fallbackPrices = $this->onlineDaifaUnitPrices($mirror);
// 代发价格也没有时,再用普通商品资料里的商品成本价兜底。
$catalogPrices = $this->catalogCostPrices($mirror);
$this->info(sprintf(
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d',
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d,商品资料成本价条目:%d',
count($shortNames['by_provider']),
count($prices),
count($fallbackPrices)
count($fallbackPrices),
count($catalogPrices)
));
$this->pruneTarget($connection, $reportMonth, $platform);
@ -243,13 +292,31 @@ class SyncStoreReceiptProductCost extends Command
$missingPriceRows = 0;
$missingWarehouseRows = 0;
$fallbackPriceRows = 0;
$catalogPriceRows = 0;
$mismatchRows = 0;
$matchedPriceRows = 0;
$placeholderExcludedRows = 0;
$bundleParentExcludedRows = 0;
$pendingCostRows = [];
foreach ($billByCore as $core => $bill) {
foreach ($this->costLines($lines[$core] ?? []) as $line) {
$short = $shortNames['by_warehouse'][$line['warehouse']]
?? $shortNames['by_provider'][$line['warehouse']]
?? '';
// 展示单号与业绩明细完全一致;$core 只用于订单侧清洗后匹配。
$displayOrderNo = (string) ($bill->online_order_no ?? '');
if ($displayOrderNo === '') {
$displayOrderNo = $core;
}
$excludedPlaceholders = 0;
$excludedBundleParents = 0;
$costLines = $this->costLines(
$lines[$core] ?? [],
$excludedPlaceholders,
$excludedBundleParents,
);
$placeholderExcludedRows += $excludedPlaceholders;
$bundleParentExcludedRows += $excludedBundleParents;
foreach ($costLines as $line) {
$short = $shortNames['by_provider'][$line['warehouse']] ?? '';
$price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null;
$priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED;
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
@ -261,6 +328,15 @@ class SyncStoreReceiptProductCost extends Command
$fallbackPriceRows++;
}
}
// 线上代发价格也没有时,最后用普通商品资料的商品成本价兜底。
if ($price === null) {
$catalogPrice = $catalogPrices[$line['sku']] ?? null;
if ($catalogPrice !== null) {
$price = $catalogPrice;
$priceSource = self::PRICE_SOURCE_CATALOG;
$catalogPriceRows++;
}
}
$quantity = round($line['qty'], 4);
$unitPrice = $price === null ? null : round((float) $price, 2);
$cost = $unitPrice === null ? null : round($quantity * $unitPrice, 2);
@ -290,8 +366,22 @@ class SyncStoreReceiptProductCost extends Command
if (! $unitMismatch) {
$remarks[] = self::REMARK_NO_PRICE;
$missingPriceRows++;
$pendingCostRows[] = [
'platform' => (string) $bill->platform,
'store_name' => (string) $bill->store_name,
'online_order_no' => $displayOrderNo,
'warehouse' => $line['warehouse'] !== '' ? $line['warehouse'] : null,
'warehouse_short_name' => $short !== '' ? $short : null,
'product_code' => $line['sku'],
'product_quantity' => $quantity,
'reason' => $short === ''
? '未匹配仓库简称'
: '仓库简称 + 商品编码在月加权、线上代发、普通商品资料中均无有效单价',
'code_type' => $this->pendingCodeType((string) $line['sku']),
];
}
} else {
$matchedPriceRows++;
$quantityTotal += $quantity;
$costTotal += (float) $cost;
}
@ -302,7 +392,7 @@ class SyncStoreReceiptProductCost extends Command
'platform' => $bill->platform,
'occurred_date' => $bill->occurred_date,
'store_name' => $bill->store_name,
'online_order_no' => $core,
'online_order_no' => $displayOrderNo,
'warehouse' => $line['warehouse'] !== '' ? $line['warehouse'] : null,
'warehouse_short_name' => $short !== '' ? $short : null,
'product_code' => $line['sku'],
@ -331,52 +421,240 @@ class SyncStoreReceiptProductCost extends Command
$this->info(sprintf(' 商品数量合计:%s', number_format($quantityTotal, 2)));
$this->info(sprintf(' 商品成本合计:%s', number_format($costTotal, 2)));
$this->info(sprintf(
' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行',
' 未匹配仓库简称:%d 行;线上代发价格兜底:%d 行;商品资料成本价兜底:%d 行;仍未匹配商品单价:%d 行;数量与单价口径不一致(不计成本):%d 行',
$missingWarehouseRows,
$fallbackPriceRows,
$catalogPriceRows,
$missingPriceRows,
$mismatchRows
));
$pendingCostQuantity = array_sum(array_column($pendingCostRows, 'product_quantity'));
$this->info(sprintf(
' 正常匹配单价:%d 行;排除平台占位行:%d 行;排除组合装主行:%d 行;待核验成本:%d 行 / %s 件',
$matchedPriceRows,
$placeholderExcludedRows,
$bundleParentExcludedRows,
$missingPriceRows,
number_format((float) $pendingCostQuantity, 2),
));
$result = [
'status' => 'success',
'report_month' => $reportMonth,
'platform' => $platform !== '' ? $platform : '全部',
'source_bill_rows' => $bills->count(),
'candidate_orders' => count($candidates),
'matched_orders' => count($lines),
'written_rows' => $imported,
'normal_matched_rows' => $matchedPriceRows,
'placeholder_excluded_rows' => $placeholderExcludedRows,
'bundle_parent_excluded_rows' => $bundleParentExcludedRows,
'excluded_rows' => $placeholderExcludedRows + $bundleParentExcludedRows,
'pending_cost_rows' => $missingPriceRows,
'pending_cost_quantity' => round((float) $pendingCostQuantity, 4),
'unit_mismatch_rows' => $mismatchRows,
'missing_warehouse_rows' => $missingWarehouseRows,
'fallback_purchase_price_rows' => $fallbackPriceRows,
'fallback_catalog_price_rows' => $catalogPriceRows,
'matched_quantity' => round($quantityTotal, 4),
'matched_cost' => round($costTotal, 2),
'pending_cost' => $pendingCostRows,
'generated_at' => now()->toDateTimeString(),
];
$resultPath = trim((string) $this->option('result-json'));
if ($resultPath !== '') {
File::ensureDirectoryExists(dirname($resultPath));
File::put(
$resultPath,
json_encode(
$result,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
return self::SUCCESS;
}
/**
* 拆单去重:聚水潭的拆单子单会把主单的商品行再列一遍,同一批商品只算一次;
* 同一商品在同一订单里既有真实发货仓库行、又有无仓库的占位行时,丢掉占位行,
* 这样「同一单分多仓发货」会按子单的实际仓库分行。
* @return \Illuminate\Support\Collection<int,object>
*/
private function billRows($mirror, string $reportMonth, string $platform)
{
$query = $mirror->table(self::SOURCE_TABLE)
->where('report_month', $reportMonth)
->whereNotNull('income_amount')
->where('income_amount', '!=', 0)
// planting_amount 是旧字段兼容:任何非空值都表示该订单命中了种菜单。
->whereNull('planting_amount')
// 新版业绩明细把两类种菜状态合并为标签;任一种菜标签都不参与商品成本。
->whereNull('planting_label');
if ($platform !== '') {
$query->where('platform', $platform);
}
return $query->get([
'platform', 'store_name', 'order_core_no', 'online_order_no', 'occurred_date',
]);
}
private function pendingCodeType(string $code): string
{
$code = mb_strtoupper(trim($code));
if (preg_match('/^\d+$/', $code) === 1) {
return '平台数字编码未映射内部商品';
}
if (str_starts_with($code, '运营组合套装')) {
return '运营组合装未找到可计价组件';
}
if (str_starts_with($code, 'MKH-') || str_starts_with($code, 'ZH-')) {
return '组合装编码不完整或无法拆分';
}
if (str_starts_with($code, 'DF-')) {
return '配件编码缺少成本价';
}
if (str_contains($code, '+')) {
return '多个商品编码拼接未拆分';
}
return '内部商品编码缺少有效价格';
}
/**
* 拆单取数:聚水潭拆单后,主单/中间汇总单(被其他行 link 到的 o_id)只是汇总记录,
* 最末级订单行(没有任何下级)才是实际发货行。
*
* @param list<array{sku:string,qty:float,amount:float,warehouse:string}> $rows
* 取数规则:
* 1) 按「o_id + oi_id + 商品编码」去掉完全重复行;
* 2) 只保留叶子行——凡是被同一订单其他行 link_o_id 引用过的 o_id 全部剔除,
* 避免主单、中间汇总层与末级发货行重复计数;
* 3) 订单内只要存在带仓库的行,就丢弃空仓库的汇总/占位行;
* 如果带仓库的行全部是 Cancelled,则整单不参与商品成本;
* 4) 排除 Cancelled 行,同一订单的多条末级行按「发货仓库 + 商品编码」汇总数量。
*
* @param list<array{source:string,sku:string,qty:float,amount:float,warehouse:string,o_id:string,parent_o_id:string,row_key:string,status:string,shop_i_id:string,src_combine_sku_id:string}> $rows
* @return list<array{sku:string,qty:float,amount:float,warehouse:string}>
*/
private function costLines(array $rows): array
private function costLines(
array $rows,
?int &$placeholderExcludedRows = null,
?int &$bundleParentExcludedRows = null,
): array
{
$placeholderExcludedRows = 0;
$bundleParentExcludedRows = 0;
if ($rows === []) {
return [];
}
// 1) 完全相同的行(商品 + 数量 + 金额 + 仓库)只保留一条。
// 1) 先按订单明细主键去掉上游的完全重复行(同一 o_id + oi_id 只算一次)。
$unique = [];
foreach ($rows as $row) {
$key = $row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount'] . "\0" . $row['warehouse'];
$key = $row['source'] . "\0" . $row['row_key'] . "\0" . $row['sku'];
$unique[$key] = $row;
}
// 2) 有真实仓库的同一(商品 + 数量 + 金额),优先于无仓库的占位行。
$withWarehouse = [];
// 2) 组合装拆分后的组件会记录 src_combine_sku_id。源订单上的组合装主行
// 只是汇总占位,不能与组件同时计入成本。
$bundleParents = [];
foreach ($unique as $row) {
if ($row['warehouse'] !== '') {
$withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']] = true;
$parentSku = mb_strtoupper(trim((string) ($row['src_combine_sku_id'] ?? '')));
if ($parentSku !== '') {
$bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $parentSku] = true;
}
}
foreach ($unique as $key => $row) {
if ($row['warehouse'] === ''
&& isset($withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']])) {
unset($unique[$key]);
if ($bundleParents !== []) {
foreach ($unique as $key => $row) {
if (isset($bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $row['sku']])) {
$bundleParentExcludedRows++;
unset($unique[$key]);
}
}
}
// 3) 汇总到「发货仓库 + 商品编码」。
// 3) 平台原始商品编码通常是一长串数字。同一订单、同一 shop_i_id
// 如果已经有内部商品编码,则数字行只是平台占位,不参与成本。
$internalSiblings = [];
foreach ($unique as $row) {
$shopItemId = trim((string) ($row['shop_i_id'] ?? ''));
if ($shopItemId !== '' && preg_match('/^\d+$/', (string) $row['sku']) !== 1) {
$internalSiblings[$row['source'] . "\0" . $row['o_id'] . "\0" . $shopItemId] = true;
}
}
if ($internalSiblings !== []) {
foreach ($unique as $key => $row) {
$shopItemId = trim((string) ($row['shop_i_id'] ?? ''));
if (
$shopItemId !== ''
&& preg_match('/^\d+$/', (string) $row['sku']) === 1
&& isset($internalSiblings[$row['source'] . "\0" . $row['o_id'] . "\0" . $shopItemId])
) {
$placeholderExcludedRows++;
unset($unique[$key]);
}
}
}
// 4) 组合装条码(ZH-/MKH-条码-数量-条码-数量)按子订单拆成组件行:
// 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。
// 解析不了的组合条码(条码被截断等)保持原样,避免丢行。
$expanded = [];
foreach ($unique as $row) {
foreach (self::expandComboSku((string) $row['sku'], (float) $row['qty']) as [$code, $codeQty, $isComponent]) {
$expanded[] = array_merge($row, [
'sku' => mb_strtoupper($code),
'qty' => $codeQty,
// 组合装明细金额对应整包,拆到组件后不再参与数量×单价校验。
'amount' => $isComponent ? 0.0 : (float) $row['amount'],
]);
}
}
$unique = $expanded;
if ($unique === []) {
return [];
}
// 5) 只保留叶子行:o_id 没有被同组其他行的 link_o_id 引用的,才是实际发货行。
$parentIds = [];
foreach ($unique as $row) {
$parent = trim((string) ($row['parent_o_id'] ?? ''));
if ($parent !== '' && $parent !== '0') {
$parentIds[$parent] = true;
}
}
if ($parentIds !== []) {
$leaves = array_filter(
$unique,
static fn (array $row): bool => ! isset($parentIds[trim((string) $row['o_id'])])
);
// 兜底:理论上不会出现全部被过滤(除非数据自相矛盾),真有则保留原集合避免整单丢失。
if ($leaves !== []) {
$unique = $leaves;
}
}
// 6) 组内只要出现带仓库的行,空仓库行就是汇总/占位行,直接丢弃。
$hasWarehouse = false;
foreach ($unique as $row) {
if (trim((string) $row['warehouse']) !== '') {
$hasWarehouse = true;
break;
}
}
if ($hasWarehouse) {
$unique = array_filter(
$unique,
static fn (array $row): bool => trim((string) $row['warehouse']) !== ''
);
}
// 7) 排除已取消行;若带仓库行全部取消,上面已把空仓库汇总行丢掉,这里结果为空 → 整单不计。
$unique = array_filter(
$unique,
static fn (array $row): bool => strtoupper(trim((string) ($row['status'] ?? ''))) !== 'CANCELLED'
);
// 8) 汇总到「发货仓库 + 商品编码」。
$aggregated = [];
foreach ($unique as $row) {
$key = $row['warehouse'] . "\0" . $row['sku'];
@ -391,6 +669,62 @@ class SyncStoreReceiptProductCost extends Command
return array_values($aggregated);
}
/**
* 组合装条码拆组件:聚水潭的组合装 sku 形如
* ZH-<条码>-<数量>[-<条码>-<数量>...]
* MKH-<条码>-<数量>[-<条码>-<数量>...]
* 条码本身含横杠,所以按「纯数字段」识别数量,再按组件展开。
*
* 返回 [条码, 数量, 是否组件];非组合装或解析失败时返回原条码 / 原数量。
*
* @return list<array{0:string,1:float,2:bool}>
*/
private static function expandComboSku(string $sku, float $quantity): array
{
$prefix = null;
foreach (self::COMBO_PREFIXES as $candidate) {
if (str_starts_with(mb_strtoupper($sku), $candidate)) {
$prefix = $candidate;
break;
}
}
if ($prefix === null) {
return [[$sku, $quantity, false]];
}
$parts = explode('-', substr($sku, strlen($prefix)));
$components = [];
$buffer = [];
foreach ($parts as $part) {
if ($part === '') {
return [[$sku, $quantity, false]];
}
if (preg_match('/^\d+(\.\d+)?$/', $part) === 1) {
if ($buffer === []) {
return [[$sku, $quantity, false]];
}
$components[] = [implode('-', $buffer), (float) $part];
$buffer = [];
continue;
}
$buffer[] = $part;
}
if ($buffer !== [] || $components === []) {
return [[$sku, $quantity, false]];
}
$expanded = [];
foreach ($components as [$code, $multiplier]) {
// 组合装里如果拆出套餐/服务占位编码,同样不取数。
if (in_array(mb_strtoupper(trim($code)), self::EXCLUDED_SKUS, true)) {
continue;
}
$expanded[] = [$code, $quantity * $multiplier, true];
}
return $expanded === [] ? [] : $expanded;
}
/**
* 仓库映射表:发货仓库 → 仓库简称。
*
@ -402,7 +736,6 @@ class SyncStoreReceiptProductCost extends Command
private function warehouseShortNames(): array
{
$byProvider = [];
$byWarehouse = [];
foreach (DB::table(self::WAREHOUSE_MAPPING_TABLE)
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) as $mapping) {
$provider = trim((string) $mapping->warehouse_provider);
@ -411,19 +744,30 @@ class SyncStoreReceiptProductCost extends Command
if ($short === '') {
continue;
}
if ($warehouse !== '' && ! isset($byWarehouse[$warehouse])) {
$byWarehouse[$warehouse] = $short;
}
if ($provider === '') {
continue;
}
// 只认「仓储方 + 仓库含主仓」的那条:发货仓库是仓储方级别的名称,
// 次品仓 / 销退仓 / 客退仓这些行的简称(多为「-不看」)不能套到主仓发货上。
$isMain = str_contains($warehouse, '主仓');
if ($isMain || ! isset($byProvider[$provider])) {
if ($isMain) {
$byProvider[$provider] = $short;
continue;
}
// 映射表没有主仓行时,仅接受「仓库名 = 仓储方名」这种同名仓库(如重庆渝北仓),
// 其余(次品仓、配件单仓等)不再兜底,避免把主仓订单误判成不看仓。
if ($warehouse === $provider && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
continue;
}
// 业务确认:常州简尚家居有限公司的发货仓对应映射表里的「配件单仓」行
// (仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 配件单仓-不看)。
if (str_contains($warehouse, '配件单仓') && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
}
}
return ['by_provider' => $byProvider, 'by_warehouse' => $byWarehouse];
return ['by_provider' => $byProvider];
}
/**
@ -532,6 +876,32 @@ class SyncStoreReceiptProductCost extends Command
return $prices;
}
/**
* 普通商品资料(operations_replica.rpt.jst_sku → ops_jst_sku)里的商品成本价:
* 只取普通商品(sku_type=normal)且成本价大于 0 的记录,按商品编码建索引。
*
* @return array<string,float>
*/
private function catalogCostPrices($mirror): array
{
$prices = [];
try {
foreach ($mirror->table('ops_jst_sku')
->where('sku_type', 'normal')
->where('cost_price', '>', 0)
->get(['sku_id', 'cost_price']) as $row) {
$sku = mb_strtoupper(trim((string) $row->sku_id));
if ($sku !== '') {
$prices[$sku] = round((float) $row->cost_price, 2);
}
}
} catch (Throwable $exception) {
$this->warn('普通商品资料成本价读取失败(跳过该兜底来源):' . $exception->getMessage());
}
return $prices;
}
private function pruneTarget(string $connection, string $reportMonth, string $platform): void
{
$query = DB::connection($connection)->table(self::TARGET_TABLE)->where('report_month', $reportMonth);

View File

@ -38,7 +38,7 @@ class SyncStoreReportMirror extends Command
}
$this->info(sprintf(
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行。',
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行,销量汇总 %s/%d 行。',
$result['lifecycles']['status'],
$result['lifecycles']['source_rows'],
$result['period_changes']['status'],
@ -48,6 +48,8 @@ class SyncStoreReportMirror extends Command
$result['bills']['source_rows'],
$result['daily']['status'],
$result['daily']['source_rows'],
$result['paid_sales']['status'],
$result['paid_sales']['stored_rows'],
));
return self::SUCCESS;

View File

@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
use Throwable;
/**
* 店铺维度报表的「销量」汇总:业绩明细「收入金额 + 优惠金额 + 冲减金额 + 退款金额」
* 按「店铺名称 + 发生日期」预汇总。
*
* 业绩明细同步(store-receipt:order-match:sync)结束后会自动刷新一次;
* 这个命令用于单独重跑(例如补数据或按最新代码强制重建)。
*/
class SyncStoreReportPaidSales extends Command
{
protected $signature = 'business-mirror:sync-store-report-paid-sales
{--force : 忽略来源指纹,强制重建销量汇总}';
protected $description = '把业绩明细的收入/优惠/冲减/退款金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
{
try {
$result = $synchronizer->syncPaidSales((bool) $this->option('force'));
} catch (Throwable $exception) {
$this->error('店铺维度报表销量汇总失败:' . $exception->getMessage());
return self::FAILURE;
}
if ($result['status'] === 'skipped') {
$this->warn('镜像里还没有业绩明细表,销量汇总未执行。');
return self::SUCCESS;
}
if ($result['status'] !== 'succeeded') {
$this->error('店铺维度报表销量汇总失败,页面继续读取上一版数据。');
return self::FAILURE;
}
$this->info($result['changed']
? sprintf('销量汇总已重建:业绩明细 %d 行 → 汇总 %d 行。', $result['source_rows'], $result['stored_rows'])
: sprintf('业绩明细没有变化,沿用当前销量汇总(%d 行)。', $result['stored_rows']));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,172 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class VerifyErpDailyBillCoverage extends Command
{
protected $signature = 'business-mirror:verify-erp-daily-coverage
{--from=2026-07-01 : 起始日期 YYYY-MM-DD}
{--to= : 截止日期 YYYY-MM-DD,默认昨日}
{--json : 输出 JSON}';
protected $description = '核对京东日账单、京东钱包、头条放心购的月份与截止日覆盖是否完整';
private const TARGETS = [
'jd-daily' => [
'label' => '京东日账单',
'periods' => 'jd_daily_bill_periods',
'rows' => 'jd_daily_bill_rows',
],
'jd-wallet' => [
'label' => '京东钱包',
'periods' => 'jd_wallet_bill_periods',
'rows' => 'jd_wallet_bill_rows',
],
'douyin' => [
'label' => '头条放心购',
'periods' => 'douyin_authoritative_periods',
'rows' => 'douyin_authoritative_bills',
],
];
public function handle(): int
{
$fromText = (string) $this->option('from');
$toText = (string) ($this->option('to') ?: now('Asia/Shanghai')->subDay()->toDateString());
$from = $this->date($fromText);
$to = $this->date($toText);
if ($from === null || $to === null || $from->gt($to)) {
$this->error('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。');
return self::FAILURE;
}
$db = DB::connection(config('upstream_mirrors.connection', 'business_mirror'));
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
$results = [];
$failed = false;
foreach (self::TARGETS as $key => $target) {
if (! $schema->hasTable($target['periods']) || ! $schema->hasTable($target['rows'])) {
$results[] = [
'dataset' => $key,
'label' => $target['label'],
'month' => null,
'required_start' => $from->toDateString(),
'required_through' => $to->toDateString(),
'imported_start' => null,
'imported_end' => null,
'row_count' => 0,
'registered_rows' => null,
'income' => null,
'expense' => null,
'status' => 'missing_table',
'issue' => '镜像表不存在',
];
$failed = true;
continue;
}
$month = $from->copy()->startOfMonth();
while ($month->lte($to)) {
$monthEnd = $month->copy()->endOfMonth();
$requiredStart = ($month->lt($from) ? $from->copy() : $month->copy())->startOfDay();
$requiredThrough = ($monthEnd->gt($to) ? $to->copy() : $monthEnd->copy())->startOfDay();
$requiredEndExclusive = $requiredThrough->copy()->addDay();
$period = $db->table($target['periods'])
->where('period_start', $month->toDateString())
->first();
$status = 'ok';
$rowCount = 0;
$issue = null;
$summary = null;
if ($period === null) {
$status = 'missing_month';
$issue = '缺少该月份导入记录';
} elseif (Carbon::parse((string) $period->period_end)->lt($requiredEndExclusive)) {
$status = 'partial';
$issue = '导入截止日早于要求日期';
} else {
$summary = $db->table($target['rows'])
->where('generation', $period->active_generation)
->selectRaw('COUNT(*) AS rows_count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')
->first();
$rowCount = (int) ($summary->rows_count ?? 0);
if ($rowCount === 0) {
$status = 'empty';
$issue = '有效版本没有明细';
} elseif ($rowCount !== (int) $period->row_count) {
$status = 'row_count_mismatch';
$issue = '有效版本实际行数与期间登记不一致';
} elseif (abs(round((float) $summary->income, 2) - (float) $period->income) > 0.005
|| abs(round((float) $summary->expense, 2) - (float) $period->expense) > 0.005) {
$status = 'amount_mismatch';
$issue = '有效版本收支合计与期间登记不一致';
}
}
if ($status !== 'ok') {
$failed = true;
}
$results[] = [
'dataset' => $key,
'label' => $target['label'],
'month' => $month->format('Y-m'),
'required_start' => $requiredStart->toDateString(),
'required_through' => $requiredThrough->toDateString(),
'imported_start' => $period?->period_start,
'imported_end' => $period?->period_end,
'row_count' => $rowCount,
'registered_rows' => $period === null ? null : (int) $period->row_count,
'income' => isset($summary) ? round((float) $summary->income, 2) : null,
'expense' => isset($summary) ? round((float) $summary->expense, 2) : null,
'status' => $status,
'issue' => $issue,
];
unset($summary);
$month->addMonthNoOverflow()->startOfMonth();
}
}
if ($this->option('json')) {
$this->line(json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
} else {
$this->table(
['数据集', '月份', '要求覆盖', '导入覆盖', '实际行数', '登记行数', '收入', '支出', '状态', '说明'],
array_map(static fn (array $row): array => [
$row['label'],
$row['month'] ?? '—',
($row['required_start'] ?? '—') . ' ~ ' . ($row['required_through'] ?? '—'),
($row['imported_start'] ?? '—') . ' ~ ' . ($row['imported_end'] ?? '—'),
number_format((int) $row['row_count']),
$row['registered_rows'] === null ? '—' : number_format((int) $row['registered_rows']),
$row['income'] === null ? '—' : number_format((float) $row['income'], 2),
$row['expense'] === null ? '—' : number_format((float) $row['expense'], 2),
$row['status'],
$row['issue'] ?? '',
], $results),
);
}
return $failed ? self::FAILURE : self::SUCCESS;
}
private function date(string $value): ?Carbon
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
return null;
}
$date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai');
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class VerifyFinanceLedger extends Command
{
protected $signature = 'finance-ledger:verify';
protected $description = '校验财务台账数量、金额勾稽和流水平衡(只读)';
public function handle(): int
{
$db = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$accounts = (int) $db->table('fin_accounts')->count();
$shops = (int) $db->table('fin_shops')->count();
$companies = (int) $db->table('fin_companies')->count();
$openingRows = (int) $db->table('fin_account_opening')->count();
$transactions = (int) $db->table('fin_transactions')->count();
$flows = (int) $db->table('fin_daily_flows')->count();
$maps = (int) $db->table('fin_account_map')->count();
$mapped = (int) $db->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count();
$accountBalance = round((float) $db->table('fin_accounts')->sum('current_balance'), 2);
$openingSum = round((float) $db->table('fin_account_opening')->sum('opening_balance'), 2);
$income = round((float) $db->table('fin_transactions')->sum('income'), 2);
$expense = round((float) $db->table('fin_transactions')->sum('expense'), 2);
$flowOpening = round((float) $db->table('fin_daily_flows')->sum('opening_balance'), 2);
$flowInflow = round((float) $db->table('fin_daily_flows')->sum('inflow'), 2);
$flowOutflow = round((float) $db->table('fin_daily_flows')->sum('outflow'), 2);
$flowClosing = round((float) $db->table('fin_daily_flows')->sum('closing_balance'), 2);
$diff = round($flowOpening + $flowInflow - $flowOutflow - $flowClosing, 2);
$this->table(['项目', '值'], [
['账户', $accounts],
['店铺', $shops],
['公司', $companies],
['期初账户', $openingRows],
['期初合计', number_format($openingSum, 2)],
['账户余额合计', number_format($accountBalance, 2)],
['动账明细', $transactions],
['收入合计', number_format($income, 2)],
['支出合计', number_format($expense, 2)],
['每日流水', $flows],
['流水期末合计', number_format($flowClosing, 2)],
['流水勾稽差异', number_format($diff, 2)],
['映射记录', $maps],
['已映射', $mapped],
]);
$errors = [];
if (abs($openingSum - $accountBalance) > 0.01) {
$errors[] = '期初合计与账户余额合计不一致。';
}
if (abs($diff) > 0.01) {
$errors[] = '每日流水期末不等于期初+入账-出账。';
}
if ($openingRows !== $flows) {
$errors[] = '期初账户数与当前流水行数不一致(期初日应一账户一行)。';
}
foreach ($errors as $error) {
$this->error($error);
}
return $errors === [] ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -9,14 +9,14 @@ use Throwable;
class WarmOrderDataShopCandidates extends Command
{
protected $signature = 'order-data:warm-shop-candidates';
protected $signature = 'order-data:warm-shop-candidates {--full : 全量核对店铺候选(夜间维护)}';
protected $description = '预热订单售后费用明细的店铺候选(要扫两张百万级订单宽表,不能放在页面请求里)';
protected $description = '增量预热订单售后费用明细的店铺候选,夜间使用 --full 全量核对';
public function handle(OrderDataMirrorRepository $orders): int
{
try {
$names = $orders->refreshShopNamesCache();
$names = $orders->refreshShopNamesCache((bool) $this->option('full'));
$this->info(sprintf('店铺候选已预热,共 %d 个店铺。', count($names)));
return self::SUCCESS;

View File

@ -76,6 +76,14 @@ class Kernel extends ConsoleKernel
->withoutOverlapping(30)
->runInBackground();
// 财务台账:从 AI 库 rpt 同步动账明细,按日重算账户流水。
// 生产宿主机 cron 仍需配置对应的 finance-ledger:sync-rpt 入口。
$schedule->command('finance-ledger:sync-rpt --days=7 --merge')
->dailyAt('02:30')
->timezone('Asia/Shanghai')
->withoutOverlapping(120)
->runInBackground();
$schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0')
->dailyAt('00:00')
->withoutOverlapping(180)
@ -88,12 +96,13 @@ class Kernel extends ConsoleKernel
->runInBackground();
}
// 聚水潭发货数据每日重拉最近三天,覆盖副本库迟到更新。
// 聚水潭发货数据:凌晨重拉最近 40 天。聚水潭常在发货后数天到数周内换单/改快递公司/作废采购退货出库,
// 只拉最近几天会让上月数据停在旧值(快递名称不一致、已作废的单仍在);40 天覆盖上月结算期。
// 默认关闭(JST_SYNC_ENABLED=false);填好凭证并完成迁移后再打开。
if ((bool) env('JST_SYNC_ENABLED', false)) {
$schedule->command('jushuitan:sync-shipping --days=3')
$schedule->command('jushuitan:sync-shipping --days=40')
->dailyAt('01:28')
->withoutOverlapping(120)
->withoutOverlapping(300)
->runInBackground();
// 凌晨副本库最近一两天的订单/商品明细常未到齐(视图、订单商品表滞后),
// 早上再拉一次最近 3 天,补齐凌晨只写入单号、缺商品明细的包裹。

View File

@ -157,7 +157,10 @@ class DingTalkLogin extends Controller
$url = $this->applicationRoute($user->landing_page, ['company_id' => $company->id]);
return redirect()->intended($url);
// 免登后固定回到首页(财务驾驶舱),不再回跳上次打开的业务页面。
$request->session()->forget('url.intended');
return redirect()->to($url);
}
protected function failed(): RedirectResponse

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Abstracts\Http\Controller;
use App\Http\Requests\Auth\Login as Request;
use App\Models\Common\Contact;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@ -79,10 +80,14 @@ class Login extends Controller
// Redirect to portal if is customer
if ($user->isCustomer()) {
$path = session('url.intended', '');
$portalHome = $user->contact instanceof Contact
&& $user->contact->type === Contact::VENDOR_TYPE
? route('portal.supplier-reconciliation.index', ['company_id' => $company->id])
: route('portal.dashboard', ['company_id' => $company->id]);
// Path must start with company id and 'portal' prefix
if (!Str::startsWith($path, $company->id . '/portal')) {
$path = route('portal.dashboard', ['company_id' => $company->id]);
$path = $portalHome;
}
if (! $request->expectsJson()) {
@ -102,8 +107,11 @@ class Login extends Controller
// Redirect to landing page if is user
$url = route($user->landing_page, ['company_id' => $company->id]);
// 登录后固定回到首页(财务驾驶舱),不再回跳上次打开/被拦截的业务页面。
$request->session()->forget('url.intended');
if (! $request->expectsJson()) {
return redirect()->intended($url);
return redirect()->to($url);
}
return response()->json([
@ -112,7 +120,7 @@ class Login extends Controller
'error' => false,
'message' => trans('auth.login_redirect'),
'data' => null,
'redirect' => redirect()->intended($url)->getTargetUrl(),
'redirect' => url($url),
]);
}

View File

@ -35,7 +35,7 @@ class AccessoryReshipDeductions extends Controller
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, ['交易单号','补发快递单号','补发快递','补发时间','原始条形码','处理后条形码','补发运费','匹配状态','首发快递公司(候选)','首发快递单号(候选)','首发所属月份(候选)','扣款比例(%)','扣款金额','说明']);
foreach ($data->rows($filters) as $row) {
if ($filters['match_status'] !== 'all' && $row['status'] !== $filters['match_status']) continue;
if (!AccessoryReshipPageData::inCategory($row, $filters['match_status'])) continue;
$values = [$row['trade_no'], $row['express_no'], ($row['express_name1'] ?: ($row['express_name2'] ?? '')), $row['ship_time'], $row['barcode'], $row['normalized_barcode'], $row['freight'], $row['deduction_state'] === 'ready' ? '可扣款' : ($row['status'] === 'matched' ? '已匹配待核对' : '未匹配不扣款'), implode(' / ', array_column($row['matches'], 'courier')), implode(' / ', array_column($row['matches'], 'express_no')), implode(' / ', array_column($row['matches'], 'month')), $row['deduction_rate_basis_points'] === null ? '' : number_format($row['deduction_rate_basis_points'] / 100, 2, '.', '') . '%', $row['deduction_cents'] === null ? '' : number_format($row['deduction_cents'] / 100, 2, '.', ''), $row['deduction_note']];
fputcsv($file, array_map(static function ($value) {
$value = (string) $value;

View File

@ -12,6 +12,7 @@ use Modules\EcommerceFinance\Services\AccountMappingCoa;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Modules\EcommerceFinance\Services\AccountMappingWorkbookImporter;
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -42,6 +43,7 @@ class AccountMappingActions extends Controller
'memo' => '财务备注',
'invoice' => '票据口径',
'category' => '大类',
'feeMemo' => '费用备注',
'dAcct' => '借方科目',
'dSub' => '借方二级',
'cAcct' => '贷方科目',
@ -74,27 +76,37 @@ class AccountMappingActions extends Controller
$this->store->create($values, $this->userId());
return response()->json(['message' => '已新增 1 条规则。'] + $this->payload());
$hint = $this->dispatchResync([(string) ($values['platform'] ?? '')]);
return response()->json(['message' => '已新增 1 条规则。' . $hint] + $this->payload());
}
public function update(Request $request, int $id): JsonResponse
{
if ($this->store->find($id) === null) {
$before = $this->store->find($id);
if ($before === null) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
$this->store->update($id, $this->payloadValues($request), $this->userId());
$values = $this->payloadValues($request);
$after = $this->store->update($id, $values, $this->userId());
return response()->json(['message' => '已保存。'] + $this->payload());
$hint = AccountMappingStore::requiresReceiptResync($before, $after)
? $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($after['platform'] ?? '')]) : '';
return response()->json(['message' => '已保存。' . $hint] + $this->payload());
}
public function destroy(int $id): JsonResponse
{
$before = $this->store->find($id);
if (! $this->store->destroy($id, $this->userId())) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
return response()->json(['message' => '已删除 1 条规则。'] + $this->payload());
$hint = $this->dispatchResync([(string) ($before['platform'] ?? '')]);
return response()->json(['message' => '已删除 1 条规则。' . $hint] + $this->payload());
}
/**
@ -181,21 +193,24 @@ class AccountMappingActions extends Controller
}
$restored = $this->store->create($before, $this->userId());
$hint = $this->dispatchResync([(string) ($restored['platform'] ?? '')]);
return response()->json([
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。',
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。' . $hint,
] + $this->payload());
}
if ($action === 'update') {
if ($this->store->find($ruleId) === null) {
$current = $this->store->find($ruleId);
if ($current === null) {
throw ValidationException::withMessages(['history' => '这条规则已经被删掉了,请用同一条的「删除」记录恢复。']);
}
$this->store->update($ruleId, $before, $this->userId());
$hint = $this->dispatchResync([(string) ($current['platform'] ?? ''), (string) ($before['platform'] ?? '')]);
return response()->json([
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。',
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。' . $hint,
] + $this->payload());
}
@ -227,6 +242,8 @@ class AccountMappingActions extends Controller
$parsed = $this->importer->parse($file);
$result = $this->store->importRows($parsed['rows'], $request->boolean('overwrite'), $this->userId());
$message = "导入完成:识别 {$result['total']} 行,新增 {$result['created']} 条,更新 {$result['updated']} 条,跳过重复 {$result['skipped']} 条。";
$platforms = $result['resync_platforms'];
$message .= $this->dispatchResync($platforms);
return response()->json(['message' => $message, 'result' => $result] + $this->payload());
}
@ -302,7 +319,7 @@ class AccountMappingActions extends Controller
{
$total = $this->store->reset($this->userId());
return response()->json(['message' => "已恢复为初始 {$total} 条规则。"] + $this->payload());
return response()->json(['message' => "已恢复为初始 {$total} 条规则。" . $this->dispatchResyncAll()] + $this->payload());
}
/** 账套科目(一级 + 各一级下的明细),供下拉使用 */
@ -353,8 +370,8 @@ class AccountMappingActions extends Controller
public function template(): StreamedResponse
{
$samples = [
['得物', '平台服务费', '平台服务费', '0.06', '费用', '销售费用', '平台佣金', '其他货币资金', '', '支出基准'],
['淘宝', '交易收入', '订单', '-', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
['得物', '平台服务费', '平台服务费', '-', '0.06', '费用', '平台服务费', '销售费用', '平台服务费', '其他货币资金', '', '支出基准'],
['淘宝', '交易收入', '订单', '-', '-', '收入', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
];
return response()->streamDownload(function () use ($samples): void {
@ -406,6 +423,44 @@ class AccountMappingActions extends Controller
return user_id() ? (int) user_id() : null;
}
/**
* 保存映射后把受影响的平台排进后台重跑队列。
*
* 映射表本身是实时读取的,但业绩明细的优惠/冲减/退款金额是同步时落库的,
* 所以保存成功后需要自动重跑对应平台和月份。队列创建失败不能把已经保存成功的规则回滚。
*
* @param list<string> $platforms
*/
private function dispatchResync(array $platforms): string
{
try {
$result = app(AccountMappingResyncDispatcher::class)->dispatchFor($platforms);
} catch (\Throwable $exception) {
report($exception);
return ' 业绩明细自动重跑任务创建失败,请稍后重试保存或联系管理员。';
}
return $result['jobs'] > 0
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
: '';
}
private function dispatchResyncAll(): string
{
try {
$result = app(AccountMappingResyncDispatcher::class)->dispatchAll();
} catch (\Throwable $exception) {
report($exception);
return ' 业绩明细自动重跑任务创建失败,请稍后重试或联系管理员。';
}
return $result['jobs'] > 0
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
: '';
}
/**
* 原始操作记录 + 页面/CSV 直接可用的字段(变更内容与操作人只在服务端算一次)。
*

View File

@ -165,6 +165,41 @@ class CourierQuoteChanges extends Controller
->with('success', $message);
}
public function batchApprove(Request $request): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
$input = $request->validate([
'ids' => ['required', 'array', 'min:1', 'max:100'],
'ids.*' => ['required', 'integer', 'min:1', 'distinct'],
'stock_rules_confirmed' => ['accepted'],
], ['stock_rules_confirmed.accepted' => '请先核对选中报价的原报价、计费规则及实际执行日期。']);
$results = [];
// 历史签署资料需要逐份核对,不接受批量标记已经核验。
$singleRequest = $request->duplicate();
$singleRequest->merge(['stock_signature_verified' => false]);
foreach ($input['ids'] as $id) {
$change = CourierQuoteChange::query()->where('company_id', company_id())->find($id);
if ($change === null) {
$results[] = ['number' => '所选记录', 'success' => false, 'message' => '记录已不存在或不属于当前公司。'];
continue;
}
try {
// 复用单条入口,权限、校验、版本、日志及后续签署流程保持一致。
$response = $this->approve($singleRequest, $change);
$results[] = ['number' => $change->number, 'success' => true, 'message' => $response->getSession()->get('success')];
} catch (\Illuminate\Validation\ValidationException $exception) {
$results[] = ['number' => $change->number, 'success' => false, 'message' => collect($exception->errors())->flatten()->implode(';')];
} catch (\Throwable $exception) {
report($exception);
$results[] = ['number' => $change->number, 'success' => false, 'message' => '处理失败,请查看该单状态并重试。'];
}
}
$passed = count(array_filter($results, static fn ($result) => $result['success']));
return redirect()->route('business.courier-quote-changes.index', ['page' => max(1, (int) $request->input('page', 1))])
->with('success', '批量审核完成:成功 ' . $passed . ' 条,未通过 ' . (count($results) - $passed) . ' 条。')
->with('batch_review_results', $results);
}
/** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */
public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse
{

View File

@ -38,6 +38,7 @@ class CourierStockQuoteEntries extends Controller
'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'],
'quote_type' => ['required', 'string', 'max:64'],
'effective_date' => ['nullable', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d'],
'billing_rules' => ['nullable', 'string', 'max:4000'],
]);
$batch = $this->batches->preview($data['source_quote'], collect($data)->except('source_quote')->all(), (int) user_id());
@ -77,6 +78,7 @@ class CourierStockQuoteEntries extends Controller
$data = Validator::make($selection, [
'quote_type' => ['required', 'string', 'max:64'],
'effective_date' => ['nullable', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],
'rule_groups' => ['required', 'array', 'min:1', 'max:10'],
'rule_groups.*' => ['array'],
@ -89,6 +91,7 @@ class CourierStockQuoteEntries extends Controller
'entries.*.courier_name' => ['nullable', 'string', 'max:255'],
'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
'entries.*.end_date' => ['nullable', 'date_format:Y-m-d'],
'submit_for_review' => ['required', 'boolean'],
'confirmed' => ['accepted_if:submit_for_review,true'],
])->validate();
@ -166,7 +169,7 @@ class CourierStockQuoteEntries extends Controller
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
if ($preview) {
$mime = $attachment['mime_type'] ?? $disk->mimeType($attachment['path']);
abort_unless(in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
abort_unless(in_array($mime, ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
return $disk->response($attachment['path'], $attachment['name'], [...$headers, 'Content-Type' => $mime]);
}
@ -178,6 +181,7 @@ class CourierStockQuoteEntries extends Controller
{
return [
'effective_date' => ['required', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],
];
@ -198,4 +202,13 @@ class CourierStockQuoteEntries extends Controller
{
abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403);
}
public function periodEnd(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
{
abort_unless(in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::SUPER_ADMIN, \App\Utilities\SystemAccess::ADMIN], true), 403);
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
$data = $request->validate(['end_date' => ['required', 'date_format:Y-m-d'], 'reason' => ['required', 'string', 'min:2', 'max:1000']]);
$this->service->setPeriodEnd($stockQuoteEntry, $data['end_date'], $data['reason'], (int) user_id());
return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])->with('success', '执行截止日期已保存,报价原文和签署原件保持不变。');
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\PlantingLossCalculator;
use Modules\EcommerceFinance\View\PlantingLossPageData;
use Modules\EcommerceFinance\View\PlantingReimbursementPageData;
/** 种菜数据汇总:种菜报销 / 种菜损耗 导出(与页面同一筛选口径)。 */
class PlantingWorkOrders extends Controller
{
public function exportReimbursements(PlantingReimbursementPageData $data)
{
$filters = $data->filters();
return $this->csv(
PlantingReimbursementPageData::TITLE . '-' . $filters['month'],
$data->exportColumns(),
static fn () => $data->rows($filters),
);
}
public function exportLoss(PlantingLossPageData $data)
{
$filters = $data->filters();
$suffix = match ($filters['result']) {
PlantingLossCalculator::RESULT_EXEMPT => '-不扣款拍A发B',
PlantingLossCalculator::RESULT_FREE_GOODS => '-不扣款虚拟条码挂钩',
PlantingLossCalculator::RESULT_UNMATCHED => '-未匹配发货',
'all' => '-全部',
default => '-扣款',
};
// 发运营的拍A发B 名单直接按「种菜报销」的字段导出。
if (request()->query('format') === 'reimbursement') {
return $this->csv(
'拍A发B发运营-' . $filters['month'],
PlantingReimbursementPageData::COLUMNS,
static fn () => $data->rows($filters),
);
}
return $this->csv(
PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix,
$data->exportColumns(),
static fn () => $data->rows($filters),
);
}
/** @param array<string,string> $columns @param callable():iterable<array<string,mixed>> $rows */
private function csv(string $name, array $columns, callable $rows)
{
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, array_values($columns));
foreach ($rows() as $row) {
$values = [];
foreach (array_keys($columns) as $key) {
$values[] = $row[$key] ?? '';
}
fputcsv($file, array_map(static function (mixed $value): string {
$value = (string) $value;
// 长数字单号/账号保留文本,避免 Excel 截断;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '聚工单镜像或发货数据未就绪,未生成不完整的导出文件,请稍后重试。');
}
return response()->streamDownload(static function () use ($file): void {
fpassthru($file);
fclose($file);
}, $name . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierAccessoryFreightMatcher;
use Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData;
class SupplierAccessoryFreight extends Controller
{
public function export(SupplierAccessoryFreightPageData $data)
{
$filters = $data->filters();
$columns = $data->exportColumns();
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, ['业务月份', '发货记录ID', '仓库', ...array_values($columns)]);
foreach ($data->rows($filters) as $row) {
if ($filters['state'] !== 'all' && $filters['state'] !== $row['state']) continue;
$cells = array_merge($row, ['courier' => ($row['express_name1'] ?? '') ?: ($row['express_name2'] ?? ''),
'freight' => $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2, '.', ''),
'state' => SupplierAccessoryFreightMatcher::STATES[$row['state']], 'deduction' => number_format($row['deduction_cents'] / 100, 2, '.', '')]);
$values = [$filters['month'], $row['source_id'], \Modules\EcommerceFinance\Services\AccessoryReshipMatcher::WAREHOUSE];
foreach ($columns as $key => $label) $values[] = $cells[$key];
fputcsv($file, array_map(static function ($value) {
$value = (string) $value;
// 纯数字标识保留文本,防止 Excel 截断长单号;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '资料读取失败,未生成不完整的扣款明细,请稍后重试。');
}
return response()->streamDownload(function () use ($file) { fpassthru($file); fclose($file); }, SupplierAccessoryFreightPageData::TITLE . '-' . $filters['month'] . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\View\SupplierBookkeepingPageData;
class SupplierBookkeeping extends Controller
{
public function export(SupplierBookkeepingPageData $data)
{
$filters = $data->filters();
$columns = $data->exportColumns();
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, array_values($columns));
foreach ($data->rows($filters) as $row) {
$values = [];
foreach (array_keys($columns) as $key) {
$values[] = $row[$key] ?? '';
}
fputcsv($file, array_map(static function (mixed $value): string {
$value = (string) $value;
// 长数字单号保留文本,避免 Excel 截断;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '聚工单镜像未就绪或读取失败,未生成不完整的厂家记账,请稍后重试。');
}
return response()->streamDownload(static function () use ($file): void {
fpassthru($file);
fclose($file);
}, SupplierBookkeepingPageData::TITLE . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule as ValidationRule;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
use Modules\EcommerceFinance\View\SupplierBookkeepingConfigPageData;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class SupplierBookkeepingConfigs extends Controller
{
public function __construct(private readonly SupplierBookkeepingConfigStore $store) {}
public function store(Request $request)
{
$this->store->save($this->validated($request), $this->actor());
return $this->back('厂家记账配置已新增,厂家记账明细按新配置重新判定。');
}
public function update(Request $request, int $configId)
{
$this->store->save($this->validated($request), $this->actor(), $configId);
return $this->back('厂家记账配置已更新,厂家记账明细按新配置重新判定。');
}
public function destroy(int $configId)
{
$this->store->changeDeleted($configId, true, $this->actor());
return $this->back('配置已删除,该供应商视同不在配置表,按正常记账扣款。');
}
public function restore(int $configId)
{
$this->store->changeDeleted($configId, false, $this->actor());
return $this->back('厂家记账配置已恢复。');
}
public function import(Request $request, SupplierBookkeepingConfigImporter $importer)
{
$request->validate(['file' => ['required', 'file', 'mimes:xlsx', 'max:10240']]);
$path = $request->file('file')->getRealPath();
$result = $this->store->import($importer->read($path), $this->actor(), hash_file('sha256', $path));
return $this->back("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
}
public function export(SupplierBookkeepingConfigPageData $data)
{
$rows = $this->store->filtered($data->filters())->get();
$book = new Spreadsheet();
$sheet = $book->getActiveSheet();
$sheet->setTitle('厂家记账配置');
$sheet->fromArray([['供应商', '记账方式', '生效开始日期', '生效结束日期', '原始说明', '创建人', '更新时间', '删除状态']]);
foreach ($rows as $index => $row) {
$values = [$row->supplier_name, Rule::MODES[$row->mode] ?? $row->mode, $row->effective_from, $row->effective_to, $row->source_text, $row->creator, $row->updated_at, $row->deleted_at ? '已删除' : '正常'];
foreach ($values as $column => $value) {
$sheet->setCellValueExplicit([$column + 1, $index + 2], (string) $value, DataType::TYPE_STRING);
}
}
foreach (['A' => 24, 'B' => 20, 'C' => 14, 'D' => 14, 'E' => 44, 'F' => 16, 'G' => 20, 'H' => 10] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
return response()->streamDownload(function () use ($book): void {
try { (new Xlsx($book))->save('php://output'); } finally { $book->disconnectWorksheets(); }
}, '厂家记账配置.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
private function validated(Request $request): array
{
return $request->validate([
'supplier_name' => ['required', 'string', 'max:191'],
'mode' => ['required', ValidationRule::in(array_keys(Rule::MODES))],
'effective_from' => ['nullable', 'date_format:Y-m-d'],
'effective_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_from'],
'source_text' => ['nullable', 'string', 'max:255'],
], [], ['supplier_name' => '供应商', 'mode' => '记账方式', 'effective_from' => '生效开始日期', 'effective_to' => '生效结束日期', 'source_text' => '备注']);
}
private function actor(): string
{
return mb_substr((string) user()->name, 0, 191);
}
private function back(string $message)
{
return redirect()->route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config'])->with('bookkeeping_config_message', $message);
}
}

View File

@ -2,26 +2,41 @@
namespace App\Http\Controllers\Business;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\SupplierFreeShippingStore;
use Modules\EcommerceFinance\Services\SupplierFreeShippingWorkbook;
use Modules\EcommerceFinance\View\SupplierManagementPageData;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplierManagement extends Controller
{
public function export(Request $request, SupplierManagementMirrorRepository $repository, SupplierManagementPageData $page)
{
public function export(
Request $request,
SupplierManagementMirrorRepository $repository,
SupplierManagementPageData $page,
SupplierFreeShippingStore $freeShipping,
): StreamedResponse {
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导出。');
$filters = $page->filters($request->query());
$columns = SupplierManagementPageData::columns();
$companyId = (int) (company_id() ?: 1);
return response()->streamDownload(static function () use ($repository, $filters): void {
return response()->streamDownload(static function () use ($repository, $filters, $columns, $freeShipping, $companyId): void {
$csv = fopen('php://output', 'w');
fputs($csv, "\xEF\xBB\xBF");
fputcsv($csv, array_values(SupplierManagementMirrorRepository::COLUMNS));
fputcsv($csv, array_values($columns));
foreach ($repository->exportRows($filters) as $row) {
$row = $freeShipping->decorateRow($row, $companyId);
$values = [];
foreach (array_keys(SupplierManagementMirrorRepository::COLUMNS) as $column) {
$value = (string) ($row->{$column} ?? '');
foreach (array_keys($columns) as $column) {
$value = $column === SupplierManagementPageData::FREE_SHIPPING_COLUMN
? ((bool) ($row->{$column} ?? false) ? '包邮' : '')
: (string) ($row->{$column} ?? '');
if ($column === 'coopersgn') $value = ['1' => '合作', '2' => '不合作'][$value] ?? '未维护';
// Excel 不能执行供应商提供的公式文本,且长编码保留为文本。
if (preg_match('/^[=+\-@\t\r\n]/', ltrim($value)) || preg_match('/^\d{12,}$/', $value)) $value = "'" . $value;
@ -33,4 +48,114 @@ class SupplierManagement extends Controller
}, 'supplier_management_' . now()->format('Ymd_His') . '.csv',
['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store']);
}
public function template(SupplierFreeShippingWorkbook $workbook): StreamedResponse
{
return $workbook->template();
}
public function import(
Request $request,
SupplierManagementMirrorRepository $repository,
SupplierFreeShippingStore $freeShipping,
SupplierFreeShippingWorkbook $workbook,
): RedirectResponse {
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导入。');
$valuesByCode = $workbook->read($request->file('import'));
$suppliers = $repository->suppliersByCodes(array_keys($valuesByCode));
$matched = [];
foreach ($suppliers as $supplier) {
$code = mb_strtolower(trim((string) ($supplier->scmcode ?? '')));
if ($code !== '') {
$matched[$code][] = $supplier;
}
}
$missing = [];
$ambiguous = [];
$valuesBySupplierId = [];
foreach ($valuesByCode as $code => $isFreeShipping) {
$key = mb_strtolower(trim((string) $code));
if (! isset($matched[$key])) {
$missing[] = (string) $code;
continue;
}
if (count($matched[$key]) !== 1) {
$ambiguous[] = (string) $code;
continue;
}
$valuesBySupplierId[(int) $matched[$key][0]->Id] = $isFreeShipping;
}
if ($ambiguous !== []) {
throw ValidationException::withMessages([
'import' => '以下供应商代码在供应商主档中存在多条记录,未导入:' . implode('、', array_slice($ambiguous, 0, 8)),
]);
}
if ($missing !== []) {
throw ValidationException::withMessages([
'import' => '以下供应商代码未找到,未导入:' . implode('、', array_slice($missing, 0, 8))
. (count($missing) > 8 ? ' 等 ' . count($missing) . ' 条' : ''),
]);
}
$result = $freeShipping->saveMany(
$valuesBySupplierId,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
'import',
);
return $this->redirectToPage($request)->with(
'supplier_management_message',
"导入完成:新增 {$result['created']} 家供应商维护记录,更新 {$result['updated']} 家供应商维护记录。",
);
}
public function updateFreeShipping(
Request $request,
int $supplier,
SupplierManagementMirrorRepository $repository,
SupplierFreeShippingStore $freeShipping,
): JsonResponse|RedirectResponse {
$request->validate(['value' => ['nullable', 'string', 'in:,包邮']]);
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能编辑。');
abort_if($repository->supplierById($supplier) === null, 404, '供应商不存在。');
$isFreeShipping = $request->input('value') === '包邮';
$freeShipping->save(
$supplier,
$isFreeShipping,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
'manual',
);
if ($request->expectsJson()) {
return response()->json([
'ok' => true,
'value' => $isFreeShipping ? '包邮' : '',
'display' => $isFreeShipping ? '包邮' : '-',
]);
}
return $this->redirectToPage($request)->with('supplier_management_message', '是否包邮厂家已保存。');
}
private function redirectToPage(Request $request): RedirectResponse
{
$query = array_filter([
...app(SupplierManagementPageData::class)->filters($request->query()),
'page' => max((int) $request->query('page', 1), 1),
], static fn (mixed $value): bool => $value !== '');
$url = route('business.pages.show', [
'company_id' => company_id() ?: 1,
'section' => 'supplier-payables',
'page' => 'supplier-management',
]);
return redirect()->to($url . ($query === [] ? '' : '?' . http_build_query($query)));
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
class SupplierPortal extends Controller
{
public function home(): RedirectResponse
{
return redirect()->route('business.pages.show', [
'company_id' => company_id(),
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]);
}
}

View File

@ -0,0 +1,264 @@
<?php
namespace App\Http\Controllers\Business;
use App\Services\Auth\SupplierPortalAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierComparisonCsvExporter;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/**
* 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)。
*/
class SupplierReconciliation extends Controller
{
public function __construct(
private readonly SupplierStatementTemplate $template,
private readonly SupplierStatementStore $statements,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
private readonly SupplierPortalAccess $supplierPortalAccess,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
public function template(): BinaryFileResponse
{
$path = $this->template->path();
abort_unless(is_file($path), 404, '对账单模板不存在。');
return response()->download($path, $this->template->filename());
}
public function upload(Request $request): JsonResponse|RedirectResponse
{
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
]);
$supplier = $this->effectiveSupplier($request);
$records = $this->template->parse($request->file('import'), $supplier);
$rows = $this->statements->save(
$supplier,
$records,
(string) $request->file('import')->getClientOriginalName(),
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
$message = "对账单已保存:{$supplier} 共 {$rows} 行。";
return $request->expectsJson()
? response()->json(['message' => $message, 'rows' => $rows])
: back()->with('success', $message);
}
public function confirmReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
}
public function disputeReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
}
public function confirmAllReturns(Request $request): RedirectResponse
{
[$supplier, $companyId] = $this->returnTarget($request);
$keys = array_values(array_map(
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
$this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()),
));
$confirmed = $this->returnConfirmations->confirmAll(
$supplier,
$keys,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
}
public function exportReturns(Request $request): StreamedResponse
{
[$supplier, $companyId] = $this->returnTarget($request);
$rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query());
return $this->returnCsvExporter->download($rows, $supplier);
}
public function exportComparison(Request $request): StreamedResponse
{
$request->validate([
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
return $this->comparisonCsvExporter->download(
$detail,
$supplier,
trim((string) $request->query('month', '')),
trim((string) $request->query('reconciler', '')),
);
}
public function saveDingTalkRobot(Request $request): RedirectResponse
{
$request->validate([
'supplier' => ['required', 'string', 'max:191'],
'dingtalk_webhook' => ['nullable', 'string', 'max:2048'],
'dingtalk_secret' => ['nullable', 'string', 'max:256'],
'dingtalk_at_all' => ['nullable', 'boolean'],
]);
[$supplier] = $this->returnTarget($request);
$this->dingTalkNotifier->save(
$supplier,
(string) $request->input('dingtalk_webhook', ''),
(string) $request->input('dingtalk_secret', ''),
$request->boolean('dingtalk_at_all'),
);
return back()->with('success', "{$supplier} 的钉钉群机器人已保存。");
}
public function pushDifferences(Request $request): RedirectResponse
{
$request->validate([
'supplier' => ['required', 'string', 'max:191'],
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
'scope' => ['required', 'in:all,row,selected'],
'purchase_no' => ['nullable', 'string', 'max:191'],
'date' => ['nullable', 'string', 'max:32'],
'barcode' => ['nullable', 'string', 'max:191'],
'difference_type' => ['nullable', 'string', 'max:32'],
'selected_rows' => ['nullable', 'array', 'max:600'],
'selected_rows.*' => ['string', 'max:2048'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->all());
$differences = array_values(array_filter(
$detail['rows'],
static fn (array $row): bool => ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
));
if ($request->input('scope') === 'row') {
$purchaseNo = trim((string) $request->input('purchase_no'));
$date = trim((string) $request->input('date'));
$barcode = trim((string) $request->input('barcode'));
$type = trim((string) $request->input('difference_type'));
$differences = array_values(array_filter(
$differences,
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
&& (string) ($row['date'] ?? '') === $date
&& (string) ($row['barcode'] ?? '') === $barcode
&& (string) ($row['type'] ?? '') === $type
));
} elseif ($request->input('scope') === 'selected') {
$selected = [];
foreach ((array) $request->input('selected_rows', []) as $token) {
$row = json_decode((string) $token, true);
if (is_array($row)) {
$selected[$this->differenceRowKey($row)] = true;
}
}
$differences = array_values(array_filter(
$differences,
fn (array $row): bool => isset($selected[$this->differenceRowKey($row)]),
));
}
if ($differences === []) {
return back()->withErrors(['dingtalk_push' => '当前筛选没有可推送的差异。']);
}
try {
$result = $this->dingTalkNotifier->push($supplier, $differences, [
'month' => (string) $request->input('month', ''),
'reconciler' => (string) $request->input('reconciler', ''),
]);
} catch (Throwable $exception) {
return back()->withErrors(['dingtalk_push' => '推送失败:' . $exception->getMessage()]);
}
return back()->with(
'success',
"已推送 {$result['rows']} 条差异到 {$supplier} 钉钉群({$result['messages']} 条消息)。",
);
}
private function markReturn(Request $request, string $status, string $message): RedirectResponse
{
$request->validate(['key' => ['required', 'string', 'max:191']]);
[$supplier, $companyId] = $this->returnTarget($request);
$key = (string) $request->input('key');
$owned = collect($this->pageData->returnRows($supplier, $companyId))
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
$this->returnConfirmations->mark(
$supplier,
$key,
$status,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', $message);
}
/** @return array{0:string,1:int} */
private function returnTarget(Request $request): array
{
return [$this->effectiveSupplier($request), (int) (company_id() ?: 1)];
}
private function effectiveSupplier(Request $request): string
{
$user = $request->user();
if ($user && SupplierPortalAccess::isSupplierUser($user)) {
$supplier = $this->supplierPortalAccess->supplierFor($user);
abort_if(! $supplier, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。');
return $supplier;
}
$request->validate(['supplier' => ['required', 'string', 'max:191']]);
$supplier = trim((string) $request->input('supplier'));
abort_if($supplier === '', 422, '请选择厂家。');
return $supplier;
}
/** @param array<string,mixed> $row */
private function differenceRowKey(array $row): string
{
return implode("\0", [
trim((string) ($row['purchaseNo'] ?? $row['purchase_no'] ?? '')),
trim((string) ($row['date'] ?? '')),
trim((string) ($row['barcode'] ?? '')),
trim((string) ($row['type'] ?? '')),
]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Portal;
use App\Models\Common\Contact;
use App\Models\Document\Document;
use App\Traits\Charts;
use App\Traits\DateTime;
@ -20,6 +21,10 @@ class Dashboard
{
$contact = user()->contact;
if ($contact instanceof Contact && $contact->type === Contact::VENDOR_TYPE) {
return redirect()->route('portal.supplier-reconciliation.index', ['company_id' => company_id()]);
}
// Redirect user redirect landing page..
if (!$contact) {
return redirect(user()->getLandingPageOfUser());

View File

@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Models\Common\Contact;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 厂家门户:账号绑定供应商后,仅可查看和操作自己的对账数据。
*/
class SupplierReconciliation extends Controller
{
public function __construct(
private readonly SupplierStatementTemplate $template,
private readonly SupplierStatementStore $statements,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
) {
}
public function index(): View
{
[$contact, $supplier, $companyId] = $this->supplierContext();
$data = $this->pageData->portalData($supplier, $companyId);
return view('portal.supplier-reconciliation.index', array_merge($data, [
'contact' => $contact,
'supplier' => $supplier,
'statementTemplateUrl' => route('portal.supplier-reconciliation.template'),
'uploadUrl' => route('portal.supplier-reconciliation.statement.upload'),
'returnConfirmUrl' => route('portal.supplier-reconciliation.return.confirm'),
'returnDisputeUrl' => route('portal.supplier-reconciliation.return.dispute'),
'returnConfirmAllUrl' => route('portal.supplier-reconciliation.return.confirm-all'),
]));
}
public function template(): BinaryFileResponse
{
$this->supplierContext();
$path = $this->template->path();
abort_unless(is_file($path), 404, '对账单模板不存在。');
return response()->download($path, $this->template->filename());
}
public function upload(Request $request): JsonResponse|RedirectResponse
{
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
]);
[, $supplier, $companyId] = $this->supplierContext();
$records = $this->template->parse($request->file('import'), $supplier);
$rows = $this->statements->save(
$supplier,
$records,
(string) $request->file('import')->getClientOriginalName(),
$companyId,
user_id() ? (int) user_id() : null,
);
$message = "对账单已保存:{$supplier} 共 {$rows} 行。";
return $request->expectsJson()
? response()->json(['message' => $message, 'rows' => $rows])
: redirect()
->route('portal.supplier-reconciliation.index', ['company_id' => $companyId])
->with('success', $message);
}
public function confirmReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
}
public function disputeReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
}
public function confirmAllReturns(Request $request): RedirectResponse
{
[, $supplier, $companyId] = $this->supplierContext();
$keys = array_values(array_map(
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
$this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()),
));
$confirmed = $this->returnConfirmations->confirmAll(
$supplier,
$keys,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
}
public function exportReturns(Request $request): StreamedResponse
{
[, $supplier, $companyId] = $this->supplierContext();
$rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query());
return $this->returnCsvExporter->download($rows, $supplier);
}
private function markReturn(Request $request, string $status, string $message): RedirectResponse
{
$request->validate(['key' => ['required', 'string', 'max:191']]);
[, $supplier, $companyId] = $this->supplierContext();
$key = (string) $request->input('key');
$owned = collect($this->pageData->returnRows($supplier, $companyId))
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
$this->returnConfirmations->mark(
$supplier,
$key,
$status,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', $message);
}
/** @return array{0:Contact,1:string,2:int} */
private function supplierContext(): array
{
$contact = user()->contact;
abort_unless(
$contact instanceof Contact
&& $contact->type === Contact::VENDOR_TYPE
&& $contact->enabled,
403,
'当前账号未绑定有效的厂家资料,请联系财务人员。',
);
$supplier = trim((string) $contact->name);
abort_if($supplier === '', 403, '当前厂家账号未设置供应商名称,请联系财务人员。');
return [$contact, $supplier, (int) (company_id() ?: 1)];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Services\Auth\SupplierPortalAccess;
use App\Utilities\SidebarPermissions;
use Closure;
use Illuminate\Http\Request;
@ -12,7 +13,35 @@ class EnsureLimitedDingTalkPageAccess
{
$user = $request->user();
$route = $request->route();
if (! $user || ! $route?->hasParameter('company_id') || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
if (! $user || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
return $next($request);
}
if (SupplierPortalAccess::isSupplierUser($user)) {
if ($route?->named('logout', 'business.supplier-portal.home')) {
return $next($request);
}
if ($route?->named('dashboard') && $route->hasParameter('company_id')) {
return redirect()->route('business.supplier-portal.home', [
'company_id' => $route->parameter('company_id'),
]);
}
if ($route?->named('business.pages.show')
&& $route->parameter('section') === 'supplier-reconciliation'
&& $route->parameter('page') === 'supplier-data') {
return $next($request);
}
foreach ($route?->gatherMiddleware() ?? [] as $middleware) {
if (is_string($middleware) && str_starts_with($middleware, 'business.page:')
&& str_replace(',', '/', substr($middleware, strlen('business.page:'))) === SupplierPortalAccess::PAGE_KEY) {
return $next($request);
}
}
abort(403, '当前账号没有该页面权限。');
}
if (! $route?->hasParameter('company_id')) {
return $next($request);
}

View File

@ -3,8 +3,9 @@
namespace App\Http\Middleware;
use App\Models\Auth\DingTalkIdentity;
use App\Services\Auth\FinanceRoster;
use App\Services\Auth\DingTalkClosedAccess;
use App\Services\Auth\FinanceRoster;
use App\Services\Auth\SupplierPortalAccess;
use Closure;
use Throwable;
@ -15,7 +16,11 @@ class LogoutIfDingTalkBindingRevoked
$identityId = $request->session()->get('dingtalk_identity_id');
$user = $request->user();
if (! $identityId) {
if (! $user || ! in_array($user->created_from, ['dingtalk_roster', 'dingtalk_roster_limited'], true)) {
if (! $user || ! in_array($user->created_from, [
'dingtalk_roster',
'dingtalk_roster_limited',
SupplierPortalAccess::CREATED_FROM,
], true)) {
return $next($request);
}
// A password reset must not bypass HR eligibility for provisioned
@ -32,7 +37,9 @@ class LogoutIfDingTalkBindingRevoked
return $next($request);
}
try {
$eligible = app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
$eligible = SupplierPortalAccess::isSupplierUser($user)
? app(SupplierPortalAccess::class)->revalidate($user) !== null
: app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
} catch (Throwable) {
// Retain the session during an HR outage, but do not serve data
// using an expired authorization decision.
@ -46,7 +53,11 @@ class LogoutIfDingTalkBindingRevoked
}
app(DingTalkClosedAccess::class)->audit('session_revoked', 'denied', $user?->id, null, $identity?->union_id, $request, [
'reason' => ! $user?->enabled ? 'local_account_ineligible' : (! $identity ? 'binding_revoked' : 'outside_finance_roster'),
'reason' => ! $user?->enabled
? 'local_account_ineligible'
: (! $identity
? 'binding_revoked'
: (SupplierPortalAccess::isSupplierUser($user) ? 'outside_supplier_roster' : 'outside_finance_roster')),
]);
auth()->logout();
$request->session()->invalidate();

View File

@ -0,0 +1,28 @@
<?php
namespace App\Listeners\Common;
use Illuminate\Console\Events\CommandStarting;
use RuntimeException;
class RequireImportBatchContainer
{
public function handle(CommandStarting $event): void
{
if (! app()->environment('production') || config('finance_batch.container')) {
return;
}
$command = (string) $event->command;
$registered = in_array($command, config('finance_batch.import_commands', []), true);
// New import commands must be registered before they can run in production.
$isImport = preg_match('/(^|:)import(?:$|[-:])/', $command) === 1;
if ($registered || $isImport) {
throw new RuntimeException(
'生产重型导入必须在受限独立批处理容器中执行,请使用:sh /opt/caiwuxitong/run-finance-batch.sh '
. $command . ' <原参数>。网页容器未执行本次导入。'
);
}
}
}

View File

@ -62,7 +62,7 @@ class ShowInAdmin
public static function groupPayables(array $navigation): array
{
$keys = ['supplier-payables-final', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables',
$keys = ['supplier-payables-final', 'supplier-reconciliation', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables',
'lamination-factory-payables', 'paper-materials', 'edge-banding-materials'];
$children = array_values(array_filter($navigation, static fn (array $section): bool => in_array($section['slug'], $keys, true)));
if ($children === []) {
@ -113,9 +113,13 @@ class ShowInAdmin
continue;
}
$sectionPages = array_values(array_filter(
$section['pages'] ?? [],
static fn (array $page): bool => ($page['show_in_section_menu'] ?? true) !== false
));
$pages = $section['slug'] === 'order-data'
? array_values($section['pages'] ?? [])
: $this->sortByTitleLength($section['pages'] ?? []);
? $sectionPages
: $this->sortByTitleLength($sectionPages);
$order = ($sectionIndex + 1) * 10;
if (empty($pages) || in_array($section['slug'], self::WORKSPACE_SECTION_SLUGS, true)) {

View File

@ -3,6 +3,7 @@
namespace App\Listeners\Menu;
use App\Events\Menu\PortalCreated as Event;
use App\Models\Common\Contact;
class ShowInPortal
{
@ -16,6 +17,19 @@ class ShowInPortal
{
$user = user();
$menu = $event->menu;
$contact = $user->contact;
if ($contact instanceof Contact && $contact->type === Contact::VENDOR_TYPE) {
$menu->route(
'portal.supplier-reconciliation.index',
'供应商对账',
[],
10,
['icon' => 'fact_check'],
);
return;
}
$inactive = false;

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models\Auth;
use Illuminate\Database\Eloquent\Model;
class SupplierPortalIdentity extends Model
{
protected $fillable = [
'company_id',
'user_id',
'supplier_code',
'supplier_name',
'dingtalk_group_id',
'dingtalk_group_name',
'last_verified_at',
];
protected $casts = [
'dingtalk_group_id' => 'integer',
'last_verified_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -83,6 +83,11 @@ class User extends Authenticatable implements HasLocalePreference
return $this->hasOne(DingTalkIdentity::class, 'user_id');
}
public function supplierPortalIdentity()
{
return $this->hasOne(SupplierPortalIdentity::class, 'user_id');
}
public function contact()
{
return $this->hasOne('App\Models\Common\Contact', 'user_id', 'id');

View File

@ -34,6 +34,7 @@ class CourierQuoteChangeEvent extends Model
'returned' => '退回修改',
'approved' => '审核通过',
'voided' => '版本作废',
'period_end_updated' => '设置执行截止日期',
default => $this->event_type,
};
}

View File

@ -14,6 +14,7 @@ class CourierQuoteVersion extends Model
'company_id', 'version_code', 'source_quote_id', 'source_change_id', 'sheet_name', 'company_name',
'carrier_name', 'courier_expcode', 'billing_rules', 'quote_type', 'effective_date', 'end_date', 'source_range', 'values_json', 'formulas_json',
'status', 'signature_status', 'created_by', 'approved_by', 'void_reason', 'voided_at', 'voided_by',
'billing_structure', 'billing_rows',
];
public const STATUS_APPROVED = 'approved';
@ -26,12 +27,14 @@ class CourierQuoteVersion extends Model
'effective_date' => 'date',
'end_date' => 'date',
'voided_at' => 'datetime',
'billing_structure' => 'array',
'billing_rows' => 'array',
];
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
public function isTemporary(): bool
{
return $this->end_date !== null;
return $this->end_date !== null && !$this->change?->isStockQuote();
}
/** 某一天是否在这一版的执行期间内(开始 ≤ 日期 ≤ 结束;长期版本没有结束)。 */
@ -52,15 +55,37 @@ class CourierQuoteVersion extends Model
}
return $this->end_date !== null
? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . '(临时)'
? $this->effective_date->toDateString() . ' 至 ' . $this->end_date->toDateString() . ($this->isTemporary() ? '(临时)' : '')
: $this->effective_date->toDateString() . ' 起';
}
/** 是否已有可用于运费核算的结构化计费规则。 */
public function hasBillingRules(): bool
{
$mode = $this->billing_structure['mode'] ?? null;
return is_string($mode) && $mode !== 'other' && is_array($this->billing_rows) && $this->billing_rows !== [];
}
public function isVoided(): bool
{
return $this->status === self::STATUS_VOIDED;
}
/** 存量报价审核后按执行日期生效,不以补签或历史签署资料为前置条件。 */
public function isApprovedStockQuote(): bool
{
return $this->status === self::STATUS_APPROVED
&& $this->change?->status === CourierQuoteChange::STATUS_APPROVED
&& $this->change->isStockQuote();
}
public function signatureLabel(): string
{
return $this->isApprovedStockQuote() && !in_array($this->signature_status, ['历史签署资料已归档', '已签署并归档'], true)
? '存量报价,审核生效(无需签章)' : (string) $this->signature_status;
}
public function change(): BelongsTo
{
return $this->belongsTo(CourierQuoteChange::class, 'source_change_id');

View File

@ -11,10 +11,10 @@ class CourierStockQuoteEntry extends Model
protected $fillable = [
'company_id', 'courier_expcode', 'courier_name', 'courier_name_key', 'quote_type', 'source_change_id',
'sheet_name', 'source_range', 'values_json', 'formulas_json', 'files', 'created_by',
'sheet_name', 'source_range', 'values_json', 'formulas_json', 'files', 'created_by', 'effective_date', 'end_date',
];
protected $casts = ['company_id' => 'integer', 'files' => 'array'];
protected $casts = ['company_id' => 'integer', 'files' => 'array', 'effective_date' => 'date', 'end_date' => 'date'];
public function change(): BelongsTo
{

View File

@ -46,6 +46,7 @@ class Event extends Provider
],
//'Illuminate\Console\Events\ScheduledTaskStarting' => [
\Illuminate\Console\Events\CommandStarting::class => [
\App\Listeners\Common\RequireImportBatchContainer::class,
\App\Listeners\Common\SkipScheduleInReadOnlyMode::class,
],
\App\Events\Auth\LandingPageShowing::class => [

View File

@ -28,9 +28,13 @@ class DingTalkClosedAccess
}
$roster = app(FinanceRoster::class)->findEligible((string) ($profile['dingtalk_user_id'] ?? ''));
if (! $roster) {
$supplierRoster = $roster ? null : app(SupplierPortalAccess::class)->resolve(
(string) ($profile['dingtalk_user_id'] ?? ''),
(string) ($profile['union_id'] ?? ''),
);
if (! $roster && ! $supplierRoster) {
$this->audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [
'reason' => 'outside_finance_roster',
'reason' => 'outside_authorized_roster',
]);
$request->attributes->set('dingtalk_denial', 'outside_roster');
return null;
@ -49,6 +53,23 @@ class DingTalkClosedAccess
return null;
}
if (SupplierPortalAccess::isSupplierUser($identity->user)) {
$company = $this->enabledCompany($identity->user);
if (! $supplierRoster || ! $company) {
$this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [
'reason' => 'outside_supplier_roster',
]);
return null;
}
app(SupplierPortalAccess::class)->bind($identity->user, (int) $company->id, $supplierRoster);
} elseif (! $roster) {
$this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [
'reason' => 'outside_finance_roster',
]);
return null;
}
$identity->forceFill([
'open_id' => $profile['open_id'] ?: $identity->open_id,
@ -69,7 +90,7 @@ class DingTalkClosedAccess
return null;
}
return DB::transaction(function () use ($profile, $roster, $request): ?DingTalkIdentity {
return DB::transaction(function () use ($profile, $roster, $supplierRoster, $request): ?DingTalkIdentity {
// Serialize first-time provisioning on the target company. Both the
// account and unique identity are committed together, including retries.
$companies = Company::enabled()
@ -92,10 +113,11 @@ class DingTalkClosedAccess
return null;
}
$roleName = FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id'])
$supplierPortal = $supplierRoster !== null;
$roleName = ! $supplierPortal && FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id'])
? SystemAccess::SUPER_ADMIN : SystemAccess::USER;
$role = Role::where('name', $roleName)->firstOrFail();
$email = $this->normalizeEmail($profile['email'] ?? null);
$email = $supplierPortal ? null : $this->normalizeEmail($profile['email'] ?? null);
if (! $email || User::withTrashed()->where('email', $email)->exists()) {
$email = 'ding-'.hash('sha256', $profile['union_id']).'@dingtalk.invalid';
}
@ -104,21 +126,25 @@ class DingTalkClosedAccess
return null;
}
$account = User::create([
'name' => Str::limit($profile['nickname'] ?: $roster['display_name'], 191, ''),
'name' => Str::limit($profile['nickname'] ?: ($roster['display_name'] ?? '供应商人员'), 191, ''),
'email' => $email,
'password' => Str::random(64),
'locale' => 'zh-CN',
'enabled' => true,
'landing_page' => 'dashboard',
'landing_page' => $supplierPortal ? 'business.supplier-portal.home' : 'dashboard',
'created_by' => 0,
'created_from' => FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
? 'dingtalk_roster_limited' : 'dingtalk_roster',
'created_from' => $supplierPortal
? SupplierPortalAccess::CREATED_FROM
: (FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
? 'dingtalk_roster_limited' : 'dingtalk_roster'),
]);
$account->roles()->attach($role->id);
// Explicit designation controls the role; ordinary roster users get shell access.
$account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
$account->companies()->attach($companies->first()->id);
if ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
if ($supplierPortal) {
app(SupplierPortalAccess::class)->bind($account, (int) $companies->first()->id, $supplierRoster);
} elseif ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
Contact::create([
'company_id' => $companies->first()->id, 'type' => Contact::EMPLOYEE_TYPE,
'user_id' => $account->id, 'name' => $account->name, 'email' => $email,
@ -131,7 +157,12 @@ class DingTalkClosedAccess
'open_id' => $profile['open_id'], 'dingtalk_user_id' => $profile['dingtalk_user_id'],
'nickname' => $profile['nickname'], 'bound_at' => now(),
]);
$this->upsertRequest($profile, DingTalkBindingRequest::APPROVED, 'finance_roster_provisioned', $account->id);
$this->upsertRequest(
$profile,
DingTalkBindingRequest::APPROVED,
$supplierPortal ? 'supplier_roster_provisioned' : 'finance_roster_provisioned',
$account->id,
);
$this->audit('account_auto_created', 'success', $account->id, null, $profile['union_id'], $request);
return $identity->load('user');
}, 3);
@ -165,9 +196,12 @@ class DingTalkClosedAccess
private function isEligible(?User $user): bool
{
return $user
&& $user->enabled
&& $user->withoutEvents(fn () => $user->companies()->enabled()->exists());
return $user && $user->enabled && $this->enabledCompany($user) !== null;
}
private function enabledCompany(User $user): ?Company
{
return $user->withoutEvents(fn () => $user->companies()->enabled()->first());
}
private function upsertRequest(array $profile, string $status, string $reason, ?int $matchedUserId): void

View File

@ -0,0 +1,194 @@
<?php
namespace App\Services\Auth;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\SupplierPortalIdentity;
use App\Models\Auth\User;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
/**
* 钉钉企业组织中的供应商成员映射。
*
* 只有当前有效的钉钉企业员工、且其部门祖先精确对应唯一供应商主档名称时,
* 才建立供应商门户身份。财务花名册不是供应商人员的准入前提。
*/
class SupplierPortalAccess
{
public const CREATED_FROM = 'dingtalk_supplier_portal';
public const PAGE_KEY = 'supplier-reconciliation/supplier-data';
public function __construct(
private readonly UpstreamReadOnlyQueryFactory $queries,
private readonly SupplierManagementMirrorRepository $suppliers,
) {
}
public static function isSupplierUser(User $user): bool
{
return $user->created_from === self::CREATED_FROM;
}
/** @return array{supplier_name:string,supplier_code:string,dingtalk_group_id:int,dingtalk_group_name:string}|null */
public function resolve(string $userId, string $unionId = ''): ?array
{
$userId = trim($userId);
$unionId = trim($unionId);
if ($userId === '') {
return null;
}
$query = $this->queries->forSource('operations_replica');
$employees = $query->select(
'SELECT TOP (2) userid, unionid, name, active, dept_id_list
FROM rpt.hr_employee WHERE userid = ?',
[$userId],
);
if (count($employees) !== 1 || ! (bool) ($employees[0]['active'] ?? false)) {
return null;
}
$employee = $employees[0];
$employeeUnionId = trim((string) ($employee['unionid'] ?? ''));
if ($employeeUnionId === '' || ($unionId !== '' && ! hash_equals($employeeUnionId, $unionId))) {
return null;
}
$departmentIds = $this->departmentIds($employee['dept_id_list'] ?? null);
if ($departmentIds === [] || count($departmentIds) > 64) {
return null;
}
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
$ancestors = $query->select(
'WITH lineage AS (
SELECT Id, parent_id, name, 0 AS depth FROM rpt.hr_groups WHERE Id IN ('.$placeholders.')
UNION ALL
SELECT parent.Id, parent.parent_id, parent.name, child.depth + 1
FROM rpt.hr_groups parent JOIN lineage child ON parent.Id = child.parent_id
WHERE child.depth < 20
) SELECT TOP (256) Id, parent_id, name, depth FROM lineage',
$departmentIds,
);
if ($ancestors === [] || count($ancestors) >= 256
|| array_filter($ancestors, static fn (array $row): bool => (int) ($row['depth'] ?? 0) >= 20)) {
return null;
}
$groupsByName = [];
foreach ($ancestors as $ancestor) {
$name = trim((string) ($ancestor['name'] ?? ''));
if ($name !== '') {
$groupsByName[$name][] = $ancestor;
}
}
if ($groupsByName === []) {
return null;
}
$supplierRows = $this->suppliers->suppliersByNames(array_keys($groupsByName));
$matchedSupplierNames = [];
foreach ($supplierRows as $supplier) {
$name = trim((string) ($supplier->scmname ?? ''));
if ($name !== '' && isset($groupsByName[$name])) {
$matchedSupplierNames[$name] = true;
}
}
if (count($matchedSupplierNames) !== 1) {
return null;
}
$supplierName = (string) array_key_first($matchedSupplierNames);
$matchingRows = array_values(array_filter(
$supplierRows,
static fn (object $supplier): bool => trim((string) ($supplier->scmname ?? '')) === $supplierName,
));
if (count($matchingRows) !== 1) {
return null;
}
usort($groupsByName[$supplierName], static fn (array $left, array $right): int =>
(int) ($right['depth'] ?? 0) <=> (int) ($left['depth'] ?? 0));
$group = $groupsByName[$supplierName][0];
return [
'supplier_name' => $supplierName,
'supplier_code' => trim((string) ($matchingRows[0]->scmcode ?? '')),
'dingtalk_group_id' => (int) ($group['Id'] ?? 0),
'dingtalk_group_name' => $supplierName,
];
}
public function bind(User $user, int $companyId, array $resolved): SupplierPortalIdentity
{
return SupplierPortalIdentity::query()->updateOrCreate(
['user_id' => $user->id],
[
'company_id' => $companyId,
'supplier_code' => $resolved['supplier_code'] ?? null,
'supplier_name' => $resolved['supplier_name'],
'dingtalk_group_id' => (int) $resolved['dingtalk_group_id'],
'dingtalk_group_name' => $resolved['dingtalk_group_name'],
'last_verified_at' => now(),
],
);
}
public function mappingFor(User $user): ?SupplierPortalIdentity
{
if (! self::isSupplierUser($user)) {
return null;
}
return SupplierPortalIdentity::query()
->where('user_id', $user->id)
->where('company_id', company_id())
->first();
}
public function supplierFor(User $user): ?string
{
$mapping = $this->mappingFor($user);
$name = trim((string) ($mapping?->supplier_name ?? ''));
return $name !== '' ? $name : null;
}
public function revalidate(User $user): ?SupplierPortalIdentity
{
if (! self::isSupplierUser($user)) {
return null;
}
$dingTalk = DingTalkIdentity::query()->where('user_id', $user->id)->first();
$company = $user->withoutEvents(fn () => $user->companies()->enabled()->first());
if (! $dingTalk || ! $company) {
return null;
}
$resolved = $this->resolve(
(string) $dingTalk->dingtalk_user_id,
(string) $dingTalk->union_id,
);
return $resolved ? $this->bind($user, (int) $company->id, $resolved) : null;
}
private function departmentIds(mixed $value): array
{
$value = trim((string) $value);
if ($value === '') {
return [];
}
$parts = json_decode($value, true);
if (! is_array($parts)) {
$parts = preg_split('/[\[\]\/>,,;;||\s]+/u', $value, -1, PREG_SPLIT_NO_EMPTY);
}
return array_values(array_filter(array_map(
static fn (mixed $id): string => is_scalar($id) ? trim((string) $id) : '',
is_array($parts) ? $parts : [],
), static fn (string $id): bool => ctype_digit($id) && (int) $id > 0));
}
}

View File

@ -12,6 +12,18 @@ final class EsignClient
private readonly EsignRequestSigner $signer;
/**
* 骑缝章中心的纵向坐标(PDF 坐标,原点在左下角,A4 高 841.89)。
* 按企业签署方顺序依次取用:第一家偏上、第二家居中偏下、第三家靠下。
* 相邻两枚间隔 200,大于骑缝章高度 120,叠页后各自完整、互不重叠。
*/
private const ACROSS_PAGE_SEAL_Y = [620.0, 420.0, 220.0];
private const ACROSS_PAGE_SEAL_SIZE = '120';
/** @var array<string, int> 本实例上传过的文件 ID => 页数(0 表示未能识别)。 */
private array $pageCounts = [];
public function __construct(?EsignConfiguration $configuration = null)
{
$configuration ??= EsignConfiguration::fromConfig();
@ -77,6 +89,7 @@ final class EsignClient
}
$this->waitUntilFileReady($fileId);
$this->pageCounts[$fileId] = self::pdfPageCount($bytes);
return $fileId;
}
@ -119,6 +132,7 @@ final class EsignClient
],
]],
]];
$signerPayload = $this->withAcrossPageSeals($fileId, $signerPayload);
$docs = [[
'fileId' => $fileId,
@ -160,6 +174,49 @@ final class EsignClient
return $signFlowId;
}
/**
* 给主文件上的每个企业签署方各加一个固定位置的骑缝签署区,并按顺序错开纵向位置。
*
* 原来骑缝章由签署人在自由签模式下自己添加,E 签宝把各方的骑缝章放在同一默认高度,
* 双方的章会叠在一起,看起来像盖了两遍。现在由系统预先放好,每方一个高度。
* 个人签署方不加骑缝;主文件少于 2 页或页数识别不出时也不加。
*/
private function withAcrossPageSeals(string $fileId, array $signerPayload): array
{
if (($this->pageCounts[$fileId] ?? 0) < 2) {
return $signerPayload;
}
$orgOrder = 0;
foreach ($signerPayload as $index => $payload) {
if (($payload['signerType'] ?? null) !== 1) {
continue;
}
$positionY = self::ACROSS_PAGE_SEAL_Y[$orgOrder++] ?? null;
if ($positionY === null) {
break;
}
$signerPayload[$index]['signFields'][] = [
'fileId' => $fileId,
'normalSignFieldConfig' => [
'freeMode' => false,
'movableSignField' => false,
'signFieldStyle' => 2,
'signFieldSize' => self::ACROSS_PAGE_SEAL_SIZE,
'signFieldPosition' => ['acrossPageMode' => 'ALL', 'positionY' => $positionY],
],
];
}
return $signerPayload;
}
/** 统计 PDF 页数:数 /Type /Page 对象(不含 /Pages)。系统生成的 dompdf 文件可直接识别;识别不出返回 0。 */
private static function pdfPageCount(string $bytes): int
{
return (int) preg_match_all('#/Type\s*/Page(?![a-zA-Z])#', $bytes);
}
private function signerPayload(string $fileId, array $signer, int $index): array
{
$type = (int) ($signer['signer_type'] ?? 1);

View File

@ -35,6 +35,7 @@ final class CourierQuoteChangeService
'change_type' => $data['change_type'],
'status' => CourierQuoteChange::STATUS_DRAFT,
'effective_date' => $data['effective_date'],
'end_date' => ($data['end_date'] ?? null) ?: null,
'is_retrospective' => $retrospective,
'recalculation_status' => $retrospective
? CourierQuoteChange::RECALCULATION_PENDING
@ -66,6 +67,7 @@ final class CourierQuoteChangeService
$change->update([
'change_type' => $data['change_type'],
'effective_date' => $data['effective_date'],
'end_date' => $change->isStockQuote() ? (($data['end_date'] ?? null) ?: null) : $change->end_date,
'is_retrospective' => $retrospective,
'recalculation_status' => $retrospective
? CourierQuoteChange::RECALCULATION_PENDING
@ -139,10 +141,19 @@ final class CourierQuoteChangeService
$source = $this->findSourceQuote($change->source_quote_id);
return DB::transaction(function () use ($change, $source, $userId, $historicalDocumentsConfirmed): CourierQuoteChange {
$change = CourierQuoteChange::query()->lockForUpdate()->findOrFail($change->id);
if ($change->status !== CourierQuoteChange::STATUS_PENDING_REVIEW) {
throw ValidationException::withMessages(['status' => '只有待审核的变更单可以审核通过,请刷新状态。']);
}
if ($change->isStockQuote()) {
\App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail();
$entry = CourierStockQuoteEntry::findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'));
app(CourierStockQuoteEntryService::class)->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $change->effective_date->toDateString(), $change->end_date?->toDateString(), $entry->id);
}
$hasHistoricalFiles = $change->isStockQuote()
&& collect(CourierStockQuoteEntry::query()->findOrFail(data_get($change->quote_payload, 'stock_quote_entry_id'))->files)->contains('kind', 'signed');
if ($change->isStockQuote() && $historicalDocumentsConfirmed && ! $hasHistoricalFiles) {
throw ValidationException::withMessages(['stock_signature_verified' => '尚未上传历史签署资料,请补充资料或保留待确认/待补签状态。']);
throw ValidationException::withMessages(['stock_signature_verified' => '尚未上传历史签署资料,请补充资料或取消历史资料核验;不影响存量报价审核生效。']);
}
$historicalSigned = $hasHistoricalFiles && $historicalDocumentsConfirmed;
$values = $source['values'];
@ -182,7 +193,7 @@ final class CourierQuoteChangeService
'values_json' => json_encode($values, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode($formulas, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'status' => 'approved',
'signature_status' => $change->isStockQuote() ? ($historicalSigned ? '历史签署资料已归档' : '待确认/待补签') : '未签署',
'signature_status' => $change->isStockQuote() ? ($historicalSigned ? '历史签署资料已归档' : '存量报价,审核生效(无需签章)') : '未签署',
'created_by' => $change->created_by,
'approved_by' => $userId,
]);

View File

@ -0,0 +1,157 @@
<?php
namespace App\Support;
use App\Models\Business\CourierQuoteVersion;
use DateTimeInterface;
use Illuminate\Support\Collection;
/**
* 按「快递编码 + 发货日期」找到当天生效的报价版本,用结构化计费规则算一票的运费。
*
* 计费重量 = max(实重, 体积重, 起算重量) 再按取整方式处理;超过限重不计算。
*
* 生效版本的判断与报价管理页一致(CourierRequoteService::resolveAt:未作废、已审核或已签署归档、
* 执行期间覆盖发货日期,短期变价优先)。没有结构化规则的版本(按体积、按条码等特殊计费)返回原因,交人工核算。
*/
class CourierQuoteFreightCalculator
{
/** @var array<string,Collection<int,CourierQuoteVersion>> */
private array $versions = [];
/**
* @param string $courierExpcode 快递编码;没有编码的存量报价可传快递名称(如「沐野竹木顺丰」)
* @return array{
* version_code:?string,company_name:?string,region:?string,gross_weight:float,volume_weight:?float,
* billable_weight:?float,base_amount:?float,surcharge:float,amount:?float,formula:?string,reason:?string
* }
*/
public function calculate(
string $courierExpcode,
DateTimeInterface $shipDate,
string $province,
float $grossWeight,
?float $length = null,
?float $width = null,
?float $height = null,
?string $city = null,
?string $quoteType = null,
): array {
$result = [
'version_code' => null, 'company_name' => null, 'region' => null, 'gross_weight' => $grossWeight,
'volume_weight' => null, 'billable_weight' => null, 'base_amount' => null, 'surcharge' => 0.0,
'amount' => null, 'formula' => null, 'reason' => null,
];
if (! is_finite($grossWeight) || $grossWeight < 0
|| collect([$length, $width, $height])->contains(static fn ($v) => $v !== null && (! is_finite($v) || $v < 0))) {
return ['reason' => '重量和尺寸必须是有效非负数字'] + $result;
}
$version = $this->versionAt(trim($courierExpcode), $shipDate, $quoteType);
if ($version === null) {
return ['reason' => '发货日期没有生效的报价'] + $result;
}
$result['version_code'] = $version->version_code;
$result['company_name'] = $version->company_name;
if (! $version->hasBillingRules()) {
return ['reason' => '该报价为特殊计费或尚未设置计费规则,需人工核算'] + $result;
}
$structure = (array) $version->billing_structure;
$row = $this->matchRow((array) $version->billing_rows, $province, $city);
if ($row === null) {
return ['reason' => '报价未覆盖目的地「' . trim($province . ' ' . $city) . '」或该地区不发'] + $result;
}
$result['region'] = (string) $row['region'];
$divisor = (float) ($structure['volumetric_divisor'] ?? 0);
$volume = $divisor > 0 && $length > 0 && $width > 0 && $height > 0 ? round($length * $width * $height / $divisor, 4) : null;
$settlement = max($grossWeight, (float) $volume);
$result['volume_weight'] = $volume;
if ($settlement <= 0) {
return ['reason' => '缺少重量,无法计算运费'] + $result;
}
$limit = (float) ($structure['weight_limit'] ?? 0);
if ($limit > 0 && $settlement > $limit + 1e-9) {
return ['reason' => '超出限重 ' . CourierQuoteStructure::kg($limit) . 'kg'] + $result;
}
// 起算重量:不足按起算重量计(如菏泽顺帆顺丰不足 5kg 按 5kg)
$minimum = (float) ($structure['min_weight'] ?? 0);
$billable = CourierQuoteStructure::roundWeight(max($settlement, $minimum), (string) ($structure['rounding'] ?? 'none'));
$result['billable_weight'] = $billable;
$freight = CourierQuoteStructure::freight($structure, $row, $billable);
if ($freight['amount'] === null) {
return ['reason' => $freight['reason']] + $result;
}
$surcharge = (float) ($structure['per_ticket_surcharge'] ?? 0);
$result['base_amount'] = round($freight['amount'], 2);
$result['surcharge'] = $surcharge;
$result['amount'] = round($freight['amount'] + $surcharge, 2);
$result['formula'] = $freight['formula'] . ($surcharge > 0 ? '+每票' . CourierQuoteStructure::kg($surcharge) : '');
return $result;
}
/** 省份名统一:去掉省/市/自治区后缀,黑龙→黑龙江、内蒙→内蒙古。 */
public static function regionKey(string $name): string
{
$text = (string) preg_replace('/\s+/u', '', $name);
$text = (string) preg_replace('/(维吾尔自治区|壮族自治区|回族自治区|自治区|特别行政区|省|市)$/u', '', $text);
return match ($text) {
'黑龙' => '黑龙江',
'内蒙' => '内蒙古',
default => $text,
};
}
private function versionAt(string $courierExpcode, DateTimeInterface $date, ?string $quoteType): ?CourierQuoteVersion
{
if ($courierExpcode === '') {
return null;
}
// 先按快递编码;部分存量版本没有编码,按快递名称(与报价管理页的分组口径一致)
$cacheKey = company_id() . ':' . $courierExpcode;
$versions = $this->versions[$cacheKey] ??= CourierQuoteVersion::query()
->with(['signature', 'change'])
->where(static fn ($query) => $query->where('courier_expcode', $courierExpcode)
->orWhere(static fn ($q) => $q->where(static fn ($e) => $e->whereNull('courier_expcode')->orWhere('courier_expcode', ''))
->where('company_name', $courierExpcode)))
->get();
return CourierRequoteService::resolveAt(
$quoteType === null ? $versions : $versions->where('quote_type', $quoteType),
$date,
);
}
/**
* 先按城市匹配单列的城市行(如「深圳(广东)」),再按省份匹配。
*
* @param list<array<string,mixed>> $rows
* @return array<string,mixed>|null
*/
private function matchRow(array $rows, string $province, ?string $city): ?array
{
$cityKey = self::regionKey((string) $city);
if ($cityKey !== '') {
foreach ($rows as $row) {
if (is_array($row) && preg_match('/^(.+?)[((](.+?)[))]$/u', trim((string) ($row['region'] ?? '')), $parts)
&& self::regionKey($parts[1]) === $cityKey && self::regionKey($parts[2]) === self::regionKey($province)) {
return $row;
}
}
}
$key = self::regionKey($province);
foreach ($rows as $row) {
if (is_array($row) && self::regionKey((string) ($row['region'] ?? '')) === $key) {
return $row;
}
}
return null;
}
}

View File

@ -40,6 +40,13 @@ final class CourierQuoteStructure
'round' => '四舍五入(如 4.4kg 按 4kg,4.5kg 按 5kg)',
];
/**
* 分段计费:不放进 MODES(入驻表单不提供),只用于存量报价导入。
* 每个省份行为 {region, segments:[{lo, hi, type: flat|waybill|first_add, price|base, rate, fw}]},
* 重量落在 (lo, hi](第一段含 0)就按该段计算;hi 为 null 表示不封顶。
*/
public const SEGMENTS = 'segments';
public const MAX_BANDS = 12;
public const MAX_TIERS = 6;
@ -272,10 +279,120 @@ final class CourierQuoteStructure
? "首重{$fw}kg价+(重量-{$fw})*续重"
: '面单+重量*费率') . $tierNote,
'other' => '特殊计费,见附件报价表',
self::SEGMENTS => '分段计费:每个重量段单独设一口价、面单+费率或首重+续重(可表示累进计费)',
default => '面单+重量*费率' . $tierNote,
};
}
/**
* 按结构化报价计算一票的基础运费(不含计泡、限重、附加费,这些由调用方先处理)。
*
* 与 describe() 口径一致:费率分段按「总重量所在区间」取费率,区间上限含本数;
* 一口价/费率为空表示该重量段不发。$weight 应为已取整的计费重量。
*
* @param array<string,mixed> $structure
* @param array<string,mixed> $row
* @return array{amount:?float,formula:?string,reason:?string}
*/
public static function freight(array $structure, array $row, float $weight): array
{
$mode = (string) ($structure['mode'] ?? 'waybill_rate');
$none = static fn (string $reason): array => ['amount' => null, 'formula' => null, 'reason' => $reason];
$ok = static fn (float $amount, string $formula): array => ['amount' => round($amount, 4), 'formula' => $formula, 'reason' => null];
if (! is_finite($weight) || $weight <= 0) {
return $none('缺少有效重量,无法计算运费');
}
$w = self::kg($weight);
$within = static fn (float $value, float $edge): bool => $value <= $edge + 1e-9;
if ($mode === self::SEGMENTS) {
foreach ((array) ($row['segments'] ?? []) as $segment) {
$lo = (float) ($segment['lo'] ?? 0);
$hi = isset($segment['hi']) ? (float) $segment['hi'] : INF;
if (! (($weight > $lo + 1e-9 || $lo <= 0.0) && $within($weight, $hi))) {
continue;
}
$type = (string) ($segment['type'] ?? '');
if (! in_array($type, ['flat', 'waybill', 'first_add'], true)) {
return $none('不支持的分段计费方式,需人工核算');
}
if ($type === 'flat') {
return isset($segment['price']) ? $ok((float) $segment['price'], '一口价' . self::kg((float) $segment['price'])) : $none('该重量段不发');
}
if (! isset($segment['base'], $segment['rate'])) {
return $none('该重量段不发');
}
$base = (float) $segment['base'];
$rate = (float) $segment['rate'];
if ($type === 'first_add') {
$fw = (float) ($segment['fw'] ?? 0);
return $within($weight, $fw)
? $ok($base, '首重' . self::kg($base))
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
}
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
}
return $none('超出报价重量范围');
}
if (! in_array($mode, ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then'], true)) {
return $none('特殊计费,需人工核算');
}
$bands = array_map('floatval', array_values((array) ($structure['flat_bands'] ?? [])));
$flat = array_values((array) ($row['flat'] ?? []));
if (in_array($mode, ['flat_bands', 'flat_then'], true) && $bands !== [] && ($mode === 'flat_bands' || $within($weight, end($bands)))) {
foreach ($bands as $index => $edge) {
if ($within($weight, $edge)) {
$price = $flat[$index] ?? null;
return is_numeric($price) ? $ok((float) $price, '≤' . self::kg($edge) . 'kg 一口价' . self::kg((float) $price)) : $none('该重量段不发');
}
}
return $none('超出报价重量范围');
}
$tiers = array_map('floatval', array_values((array) ($structure['rate_tiers'] ?? [])));
$index = count($tiers);
foreach ($tiers as $i => $edge) {
if ($within($weight, $edge)) {
$index = $i;
break;
}
}
$rate = $row['rates'][$index] ?? null;
$base = $row['base'] ?? null;
if (! is_numeric($rate) || ! is_numeric($base)) {
return $none('该重量段不发');
}
$rate = (float) $rate;
$base = (float) $base;
$then = $mode === 'flat_then' ? (string) ($structure['then_mode'] ?? 'waybill_rate') : $mode;
if ($then === 'first_additional') {
$fw = (float) ($structure['first_weight'] ?? 0);
return $within($weight, $fw)
? $ok($base, '首重' . self::kg($base))
: $ok($base + ($weight - $fw) * $rate, self::kg($base) . '+(' . $w . '-' . self::kg($fw) . ')×' . self::kg($rate));
}
return $ok($base + $weight * $rate, self::kg($base) . '+' . $w . '×' . self::kg($rate));
}
/** 按取整方式得到计费重量:ceil 向上取整,round 四舍五入(4.5 进为 5)。 */
public static function roundWeight(float $weight, string $rounding): float
{
return match ($rounding) {
'ceil' => (float) ceil(round($weight, 6)),
'round' => (float) floor($weight + 0.5),
default => $weight,
};
}
public static function usesRates(string $mode): bool
{
return in_array($mode, ['waybill_rate', 'first_additional', 'flat_then'], true);

View File

@ -370,7 +370,7 @@ final class CourierRequoteService
{
return $versions
->filter(static fn (CourierQuoteVersion $version): bool => ! $version->isVoided()
&& self::isSignedVersion($version)
&& ($version->isApprovedStockQuote() || self::isSignedVersion($version))
&& ($includeTemporary || ! $version->isTemporary())
&& $version->coversDate($date))
->sortBy([

View File

@ -77,7 +77,7 @@ final class CourierStockQuoteBatchService
], true)));
$courier = count($matches) === 1 ? $matches[0] : ['code' => '', 'name' => trim($sheet['sheet_name'])];
$code = $courier['code'] ?? '';
$duplicate = $this->entries->isDuplicate($courier, $manifest['defaults']['quote_type'], $existing);
$duplicate = $this->entries->isDuplicate($courier, $manifest['defaults']['quote_type'], $existing, ($manifest['defaults']['effective_date'] ?? null) ?: null, ($manifest['defaults']['end_date'] ?? null) ?: null);
return [...$sheet, 'courier_expcode' => $code, 'courier_name' => $courier['name'], 'duplicate' => $duplicate,
'match_status' => $sheet['error'] ?: ($duplicate ? '已录入,默认跳过' : ($code !== '' ? '已匹配' : (count($matches) > 1 ? '名称对应多条资料,请确认发货名称;按名称保存' : '可手填名称,请与发货数据一致'))),
@ -110,10 +110,12 @@ final class CourierStockQuoteBatchService
$rules = $data['rule_groups'][$row['rule_group'] ?? -1]['rules'] ?? '';
$metadata = [
'quote_type' => trim($data['quote_type']), 'effective_date' => ($row['effective_date'] ?? '') ?: ($data['effective_date'] ?? ''),
'end_date' => ($row['end_date'] ?? null) ?: (($data['end_date'] ?? null) ?: null),
'billing_rules' => trim($rules), 'summary' => $data['summary'],
];
$validation = Validator::make($metadata, [
'effective_date' => ['required', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
], ['effective_date.required' => '请填写实际执行日期或设置共用日期。', 'billing_rules.required' => '请选择并填写计费规则。']);
$reason = $sheet === null ? '工作表不存在。' : ($sheet['error'] ?? null);
@ -125,9 +127,9 @@ final class CourierStockQuoteBatchService
}
}
if ($courier !== null) {
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing) ? '此快递的同类报价已录入,请取消选择;后续调价使用变价。' : null;
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities) ? '与本批其他工作表填写了同一家快递,请检查名称或取消选择。' : null;
$identities[] = [...$courier, 'quote_type' => $metadata['quote_type']];
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $existing, $metadata['effective_date'], $metadata['end_date']) ? '与已录入的同类报价执行期间重叠,请核对起止日期。' : null;
$reason ??= $this->entries->isDuplicate($courier, $metadata['quote_type'], $identities, $metadata['effective_date'], $metadata['end_date']) ? '与本批同一家快递的执行期间重叠,请核对起止日期。' : null;
$identities[] = [...$courier, 'quote_type' => $metadata['quote_type'], 'effective_date' => $metadata['effective_date'], 'end_date' => $metadata['end_date']];
}
if ($reason !== null) {
$errors['entries.' . $index] = '「' . ($sheet['sheet_name'] ?? $index) . '」' . $reason;

View File

@ -73,17 +73,20 @@ final class CourierStockQuoteEntryService
public function existingIdentities(?int $exceptId = null): array
{
return CourierStockQuoteEntry::withTrashed()->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
->get(['courier_expcode', 'courier_name', 'quote_type'])
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type])->all();
return CourierStockQuoteEntry::withTrashed()->with(['change:id,company_id', 'change.version:id,company_id,source_change_id,status'])->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
->get(['id', 'company_id', 'source_change_id', 'courier_expcode', 'courier_name', 'quote_type', 'effective_date', 'end_date'])->filter(fn ($entry) => !$entry->change?->version?->isVoided())
->map(fn ($entry) => ['code' => $entry->courier_expcode, 'name' => $entry->courier_name, 'quote_type' => $entry->quote_type,
'effective_date' => $entry->effective_date?->toDateString(), 'end_date' => $entry->end_date?->toDateString()])->all();
}
public function isDuplicate(array $courier, string $quoteType, array $existing): bool
public function isDuplicate(array $courier, string $quoteType, array $existing, ?string $start = null, ?string $end = null): bool
{
foreach ($existing as $entry) {
if ($entry['quote_type'] !== $quoteType) {
continue;
}
if ($start !== null && isset($entry['effective_date'])
&& ($start > ($entry['end_date'] ?? '9999-12-31') || ($end ?? '9999-12-31') < $entry['effective_date'])) continue;
if (filled($courier['code']) && filled($entry['code'])) {
if ($courier['code'] === $entry['code']) {
return true;
@ -96,6 +99,18 @@ final class CourierStockQuoteEntryService
return false;
}
public function assertPeriod(array $courier, string $quoteType, string $start, ?string $end, ?int $exceptId = null): void
{
if ($end !== null && $end < $start) throw ValidationException::withMessages(['end_date' => '截止日期不能早于开始日期。']);
if ($this->isDuplicate($courier, $quoteType, $this->existingIdentities($exceptId), $start, $end)) $this->duplicateError();
}
private function lockCompany(): void
{
// 同公司导入/改期串行,覆盖空集合及无编号快递,防止并发导入重叠时期。
\App\Models\Common\Company::whereKey(company_id())->lockForUpdate()->firstOrFail();
}
/** 保留公式和 Excel 缓存值,不执行上传文件中的公式或外部引用。 */
public function readWorkbook(UploadedFile $file, ?string $sheetName): array
{
@ -203,9 +218,7 @@ final class CourierStockQuoteEntryService
{
$data['quote_type'] = trim($data['quote_type']);
$courier = $this->resolveCourier($data);
if ($this->isDuplicate($courier, $data['quote_type'], $this->existingIdentities())) {
$this->duplicateError();
}
$this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null);
$matrix = $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null);
return $this->createPrepared($data, $userId, $courier, $matrix);
@ -217,19 +230,21 @@ final class CourierStockQuoteEntryService
$savedPaths = [];
try {
return DB::transaction(function () use ($data, $userId, $courier, $matrix, $sourceFiles, &$savedPaths): CourierStockQuoteEntry {
$this->lockCompany();
$this->assertPeriod($courier, $data['quote_type'], $data['effective_date'], ($data['end_date'] ?? null) ?: null);
$entry = CourierStockQuoteEntry::create([
'company_id' => company_id(), 'courier_expcode' => $courier['code'], 'courier_name' => $courier['name'],
'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null,
'quote_type' => $data['quote_type'], 'sheet_name' => $matrix['sheet_name'], 'source_range' => $matrix['source_range'],
'values_json' => json_encode($matrix['values'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'formulas_json' => json_encode($matrix['formulas'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'files' => [], 'created_by' => $userId,
'files' => [], 'created_by' => $userId, 'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null,
]);
$files = [...$sourceFiles, ...$this->storeFiles($entry, $data, $savedPaths)];
$entry->update(['files' => $files]);
$change = $this->changes->create([
'source_quote_id' => 'STOCK-' . $entry->id, 'change_type' => '存量报价',
'effective_date' => $data['effective_date'], 'summary' => $data['summary'], 'items' => [],
'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null, 'summary' => $data['summary'], 'items' => [],
'quote_payload' => ['stock_quote_entry_id' => $entry->id, 'billing_rules' => $data['billing_rules']],
'proposed_values_json' => $entry->values_json,
], $userId);
@ -253,14 +268,13 @@ final class CourierStockQuoteEntryService
$courier = ['code' => $entry->courier_expcode, 'name' => $entry->courier_name];
if (isset($data['courier_name']) && trim($data['courier_name']) !== $entry->courier_name) {
$courier = $this->resolveCourier(['courier_name' => $data['courier_name']]);
if ($this->isDuplicate($courier, $entry->quote_type, $this->existingIdentities($entry->id))) {
$this->duplicateError();
}
}
$matrix = isset($data['source_quote']) ? $this->readWorkbook($data['source_quote'], $data['sheet_name'] ?? null) : null;
$savedPaths = [];
try {
DB::transaction(function () use ($entry, $data, $userId, $matrix, $courier, &$savedPaths): void {
$this->lockCompany();
$this->assertPeriod($courier, $entry->quote_type, $data['effective_date'], ($data['end_date'] ?? null) ?: null, $entry->id);
$files = $entry->files;
if ($matrix !== null) {
foreach ($files as &$file) {
@ -279,6 +293,7 @@ final class CourierStockQuoteEntryService
'courier_name' => $courier['name'], 'courier_expcode' => $courier['code'],
'courier_name_key' => blank($courier['code']) ? hash('sha256', mb_strtolower(trim($courier['name']))) : null,
'files' => [...$files, ...$this->storeFiles($entry, $data, $savedPaths)],
'effective_date' => $data['effective_date'], 'end_date' => ($data['end_date'] ?? null) ?: null,
])->save();
$entry->change->update(['carrier_name' => $courier['name'], 'source_sheet_name' => $entry->sheet_name, 'proposed_values_json' => $entry->values_json]);
$this->changes->update($entry->change, [...$data, 'change_type' => '存量报价', 'items' => []], $userId);
@ -311,6 +326,28 @@ final class CourierStockQuoteEntryService
private function duplicateError(): never
{
throw ValidationException::withMessages(['courier_name' => '该快递的此类存量报价已录入,请到报价历史继续处理;后续调价请使用变价。']);
throw ValidationException::withMessages(['courier_name' => '该快递的同类报价执行期间重叠,请核对起止日期;已有长期报价请先设置截止日期,不覆盖原报价。']);
}
public function setPeriodEnd(CourierStockQuoteEntry $entry, string $end, string $reason, int $userId): void
{
abort_unless($entry->company_id === (int) company_id(), 404);
DB::transaction(function () use ($entry, $end, $reason, $userId): void {
$this->lockCompany();
$entry->refresh()->load('change.version');
$version = $entry->change?->version;
if (!$version?->isApprovedStockQuote()) throw ValidationException::withMessages(['end_date' => '仅已审核且未作废的存量报价可设置截止日期;草稿请从报价历史编辑。']);
$this->assertPeriod(['code' => $entry->courier_expcode, 'name' => $entry->courier_name], $entry->quote_type, $entry->effective_date->toDateString(), $end, $entry->id);
$old = $entry->end_date?->toDateString();
$entry->update(['end_date' => $end]);
$entry->change->update(['end_date' => $end]);
$version->update(['end_date' => $end]);
\App\Models\Business\CourierQuoteChangeEvent::create([
'company_id' => company_id(), 'quote_change_id' => $entry->source_change_id, 'event_type' => 'period_end_updated',
'from_status' => $entry->change->status, 'to_status' => $entry->change->status, 'created_by' => $userId,
'note' => '设置执行截止日期:' . $reason,
'details_json' => json_encode(['previous_end_date' => $old, 'end_date' => $end], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
]);
});
}
}

View File

@ -142,6 +142,23 @@ final class QuoteManagementSnapshot
return $companyName !== '' ? $companyName : $name;
}
/** 「查看」面板的计费规则一行:计费方式 + 取整/计泡/限重/每票加收。 */
private function billingSummary(CourierQuoteVersion $version): string
{
$structure = (array) $version->billing_structure;
$kg = static fn ($value): string => CourierQuoteStructure::kg((float) $value);
$parts = array_filter([
CourierQuoteStructure::describe($structure),
($structure['rounding'] ?? 'none') !== 'none' ? (CourierQuoteStructure::ROUNDING[$structure['rounding']] ?? null) : null,
filled($structure['min_weight'] ?? null) ? '不足' . $kg($structure['min_weight']) . 'kg按' . $kg($structure['min_weight']) . 'kg计' : null,
filled($structure['volumetric_divisor'] ?? null) ? '计泡 ' . $structure['volumetric_divisor'] : null,
filled($structure['weight_limit'] ?? null) ? '限重 ' . $kg($structure['weight_limit']) . 'kg' : null,
filled($structure['per_ticket_surcharge'] ?? null) ? '每票加收 ' . $kg($structure['per_ticket_surcharge']) . ' 元' : null,
]);
return implode(';', $parts) . '(' . count((array) $version->billing_rows) . ' 个地区)';
}
private function normalizeVersion(CourierQuoteVersion $version): array
{
$values = $this->decodeMatrix($version->values_json);
@ -155,21 +172,29 @@ final class QuoteManagementSnapshot
'company_name' => $version->company_name,
'courier_expcode' => $version->courier_expcode,
'billing_rules' => $version->billing_rules,
'billing_summary' => $version->hasBillingRules() ? $this->billingSummary($version) : null,
'carrier' => $version->carrier_name,
'quote_type' => $version->quote_type,
'effective_date' => $version->effective_date?->toDateString(),
'end_date' => $version->end_date?->toDateString(),
'period_label' => $version->isTemporary() ? $version->periodLabel() : null,
'period_label' => $version->end_date !== null ? $version->periodLabel() : null,
'group_key' => (filled($version->courier_expcode) ? 'code:' . $version->courier_expcode : 'name:' . $version->company_name) . ':' . $version->quote_type,
'stock_entry_id' => $version->change?->isStockQuote() ? data_get($version->change->quote_payload, 'stock_quote_entry_id') : null,
'eligible' => !$version->isVoided() && $this->isExecutable($version),
'is_stock' => (bool) $version->change?->isStockQuote(),
'period_end_url' => $version->isApprovedStockQuote() && in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::ADMIN, \App\Utilities\SystemAccess::SUPER_ADMIN], true)
? route('business.courier-stock-quotes.period-end', data_get($version->change->quote_payload, 'stock_quote_entry_id')) : null,
'source_range' => $version->source_range ?? '',
'row_count' => count($values),
'column_count' => $columnCount,
'formula_count' => $this->formulaCount($formulas),
'status' => $this->versionLifecycleStatus($version),
'signature_status' => $version->signature_status,
'signature_status' => $version->signatureLabel(),
'values' => $values,
'formulas' => $formulas,
'source_change_id' => $version->source_change_id,
'signed_files' => $this->signedFiles($version),
'stock_originals' => $this->stockOriginals($version),
'signed_at' => $version->signature?->completed_at?->format('Y-m-d H:i')
?? $version->signature?->archived_at?->format('Y-m-d H:i'),
];
@ -205,6 +230,24 @@ final class QuoteManagementSnapshot
return $files;
}
private function stockOriginals(CourierQuoteVersion $version): array
{
if (!$version->change?->isStockQuote() || !($entry = $version->change->stockEntry)) return [];
$files = [];
foreach ($entry->files as $index => $file) {
if (($file['kind'] ?? '') !== 'signed') continue;
// 早期手动上传未记录 MIME;入口按扩展名提供,响应仍按实际 MIME 白名单校验。
$previewable = isset($file['mime_type'])
? in_array($file['mime_type'], ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true)
: in_array(strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)), ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'], true);
$files[] = [
'name' => $file['name'], 'previewable' => $previewable,
'url' => route('business.courier-stock-quotes.download', [$entry, $index, 'preview' => $previewable ? 1 : 0]),
];
}
return $files;
}
private function decodeMatrix(?string $json): array
{
if ($json === null || trim($json) === '') {
@ -223,8 +266,9 @@ final class QuoteManagementSnapshot
if ($version->isVoided()) {
return '已作废';
}
if ($version->end_date?->lt(today()) && $this->isExecutable($version)) return '已到期';
if (! $this->isSigned($version)) {
if (! $this->isExecutable($version)) {
return '待生效(待签章)';
}
@ -235,10 +279,10 @@ final class QuoteManagementSnapshot
return $version->isTemporary() ? '生效中(临时)' : '生效中';
}
/** 回签单已归档(以签署记录为准,版本上的文字状态可能滞后)。 */
private function isSigned(CourierQuoteVersion $version): bool
/** 存量报价审核生效;其他报价仍要求回签归档。 */
private function isExecutable(CourierQuoteVersion $version): bool
{
return in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
return $version->isApprovedStockQuote() || in_array($version->signature_status, ['已签署并归档', '历史签署资料已归档'], true)
|| $version->signature?->status === \App\Models\Business\CourierQuoteSignature::STATUS_ARCHIVED;
}
@ -261,7 +305,7 @@ final class QuoteManagementSnapshot
: 'name:' . $version->company_name)) as $group) {
$current = CourierRequoteService::resolveAt($group, $today);
$base = CourierRequoteService::resolveAt($group, $today, false);
$started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isSigned($version)
$started = $group->filter(fn (CourierQuoteVersion $version): bool => ! $version->isVoided() && $this->isExecutable($version)
&& $version->effective_date !== null && $version->effective_date->lte($today));
foreach ($started as $version) {
if ($current !== null && $version->id === $current->id) {
@ -270,8 +314,8 @@ final class QuoteManagementSnapshot
}
continue;
}
if ($version->isTemporary() && $version->end_date->lt($today)) {
$result[$version->id] = ['status' => '已到期', 'status_note' => '临时价 ' . $version->periodLabel()];
if ($version->end_date?->lt($today)) {
$result[$version->id] = ['status' => '已到期', 'status_note' => ($version->isTemporary() ? '临时价 ' : '执行期间 ') . $version->periodLabel()];
} elseif ($base !== null && $version->id === $base->id && $current?->isTemporary()) {
$result[$version->id] = ['status' => '暂停中', 'status_note' => $current->version_code . ' 临时价执行中,' . $current->end_date->copy()->addDay()->toDateString() . ' 起恢复'];
} elseif ($current !== null) {

View File

@ -0,0 +1,48 @@
<?php
namespace App\Utilities;
use App\Models\Auth\User;
class FinanceLedgerAccess
{
/**
* 账户映射的写权限:超级管理员,或配置中的财务台账管理员角色。
* 页面查看权限仍由 SidebarPermissions 的页面键控制。
*/
public static function canEditMapping(?User $user = null): bool
{
if (SystemAccess::isSuperAdmin($user)) {
return true;
}
$user ??= user();
if (! $user) {
return false;
}
if (SystemAccess::roleName($user) === SystemAccess::ADMIN) {
return true;
}
$roles = array_values(array_filter(array_map(
'trim',
explode(',', (string) config('finance_ledger.mapping_editor_roles', '财务台账管理员')),
)));
return in_array(SystemAccess::roleName($user), $roles, true);
}
/**
* 账户状态、账户类型与账户映射使用同一组财务台账编辑角色。
*/
public static function canEditAccountStatus(?User $user = null): bool
{
return self::canEditMapping($user);
}
public static function canEditAccountType(?User $user = null): bool
{
return self::canEditMapping($user);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Utilities;
use Illuminate\Database\Query\Builder;
class FinanceLedgerAccountShouldKeep
{
public const YES = '是';
public const NO = '否';
public const OPTIONS = [self::YES, self::NO];
public static function normalize(?string $value): string
{
$value = trim((string) $value);
if ($value === '') {
return '';
}
$compact = mb_strtoupper((string) preg_replace('/\s+/u', '', $value));
if (in_array($compact, ['是', '保留', '需要保留', '需要', 'Y', 'YES', 'TRUE', '1'], true)) {
return self::YES;
}
if (in_array($compact, ['否', '不保留', '不需要保留', '不需要', 'N', 'NO', 'FALSE', '0'], true)) {
return self::NO;
}
return $value;
}
public static function display(?string $explicitValue, ?string $companyPlan): string
{
$normalized = self::normalize($explicitValue);
if ($normalized !== '') {
return $normalized;
}
$plan = trim((string) $companyPlan);
if ($plan === '') {
return '';
}
return str_contains($plan, '保留') ? self::YES : self::NO;
}
public static function applyUnretainedCondition(
Builder $query,
string $explicitColumn = 'account_should_keep',
string $companyPlanColumn = 'company_plan',
): Builder {
return $query->where(function (Builder $keep) use ($explicitColumn, $companyPlanColumn): void {
$keep->where($explicitColumn, self::NO)
->orWhere(function (Builder $inferred) use ($explicitColumn, $companyPlanColumn): void {
$inferred->where(function (Builder $blank) use ($explicitColumn): void {
$blank->whereNull($explicitColumn)->orWhere($explicitColumn, '');
})
->whereNotNull($companyPlanColumn)
->where($companyPlanColumn, '<>', '')
->where($companyPlanColumn, 'not like', '%保留%');
});
});
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Utilities;
final class FinanceLedgerAccountStatus
{
public const NORMAL = 'normal';
public const ONLINE_BANKING_ABNORMAL = 'online_banking_abnormal';
public const LIMITED = 'limited';
public const BUSINESS_DEREGISTERED = 'business_deregistered';
public const OPTIONS = [
self::NORMAL => '正常',
self::ONLINE_BANKING_ABNORMAL => '网银异常',
self::LIMITED => '限额',
self::BUSINESS_DEREGISTERED => '工商注销',
];
public static function normalize(?string $status): string
{
$status = trim((string) $status);
return array_key_exists($status, self::OPTIONS) ? $status : self::NORMAL;
}
public static function label(?string $status): string
{
return self::OPTIONS[self::normalize($status)];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Utilities;
final class FinanceLedgerAccountType
{
public const ALIPAY = '支付宝';
public const BANK_ACCOUNT_TYPES = ['公户', '个人卡', '网商银行'];
public const DEFAULT_OPTIONS = [
'个人卡',
'公户',
self::ALIPAY,
'网商银行',
];
public static function options(array ...$groups): array
{
$types = self::DEFAULT_OPTIONS;
foreach ($groups as $group) {
foreach ($group as $type) {
$type = trim((string) $type);
if ($type !== '') {
$types[] = $type;
}
}
}
$types = array_values(array_unique($types));
sort($types, SORT_NATURAL | SORT_FLAG_CASE);
return $types;
}
public static function last4(?string $accountType, ?string $accountNo, ?string $fallback = null): string
{
$type = trim((string) $accountType);
if ($type === self::ALIPAY) {
return '/';
}
if (in_array($type, self::BANK_ACCOUNT_TYPES, true)) {
$digits = preg_replace('/\D+/', '', (string) $accountNo);
if (strlen((string) $digits) >= 4) {
return substr((string) $digits, -4);
}
}
$fallback = trim((string) $fallback);
return $fallback !== '' ? $fallback : '/';
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Utilities;
class FocusMode
{
/**
* 单页模式(钉钉工作台入口,或显式 ?focus=1):只保留该目录自己的页签,隐藏全局菜单。
* 网页端正常登录时不启用,避免页面上的目录页签和左侧菜单、已打开页面标签栏重复。
*/
public static function activeFor(?string $sectionSlug = null): bool
{
$section = $sectionSlug ?: (string) (request()->route('section') ?: request()->segment(3));
return in_array($section, ['financial-risk-control'], true)
&& (request()->boolean('focus') || str_contains((string) request()->userAgent(), 'DingTalk'));
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Utilities;
use App\Models\Auth\User;
class SidebarMenu
{
/**
* 侧边栏与首页共用的功能分组,按当前账号可访问的页面过滤,标题跟随后台自定义名称。
*
* @return array<int, array{title: string, items: array<int, array{section: string, page: string, title: string, icon: string}>}>
*/
public static function groups(?User $user = null): array
{
$user ??= user();
$groups = [
['title' => '工作台', 'items' => [
['workspace', 'overview', '经营总览', 'monitoring'],
]],
['title' => '经营报表', 'items' => [
['financial-operations', 'ledger', '财务台账', 'menu_book'],
['company-management', 'subject-operating-report', '主体维度经营报表', 'domain'],
['company-management', 'company-cost-arrangement', '公司成本安排', 'account_balance_wallet'],
['company-management', 'tax-forecast', '税金预测表', 'calculate'],
['store-management', 'store-report', '店铺维度报表', 'storefront'],
['store-management', 'internal-flow-report', '内部流转报表', 'swap_horiz'],
['company-management', 'personnel-inventory', '人员盘点表', 'groups'],
]],
['title' => '基础资料', 'items' => [
['store-management', 'basic-information', '店铺基础信息', 'shopping_bag'],
['store-management', 'store-accounts', '店铺账号密码', 'key'],
['company-management', 'basic-information', '公司基础信息', 'badge'],
['trademark-management', 'information', '商标基础信息', 'workspace_premium'],
]],
['title' => '规则信息', 'items' => [
['trademark-management', 'subject-trademark-rules', '主体商标规则', 'rule'],
['trademark-management', 'store-change-rules', '店铺变更规则', 'rule'],
['trademark-management', 'store-report-data-logic', '店铺维度报表逻辑配置', 'table_chart'],
['trademark-management', 'tax-forecast-data-logic', '税金预测表逻辑配置', 'calculate'],
]],
['title' => '业务进度', 'items' => [
['company-management', 'company-progress', '公司进度表', 'timeline'],
['store-management', 'change-progress', '店铺变更进度表', 'swap_horiz'],
['trademark-management', 'trademark-progress', '商标进度表', 'format_list_bulleted'],
['workspace', 'tasks', '待办事项', 'assignment_turned_in'],
['workspace', 'warnings', '预警页面', 'warning_amber'],
]],
['title' => '财务报表', 'items' => [
['prototype-finance-reports', 'profit-report', '公司利润报表', 'request_quote'],
['prototype-finance-reports', 'asset-report', '公司资产报表', 'account_balance'],
]],
];
$visiblePages = self::visiblePages($user);
$groups = array_map(function (array $group) use ($visiblePages): array {
$items = [];
foreach ($group['items'] as [$section, $page, $title, $icon]) {
$key = $section . '/' . $page;
if (! isset($visiblePages[$key])) {
continue;
}
$items[] = [
'section' => $section,
'page' => $page,
'title' => $visiblePages[$key],
'icon' => $icon,
];
}
$group['items'] = $items;
return $group;
}, $groups);
return array_values(array_filter($groups, static fn (array $group): bool => $group['items'] !== []));
}
/**
* 当前账号可访问的全部业务模块及页面,供首页做完整导航。
*
* @return array<int, array{slug: string, title: string, icon: string, pages: array<int, array{slug: string, title: string}>}>
*/
public static function sections(?User $user = null): array
{
$user ??= user();
$sections = [];
foreach (SidebarPermissions::navigationFor($user) as $section) {
$pages = [];
foreach ($section['pages'] ?? [] as $page) {
$pages[] = ['slug' => $page['slug'], 'title' => $page['title']];
foreach ($page['children'] ?? [] as $child) {
$pages[] = ['slug' => $child['slug'], 'title' => $child['title']];
}
}
if ($pages === []) {
continue;
}
$sections[] = [
'slug' => $section['slug'],
'title' => $section['title'],
'icon' => $section['icon'] ?? 'folder',
'pages' => $pages,
];
}
return $sections;
}
/**
* @return array<string, string> 「section/page」 => 页面标题
*/
private static function visiblePages(User $user): array
{
$pages = [];
foreach (SidebarPermissions::navigationFor($user) as $section) {
foreach ($section['pages'] ?? [] as $page) {
$pages[$section['slug'] . '/' . $page['slug']] = $page['title'];
foreach ($page['children'] ?? [] as $child) {
$pages[$section['slug'] . '/' . $child['slug']] = $child['title'];
}
}
}
return $pages;
}
}

View File

@ -5,11 +5,14 @@ namespace App\Utilities;
use App\Models\Auth\User;
use App\Models\Auth\UserAccessGroup;
use App\Services\Auth\FinanceRoster;
use App\Services\Auth\SupplierPortalAccess;
class SidebarPermissions
{
private const LEGACY_PAGE_KEYS = [
'refunds/small-payment-work-orders' => 'financial-operations/small-payment-work-orders',
'financial-ledger' => 'financial-operations/ledger',
'financial-ledger/ledger' => 'financial-operations/ledger',
];
private const FINAL_SECTION_KEYS = [
@ -43,6 +46,10 @@ class SidebarPermissions
public static function allowedKeys(User $user): ?array
{
// Limited identities cannot inherit full navigation from roles, groups or user overrides.
if (SupplierPortalAccess::isSupplierUser($user)) {
return [SupplierPortalAccess::PAGE_KEY];
}
if (self::hasLimitedDingTalkAccess($user)) {
return config('dingtalk.limited_login_page_keys', []);
}
@ -75,7 +82,8 @@ class SidebarPermissions
public static function hasLimitedDingTalkAccess(User $user): bool
{
return $user->created_from === 'dingtalk_roster_limited'
return SupplierPortalAccess::isSupplierUser($user)
|| $user->created_from === 'dingtalk_roster_limited'
|| (config('dingtalk.limited_login_user_ids', []) !== []
&& FinanceRoster::isLimitedLoginUser((string) $user->dingtalkIdentity?->dingtalk_user_id));
}
@ -193,6 +201,17 @@ class SidebarPermissions
// 「种菜明细表 / 聚水潭订单标签明细」原先挂在「账单流水数据」下,后来移入新的一级菜单
// 「种菜数据汇总」。按页面键保存过精确权限的账号(访问组/角色)里不会有新的 planting-data,
// 会因此看不到这两个被移动的页面;这里按「能看账单流水数据 就能看这两个页面」兼容旧配置。
// 「供应商对账」与「供应商应付(终版)」同属应付款项,先按同样的权限跟随授权
// (厂家账号后续按单页面单独配置)。
$visibleSlugs = array_column($navigation, 'slug');
if (! in_array('supplier-reconciliation', $visibleSlugs, true) && in_array('supplier-payables-final', $visibleSlugs, true)) {
$reconciliationSection = collect(BusinessNavigation::all())->firstWhere('slug', 'supplier-reconciliation');
if (! empty($reconciliationSection)) {
$index = array_search('supplier-payables-final', $visibleSlugs, true);
array_splice($navigation, $index + 1, 0, [$reconciliationSection]);
}
}
$slugs = array_column($navigation, 'slug');
if (! in_array('planting-data', $slugs, true) && in_array('billing-transactions', $slugs, true)) {
$plantingSection = collect(BusinessNavigation::all())->firstWhere('slug', 'planting-data');

View File

@ -68,6 +68,7 @@ services:
volumes:
- caiwu_clickhouse_database:/var/lib/clickhouse
- caiwu_clickhouse_logs:/var/log/clickhouse-server
- ./clickhouse-resource-policy.xml:/etc/clickhouse-server/config.d/caiwu-resource-policy.xml:ro
mem_limit: 4g
cpus: "2.00"
ulimits:
@ -93,6 +94,9 @@ services:
pull_policy: never
container_name: caiwu_app
restart: unless-stopped
# Web requests and small scheduled jobs have their own memory boundary.
mem_limit: 2g
memswap_limit: 2g
depends_on:
db:
condition: service_healthy
@ -100,6 +104,7 @@ services:
condition: service_healthy
environment:
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-0}
FINANCE_BATCH_WORKER_ENABLED: "1"
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
JST_DB_CONNECTION: business_mirror
env_file:
@ -121,6 +126,36 @@ services:
retries: 10
start_period: 45s
# Heavy CLI jobs use a separate cgroup from Apache.
batch:
image: caiwu_akaunting:${IMAGE_TAG:?IMAGE_TAG is required}
pull_policy: never
profiles: [batch]
user: www-data
entrypoint: [php, artisan]
command: [list]
restart: "no"
environment:
FINANCE_BATCH_CONTAINER: "1"
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
JST_DB_CONNECTION: business_mirror
env_file:
- ./runtime/qiniu-storage.env
- ./runtime/clickhouse.env
volumes:
- ./runtime/.env:/var/www/html/.env:ro
- caiwu_storage:/var/www/html/storage
cpus: "1.00"
mem_limit: ${FINANCE_BATCH_MEMORY_LIMIT:-3g}
memswap_limit: ${FINANCE_BATCH_MEMORY_LIMIT:-3g}
healthcheck:
disable: true
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
caiwu_database:
name: caiwu_database

View File

@ -177,5 +177,10 @@ return [
'target_store' => 'mariadb',
'consumer' => '店铺经营报表',
],
'store_report_paid_sales_rows' => [
'role' => 'aggregate',
'target_store' => 'mariadb',
'consumer' => '店铺维度报表销量(业绩明细收入+优惠+冲减+退款金额合计)',
],
],
];

View File

@ -252,7 +252,7 @@ $navigation = [
'directory' => [
'icon' => 'query_stats',
'status' => '共享镜像',
'description' => '店铺资料、主体生效区间和平台账单统一读取团队共享业务镜像;销量按账单收入汇总。',
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入) + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
'empty' => '店铺维度报表尚未完成共享数据导入。',
'fields' => [
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],
@ -540,6 +540,8 @@ $navigation = [
'pages' => [
['slug' => 'planting-details', 'title' => '种菜明细表'],
['slug' => 'jst-order-labels', 'title' => '聚水潭订单标签明细'],
['slug' => 'planting-reimbursements', 'title' => '种菜报销'],
['slug' => 'planting-loss', 'title' => '种菜损耗'],
],
],
[
@ -646,6 +648,15 @@ $navigation = [
['slug' => 'supplier-payables-report', 'title' => '供应商应付表'],
],
],
[
'slug' => 'supplier-reconciliation',
'title' => '供应商对账',
'icon' => 'fact_check',
'pages' => [
['slug' => 'finance-data', 'title' => '财务端数据'],
['slug' => 'supplier-data', 'title' => '厂家端数据'],
],
],
[
'slug' => 'courier-payables-final',
'title' => '快递应付(终版)',
@ -676,6 +687,7 @@ $navigation = [
'icon' => 'local_shipping',
'pages' => [
['slug' => 'supplier-management', 'title' => '供应商管理'],
['slug' => 'accessory-freight-deductions', 'title' => '新河北仓、曹县仓配件运费扣除明细'],
['slug' => 'product-prices', 'title' => '商品价格'],
['slug' => 'payment-reconciliation', 'title' => '货款对账'],
['slug' => 'return-reconciliation', 'title' => '退货对账'],
@ -685,6 +697,7 @@ $navigation = [
['slug' => 'advance-details', 'title' => '借支明细'],
['slug' => 'sample-deductions', 'title' => '样品扣款'],
['slug' => 'other-adjustments', 'title' => '其他扣款补款'],
['slug' => 'manufacturer-bookkeeping', 'title' => '厂家记账'],
[
'slug' => 'unpriced-details',
'title' => '未定价明细',
@ -809,7 +822,7 @@ $navigation = [
'directory' => [
'icon' => 'receipt_long',
'status' => '共享镜像',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」另取总账备注=优惠收入 / 冲减收入的账单金额,不计入收入金额;「退款金额」另取总账备注=退款的账单金额,只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额(两个条件是或的关系),只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额)−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注)、差额=直营补贴款(待开票结算)(仅京喜账单·出类拔萃等 10 家京喜自营工厂店负差额且无退款(已逐店在结算单核实):京东直营补贴款待开票结算时标注)、差额=直营补贴款(订单退款已冲回)(同批店负差额但订单已退款:补贴款已随退款冲回)、差额=订单实付包含商家承担优惠和以旧换新国补(仅京东账单·索菲亚客厅/办公/书房/功能、亿家达全屋、朴美家居店负差额、且该单含国补(聚水潭应付−账单收入>0):差额为商家承担优惠(店铺东券/单品折扣等)与以旧换新国补之和,已用营销对账逐单核实)、差额=订单实付包含商家承担优惠(同店负差额但不含国补的单:差额即商家承担优惠)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)(以上三档等式对带口径标识的行让位给口径兜底)、订单实付含已退款部分(抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注;淘系支付宝/微信负差额:差额=退款合计、或退款合计=实付−收入(收款缺口)、或整单全退(退款合计≈实付)时同样标注(退款取聚水潭已确认记录));都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'description' => '覆盖店铺收款账户账单各平台页签(总账备注=订单收入),按「店铺 + 订单号」匹配订单侧商品金额与订单实付金额;一行 = 一个店铺的一个订单,商品金额只挂一次。「商品金额」按「主单(拆单子单不参与)→ 原单行(merge_so_id 为空)→ 全部行」优先级取数,合单发货(原单与合并行共用同一线上单号、没有主单)时只取本单原单行,避免把合并的另一单算进来;并排除虚拟条码(X0000x:信誉订单 / 虚拟订单)明细行;「订单实付金额」取订单级实付(同一套优先级,末档兜底取该单号下最大值)再减去虚拟条码行实付。「优惠金额」「冲减金额」「违约赔付(冲减收入)」分别取对应总账备注的账单净额,不计入收入金额;「退款金额」只单独列示,不计入收入金额、也不参与差额;「种菜金额」是订单号在种菜明细表或聚水潭订单标签明细(是否种菜订单=是)任意一边出现过时,该订单的收入金额,只是收入金额里的一部分,不参与差额;差额=(收入金额+优惠金额+冲减金额+违约赔付(冲减收入))−订单实付金额,匹配状态与差额异常备注同样按订单实付金额判定,未匹配到订单实付金额的行按订单实付金额=0 计入差额,只反映真正对不上的部分。挂不到订单行的抵减项会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定;具体规则均未命中且相关金额在 2 分钱内精确对平时,退款及其与抵减项的组合复用「订单实付含已退款部分」,优惠、冲减与违约赔付组合复用「订单实付未减优惠和冲减」;没有可复用的原备注时继续标「差额待核实」。都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
'fields' => [
['name' => 'platform', 'label' => '平台'],
@ -819,8 +832,10 @@ $navigation = [
['name' => 'income_amount', 'label' => '收入金额', 'kind' => 'number'],
['name' => 'discount_amount', 'label' => '优惠金额', 'kind' => 'number'],
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
['name' => 'default_compensation_amount', 'label' => '违约赔付(冲减收入)', 'kind' => 'number'],
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
['name' => 'planting_label', 'label' => '标签'],
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
['name' => 'paid_amount', 'label' => '订单实付/实收金额', 'kind' => 'number'],
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],
@ -866,6 +881,7 @@ $navigation = [
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
['slug' => 'non-operating-expenses', 'title' => '营业外支出'],
['slug' => 'financial-analysis', 'title' => '财务经营分析表'],
['slug' => 'ledger', 'title' => '财务台账', 'show_in_section_menu' => false],
],
],
[
@ -877,7 +893,7 @@ $navigation = [
['slug' => 'preview-statistics', 'title' => '预览统计'],
[
'slug' => 'process-statistics',
'title' => '流程统计',
'title' => '店铺流程',
'directory' => [
'icon' => 'timeline',
'status' => '共享镜像',

7
config/finance_batch.php Normal file
View File

@ -0,0 +1,7 @@
<?php
return [
'container' => (bool) env('FINANCE_BATCH_CONTAINER', false),
// The host runner and application share this registry.
'import_commands' => json_decode(file_get_contents(base_path('deploy/finance-batch-imports.json')), true, 512, JSON_THROW_ON_ERROR),
];

29
config/finance_ledger.php Normal file
View File

@ -0,0 +1,29 @@
<?php
return [
// 台账期初日,也是每日资金流水滚动的起点。
'opening_date' => env('FINANCE_LEDGER_OPENING_DATE', '2026-10-17'),
// 台账取数窗口。期初日可以晚于窗口结束日,二者互不影响。
'window_start' => env('FINANCE_LEDGER_WINDOW_START', '2026-04-18'),
// 留空表示同步到当天;首次建账快照截止日由导入文件自身决定。
'window_end' => env('FINANCE_LEDGER_WINDOW_END', ''),
// 动账明细的唯一上游来源;本地调试可切换到本地快照导入。
'source' => env('FINANCE_LEDGER_RPT_SOURCE', 'operations_replica'),
// 台账上游查询允许更长的超时;普通页面仍使用共享连接的 15 秒。
'query_timeout' => (int) env('FINANCE_LEDGER_QUERY_TIMEOUT', 120),
// 具备账户映射写权限的角色;超级管理员始终可写。
'mapping_editor_roles' => env('FINANCE_LEDGER_MAPPING_EDITOR_ROLES', '财务台账管理员'),
'dingtalk_process_codes' => [
'PROC-B290696F-EC2A-4F76-87BA-E0A12E10D09F' => '日常费用报销流程',
'PROC-C5159B8D-F412-4DF8-981D-64EBB8D03EB6' => '种菜报销流程',
'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88' => '应付申请流程',
'PROC-BBAC7187-9E59-4942-941A-59C7EAF0CEDA' => '快递面单应付流程',
'PROC-ACF09A14-B094-46B1-A9E2-8BB1F4E1FDCF' => '线上代发业务报销',
'PROC-6407E98B-040A-4C96-AA7D-696650FC1A25' => '固定资产报废或出售流程',
],
];

View File

@ -0,0 +1,143 @@
<?php
/*
|--------------------------------------------------------------------------
| 订单售后费用明细(聚水潭「订单售后费用数据」导出)
|--------------------------------------------------------------------------
|
| 聚水潭导出文件是中文表头,这里把表头映射成页面字段键,导入时按这个映射入库。
| 未列出的表头会忽略;新增表头时补在这里即可。
*/
return [
'headers' => [
'内部订单号' => 'internal_order_no',
'线上订单号' => 'online_order_no',
'子订单号' => 'sub_order_no',
'售后单号' => 'after_sales_no',
'支付时间' => 'pay_time',
'订单完成时间' => 'order_complete_time',
'发货时间' => 'send_time',
'订单时间' => 'order_time',
'店铺名称' => 'shop_name',
'店铺编码' => 'shop_code',
'商品编码' => 'product_code',
'款式编码' => 'style_code',
'款式编码(参考)' => 'style_code',
'店铺商品编码' => 'shop_product_code',
'店铺款式编码' => 'shop_style_code',
'链接' => 'product_link',
"链接\u{3000}" => 'product_link',
'品牌' => 'brand',
'分类' => 'category',
'虚拟分类' => 'virtual_category',
'颜色及规格' => 'color_spec',
'商品名称' => 'product_name',
'唯一码' => 'unique_code',
'商品数量' => 'product_quantity',
'商品金额' => 'product_amount',
'商品成本' => 'product_cost',
'成本来源' => 'cost_source',
'其中:红冲订单成本' => 'reversal_order_cost',
'手动调整收入' => 'manual_adjust_income',
'手动调整成本' => 'manual_adjust_cost',
'商家优惠' => 'merchant_discount',
'运费收入' => 'freight_income',
'付款金额' => 'payment_amount',
'其中:平台优惠' => 'platform_discount',
'其中:红冲订单金额' => 'reversal_order_amount',
'实发金额' => 'actual_shipped_amount',
'实发数量' => 'actual_shipped_quantity',
'实发成本' => 'actual_shipped_cost',
'订单类型' => 'order_type',
'登记时间' => 'registration_time',
'确认时间' => 'confirmation_time',
'进仓时间' => 'warehouse_entry_time',
'线上商品名称' => 'online_product_name',
'发货前申请数量' => 'pre_application_quantity',
'发货前申请金额' => 'pre_application_amount',
'发货前申请成本' => 'pre_application_cost',
'发货前退回数量' => 'pre_return_quantity',
'发货前退回金额' => 'pre_return_amount',
'发货前退回成本' => 'pre_return_cost',
'发货前退款金额' => 'pre_refund_amount',
'发货前退款成本' => 'pre_refund_cost',
'发货前退平台补贴' => 'pre_platform_subsidy',
'发货后申请数量' => 'post_application_quantity',
'发货后申请金额' => 'post_application_amount',
'发货后申请成本' => 'post_application_cost',
'发货后退回数量' => 'post_return_quantity',
'发货后退回金额' => 'post_return_amount',
'发货后退回成本' => 'post_return_cost',
'发货后退款金额' => 'post_refund_amount',
'发货后退款成本' => 'post_refund_cost',
'发货后退平台补贴' => 'post_platform_subsidy',
'其中:次品商品数量' => 'defective_quantity',
'退款类型' => 'refund_type',
'问题类型' => 'question_type',
'明细退货类型' => 'detail_return_type',
'分类单' => 'classification_order',
'赠品' => 'is_gift',
'组合装' => 'combine_type',
'订单标签' => 'order_tags',
'售后单标签' => 'after_sales_tags',
'商品标签' => 'item_tags',
'发货仓编号' => 'shipping_warehouse_code',
'发货仓库' => 'shipping_warehouse',
'运营云仓' => 'operation_cloud_warehouse',
'收货仓编号' => 'receiving_warehouse_code',
'收货仓' => 'receiving_warehouse',
'供销商编号' => 'supplier_code',
'供销商' => 'supplier_name',
'快递单号' => 'express_no',
'快递公司' => 'express_company',
'省' => 'province',
'市' => 'city',
'业务员' => 'salesperson',
'线下客户编号' => 'offline_customer_code',
'线下客户编码' => 'offline_customer_code',
'线下客户名称' => 'offline_customer_name',
'500112冲减收入' => 'account_500112',
'60031erp快递费' => 'account_60031',
'800101其他服务费' => 'account_800101',
'800103其他活动费用' => 'account_800103',
'800104违约赔付' => 'account_800104',
'800105平台佣金' => 'account_800105',
'800105佣金' => 'account_800105',
'800106保证金' => 'account_800106',
'800107推广充值' => 'account_800107',
'800108品牌新享' => 'account_800108',
'800109淘宝客/多多进宝' => 'account_800109',
'800110运费险' => 'account_800110',
'800114公益宝贝' => 'account_800114',
'8001201直通车/钻展/快车等' => 'account_8001201',
'8001202推广红包冲减' => 'account_8001202',
'800122售后补偿' => 'account_800122',
'800125红包' => 'account_800125',
'800127退货运费' => 'account_800127',
'800129退款' => 'account_800129',
'800130订单' => 'account_800130',
'800132其他费用' => 'account_800132',
'800133品牌新享-收入' => 'account_800133',
'800304售后补偿-聚工单' => 'account_800304',
'800501种菜佣金' => 'account_800501',
'800503发空包/赠品费用' => 'account_800503',
],
'number_keys' => [
'product_quantity', 'product_amount', 'product_cost', 'reversal_order_cost',
'manual_adjust_income', 'manual_adjust_cost', 'merchant_discount', 'freight_income',
'payment_amount', 'platform_discount', 'reversal_order_amount', 'actual_shipped_amount',
'actual_shipped_quantity', 'actual_shipped_cost', 'pre_application_quantity',
'pre_application_amount', 'pre_application_cost', 'pre_return_quantity', 'pre_return_amount',
'pre_return_cost', 'pre_refund_amount', 'pre_refund_cost', 'pre_platform_subsidy',
'post_application_quantity', 'post_application_amount', 'post_application_cost',
'post_return_quantity', 'post_return_amount', 'post_return_cost', 'post_refund_amount',
'post_refund_cost', 'post_platform_subsidy', 'defective_quantity',
'account_500112', 'account_60031', 'account_800101', 'account_800103', 'account_800104',
'account_800105', 'account_800106', 'account_800107', 'account_800108', 'account_800109',
'account_800110', 'account_800114', 'account_8001201', 'account_8001202', 'account_800122',
'account_800125', 'account_800127', 'account_800129', 'account_800130', 'account_800132',
'account_800133', 'account_800304', 'account_800501', 'account_800503',
],
];

View File

@ -9,7 +9,8 @@ return [
*
* 两个口径的取数链完全一致(同店同号 → 店铺名不一致 → 同号多条 → 空店铺兜底),
* 只是把「订单侧金额」从商品金额换成订单实付金额;差额公式仍是
* (收入金额 + 优惠金额 + 冲减金额)− 订单侧金额,退款金额单独列示、不参与差额。
* (收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入))− 订单侧金额,
* 退款金额单独列示、不参与差额。
*/
'difference_basis' => env('PERFORMANCE_DETAILS_DIFFERENCE_BASIS', 'paid'),
];

16
config/sidebar_links.php Normal file
View File

@ -0,0 +1,16 @@
<?php
/*
|--------------------------------------------------------------------------
| 侧边栏「常用网址」
|--------------------------------------------------------------------------
| 搜索框右侧的网址入口,按数组顺序展示,点击在新标签页打开。
| 例:['title' => '聚水潭 ERP', 'url' => 'https://example.com'],
*/
return [
['title' => '聚水潭', 'url' => 'https://w.erp321.com/login.aspx'],
['title' => '工作平台', 'url' => 'https://www.jiansun.vip/MiniUI/Index'],
['title' => '预策', 'url' => 'http://218.93.9.141:8088/#/site-view/RPucab5JJI/J18wWGR1uC'],
['title' => '由壹点「脉象」', 'url' => 'https://yyd.jiansuntuzhi.cn/login'],
];

View File

@ -193,6 +193,13 @@ return [
*/
'table_sync' => [
'batch_size' => (int) env('BUSINESS_MIRROR_TABLE_BATCH_SIZE', 500),
// These wide SQL Server views time out when TOP/pagination is planned
// together with every payload column. Select the bounded keys first,
// then look up only those rows in the same read-only statement.
'key_first_datasets' => [
'operations_replica.rpt.jst_Orders',
'operations_replica.rpt.jst_originalBillInfo',
],
'max_batches_per_run' => (int) env('BUSINESS_MIRROR_TABLE_MAX_BATCHES', 2),
'max_datasets_per_run' => (int) env('BUSINESS_MIRROR_MAX_DATASETS', 1),
'overlap_hours' => (int) env('BUSINESS_MIRROR_OVERLAP_HOURS', 48),
@ -211,8 +218,13 @@ return [
'full_reconcile_hours' => 1,
'columns' => ['Id', 'guid', 'sourcefrom', 'coopersgn', 'scmcode', 'scmname', 'scmtype',
'tradebody', 'company', 'enterpriseType', 'creditcode', 'handlePerson', 'accountbankno', 'legalPerson',
'buyer', 'tradebtime', 'tradeetime', 'cooperbtime', 'cooperetime', 'memo',
'updator', 'local_created', 'local_updated'],
'paycyclesgn', 'corpmobile', 'tradebtime', 'tradeetime', 'cooperbtime', 'cooperetime',
'undefined1', 'undefined2', 'dsper', 'reconciliter', 'levelname',
'linkerprovience', 'linkercity', 'linkerarea', 'linkeradd', 'memo',
'buyer', 'buymid', 'buytop', 'craftType', 'outline', 'capacity', 'freereason',
'area', 'stocker', 'region', 'sindou', 'follower', 'presc', 'strategy',
'updator', 'local_created', 'local_updated', 'send_date',
'endTimeA', 'endTimeB', 'finishTimeA', 'finishTimeB'],
'indexes' => [['scmcode', 'Id'], ['coopersgn', 'Id']],
],
'jiansun.dbo.js_supplier_contract_View' => [
@ -313,9 +325,14 @@ return [
'keys' => ['Id'],
'watermark' => 'updateTime',
'indexes' => [
['Id'],
['as_date', 'Id'],
['o_id', 'Id'],
['as_id', 'Id'],
['shop_id', 'outer_as_id(100)'],
['so_id(64)'],
['outer_as_id(64)'],
['as_date', 'Id', 'as_id', 'shop_site(300)'],
],
],
'operations_replica.rpt.jst_returnAndRefundItem' => [
@ -324,11 +341,14 @@ return [
'object' => 'jst_returnAndRefundItem',
'target' => 'ops_jst_return_refund_item',
'keys' => ['Id'],
// The replica can replace historical detail IDs. Append-only sync
// cannot discover those changes; reconcile in bounded weekly runs.
'full_reconcile_hours' => 168,
'indexes' => [
['as_id', 'Id'],
],
],
'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime'],
'operations_replica.rpt.jst_shopInfo' => ['source_key' => 'operations_replica', 'schema' => 'rpt', 'object' => 'jst_shopInfo', 'target' => 'ops_jst_shop_info', 'keys' => ['Id'], 'watermark' => 'updateTime', 'indexes' => [['shop_id', 'Id']]],
// 淘系(天猫/淘宝)订单与售后:聚水潭单独存放在 qmcust 这套表里,
// 与 jst_Orders / jst_OrderItem / jst_returnAndRefundInfo 分开。
@ -371,6 +391,8 @@ return [
['as_id'],
['o_id', 'as_id'],
['as_date', 'as_id'],
['so_id(64)'],
['outer_as_id(64)'],
],
],
'operations_replica.rpt.jst_qmcust_refundlist_item' => [
@ -384,6 +406,35 @@ return [
['as_id', 'asi_id'],
],
],
// 聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」的原表。
// 列名是 undefined1..undefinedN,中文表头由 ..._schemas 表提供映射。
'operations_replica.rpt.jst_goods_profit_kpi_items_afs' => [
'source_key' => 'operations_replica',
'schema' => 'rpt',
'object' => 'jst_goods_profit_kpi_items_afs',
'target' => 'ops_jst_goods_profit_kpi_items_afs',
'keys' => ['id'],
'watermark' => 'updatedAt',
'batch_size' => 2000,
'max_batches_per_run' => 4,
// 160 多个 nvarchar 列,整行按 VARCHAR 建表会超过 InnoDB 行长度上限。
'wide_strings' => true,
'indexes' => [
['undefined8', 'undefined7'],
['undefined1'],
['undefined2'],
['undefined10'],
],
],
'operations_replica.rpt.jst_goods_profit_kpi_items_afs_schemas' => [
'source_key' => 'operations_replica',
'schema' => 'rpt',
'object' => 'jst_goods_profit_kpi_items_afs_schemas',
'target' => 'ops_jst_goods_profit_kpi_items_afs_schemas',
'keys' => ['id'],
'watermark' => 'id',
],
'operations_replica.rpt.jst_originalBillInfo' => [
'source_key' => 'operations_replica',
'schema' => 'rpt',

View File

@ -1,6 +1,7 @@
<?php
return [
'batch_worker_enabled' => (bool) env('FINANCE_BATCH_WORKER_ENABLED', false),
// Connection records supply addresses and credentials, never these defaults.
'defaults' => ['enabled' => false, 'connection' => ['connect_timeout' => 5, 'query_timeout' => 15]],
'sync' => [

View File

@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 店铺维度报表的「销量」改为读取业绩明细的「订单实付/实收金额」:
* 业绩明细(store_receipt_order_match_rows)按「店铺名称 + 发生时间」预汇总到本表,
* 页面用发生日期与主体生效/失效时间关联,不再按平台账单收入计算销量。
*
* 预汇总表很小(每店铺每天一行),金额合计与业绩明细的「订单实付/实收金额」列一致。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_report_paid_sales_rows')) {
$schema->create('store_report_paid_sales_rows', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->uuid('generation');
$table->string('store_name', 191);
$table->date('occurred_on');
$table->decimal('paid_amount', 20, 4)->default(0);
$table->unsignedInteger('order_count')->default(0);
$table->dateTime('updated_at');
$table->unique(
['generation', 'store_name', 'occurred_on'],
'store_report_paid_generation_store_date_unique',
);
$table->index(['generation', 'occurred_on'], 'store_report_paid_generation_date_index');
});
}
// 业绩明细按月/平台整体重写,用指纹判断是否需要重建销量汇总。
if ($schema->hasTable('store_report_import_states')
&& ! $schema->hasColumn('store_report_import_states', 'source_fingerprint')) {
$schema->table('store_report_import_states', function (Blueprint $table): void {
$table->string('source_fingerprint', 64)->nullable()->after('source_files_json');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
$schema->dropIfExists('store_report_paid_sales_rows');
if ($schema->hasTable('store_report_import_states')
&& $schema->hasColumn('store_report_import_states', 'source_fingerprint')) {
$schema->table('store_report_import_states', function (Blueprint $table): void {
$table->dropColumn('source_fingerprint');
});
}
}
};

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