Compare commits

...

111 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
231 changed files with 24056 additions and 3524 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` 是同源机器规范。
@ -71,6 +80,7 @@
- 当前生产入口为 `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

@ -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

@ -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

@ -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

@ -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

@ -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,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,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

@ -11,17 +11,28 @@ 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'),
$this->option('from') ?: null,
$from,
$this->option('to') ?: null,
$merge,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());

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,22 +186,26 @@ class SyncStoreReceiptProductCost extends Command
}
$this->info('订单侧候选单号:' . count($candidates));
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key: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, "
. " CASE WHEN o.link_o_id IS NULL OR o.link_o_id = 0 THEN 1 ELSE 0 END as is_main, "
. " 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
. " 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());
@ -212,22 +236,33 @@ class SyncStoreReceiptProductCost extends Command
$warehouse = '';
}
}
// 组合装条码(ZH-条码-数量-条码-数量)按子订单拆成组件行:
// 一个订单有几个发货仓库、商品编码、数量就分开列,组件数量 = 明细数量 × 组件倍数。
// 解析不了的组合条码(条码被截断等)保持原样,避免丢行。
foreach (self::expandComboSku($sku, $qty) as [$code, $codeQty, $isComponent]) {
$lines[$core][] = [
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
'sku' => mb_strtoupper($code),
'qty' => $codeQty,
// 组合装明细金额对应整包,拆到组件后不再参与「数量×单价 vs 明细金额」校验。
'amount' => $isComponent ? 0.0 : $amount,
'warehouse' => $warehouse,
// 拆单后子单(link_o_id 非空)才是实际发货行;主单只是原始下单记录。
'is_main' => (int) $row->is_main === 1,
'row_key' => (string) $row->row_key,
];
// 发货仓库过滤(在商品编码全局排除之后执行):
// 默认排除常州简尚家居有限公司 / 简尚-苏州福利品仓 / 简尚-自动发货仓;
// 例外:常州简尚家居有限公司里「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 ?? ''))),
];
}
}
$this->info('订单侧命中核心单号:' . count($lines));
@ -236,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);
@ -254,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;
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
@ -272,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);
@ -301,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;
}
@ -313,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'],
@ -342,30 +421,128 @@ 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;
}
/**
* 拆单取数:聚水潭拆单后,主单(link_o_id 为空)保留的是原始下单行,
* 子单(link_o_id 指向主单)才是实际发货行。一个订单只要出现子单,就只取子单,
* 否则主单和子单会被各算一遍(主单的原始商品 + 子单的实际发货商品)。
* 没有子单的订单才用主单行。
* @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)只是汇总记录,
* 最末级订单行(没有任何下级)才是实际发货行。
*
* 同一订单的多条子单(不同仓库 / 分批发货)按「发货仓库 + 商品编码」汇总,数量相加,
* 这样「一个订单有几个发货仓库、商品编码、数量」会分开列。
* 取数规则:
* 1) 按「o_id + oi_id + 商品编码」去掉完全重复行;
* 2) 只保留叶子行——凡是被同一订单其他行 link_o_id 引用过的 o_id 全部剔除,
* 避免主单、中间汇总层与末级发货行重复计数;
* 3) 订单内只要存在带仓库的行,就丢弃空仓库的汇总/占位行;
* 如果带仓库的行全部是 Cancelled,则整单不参与商品成本;
* 4) 排除 Cancelled 行,同一订单的多条末级行按「发货仓库 + 商品编码」汇总数量。
*
* @param list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}> $rows
* @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 [];
}
@ -373,23 +550,111 @@ class SyncStoreReceiptProductCost extends Command
// 1) 先按订单明细主键去掉上游的完全重复行(同一 o_id + oi_id 只算一次)。
$unique = [];
foreach ($rows as $row) {
$key = $row['row_key'] . "\0" . $row['sku'];
$key = $row['source'] . "\0" . $row['row_key'] . "\0" . $row['sku'];
$unique[$key] = $row;
}
// 2) 有子单就只取子单(实际发货行);整单没有子单才用主单行。
$hasSubOrder = false;
// 2) 组合装拆分后的组件会记录 src_combine_sku_id。源订单上的组合装主行
// 只是汇总占位,不能与组件同时计入成本。
$bundleParents = [];
foreach ($unique as $row) {
if (! $row['is_main']) {
$hasSubOrder = true;
$parentSku = mb_strtoupper(trim((string) ($row['src_combine_sku_id'] ?? '')));
if ($parentSku !== '') {
$bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $parentSku] = true;
}
}
if ($bundleParents !== []) {
foreach ($unique as $key => $row) {
if (isset($bundleParents[$row['source'] . "\0" . $row['o_id'] . "\0" . $row['sku']])) {
$bundleParentExcludedRows++;
unset($unique[$key]);
}
}
}
// 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 ($hasSubOrder) {
$unique = array_filter($unique, static fn (array $row): bool => ! $row['is_main']);
if ($hasWarehouse) {
$unique = array_filter(
$unique,
static fn (array $row): bool => trim((string) $row['warehouse']) !== ''
);
}
// 3) 汇总到「发货仓库 + 商品编码」。
// 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'];
@ -407,6 +672,7 @@ class SyncStoreReceiptProductCost extends Command
/**
* 组合装条码拆组件:聚水潭的组合装 sku 形如
* ZH-<条码>-<数量>[-<条码>-<数量>...]
* MKH-<条码>-<数量>[-<条码>-<数量>...]
* 条码本身含横杠,所以按「纯数字段」识别数量,再按组件展开。
*
* 返回 [条码, 数量, 是否组件];非组合装或解析失败时返回原条码 / 原数量。
@ -415,11 +681,18 @@ class SyncStoreReceiptProductCost extends Command
*/
private static function expandComboSku(string $sku, float $quantity): array
{
if (! str_starts_with(mb_strtoupper($sku), 'ZH-')) {
$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, 3));
$parts = explode('-', substr($sku, strlen($prefix)));
$components = [];
$buffer = [];
foreach ($parts as $part) {
@ -442,10 +715,14 @@ class SyncStoreReceiptProductCost extends Command
$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;
return $expanded === [] ? [] : $expanded;
}
/**
@ -459,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);
@ -468,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];
}
/**
@ -589,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

@ -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

@ -78,7 +78,7 @@ class Kernel extends ConsoleKernel
// 财务台账:从 AI 库 rpt 同步动账明细,按日重算账户流水。
// 生产宿主机 cron 仍需配置对应的 finance-ledger:sync-rpt 入口。
$schedule->command('finance-ledger:sync-rpt')
$schedule->command('finance-ledger:sync-rpt --days=7 --merge')
->dailyAt('02:30')
->timezone('Asia/Shanghai')
->withoutOverlapping(120)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,15 @@ class PlantingWorkOrders extends Controller
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(),

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

@ -2,10 +2,12 @@
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;
@ -29,6 +31,8 @@ class SupplierReconciliation extends Controller
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');
@ -46,13 +50,9 @@ class SupplierReconciliation extends Controller
{
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
'supplier' => ['required', 'string', 'max:191'],
]);
$supplier = trim((string) $request->input('supplier'));
if ($supplier === '') {
abort(422, '请选择厂家。');
}
$supplier = $this->effectiveSupplier($request);
$records = $this->template->parse($request->file('import'), $supplier);
$rows = $this->statements->save(
@ -105,6 +105,23 @@ class SupplierReconciliation extends Controller
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([
@ -131,9 +148,10 @@ class SupplierReconciliation extends Controller
'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'],
'unit_price' => ['nullable', 'numeric'],
'selected_rows' => ['nullable', 'array', 'max:600'],
'selected_rows.*' => ['string', 'max:2048'],
]);
@ -145,14 +163,16 @@ class SupplierReconciliation extends Controller
));
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'));
$unitPrice = (float) $request->input('unit_price');
$differences = array_values(array_filter(
$differences,
static fn (array $row): bool => (string) ($row['barcode'] ?? '') === $barcode
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
&& (string) ($row['date'] ?? '') === $date
&& (string) ($row['barcode'] ?? '') === $barcode
&& (string) ($row['type'] ?? '') === $type
&& abs((float) ($row['unitPrice'] ?? 0) - $unitPrice) < 0.005,
));
} elseif ($request->input('scope') === 'selected') {
$selected = [];
@ -191,9 +211,14 @@ class SupplierReconciliation extends Controller
{
$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,
(string) $request->input('key'),
$key,
$status,
$companyId,
user_id() ? (int) user_id() : null,
@ -205,18 +230,34 @@ class SupplierReconciliation extends Controller
/** @return array{0:string,1:int} */
private function returnTarget(Request $request): array
{
$request->validate(['supplier' => ['required', 'string', 'max:191']]);
return [$this->effectiveSupplier($request), (int) (company_id() ?: 1)];
}
return [trim((string) $request->input('supplier')), (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'] ?? '')),
number_format((float) ($row['unitPrice'] ?? 0), 4, '.', ''),
]);
}

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

@ -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

@ -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

@ -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,6 +27,8 @@ class CourierQuoteVersion extends Model
'effective_date' => 'date',
'end_date' => 'date',
'voided_at' => 'datetime',
'billing_structure' => 'array',
'billing_rows' => 'array',
];
/** 短期(临时)变价:有结束日期,到期后回到原来的长期报价。 */
@ -56,6 +59,14 @@ class CourierQuoteVersion extends Model
: $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;

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

@ -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

@ -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,6 +172,7 @@ 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(),

View File

@ -21,6 +21,10 @@ class FinanceLedgerAccess
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', '财务台账管理员')),
@ -28,4 +32,17 @@ class FinanceLedgerAccess
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

@ -20,6 +20,7 @@ class SidebarMenu
['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'],

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));
}

View File

@ -94,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
@ -123,7 +126,7 @@ services:
retries: 10
start_period: 45s
# Heavy CLI jobs use a separate cgroup, not Apache's unbounded app container.
# Heavy CLI jobs use a separate cgroup from Apache.
batch:
image: caiwu_akaunting:${IMAGE_TAG:?IMAGE_TAG is required}
pull_policy: never
@ -133,6 +136,7 @@ services:
command: [list]
restart: "no"
environment:
FINANCE_BATCH_CONTAINER: "1"
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
JST_DB_CONNECTION: business_mirror
env_file:
@ -142,8 +146,8 @@ services:
- ./runtime/.env:/var/www/html/.env:ro
- caiwu_storage:/var/www/html/storage
cpus: "1.00"
mem_limit: 3g
memswap_limit: 3g
mem_limit: ${FINANCE_BATCH_MEMORY_LIMIT:-3g}
memswap_limit: ${FINANCE_BATCH_MEMORY_LIMIT:-3g}
healthcheck:
disable: true
logging:

View File

@ -252,7 +252,7 @@ $navigation = [
'directory' => [
'icon' => 'query_stats',
'status' => '共享镜像',
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入) + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
'empty' => '店铺维度报表尚未完成共享数据导入。',
'fields' => [
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],
@ -822,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' => '平台'],
@ -832,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'],
@ -879,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],
],
],
[
@ -941,14 +944,6 @@ $navigation = [
['slug' => 'account-mapping', 'title' => '科目映射表'],
],
],
[
'slug' => 'financial-ledger',
'title' => '财务台账',
'icon' => 'menu_book',
'pages' => [
['slug' => 'ledger', 'title' => '财务台账'],
],
],
[
'slug' => 'data-monitoring',
'title' => '数据监控',

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),
];

View File

@ -12,6 +12,9 @@ return [
// 动账明细的唯一上游来源;本地调试可切换到本地快照导入。
'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', '财务台账管理员'),

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'),
];

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),
@ -318,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' => [
@ -329,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 分开。
@ -376,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' => [
@ -389,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

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 报价版本增加结构化计费规则,供运费核算使用;原始单元格(values_json)保留不动。
*
* billing_structure:{mode, flat_bands, then_mode, first_weight, rate_tiers, rounding, volumetric_divisor,
* weight_limit, min_weight(起算重量), per_ticket_surcharge, notes}
* billing_rows: [{region, flat, base, rates}] 或分段计费 [{region, segments}]
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_structure')) {
$table->longText('billing_structure')->nullable();
}
if (! Schema::hasColumn('business_courier_quote_versions', 'billing_rows')) {
$table->longText('billing_rows')->nullable();
}
});
}
public function down(): void
{
if (! Schema::hasTable('business_courier_quote_versions')) {
return;
}
Schema::table('business_courier_quote_versions', function (Blueprint $table): void {
foreach (['billing_structure', 'billing_rows'] as $column) {
if (Schema::hasColumn('business_courier_quote_versions', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! $schema->hasTable('jd_daily_bill_rows')) return;
if (collect($schema->getIndexes('jd_daily_bill_rows'))->contains('name', 'jd_daily_summary_cover')) return;
$columns = ['generation', 'occurred_at', 'income', 'expense', 'store_name', 'bill_item'];
$connection = $schema->getConnection();
if ($connection->getDriverName() === 'mysql') {
// Platform scope reads store_name: without it SUM must fetch every wide JSON row.
$wait = (int) $connection->selectOne('SELECT @@SESSION.lock_wait_timeout AS seconds')->seconds;
$connection->statement('SET SESSION lock_wait_timeout=5');
try {
$table = $connection->getQueryGrammar()->wrapTable('jd_daily_bill_rows');
$connection->statement('ALTER TABLE ' . $table . ' ADD INDEX jd_daily_summary_cover (generation, occurred_at, income, expense, store_name, bill_item), ALGORITHM=INPLACE, LOCK=NONE');
} finally {
$connection->statement('SET SESSION lock_wait_timeout=' . $wait);
}
} else {
$schema->table('jd_daily_bill_rows', fn (Blueprint $table) => $table->index($columns, 'jd_daily_summary_cover'));
}
}
public function down(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if ($schema->hasTable('jd_daily_bill_rows') && collect($schema->getIndexes('jd_daily_bill_rows'))->contains('name', 'jd_daily_summary_cover')) {
$schema->table('jd_daily_bill_rows', fn (Blueprint $table) => $table->dropIndex('jd_daily_summary_cover'));
}
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细新增「费用项」列:
* 按科目映射表的 fee_memo(费用备注)归集到订单,一单多费用时用顿号连接。
* 只用于展示,不参与收入、优惠、冲减、退款和差额计算。
*/
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_receipt_order_match_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->string('fee_item', 191)->nullable()->after('planting_amount');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('fee_item');
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 京东网银钱包账户不再进入业绩明细:清理历史镜像行,收、支均不展示。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (! Schema::connection($this->connection())->hasTable('store_receipt_order_match_rows')) {
return;
}
DB::connection($this->connection())
->table('store_receipt_order_match_rows')
->where('platform', '京东网银钱包')
->delete();
}
public function down(): void
{
// 历史镜像可由同步命令重建;本迁移只做排除清理,不回填已删除的数据。
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 撤销「费用项」功能:删除业绩明细镜像中的 fee_item 列。
*/
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_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('fee_item');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| $schema->hasColumn('store_receipt_order_match_rows', 'fee_item')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->string('fee_item', 191)->nullable()->after('planting_amount');
});
}
};

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细新增「种菜报销」列(放在种菜金额后面):
* 仅按「种菜明细表」的订单号命中,金额取该订单的收入金额。
*/
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_receipt_order_match_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('planting_reimbursement_amount', 20, 4)
->nullable()
->after('planting_amount');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('planting_reimbursement_amount');
});
}
};

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细新增「种菜未报销」列(放在种菜报销后面):
* 按聚水潭订单标签明细「是否种菜订单=是」且未出现在种菜明细表的订单号命中,金额取收入金额。
*/
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_receipt_order_match_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('planting_unreimbursed_amount', 20, 4)
->nullable()
->after('planting_reimbursement_amount');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('planting_unreimbursed_amount');
});
}
};

View File

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 财务台账账户状态。
*
* 状态由财务人员在页面维护,独立于可被全量覆盖的 fin_accounts 基础表,
* 因此重新导入《银行当前余额》不会丢失。
*/
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('fin_account_statuses')) {
$schema->create('fin_account_statuses', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100)->unique('fin_account_statuses_account_unique');
$table->string('status', 40)->default('normal');
$table->string('updated_by', 100)->nullable();
$table->timestamps();
$table->index(['status'], 'fin_account_statuses_status_index');
});
}
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('fin_account_statuses');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 财务台账账户类型人工维护值。
*
* 基础表 fin_accounts 在导入时会被全量覆盖,因此人工调整后的类型单独保存。
*/
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('fin_account_type_overrides')) {
$schema->create('fin_account_type_overrides', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100)->unique('fin_account_type_overrides_account_unique');
$table->string('account_type', 50)->nullable();
$table->string('updated_by', 100)->nullable();
$table->timestamps();
$table->index(['account_type'], 'fin_account_type_overrides_type_index');
});
}
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('fin_account_type_overrides');
}
};

View File

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
// 生产可能保留旧 SKU 级快照;使用独立源行基线,不修改或删除旧表。
if (! $schema->hasTable('jushuitan_cost_price_observation_baselines')) {
$schema->create('jushuitan_cost_price_observation_baselines', function (Blueprint $table): void {
$table->char('source_key', 64)->primary();
$table->string('sku_id', 255);
$table->decimal('cost_price', 20, 4);
$table->dateTime('captured_at', 6);
});
}
if (! $schema->hasTable('jushuitan_cost_price_observations')) {
$schema->create('jushuitan_cost_price_observations', function (Blueprint $table): void {
$table->char('id', 64)->primary();
$table->char('source_key', 64);
$table->string('sku_id', 255);
$table->decimal('before_price', 20, 4);
$table->decimal('after_price', 20, 4);
$table->dateTime('previous_observed_at', 6);
$table->dateTime('observed_at', 6);
$table->dateTime('source_modified_at', 6)->nullable();
$table->index(['source_key', 'observed_at', 'id'], 'jst_cost_observations_lookup');
});
}
}
public function down(): void
{
if (app()->environment('testing') || config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
$schema->dropIfExists('jushuitan_cost_price_observations');
$schema->dropIfExists('jushuitan_cost_price_observation_baselines');
}
};

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入明细。
*
* 聚水潭报表已按商品编码(组合装拆成组件)给出行和成本,这里原样保留每行字段,
* 订单售后费用明细页面在导入数据覆盖的店铺/日期范围内优先读这张表。
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('ops_after_sales_cost_rows', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('row_key', 40)->unique();
$table->string('shop_name', 191);
$table->string('shop_code', 64)->nullable();
$table->string('online_order_no', 64)->nullable();
$table->string('internal_order_no', 64)->nullable();
$table->string('product_code', 191)->nullable();
$table->dateTime('order_time')->nullable();
$table->decimal('product_quantity', 14, 2)->default(0);
$table->decimal('product_amount', 16, 2)->default(0);
$table->decimal('product_cost', 16, 2)->default(0);
$table->string('cost_source', 32)->nullable();
$table->string('combine_type', 32)->nullable();
$table->json('payload');
$table->timestamp('imported_at')->nullable();
$table->timestamps();
$table->index(['shop_name', 'order_time']);
$table->index('online_order_no');
$table->index('product_code');
});
}
public function down(): void
{
Schema::dropIfExists('ops_after_sales_cost_rows');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
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('fin_accounts') || $schema->hasColumn('fin_accounts', 'account_should_keep')) {
return;
}
$schema->table('fin_accounts', function (Blueprint $table): void {
$table->string('account_should_keep', 20)->nullable()->after('company_plan');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if ($schema->hasTable('fin_accounts') && $schema->hasColumn('fin_accounts', 'account_should_keep')) {
$schema->table('fin_accounts', function (Blueprint $table): void {
$table->dropColumn('account_should_keep');
});
}
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
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('fin_accounts') || $schema->hasTable('fin_account_todos')) {
return;
}
$schema->create('fin_account_todos', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100)->unique('fin_account_todos_account_unique');
$table->text('abnormal_reason')->nullable();
$table->string('handling_method', 20)->nullable();
$table->string('owner_name', 100)->nullable();
$table->dateTime('started_at')->nullable();
$table->dateTime('completed_at')->nullable();
$table->string('current_progress', 100)->nullable();
$table->string('updated_by', 100)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('fin_account_todos');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('supplier_portal_identities')) {
return;
}
Schema::create('supplier_portal_identities', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('company_id');
$table->unsignedInteger('user_id')->unique();
$table->string('supplier_code', 191)->nullable();
$table->string('supplier_name', 191);
$table->unsignedBigInteger('dingtalk_group_id');
$table->string('dingtalk_group_name', 191);
$table->timestamp('last_verified_at')->nullable();
$table->timestamps();
$table->index(['company_id', 'supplier_name'], 'supplier_portal_company_name');
$table->index(['company_id', 'dingtalk_group_id'], 'supplier_portal_company_group');
});
}
public function down(): void
{
Schema::dropIfExists('supplier_portal_identities');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
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('fin_account_todos') || $schema->hasColumn('fin_account_todos', 'completion_status')) {
return;
}
$schema->table('fin_account_todos', function (Blueprint $table): void {
$table->string('completion_status', 20)->default('not_started')->after('current_progress');
$table->index(['completion_status'], 'fin_account_todos_completion_status_index');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_account_todos') || ! $schema->hasColumn('fin_account_todos', 'completion_status')) {
return;
}
$schema->table('fin_account_todos', function (Blueprint $table): void {
$table->dropIndex('fin_account_todos_completion_status_index');
$table->dropColumn('completion_status');
});
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
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('fin_accounts') || $schema->hasTable('fin_account_keep_todos')) {
return;
}
$schema->create('fin_account_keep_todos', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100)->unique('fin_account_keep_todos_account_unique');
$table->string('handling_method', 20)->nullable();
$table->string('owner_name', 100)->nullable();
$table->dateTime('started_at')->nullable();
$table->dateTime('completed_at')->nullable();
$table->string('current_progress', 20)->default('not_started');
$table->string('updated_by', 100)->nullable();
$table->timestamps();
$table->index(['current_progress'], 'fin_account_keep_todos_progress_index');
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('fin_account_keep_todos');
}
};

View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
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('fin_transactions')) {
return;
}
if (! $schema->hasColumn('fin_transactions', 'created_by')) {
$schema->table('fin_transactions', function (Blueprint $table): void {
$table->string('created_by', 100)->nullable()->after('updated_at');
});
}
if (! $schema->hasColumn('fin_transactions', 'updated_by')) {
$schema->table('fin_transactions', function (Blueprint $table): void {
$table->string('updated_by', 100)->nullable()->after('created_by');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_transactions')) {
return;
}
if ($schema->hasColumn('fin_transactions', 'updated_by')) {
$schema->table('fin_transactions', function (Blueprint $table): void {
$table->dropColumn('updated_by');
});
}
if ($schema->hasColumn('fin_transactions', 'created_by')) {
$schema->table('fin_transactions', function (Blueprint $table): void {
$table->dropColumn('created_by');
});
}
}
};

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\GoodsProfitAfsLookupSchema;
return new class extends Migration
{
public function up(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
app(GoodsProfitAfsLookupSchema::class)->ensure(
DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')),
);
}
public function down(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$db = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! in_array($db->getDriverName(), ['mysql', 'mariadb'], true)) return;
$schema = Schema::connection($db->getName());
if (! $schema->hasTable(GoodsProfitAfsLookupSchema::TABLE)) return;
$changes = [];
foreach (array_keys(GoodsProfitAfsLookupSchema::INDEXES) as $index) {
if ($schema->hasIndex(GoodsProfitAfsLookupSchema::TABLE, $index)) {
$changes[] = 'DROP INDEX `' . $index . '`';
}
}
if ($changes !== []) {
$db->statement('ALTER TABLE `' . GoodsProfitAfsLookupSchema::TABLE . '` '
. implode(', ', $changes) . ', ALGORITHM=INPLACE, LOCK=NONE');
}
if ($schema->hasColumn(GoodsProfitAfsLookupSchema::TABLE, GoodsProfitAfsLookupSchema::DATE_COLUMN)) {
$db->statement('ALTER TABLE `' . GoodsProfitAfsLookupSchema::TABLE . '` DROP COLUMN `'
. GoodsProfitAfsLookupSchema::DATE_COLUMN . '`, ALGORITHM=INPLACE, LOCK=NONE');
}
}
};

View File

@ -0,0 +1,72 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
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_receipt_order_match_rows')) {
return;
}
if (! $schema->hasColumn('store_receipt_order_match_rows', 'planting_label')) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->string('planting_label', 32)
->nullable()
->after('planting_amount')
->comment('种菜标签:种菜报销 / 种菜未报销');
});
}
$oldColumns = array_values(array_filter(
['planting_reimbursement_amount', 'planting_unreimbursed_amount'],
static fn (string $column): bool => $schema->hasColumn('store_receipt_order_match_rows', $column),
));
if ($oldColumns !== []) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table) use ($oldColumns): void {
$table->dropColumn($oldColumns);
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')) {
return;
}
$hasReimbursement = $schema->hasColumn('store_receipt_order_match_rows', 'planting_reimbursement_amount');
$hasUnreimbursed = $schema->hasColumn('store_receipt_order_match_rows', 'planting_unreimbursed_amount');
$hasLabel = $schema->hasColumn('store_receipt_order_match_rows', 'planting_label');
if (! $hasReimbursement) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('planting_reimbursement_amount', 20, 4)
->nullable()
->after('planting_amount');
});
}
if (! $hasUnreimbursed) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('planting_unreimbursed_amount', 20, 4)
->nullable()
->after('planting_reimbursement_amount');
});
}
if ($hasLabel) {
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('planting_label');
});
}
}
};

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 业绩明细把「违约赔付(冲减收入)」从普通「冲减收入」中拆开。
*
* 金额仍按账单行「收入 - 支出」保存,负数表示净扣减;差额和收入汇总会
* 分别计入本列,避免只保留在 offset_amount 里造成口径混淆。
*/
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_receipt_order_match_rows')
|| $schema->hasColumn('store_receipt_order_match_rows', 'default_compensation_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->decimal('default_compensation_amount', 20, 4)
->nullable()
->after('offset_amount');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('store_receipt_order_match_rows')
|| ! $schema->hasColumn('store_receipt_order_match_rows', 'default_compensation_amount')) {
return;
}
$schema->table('store_receipt_order_match_rows', function (Blueprint $table): void {
$table->dropColumn('default_compensation_amount');
});
}
};

View File

@ -1,4 +1,11 @@
FROM php:8.3-apache-bookworm@sha256:fa8852a2e01747ffe8c8768bfd6bbc2f296f974aa0de90ee157a66664996d263 AS php-base
FROM php:8.3.36-apache-bookworm@sha256:cbd3b3d9011668443d54ab40ec31db56c73d91b4c8198935d639aa80d2d815c1 AS php-base
# 国内构建机访问 deb.debian.org 常被代理拦截(502),统一改用清华 Debian 镜像。
RUN set -eu; \
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list; do \
[ -f "$f" ] || continue; \
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g; s|security.debian.org|mirrors.tuna.tsinghua.edu.cn|g' "$f"; \
done
RUN apt-get -o Acquire::Retries=3 update \
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends \

View File

@ -4,7 +4,7 @@
# 基于 deploy/Dockerfile,额外安装 composer 依赖与前端构建产物
# 由 docker-compose.local.yml 使用
FROM php:8.3-apache-bookworm
FROM php:8.3.36-apache-bookworm@sha256:cbd3b3d9011668443d54ab40ec31db56c73d91b4c8198935d639aa80d2d815c1
# ---------------- 系统依赖 + PHP 扩展 ----------------
ENV DEBIAN_FRONTEND=noninteractive
@ -41,7 +41,7 @@ RUN apt-get update \
g++ \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-configure pdo_odbc --with-pdo-odbc=unixODBC,/usr \
&& docker-php-ext-install -j"$(nproc)" \
&& docker-php-ext-install -j2 \
bcmath \
curl \
dom \
@ -61,8 +61,8 @@ RUN apt-get update \
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# ---------------- Node.js (LTS) ----------------
COPY --from=node:20-bookworm-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:20-bookworm-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node:24.22.1-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 /usr/local/bin/node /usr/local/bin/node
COPY --from=node:24.22.1-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx

View File

@ -7,6 +7,11 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
DEPLOY_ACTOR="${8:-unknown}"
RELEASE_APP_URL="${9:-}"
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$RELEASE_DIR/release-guard.sh"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
REGISTRY_API="http://127.0.0.1:5000/v2/caiwuxitong/app"
@ -65,6 +70,12 @@ if ! flock -n 9; then
exit 1
fi
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_RESULT=blocked
trap 'release_status=$?; caiwu_record_release_result "$release_status"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
exec 8>/run/caiwu-upstream-sync.lock
echo "Waiting for active upstream synchronization before replacing the application container"
@ -74,7 +85,8 @@ if ! flock -w 1800 8; then
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
caiwu_verify_release_base
RELEASE_RESULT=running
restore_previous_image() {
case "$PREVIOUS_IMAGE" in
@ -96,6 +108,8 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
exit 1
fi
caiwu_install_release_files
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
@ -196,7 +210,11 @@ CRON_TZ=Asia/Shanghai
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
# 商品月加权与成本:先算月加权单价,再同步商品成本,避免成本先于价格生成。
0 18 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh finance:monthly-weighted-product-cost:auto >/dev/null 2>&1
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。
@ -206,7 +224,7 @@ CRON_TZ=Asia/Shanghai
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1

View File

@ -7,6 +7,11 @@ REMOTE_DIR="${3:-/opt/caiwuxitong}"
DEPLOY_SHA="${4:?DEPLOY_SHA is required}"
RUN_MIGRATIONS="${5:-0}"
KEEP_IMAGES="${6:-2}"
EXPECTED_PREVIOUS_SHA="${7:?Checked production revision is required; update the deployment scripts}"
DEPLOY_ACTOR="${8:-unknown}"
RELEASE_APP_URL="${9:-}"
RELEASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$RELEASE_DIR/release-guard.sh"
IMAGE_NAME="caiwu_akaunting"
LOCK_FILE="$REMOTE_DIR/.deploy.lock"
@ -14,7 +19,9 @@ cleanup() {
rm -f "$IMAGE_ARCHIVE"
}
trap cleanup EXIT INT TERM
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
if [ "$(id -u)" -ne 0 ]; then
echo "activate-release.sh must run as root" >&2
@ -69,6 +76,10 @@ if ! flock -n 9; then
exit 1
fi
RELEASE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_RESULT=blocked
trap 'release_status=$?; caiwu_record_release_result "$release_status"; cleanup' EXIT
# 容器替换会终止其中的同步进程;与生产刷新任务共享同一把锁。
exec 8>/run/caiwu-upstream-sync.lock
echo "Waiting for active upstream synchronization before replacing the application container"
@ -78,7 +89,8 @@ if ! flock -w 1800 8; then
fi
PREVIOUS_IMAGE="$(docker inspect caiwu_app --format '{{.Config.Image}}' 2>/dev/null || true)"
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
caiwu_verify_release_base
RELEASE_RESULT=running
restore_previous_image() {
case "$PREVIOUS_IMAGE" in
@ -100,6 +112,8 @@ if [ "$LOADED_SHA" != "$DEPLOY_SHA" ]; then
exit 1
fi
caiwu_install_release_files
cd "$REMOTE_DIR"
export IMAGE_TAG RUN_MIGRATIONS
@ -200,7 +214,11 @@ CRON_TZ=Asia/Shanghai
40 6 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:follow-upstream-columns --quiet >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-third-party-warehouse-storage --max-batches=0 --quiet && /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:warm-report-caches --quiet' >/dev/null 2>&1
0 0 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --quiet >/dev/null 2>&1
# 镜像价格观测独立保存,按索引分批续传,不替代正式操作历史。
3,13,23,33,43,53 * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-jushuitan-cost-price-history --source=snapshot --page-size=500 --max-pages=20 --quiet >/dev/null 2>&1
0 5 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-purchase-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_Orders --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-ding-online-daifa-price --quiet' >/dev/null 2>&1
# 商品月加权与成本:先算月加权单价,再同步商品成本,避免成本先于价格生成。
0 18 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh finance:monthly-weighted-product-cost:auto >/dev/null 2>&1
# 店铺违规信息预警:每天 05:30 把副本库抓取结果追平到统一表(本地补充字段不覆盖)。
30 5 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan store-violations:sync --quiet >/dev/null 2>&1
# 店铺违规预警推送:P0/P1 每天 09:00 起每 5 分钟检查;P2 每日 10:00 汇总(未配置 Webhook 时自动跳过)。
@ -210,7 +228,7 @@ CRON_TZ=Asia/Shanghai
15 8 * * * root /usr/bin/flock -w 3600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-supplier-management --quiet >/dev/null 2>&1
*/2 * * * * root /usr/bin/flock -n /run/caiwu-esign-polling.lock /usr/bin/docker exec -u www-data caiwu_app php artisan esign:poll-signatures --quiet >/dev/null 2>&1
# 财务台账:每天 02:30 从 AI 库 rpt 同步动账明细并重算流水。
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * root /usr/bin/flock -n /run/caiwu-account-mapping-resync.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:auto-resync --quiet >/dev/null 2>&1
* * * * * root /usr/bin/flock -n /run/caiwu-account-bills-export.lock /bin/sh /opt/caiwuxitong/run-finance-batch.sh account-bills:exports:run --quiet >/dev/null 2>&1

View File

@ -0,0 +1,100 @@
-- 财务台账 AI 库轻量视图与索引
-- 目标库:JSReportReplica
-- 只新增视图/索引并授予 caiwu 只读 SELECT;不修改任何 base 业务数据。
-- 建议用具备 DDL 权限的维护账号执行。
CREATE OR ALTER VIEW rpt.fin_ledger_workorder AS
SELECT Id, woNo, createby, created, completeTime, businessFieldList
FROM rpt.jst_workOrderInfo
WHERE woTypeId = N'9E5A531F48D94AF4B8611FA6918ADA29'
AND status = N'CONFIRMED';
GO
CREATE OR ALTER VIEW rpt.fin_ledger_withdrawal AS
SELECT a.Id, a.shop_id, b.shop_name, a.channel, a.fee_time, a.fee_name, a.amount, a.primary_key
FROM rpt.jst_originalBillInfo a
LEFT JOIN rpt.jst_shopInfo b ON b.shop_id = a.shop_id
WHERE a.inout_type = N'-1'
AND a.fee_name LIKE N'%提现%'
AND a.fee_time >= '2026-01-01';
GO
CREATE OR ALTER VIEW rpt.fin_ledger_dingtalk_workflow AS
SELECT processInstanceId, processCode, businessId, title, status, originatorUserId,
originatorDeptName, joinAt, finishTime
FROM rpt.ding_Workflow
WHERE processCode IN (
'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'
);
GO
CREATE OR ALTER VIEW rpt.fin_ledger_dingtalk_scheme AS
SELECT s.id, s.processInstanceId, s.name, s.value
FROM rpt.ding_WorkflowScheme s
WHERE s.name IN (N'付款明细', N'收款费用明细', N'应付明细', N'固定资产报废或出售明细', N'收款合计', N'付款金额合计');
GO
CREATE OR ALTER VIEW rpt.fin_ledger_store_opening AS
SELECT a.flowId, a.AccountCode, a.AccountName, a.costType, a.money, a.paymentTime,
a.paymentAccount, b.erpShopName, b.platform, b.applyUser, b.applyDepartment
FROM rpt.fys_shopInfo_kingdeeCode_flow a
LEFT JOIN rpt.fys_shopInfo_flow b ON a.flowId = b.flowId
WHERE a.flowId IN (SELECT flowId FROM rpt.flow_flow WHERE flowType = 8 AND flowStatus = 3)
AND a.type = 1 AND a.isPaymentMade = 1 AND a.money > 0;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_jst_workOrderInfo_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_jst_workOrderInfo_fin_ledger
ON base.jst_workOrderInfo (Id)
INCLUDE (woNo, createby, created, completeTime)
WHERE woTypeId = N'9E5A531F48D94AF4B8611FA6918ADA29' AND status = N'CONFIRMED'
WITH (ONLINE = ON, MAXDOP = 2);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_jst_originalBillInfo_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_jst_originalBillInfo_fin_ledger
ON base.jst_originalBillInfo (fee_time)
INCLUDE (Id, shop_id, channel, fee_name, amount, primary_key)
WHERE inout_type = N'-1' AND fee_time >= '2026-01-01'
WITH (ONLINE = ON, MAXDOP = 4, SORT_IN_TEMPDB = ON);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_ding_Workflow_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_ding_Workflow_fin_ledger
ON base.ding_Workflow (joinAt, processCode)
INCLUDE (processInstanceId, businessId, title, status, originatorUserId, originatorDeptName, finishTime)
WITH (ONLINE = ON, MAXDOP = 2);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_flow_flow_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_flow_flow_fin_ledger
ON base.flow_flow (flowType, flowStatus, flowId)
INCLUDE (applyUser)
WITH (ONLINE = ON, MAXDOP = 2);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_fys_shopInfo_kingdeeCode_flow_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_fys_shopInfo_kingdeeCode_flow_fin_ledger
ON base.fys_shopInfo_kingdeeCode_flow (flowId)
INCLUDE (type, isPaymentMade, paymentTime, paymentAccount, costType, money, AccountCode, AccountName)
WITH (ONLINE = ON, MAXDOP = 2);
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_base_fys_shopInfo_flow_fin_ledger')
CREATE NONCLUSTERED INDEX IX_base_fys_shopInfo_flow_fin_ledger
ON base.fys_shopInfo_flow (flowId)
INCLUDE (erpShopName, platform, applyUser, applyDepartment)
WITH (ONLINE = ON, MAXDOP = 2);
GO
GRANT SELECT ON OBJECT::rpt.fin_ledger_workorder TO [caiwu];
GRANT SELECT ON OBJECT::rpt.fin_ledger_withdrawal TO [caiwu];
GRANT SELECT ON OBJECT::rpt.fin_ledger_dingtalk_workflow TO [caiwu];
GRANT SELECT ON OBJECT::rpt.fin_ledger_dingtalk_scheme TO [caiwu];
GRANT SELECT ON OBJECT::rpt.fin_ledger_store_opening TO [caiwu];
GO

View File

@ -0,0 +1,32 @@
[
"accessory-reship:import-courier-configs",
"account-ledger-remarks:import",
"account-mapping:import-files",
"order-data:import-after-sales-cost",
"business:import-alibaba-account-balances",
"courier-freight:import",
"courier-quotes:import-billing-rules",
"dewu-bills:import-authoritative",
"business-mirror:import-douyin-authoritative",
"finance-ledger:import-base",
"finance-ledger:import-snapshot",
"internal-flow:import",
"inventory:inbound-detail:import",
"inventory:supplier-summary:import",
"business-mirror:import-inventory-report-month",
"business-mirror:import-jd-daily",
"business-mirror:import-jd-wallet",
"jst-order-labels:import-planting-rules",
"jst-order-labels:import",
"business:import-jushuitan-shop-balances",
"pinduoduo-bills:import-authoritative",
"planting-details:import",
"product-catalog:import-authoritative-export",
"product:monthly-weighted-average:base",
"store-violations:import-rules",
"supplier-bookkeeping:import-configs",
"business:import-taobao-alipay-bills",
"taobao-alipay-bills:import-authoritative",
"taobao-wechat-bills:import-authoritative",
"business-mirror:import-third-party-warehouse-anchor"
]

View File

@ -0,0 +1,43 @@
#!/bin/sh
# Caller holds the production deployment lock. Does not restart any service.
set -eu
REMOTE_DIR="${1:-/opt/caiwuxitong}"
SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
LEGACY_STUB="$SOURCE_DIR/retired-activation.sh"
[ "$(id -u)" -eq 0 ] || { echo 'Release protection must be installed as root' >&2; exit 1; }
[ -f "$LEGACY_STUB" ] || { echo 'Missing retired activation stub' >&2; exit 1; }
is_immutable() { lsattr -d "$1" 2>/dev/null | awk '{print $1}' | grep -q i; }
needs_install=0
for legacy in activate-release.sh activate-incremental-release.sh; do
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
continue
fi
if is_immutable "$REMOTE_DIR/$legacy"; then
echo "Unexpected immutable legacy entry point: $legacy; verify it before changing protection" >&2
exit 1
fi
needs_install=1
done
[ "$needs_install" -eq 1 ] || exit 0
# Test support on an owned temporary file before touching either entry point.
probe="$(mktemp "$REMOTE_DIR/.release-protection.XXXXXX")"
cleanup_probe() { chattr -i "$probe" 2>/dev/null || true; rm -f "$probe"; }
trap cleanup_probe EXIT
chattr +i "$probe" || { echo 'Filesystem cannot protect legacy entry points; no application was changed' >&2; exit 1; }
chattr -i "$probe"
backup="$REMOTE_DIR/runtime/private/release-protection/$(date -u +%Y%m%dT%H%M%SZ)-$$"
umask 077
mkdir -p "$backup"
for legacy in activate-release.sh activate-incremental-release.sh; do
if cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"; then
continue
fi
[ ! -f "$REMOTE_DIR/$legacy" ] || cp -p "$REMOTE_DIR/$legacy" "$backup/$legacy"
install -m 700 "$LEGACY_STUB" "$REMOTE_DIR/.$legacy.next"
mv "$REMOTE_DIR/.$legacy.next" "$REMOTE_DIR/$legacy"
chattr +i "$REMOTE_DIR/$legacy"
cmp -s "$LEGACY_STUB" "$REMOTE_DIR/$legacy" && is_immutable "$REMOTE_DIR/$legacy"
done
echo 'Legacy activation paths protected; current application and databases were not restarted'

72
deploy/release-guard.sh Normal file
View File

@ -0,0 +1,72 @@
#!/bin/sh
# Sourced by both activation paths after the deployment and synchronization locks.
caiwu_verify_release_base() {
case "$EXPECTED_PREVIOUS_SHA" in
none) ;;
*[!0-9a-f]*|'') echo 'Missing or invalid checked production revision; update the deployment scripts' >&2; return 1 ;;
*) [ "${#EXPECTED_PREVIOUS_SHA}" -eq 40 ] || return 1 ;;
esac
PREVIOUS_SHA="$(cat "$REMOTE_DIR/.deployed-sha" 2>/dev/null || true)"
[ -n "$PREVIOUS_SHA" ] || PREVIOUS_SHA=none
if [ "$PREVIOUS_SHA" != none ] && ! printf '%s\n' "$PREVIOUS_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
echo 'Invalid production revision marker; refusing activation' >&2
return 1
fi
if [ "$PREVIOUS_SHA" != "$EXPECTED_PREVIOUS_SHA" ]; then
echo "Production changed while this release was being prepared: checked=$EXPECTED_PREVIOUS_SHA current=$PREVIOUS_SHA requested=$DEPLOY_SHA. Fetch latest main and retry; the running application was not changed." >&2
return 1
fi
# A failed/interrupted older activation must not leave the marker ahead of the app.
running_sha="$(docker inspect caiwu_app --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true)"
if [ "$PREVIOUS_SHA" != none ] && [ "$running_sha" != "$PREVIOUS_SHA" ]; then
echo "Running image and production marker disagree; refusing activation: marker=$PREVIOUS_SHA image=$running_sha" >&2
return 1
fi
if [ "$PREVIOUS_SHA" = none ] && [ -n "$running_sha" ]; then
echo 'An application is already running without a production marker; verify its revision before deploying' >&2
return 1
fi
}
caiwu_install_release_files() {
# Each uploader has its own directory. Shared runtime files change only inside
# the activation lock and after the production revision has been rechecked.
[ "$RELEASE_DIR" != "$REMOTE_DIR" ] || return 0
release_files='compose.jiangling.yml activate-release.sh activate-incremental-release.sh release-guard.sh retired-activation.sh install-release-protection.sh config-db-init.sh run-finance-batch.sh run-finance-import.py finance-batch-imports.json clickhouse-resource-policy.xml update-runtime-url.sh configure-dingtalk-callback.sh host-nginx-8442-dingtalk.conf setup-incremental-registry.sh registry-config.yml'
for release_file in $release_files; do
[ -f "$RELEASE_DIR/$release_file" ] || { echo "Incomplete staged release: $release_file" >&2; return 1; }
done
sh "$RELEASE_DIR/install-release-protection.sh" "$REMOTE_DIR" || return 1
for release_file in $release_files; do
case "$release_file" in activate-release.sh|activate-incremental-release.sh) continue ;; esac
case "$release_file" in *.sh) release_mode=700 ;; *) release_mode=644 ;; esac
[ "$release_file" != config-db-init.sh ] || release_mode=644
install -m "$release_mode" "$RELEASE_DIR/$release_file" "$REMOTE_DIR/.$release_file.next"
mv "$REMOTE_DIR/.$release_file.next" "$REMOTE_DIR/$release_file"
done
if [ -n "$RELEASE_APP_URL" ]; then
"$REMOTE_DIR/update-runtime-url.sh" "$RELEASE_APP_URL" "$REMOTE_DIR"
fi
printf '%s\n' "$DEPLOY_SHA" > "$REMOTE_DIR/.release-controls-sha.next"
mv "$REMOTE_DIR/.release-controls-sha.next" "$REMOTE_DIR/.release-controls-sha"
}
caiwu_record_release_result() {
release_exit="$1"
[ "$RELEASE_RESULT" != running ] || RELEASE_RESULT=failed
[ "$release_exit" -ne 0 ] || RELEASE_RESULT=success
# Client identity is declared by the workstation; the SSH source is recorded
# separately. Neither field is inferred from the commit author.
release_actor="$(printf '%s' "$DEPLOY_ACTOR" | tr -cd 'A-Za-z0-9_.@:-' | cut -c1-160)"
release_peer="$(printf '%s' "${SSH_CONNECTION:-local}" | tr -cd 'A-Za-z0-9 .:-' | cut -c1-160)"
(
umask 077
mkdir -p "$REMOTE_DIR/runtime/private"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$RELEASE_STARTED_AT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$release_actor" "$release_peer" \
"${PREVIOUS_SHA:-unknown}" "$DEPLOY_SHA" "$RELEASE_RESULT" "$release_exit" "$EXPECTED_PREVIOUS_SHA" \
>> "$REMOTE_DIR/runtime/private/deployments.tsv"
) || echo 'Could not write the private deployment receipt' >&2
}

View File

@ -0,0 +1,4 @@
#!/bin/sh
# This legacy shared path is intentionally retired and immutable on production.
echo 'Legacy activation is disabled. Fetch latest main and use the workstation deployment script with an isolated release directory and checked production revision. The running application was not changed.' >&2
exit 1

View File

@ -3,8 +3,12 @@ set -eu
REMOTE_DIR="${CAIWU_REMOTE_DIR:-/opt/caiwuxitong}"
LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-/run/caiwu-upstream-sync.lock}"
is_import=0
CAIWU_BATCH_CALLER_DIR="$PWD"
export CAIWU_BATCH_CALLER_DIR
case "${1:-}" in
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run) ;;
*:import|*:import-*|*:import:*|product:monthly-weighted-average:base) is_import=1 ;;
jushuitan:sync-shipping|store-receipt:order-match:sync|store-receipt:product-cost:sync|store-receipt:auto-resync|order-data:warm-shop-candidates|business-mirror:index-work-order-waybills|business-mirror:refresh-paid-sales|account-bills:exports:run|finance-ledger:recalculate|product:monthly-weighted-average:auto|product:monthly-weighted-average:compute|product:monthly-weighted-average:report|finance:monthly-weighted-product-cost:auto) ;;
*) echo 'Unsupported finance batch command' >&2; exit 64 ;;
esac
cd "$REMOTE_DIR"
@ -34,6 +38,9 @@ if [ "$1" != 'account-bills:exports:run' ]; then
exit 75
fi
fi
if [ "$is_import" -eq 1 ]; then
exec python3 "$REMOTE_DIR/run-finance-import.py" "$@"
fi
image="$(docker inspect caiwu_app --format '{{.Config.Image}}')"
case "$image" in
caiwu_akaunting:*) IMAGE_TAG="${image#*:}" ;;

View File

@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Run registered imports in the existing, resource-limited batch profile."""
import json
import os
import pathlib
import shutil
import signal
import subprocess
import sys
import tempfile
import uuid
def available_memory_kib():
meminfo = dict(line.split(':', 1) for line in pathlib.Path('/proc/meminfo').read_text().splitlines())
return int(meminfo['MemAvailable'].split()[0])
def main():
arguments = sys.argv[1:]
registry = json.loads(pathlib.Path(__file__).with_name('finance-batch-imports.json').read_text())
if not arguments or arguments[0] not in registry:
print('Unsupported import; register the command in finance-batch-imports.json first.', file=sys.stderr)
return 64
# Keep a GiB available to the host in addition to this import's one-GiB cap.
if available_memory_kib() < 2 * 1024 * 1024:
print('Available memory is below 2GiB; no import container started. Retry after other tasks finish.', file=sys.stderr)
return 75
environment = os.environ.copy()
environment['FINANCE_BATCH_MEMORY_LIMIT'] = '1g'
environment['RUN_MIGRATIONS'] = '0'
image = subprocess.check_output(['docker', 'inspect', 'caiwu_app', '--format', '{{.Config.Image}}'], text=True).strip()
if not image.startswith('caiwu_akaunting:'):
print('Unsupported active finance image; no import container started.', file=sys.stderr)
return 65
environment['IMAGE_TAG'] = image.split(':', 1)[1]
container_name = 'caiwu_import_' + uuid.uuid4().hex
command = ['docker', 'compose', '-f', 'compose.jiangling.yml', '--profile', 'batch', 'run', '--rm', '-T', '--no-deps', '--name', container_name]
# Copy explicit host input files into a private per-run directory. The source
# is never modified; the container gets read-only copies readable by www-data.
with tempfile.TemporaryDirectory(prefix='caiwu-import-') as staging:
inputs = pathlib.Path(staging) / 'inputs'
inputs.mkdir(mode=0o755)
staged_arguments = [arguments[0]]
staged_count = 0
for argument in arguments[1:]:
prefix = ''
value = argument
if argument.startswith(('--file=', '--path=')):
prefix, value = argument.split('=', 1)
prefix += '='
source = pathlib.Path(value)
if not source.is_absolute():
source = pathlib.Path(os.environ.get('CAIWU_BATCH_CALLER_DIR', os.getcwd())) / source
if (prefix or not argument.startswith('-')) and source.is_file():
parent = inputs / str(staged_count)
parent.mkdir(mode=0o755)
destination = parent / source.name
shutil.copyfile(source, destination)
destination.chmod(0o444)
staged_arguments.append(prefix + '/run/caiwu-import/' + parent.name + '/' + destination.name)
staged_count += 1
else:
staged_arguments.append(argument)
if staged_count:
command += ['--volume', str(inputs) + ':/run/caiwu-import:ro']
command += ['--entrypoint', 'php', 'batch', '-d', 'memory_limit=512M', 'artisan', *staged_arguments]
print('Import limits: 1 CPU, 1GiB RAM, no Swap, PHP 512MiB; existing synchronization lock held.', flush=True)
try:
return subprocess.run(command, env=environment, timeout=3600).returncode
except subprocess.TimeoutExpired:
print('Import exceeded one hour; stop and inspect its checkpoint before retrying.', file=sys.stderr)
return 124
except KeyboardInterrupt:
return 130
finally:
# Killing only the Compose client would leave the job consuming RAM.
subprocess.run(['docker', 'rm', '--force', container_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
if __name__ == '__main__':
def interrupted(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
signal.signal(signal.SIGHUP, interrupted)
sys.exit(main())

View File

@ -35,6 +35,14 @@
抖音月中校准可在上述导入命令追加 `--through=YYYY-MM-DD`,文件必须包含该月 1 日至截止日(含当天)的全部店铺和流水。系统仅替换这一日期范围,截止日之后继续读取原始账单镜像;省略参数仍表示完整月份。截止日期不得早于同月已校准范围。每天更新时重新导出月初至昨日的完整结果并核对笔数、收支总额,以覆盖迟到及修改记录,不能把单日增量当作月初至截止日的完整结果导入。
### 成本价镜像观测
`business-mirror:sync-jushuitan-cost-price-history --source=snapshot` 仅比较既有 `ops_jst_sku` 的当前成本价,首次读取和新增商品只建立基线。基线写入 `jushuitan_cost_price_observation_baselines`,后续观测变化写入独立的 `jushuitan_cost_price_observations`;状态使用 `business_mirror_table_states` 中的 `jushuitan.product-cost-price-observations`,不共用正式操作历史的同步状态。生产保留的旧 SKU 级 `jushuitan_cost_price_snapshots` 不作覆盖、删除或追溯导入。
观测任务沿 `_mirror_synced_at + _mirror_key` 索引顺序分批续传,默认每批 500 条、每轮最多 20 批;固定窗口并保留两分钟重叠,基线、事件、游标同事务提交。不进行每轮全表计数。生产任务每十分钟尝试运行,沿用 `/run/caiwu-upstream-sync.lock`,繁忙时跳过。首次基线较大时可由同步节点持有同一互斥锁分批完成。
普通商品资料独立展示每个源商品最近 20 条镜像价格观测,时间明确表示“发现时间”。`modified` 只是商品更新时间,不能充当真实改价时间;采集间隔内的中间价格、建立基线前的历史无法从当前镜像还原。正式操作历史仍使用默认 `--source=open_api`;月度加权成本及其他财务计算继续只读原正式历史,不使用观测结果。
## 经营分析副本库(operations_replica)
店铺维度报表不使用订单件数代替销量。中央刷新节点按上游主键 `Id`
@ -52,7 +60,7 @@
| 订单 | `rpt.jst_Orders`、`rpt.jst_OrderItem` | `ops_jst_orders`、`ops_jst_order_item` |
| 商品与仓库 | `rpt.jst_sku`、`rpt.jst_warehouseInfo`、`rpt.jst_skumap` | `ops_jst_*`(普通商品资料已切换) |
| 推广费用 | `rpt.ads_itemid_sales_cost_ratio_v2_di` | `ops_item_sales_cost_ratio` |
| 退款退货 | `rpt.jst_returnAndRefundInfo`、`rpt.jst_returnAndRefundItem` | `ops_jst_return_refund_*` |
| 退款退货 | `rpt.jst_returnAndRefundInfo`、`rpt.jst_returnAndRefundItem`、`rpt.jst_qmcust_refundlist`、`rpt.jst_qmcust_refundlist_item` | `ops_jst_return_refund_*`、`ops_jst_qmcust_refunds`、`ops_jst_qmcust_refund_items` |
| 聚水潭店铺 | `rpt.jst_shopInfo` | `ops_jst_shop_info` |
| 聚水潭原始账单 | `rpt.jst_originalBillInfo` | `ops_jst_original_bill_info` |
| 店铺维度报表 | `jiansun/dbo.jst_originalBillInfo + dbo.jst_shopInfo + dbo.fys_shopInfo_flow` | `store_report_bill_rows`、`store_report_lifecycle_rows`、`store_report_daily_rows` |
@ -60,6 +68,10 @@
商品价格 `rpt.Bulk_Price_Limit` 继续使用已经上线的类型化同步器,不在通用原表任务中重复同步。
售后(退货退款)页面及其导出共同读取上述两套售后主单、各自商品明细与店铺镜像,按 `as_date` 筛选。原售后资料优先;退款清单按 `as_id`、或非空的 `(shop_id, outer_as_id)` 排除已存在的业务单。无商品明细的主单仍显示,商品数量、明细金额及成本留空;店铺平台无法唯一确认时标为“待确认”,不推断平台。两个来源的订单关联不得混用。订单号筛选按完整内部订单号、线上订单号或平台售后单号精确查询,避免长文本标识的全表扫描;店铺和商品仍支持包含搜索。分页先限制各来源的候选主单及行标识,再加载当前页明细;导出使用同一筛选及稳定游标。
`jst_returnAndRefundItem` 没有更新时间,除按 `Id` 追加外,每 168 小时通过现有调度分批执行可续传全量校准,覆盖历史编号变化及删除;全量完成后才清理上一代残留。人工历史校准前须备份相关镜像、目录及同步状态,并持有生产上游同步互斥锁。退款清单两表继续按 `local_updated` 使用重叠增量,不新建重复镜像。
## JianSun 旧业务库(jiansun)
| 业务范围 | 上游对象 |

View File

@ -42,6 +42,14 @@
3. 首次部署仅需在本机密码窗口输入一次服务器密码,之后 Codex 自动复用本机加密保存的登录信息;不要求新人生成、配置或管理私钥。Windows 上密码使用当前用户绑定的 DPAPI 加密,保存在 `%LOCALAPPDATA%\CaiWuXiTong\deploy-credentials`;macOS 上保存在系统钥匙串(服务名 `CaiWuXiTong-deploy`)。两者都不进入仓库或日志。已有私钥的机器仍可选传 `-KeyPath`(macOS 为 `--key`)。
4. 只使用仓库里的发布脚本。不要自行执行 `docker system prune -a`,也不要重置 Docker Desktop。
### 2.1 开发与构建工具安全基线
- 项目统一使用 Node.js `24.22.1`,由 `.nvmrc`、Windows/macOS 构建脚本、开发镜像和 CI 固定;构建依赖使用 Dart Sass,不再安装已停止维护的 `node-sass`。安装使用 `npm ci --legacy-peer-deps`,与依赖锁文件保持一致。
- 发布工具使用 `regctl v0.11.7`,各平台下载均校验固定 SHA256。更新全局 Go 编译器不会修复旧的 regctl、Docker 等已编译程序,必须分别更新软件并核对实际构建版本。
- 财务专用 BuildKit 使用固定摘要的 `v0.34.1`。现有构建器不会随 Docker 更新;脚本检测到旧镜像会在构建发布镜像前停止。Codex 应先保存该项目构建器的驱动配置、资源参数和缓存卷名称,使用 `docker buildx rm --keep-state <构建器名>` 保留缓存后,以同名构建器和新的镜像、原驱动配置及资源参数重建,再核对缓存卷与实际运行版本;不删除缓存卷或其他项目的构建器。
- Docker Engine 安全基线为 `29.9.2`;本机 Docker Desktop 使用包含该修复的维护版本。Go 工具链使用 `1.26.10`,或较新受支持版本中的对应安全修复。
- 升级保留已有数据盘、数据卷、数据库连接和资源限制。项目构建仍限制 4GiB Node 堆、2 个压缩工作进程;开发容器 Node 堆保持 2GiB。生产网页与独立批处理的限额按第 8 节保留,不通过重置 Docker 或取消内存限制完成升级。
Codex 已有用户提供的密码时可通过 `-Password` 的 `SecureString` 参数直接使用并保存,无需用户再输入;不要把明文写进命令参数或日志。密码变更时由 Codex 使用 `-UpdatePassword` 更新本机登录信息。部署结束保留加密凭据,下次自动使用。
说明:Docker 虚拟磁盘清理后,Windows 上看到的 `.vhdx` 文件可能不会立刻变小,但内部可用空间已经释放。不要为了让文件数字变小而删除虚拟磁盘。
@ -129,14 +137,25 @@ bash scripts/deploy-from-mac.sh
8. 验证唯一私有七牛桶的写入、读取和删除,并把旧本机业务上传幂等复制到七牛;不删除本机原件;
9. 检查服务器本机后端页面;
10. 任一存储验证或页面验证失败时恢复上一个镜像;
11. 使用服务器互斥锁阻止两个人同时切换版本;
11. 每次上传使用独立暂存目录;服务器取得发布锁和同步锁后,再核对线上 SHA 与发布机已检查的 SHA,确认未变化才切换配置和应用;
12. 删除本地临时镜像包并关闭 SSH 隧道;
13. 开发机、服务器 Docker 和服务器 Registry 均仅保留当前和上一个本项目版本;
14. 清理 7 天前的本项目构建缓存,并将总量压到最多 3GB。
### 5.0 多人发布保护
- 远端 `main` 禁止直接推送和强推,任务分支通过 PR 合并;不额外要求人工审批或新增 CI 门槛。
- 发布机先刷新远端 `main`,正式发布必须与最新主线一致,并检查本次版本包含线上提交。
- 上传期间若别人已发布,服务器在锁内拒绝本次激活,运行中的应用保持原版本。先 `git fetch`、快进到最新 `main` 并重新发布,不用回滚开关绕过并发校验。
- Windows 和 macOS 发布机都必须使用最新脚本。共享根目录的两个旧激活入口已停用并设置 Linux immutable 属性,旧发布机无法用上传覆盖它们;新脚本从每次独立暂存目录执行激活,缺少已检查线上版本参数也会被拒绝。
- 运行配置按每次发布独立暂存,核对通过后才在锁内安装;上传文件不会提前覆盖其他发布使用的配置。
- 服务器私有 `runtime/private/deployments.tsv` 记录起止 UTC 时间、发布机声明的身份、SSH 来源、前后 SHA、结果、退出码和已检查的 SHA。声明身份及提交作者不能单独证明具体操作者;记录不包含凭据或业务数据。
- 安装旧入口保护前自动备份到服务器私有 `runtime/private/release-protection/`。普通发布不解除 immutable 属性;维护这些停用入口时先核对备份和目的,再由维护人员使用 `chattr -i` 处理指定文件。应用镜像回滚不需要启用旧入口。
- 仅维护发布控制脚本时,可在发布锁内备份并安装已合并主线的控制脚本,记录独立的 `.release-controls-sha`,不重建应用容器、不修改表示应用镜像版本的 `.deployed-sha`。
### 5.1 安全软件拦截 regctl 时的降级
第 6 步默认用项目专用 `regctl`(未签名)把 BuildKit 原生 OCI 分层包推入服务器本机 Registry。少数开发机的 360 安全卫士、金山毒霸等安全软件会拦截未签名程序,表现为 `regctl-v0.11.5.exe` 启动即报“拒绝访问”。
第 6 步默认用项目专用 `regctl`(未签名)把 BuildKit 原生 OCI 分层包推入服务器本机 Registry。少数开发机的 360 安全卫士、金山毒霸等安全软件会拦截未签名程序,表现为 `regctl-v0.11.7.exe` 启动即报“拒绝访问”。
- 脚本会自动降级:regctl 无法运行或连续上传失败时,改用 `docker save` 全量包经 `scp` 上传,在服务器上 `docker load`、推入本机 Registry,再执行同一个 `activate-incremental-release.sh`(回滚、健康检查、双版本保留和旧层回收逻辑不变)。降级只增加传输量,功能与发布结果一致。
- 也可以在受拦截的开发机上显式跳过 regctl:`.\scripts\deploy-from-workstation.ps1 -SkipRegctl`(macOS 为 `bash scripts/deploy-from-mac.sh --skip-regctl`)。
@ -278,6 +297,14 @@ sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:order-match:sync YYYY-MM
入口持有现有上游同步锁,使用 `batch` Profile 的独立容器(1 核、3GiB 内存、不使用 Swap、无 Web 健康检查);正常发布不启动该 Profile。先备份再执行回补,失败时保留原有未完成状态,不重复并行启动任务。店铺候选平时按镜像同步时间增量刷新,02:45 使用 `--full` 全量核对。ClickHouse 系统日志的保留期由 `deploy/clickhouse-resource-policy.xml` 管理;业务表不受日志策略影响。
主应用独立限制为 2GiB 内存,不额外使用 Swap。生产 CLI 导入由同一入口调度,但采用更小的独立额度:1 核、1GiB 内存、PHP 512MiB、不使用 Swap,最长一小时;宿主机可用内存不足 2GiB 时不启动。导入与上游写同步共用互斥锁,退出、超时或中断后清理本次容器;不自动重试或重复导入。既有其他批任务仍使用原来的 3GiB 额度。
```bash
sh /opt/caiwuxitong/run-finance-batch.sh order-data:import-after-sales-cost /绝对路径/售后费用.xlsx --dry-run
```
输入文件可使用宿主机路径或容器共享 `storage` 路径。宿主机文件会生成临时只读副本,保留原文件名,原件不修改;支持 `--file=/绝对路径/文件.xlsx`。生产网页容器直接运行导入命令会在解析文件前拒绝。本地开发、测试环境沿用原用法。新增导入须登记 `deploy/finance-batch-imports.json`,验证代表性文件的耗时和峰值内存后再发布。正式财务写入仍遵循原有授权及备份要求,使用批入口不等于授权数据修复。
查件运单索引 `work_order_waybill_lookup` 只保存原始工单镜像的稳定键、运单号与排序键;工单 JSON 仍为事实来源。升级后先通过上述批入口执行 `business-mirror:index-work-order-waybills --max-batches=0 --pause-ms=50` 完成一次可续传初始化,完成前保留现有核验路径。其后索引与原始工单批次在同一事务中更新,全量校准同时移除失效关联。不得每次导入重新扫描工单 JSON。
销量汇总按 `store_report_paid_sales_dirty_dates` 中的受影响日期更新。业绩明细写入与汇总共用互斥锁,改金额、替换账期和删除都必须登记旧日期及新日期。镜像人工校准后,可通过批入口执行 `business-mirror:refresh-paid-sales --full` 全量重建派生汇总;普通刷新无需全表指纹扫描。科目重算每轮默认处理一个平台月份,再释放同步锁,剩余任务由下一轮继续。

View File

@ -16,8 +16,12 @@
| `fin_daily_flows` | 每日资金流水 |
| `fin_account_map` | 来源值 → 账户简称 |
| `fin_account_map_events` | 映射修改审计 |
| `fin_account_statuses` | 账户状态维护(正常、网银异常、限额、工商注销) |
| `fin_account_type_overrides` | 账户类型人工维护值 |
迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`。
迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`、
`database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php`、
`database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php`。
## 本地导入
@ -34,7 +38,7 @@ docker compose -f docker-compose.local.yml exec -u www-data app \
```bash
docker compose -f docker-compose.local.yml exec -u www-data app \
php artisan finance-ledger:sync-rpt
php artisan finance-ledger:sync-rpt --days=7 --merge
```
本地尚未接入共享系统数据库时,可在 `runtime/.env` 临时启用
@ -57,14 +61,49 @@ docker compose -f docker-compose.local.yml exec -u www-data app \
## 页面
- 路由:`/workspace/financial-ledger/ledger`
- 路由:`/workspace/financial-operations/ledger`(旧地址会自动跳转)
- 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源
- 写权限:账户映射修改仅系统管理员;其他账号只读
- 调度:`finance-ledger:sync-rpt` 每天 02:30(Asia/Shanghai)
- 账户总表支持导出空白 Excel 录入模板,包含页面列、状态和账户类型选项及填写说明
- 写权限:账户映射、账户状态和账户类型修改仅系统管理员及配置的财务台账管理员角色;其他账号只读
- 调度:`finance-ledger:sync-rpt --days=7 --merge` 每天 02:30(Asia/Shanghai)
账户状态独立保存在 `fin_account_statuses`,账户类型人工维护值独立保存在
`fin_account_type_overrides`,重新导入账户基础数据均不会覆盖;页面修改使用
异步保存,不触发整页刷新。
“账户映射”页支持按店铺台账批量关联:仅匹配 `source = shop`、来源值与
`fin_shops.shop_name` 完全一致、店铺回款账户简称存在于 `fin_accounts`
的未映射记录。批量操作写入映射审计并重算流水,未精确匹配的来源值保留人工处理。
同时支持按账户总表批量关联供应商应付:公司名称与 `fin_accounts.company_name`
完全一致时,只有唯一账户或唯一公户才自动映射;存在多公户或仅私人卡等歧义的公司
保留人工选择。
生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行
`schedule:run` 的容器,调度登记不能替代宿主机 cron。
## AI 库前置对象
生产同步读取以下轻量视图,不再直接查大表:
- `rpt.fin_ledger_workorder`
- `rpt.fin_ledger_withdrawal`
- `rpt.fin_ledger_dingtalk_workflow`
- `rpt.fin_ledger_dingtalk_scheme`
- `rpt.fin_ledger_store_opening`
对应索引、视图和 `caiwu` 只读授权脚本在 `deploy/ai-fin-ledger-views.sql`。
AI 库 ETL 重建 base 表后,需要确认这些索引仍存在。
日常同步使用最近 7 天增量合并:
```bash
php artisan finance-ledger:sync-rpt --days=7 --merge
```
历史全量(会删除并重建 `fin_transactions`)使用 `--from/--to`,仅在明确需要重建快照时执行。
## 数据安全
真实期初、账户余额和动账明细不得提交到 Git。本地导入文件放在仓库外的
@ -78,14 +117,14 @@ docker compose -f docker-compose.local.yml exec -u www-data app \
```bash
docker exec -u www-data caiwu_app php artisan finance-ledger:import-base /path/银行当前余额-*.xlsx --recalculate
docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt
docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge
docker exec -u www-data caiwu_app php artisan finance-ledger:verify
```
4. 在宿主机 `/etc/cron.d/caiwu-jiansun-snapshots` 增加:
```cron
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --quiet >/dev/null 2>&1
30 2 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan finance-ledger:sync-rpt --days=7 --merge --quiet >/dev/null 2>&1
```
5. 在「系统设置 → 权限」把「财务台账」页面分配给财务角色;账户映射修改允许超级管理员和「财务台账管理员」角色(可用 `FINANCE_LEDGER_MAPPING_EDITOR_ROLES` 覆盖)。

View File

@ -1,5 +1,18 @@
# 钉钉授权与财务花名册自动开户
## 供应商门户身份
供应商人员与财务人员使用不同的准入来源:
- 财务人员继续按 `hr_flowerName_book`、任职登记和财务部门范围核验。
- 供应商人员按当前有效的 `hr_employee` 企业成员、`hr_employee.dept_id_list` 与 `hr_groups` 祖先组织核验,不要求进入财务花名册。
- 部门祖先名称必须与共享供应商主档中的供应商名称精确匹配且唯一;同名多供应商、同一员工命中多个供应商或组织循环时拒绝登录。
- 首次登录自动创建 `dingtalk_supplier_portal` 普通账号,并写入 `supplier_portal_identities`。不创建财务角色、不创建供应商联系人、不授予财务页面权限。
- 供应商账号只允许“供应商对账 → 厂家端数据”及其模板、上传、确认、异议和导出接口。服务端始终覆盖请求中的供应商参数,并校验退货明细属于绑定供应商。
- 厂家页面显示只读供应商名称,不返回其他供应商候选列表。
- 会话每 5 分钟复核钉钉在职状态和供应商组织;人员停用、离开供应商组织或组织归属不唯一时立即失效。上游故障时返回 503,不使用过期授权继续展示数据。
- 供应商别名尚未启用;“常州市可乐办公家具有限公司”与“可乐办公”的对应关系未核实前按未授权处理。
## 用户流程
首次钉钉授权成功后,系统用企业 `userid` 对应 HR 花名册中的 `flowerNameId`,核验人员状态和组织归属,自动创建普通用户、绑定钉钉身份并登录。不要求填写注册表、设置密码或等待账号绑定审批。

View File

@ -0,0 +1,183 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 订单售后费用明细(聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」导入)。
*
* 聚水潭报表已经按商品编码(组合装拆成组件)给出行和成本,页面直接读这张导入表,
* 不再从订单明细里自己拆组合装。
*/
class AfterSalesCostRowRepository
{
private const TABLE = 'ops_after_sales_cost_rows';
private ?bool $available = null;
public function available(): bool
{
if ($this->available === null) {
try {
$this->available = Schema::hasTable(self::TABLE)
&& DB::table(self::TABLE)->exists();
} catch (\Throwable) {
$this->available = false;
}
}
return $this->available;
}
/**
* 导入数据是否覆盖本次筛选(有至少一行命中即视为覆盖)。
*
* @param array<string,mixed> $filters
*/
public function covers(array $filters): bool
{
if (! $this->available()) {
return false;
}
// 指定单号时,导入表里命中该单就完整(同单的所有明细行都在导入数据里)。
if (trim((string) ($filters['order_no'] ?? '')) !== '') {
return $this->filtered($filters)->limit(1)->exists();
}
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
// 不限日期时无法保证覆盖范围,回退镜像,避免把导入数据当成全量。
if ($from === '' || $to === '') {
return false;
}
$range = $this->filtered(['shop_names' => $filters['shop_names'] ?? []])
->selectRaw('MIN(order_time) as min_time, MAX(order_time) as max_time')
->first();
if (! $range || $range->min_time === null) {
return false;
}
// 只有导入范围完整覆盖所选日期时才使用导入数据,避免跨月时数据不全。
return substr((string) $range->min_time, 0, 10) <= $from
&& substr((string) $range->max_time, 0, 10) >= $to;
}
/**
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
public function paginate(array $filters, int $page, int $limit): array
{
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$records = $this->filtered($filters)
->orderByDesc('order_time')
->orderByDesc('id')
->forPage($page, $limit + 1)
->get();
$rows = [];
foreach ($records as $record) {
$payload = json_decode((string) $record->payload, true);
$rows[] = is_array($payload) ? $payload : [];
}
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
];
}
/**
* 导入一行(命令内部使用,批量写入)。
*
* @param array<string,mixed> $payload
*/
public static function toRecord(array $payload, ?string $importedAt = null): array
{
// 以整行内容做键:同一单同一商品可能出现多行,只有完全相同的一行才合并。
ksort($payload);
$rowKey = sha1((string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$now = $importedAt ?? now()->toDateTimeString();
return [
'row_key' => $rowKey,
'shop_name' => mb_substr((string) ($payload['shop_name'] ?? ''), 0, 191),
'shop_code' => mb_substr((string) ($payload['shop_code'] ?? ''), 0, 64),
'online_order_no' => mb_substr((string) ($payload['online_order_no'] ?? ''), 0, 64),
'internal_order_no' => mb_substr((string) ($payload['internal_order_no'] ?? ''), 0, 64),
'product_code' => mb_substr((string) ($payload['product_code'] ?? ''), 0, 191),
'order_time' => self::dateTime($payload['order_time'] ?? null),
'product_quantity' => (float) ($payload['product_quantity'] ?? 0),
'product_amount' => (float) ($payload['product_amount'] ?? 0),
'product_cost' => (float) ($payload['product_cost'] ?? 0),
'cost_source' => mb_substr((string) ($payload['cost_source'] ?? ''), 0, 32),
'combine_type' => mb_substr((string) ($payload['combine_type'] ?? ''), 0, 32),
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'imported_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
/** @param array<string,mixed> $filters */
private function filtered(array $filters): Builder
{
$query = DB::table(self::TABLE);
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
$orderNo = trim((string) ($filters['order_no'] ?? ''));
$sku = trim((string) ($filters['sku_id'] ?? ''));
$shops = array_values(array_filter((array) ($filters['shop_names'] ?? []), static fn ($v) => trim((string) $v) !== ''));
if ($from !== '') {
$query->where('order_time', '>=', $from . ' 00:00:00');
}
if ($to !== '') {
$query->where('order_time', '<=', $to . ' 23:59:59');
}
if ($shops !== []) {
$query->whereIn('shop_name', $shops);
}
if ($orderNo !== '') {
$query->where(function (Builder $inner) use ($orderNo): void {
$inner->where('online_order_no', 'like', $orderNo . '%')
->orWhere('internal_order_no', 'like', $orderNo . '%')
->orWhere('internal_order_no', '=', $orderNo);
});
}
if ($sku !== '') {
$query->where('product_code', 'like', '%' . addcslashes($sku, '%_\\') . '%');
}
return $query;
}
private static function dateTime(mixed $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
}

View File

@ -0,0 +1,361 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\GoodsProfitAfsLookupSchema;
/**
* 订单售后费用明细:聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」原表。
*
* 原表 rpt.jst_goods_profit_kpi_items_afs 的列名是 undefined1..undefinedN,
* 中文表头放在 rpt.jst_goods_profit_kpi_items_afs_schemas(column → name)。
* 两张表都镜像到共享业务库后,页面按 schemas 映射读取字段。
*/
class GoodsProfitAfsRepository
{
public const MAIN_DATASET = 'operations_replica.rpt.jst_goods_profit_kpi_items_afs';
public const SCHEMA_DATASET = 'operations_replica.rpt.jst_goods_profit_kpi_items_afs_schemas';
private const MAIN_TABLE = 'ops_jst_goods_profit_kpi_items_afs';
private const SCHEMA_TABLE = 'ops_jst_goods_profit_kpi_items_afs_schemas';
/** @var array<string,string>|null */
private ?array $fieldColumns = null;
private ?bool $hasDateLookup = null;
public function ready(): bool
{
try {
return Schema::connection($this->connectionName())->hasTable(self::MAIN_TABLE)
&& Schema::connection($this->connectionName())->hasTable(self::SCHEMA_TABLE)
&& $this->initialSyncCompleted()
&& $this->connection()->table(self::MAIN_TABLE)->exists();
} catch (\Throwable) {
return false;
}
}
/** 首次全量同步完成前不对外使用,避免页面读到不完整的数据。 */
private function initialSyncCompleted(): bool
{
$state = $this->connection()->table('business_mirror_table_states')
->where('dataset_key', self::MAIN_DATASET)
->first(['initial_completed_at']);
return $state !== null && $state->initial_completed_at !== null;
}
/**
* 页面字段键 => 上游列名(undefinedN)。
*
* @return array<string,string>
*/
public function fieldColumns(): array
{
if ($this->fieldColumns !== null) {
return $this->fieldColumns;
}
$headers = (array) config('order_after_sales_cost.headers', []);
$map = [];
$existing = array_map(
'strtolower',
Schema::connection($this->connectionName())->getColumnListing(self::MAIN_TABLE),
);
foreach ($this->connection()->table(self::SCHEMA_TABLE)->orderBy('id')->get(['column', 'name']) as $schema) {
$label = self::normalizeLabel((string) $schema->name);
$field = $headers[$label] ?? null;
$column = (string) $schema->column;
// 同一科目出现多次(如 800105佣金 / 800105平台佣金)时保留第一次映射。
// schemas 里有 170 条,但原表只有 160 列,提前过滤掉不存在的列。
if ($field !== null && ! isset($map[$field]) && in_array(strtolower($column), $existing, true)) {
$map[$field] = $column;
}
}
return $this->fieldColumns = $map;
}
/** @param array<string,mixed> $filters */
public function covers(array $filters): bool
{
if (! $this->ready()) {
return false;
}
$columns = $this->fieldColumns();
$orderNo = trim((string) ($filters['order_no'] ?? ''));
// 单号查询走各自列的索引,避免 undefined2 OR undefined1 触发全表扫描。
if ($orderNo !== '') {
foreach ($this->orderNumberFields($orderNo) as $field) {
if (! isset($columns[$field])) {
continue;
}
$query = $this->connection()->table(self::MAIN_TABLE)
->where($columns[$field], 'like', $orderNo . '%');
$this->applyDateRange($query, $filters, $columns);
if ($query->limit(1)->exists()) {
return true;
}
}
return false;
}
return $this->filtered($filters, $columns)->limit(1)->exists();
}
/**
* @param array<string,mixed> $filters
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
public function paginate(array $filters, int $page, int $limit): array
{
$page = max($page, 1);
$limit = min(max($limit, 1), 100);
$columns = $this->fieldColumns();
$select = array_values(array_unique(array_merge(['id'], array_values($columns))));
$orderColumn = $columns['order_time'] ?? null;
$orderNo = trim((string) ($filters['order_no'] ?? ''));
if ($orderNo !== '') {
return $this->paginateByOrderNumber($orderNo, $filters, $columns, $select, $page, $limit);
}
$query = $this->filtered($filters, $columns)->select($select);
if ($orderColumn !== null) {
$query->orderByRaw($this->dateExpression($orderColumn) . ' desc');
}
$records = $query->orderByDesc('id')->offset(($page - 1) * $limit)->limit($limit + 1)->get();
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
$rows = [];
foreach ($records as $record) {
$row = [];
foreach ($columns as $field => $column) {
$value = $record->{$column} ?? null;
if (isset($numberKeys[$field])) {
$value = $value === null ? null : (float) $value;
}
$row[$field] = is_string($value) ? trim($value) : $value;
}
$rows[] = $row;
}
return [
'rows' => array_slice($rows, 0, $limit),
'has_more' => count($rows) > $limit,
];
}
/**
* 单号查询:分别走 online/internal 两个索引,合并后在 PHP 排序分页。
*
* @param array<string,mixed> $filters
* @param array<string,string> $columns
* @param list<string> $select
* @return array{rows:list<array<string,mixed>>,has_more:bool}
*/
private function paginateByOrderNumber(
string $orderNo,
array $filters,
array $columns,
array $select,
int $page,
int $limit,
): array {
$records = [];
$cap = max($limit * 4, 400);
foreach ($this->orderNumberFields($orderNo) as $field) {
if (! isset($columns[$field])) {
continue;
}
$query = $this->connection()->table(self::MAIN_TABLE)
->select($select)
->where($columns[$field], 'like', $orderNo . '%');
$this->applyDateRange($query, $filters, $columns);
foreach ($query->limit($cap)->get() as $record) {
$records[(int) $record->id] = $record;
}
}
$timeColumn = $columns['order_time'] ?? null;
usort($records, static function ($left, $right) use ($timeColumn): int {
$leftTime = $timeColumn !== null ? strtotime(str_replace('/', '-', (string) ($left->{$timeColumn} ?? ''))) : false;
$rightTime = $timeColumn !== null ? strtotime(str_replace('/', '-', (string) ($right->{$timeColumn} ?? ''))) : false;
return [$rightTime ?: 0, (int) $right->id] <=> [$leftTime ?: 0, (int) $left->id];
});
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
$rows = [];
foreach ($records as $record) {
$row = [];
foreach ($columns as $field => $column) {
$value = $record->{$column} ?? null;
if (isset($numberKeys[$field])) {
$value = $value === null ? null : (float) $value;
}
$row[$field] = is_string($value) ? trim($value) : $value;
}
$rows[] = $row;
}
$offset = ($page - 1) * $limit;
return [
'rows' => array_slice($rows, $offset, $limit),
'has_more' => count($rows) > $offset + $limit,
];
}
/**
* @param array<string,mixed> $filters
* @param array<string,string> $columns
*/
private function filtered(array $filters, array $columns): \Illuminate\Database\Query\Builder
{
$query = $this->connection()->table(self::MAIN_TABLE);
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
$orderNo = trim((string) ($filters['order_no'] ?? ''));
$sku = trim((string) ($filters['sku_id'] ?? ''));
$shops = array_values(array_filter((array) ($filters['shop_names'] ?? []), static fn ($v) => trim((string) $v) !== ''));
// 原表的订单时间是字符串(如 2026/8/23 14:58:51,不补零),不能直接和 ISO 日期做字符串比较。
if (($from !== '' || $to !== '') && isset($columns['order_time'])) {
$expression = $this->dateExpression($columns['order_time']);
if ($from !== '') {
$query->whereRaw($expression . ' >= ?', [$from . ' 00:00:00']);
}
if ($to !== '') {
$query->whereRaw($expression . ' <= ?', [$to . ' 23:59:59']);
}
}
if ($shops !== [] && isset($columns['shop_name'])) {
$query->whereIn($columns['shop_name'], $shops);
}
if ($orderNo !== '') {
$query->where(function ($inner) use ($columns, $orderNo): void {
$matched = false;
foreach (['online_order_no', 'internal_order_no'] as $field) {
if (! isset($columns[$field])) {
continue;
}
$inner->orWhere($columns[$field], 'like', $orderNo . '%');
$matched = true;
}
if (! $matched) {
$inner->whereRaw('1 = 0');
}
});
}
if ($sku !== '' && isset($columns['product_code'])) {
$query->where($columns['product_code'], 'like', '%' . addcslashes($sku, '%_\\') . '%');
}
return $query;
}
/**
* @param array<string,mixed> $filters
* @param array<string,string> $columns
*/
private function applyDateRange(\Illuminate\Database\Query\Builder $query, array $filters, array $columns): void
{
$from = trim((string) ($filters['from'] ?? ''));
$to = trim((string) ($filters['to'] ?? ''));
if (($from === '' && $to === '') || ! isset($columns['order_time'])) {
return;
}
$expression = $this->dateExpression($columns['order_time']);
if ($from !== '') {
$query->whereRaw($expression . ' >= ?', [$from . ' 00:00:00']);
}
if ($to !== '') {
$query->whereRaw($expression . ' <= ?', [$to . ' 23:59:59']);
}
}
private function dateExpression(string $column): string
{
// Dynamic schemas can move the source field. Keep the original parser
// until the matching additive lookup has been installed.
if ($column === 'undefined7') {
$this->hasDateLookup ??= Schema::connection($this->connectionName())
->hasColumn(self::MAIN_TABLE, GoodsProfitAfsLookupSchema::DATE_COLUMN);
if ($this->hasDateLookup) {
return '`' . GoodsProfitAfsLookupSchema::DATE_COLUMN . '`';
}
}
return "STR_TO_DATE(REPLACE(`{$column}`, '/', '-'), '%Y-%m-%d %H:%i:%s')";
}
private function connection(): \Illuminate\Database\ConnectionInterface
{
return DB::connection($this->connectionName());
}
private function connectionName(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
private static function normalizeLabel(string $label): string
{
$label = str_replace(["\u{3000}", "\u{00A0}"], ' ', $label);
$label = preg_replace('/\s+/u', ' ', $label) ?? $label;
return trim($label);
}
/**
* 线上订单号和内部订单号长度差很多(19 位 vs 7~8 位)。长单号只查线上订单号,
* 避免用内部订单号列做 `like '长单号%'` 的全表扫描。
*
* @return list<string>
*/
private function orderNumberFields(string $orderNo): array
{
return mb_strlen($orderNo) >= 12
? ['online_order_no']
: ['online_order_no', 'internal_order_no'];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
class JushuitanCostPriceObservationRepository
{
public function __construct(private readonly GenericBusinessMirrorRepository $mirror) {}
/** 仅当前页每个源商品最近 20 条观测;不混入正式财务取价历史。 */
public function attach(array $rows): void
{
foreach ($rows as $row) {
$row->jushuitan_cost_price_observations = [];
}
$db = $this->mirror->connection();
if ($rows === [] || ! Schema::connection($db->getName())->hasTable(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)) {
return;
}
$keys = collect($rows)->pluck('_mirror_key')->filter()->unique()->values()->all();
if ($keys === []) {
return;
}
$ranked = $db->table(JushuitanCostPriceSnapshotDiffer::OBSERVATIONS)
->whereIn('source_key', $keys)
->select('*')->selectRaw('ROW_NUMBER() OVER (PARTITION BY source_key ORDER BY observed_at DESC, id DESC) AS observation_rank');
$events = $db->query()->fromSub($ranked, 'ranked')->where('observation_rank', '<=', 20)
->orderBy('observed_at', 'desc')->get()->groupBy('source_key');
foreach ($rows as $row) {
$row->jushuitan_cost_price_observations = ($events->get($row->_mirror_key ?? '') ?? collect())->map(static fn ($event) => [
'id' => $event->id, 'source' => '镜像价格观测(非完整改价历史)',
'record_type_label' => '观测到成本价变化', 'effect_start_time' => $event->observed_at,
'before_price' => $event->before_price, 'after_price' => $event->after_price,
'diff_amount' => (float) $event->after_price - (float) $event->before_price,
'operator' => '-', 'operation' => '镜像快照比较',
'remark' => '上次观测:' . $event->previous_observed_at . ';本次为发现时间,不是真实改价时间。',
])->all();
}
}
}

View File

@ -17,6 +17,12 @@ class NonJingxiRefundMirrorRepository
public const ITEM_DATASET = 'operations_replica.rpt.jst_returnAndRefundItem';
public const SUPPLEMENT_DATASET = 'operations_replica.rpt.jst_qmcust_refundlist';
public const SUPPLEMENT_ITEM_DATASET = 'operations_replica.rpt.jst_qmcust_refundlist_item';
public const SHOP_DATASET = 'operations_replica.rpt.jst_shopInfo';
private const AUXILIARY_DATASETS = [
'operations_replica.rpt.jst_Orders',
'operations_replica.rpt.jst_OrderItem',
@ -25,14 +31,21 @@ class NonJingxiRefundMirrorRepository
];
private const REQUIRED_QUERY_INDEXES = [
self::INFO_DATASET => [['as_date', 'Id'], ['o_id', 'Id'], ['as_id', 'Id']],
self::INFO_DATASET => [['Id'], ['as_date', 'Id'], ['o_id', 'Id'], ['as_id', 'Id'], ['shop_id', 'outer_as_id'], ['so_id'], ['outer_as_id'], ['as_date', 'Id', 'as_id', 'shop_site']],
self::ITEM_DATASET => [['as_id', 'Id']],
self::SUPPLEMENT_DATASET => [['as_id'], ['as_date', 'as_id'], ['so_id'], ['outer_as_id']],
self::SUPPLEMENT_ITEM_DATASET => [['as_id', 'asi_id']],
self::SHOP_DATASET => [['shop_id', 'Id']],
'operations_replica.rpt.jst_Orders' => [['o_id', 'Id']],
'operations_replica.rpt.jst_OrderItem' => [['o_id', 'Id']],
];
private readonly ConnectionInterface $connection;
private ?array $excludedShopIds = null;
private ?string $supplementDateIndex = null;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
@ -45,14 +58,14 @@ class NonJingxiRefundMirrorRepository
$schema = Schema::connection($this->connection->getName());
return $schema->hasTable('business_mirror_table_states')
&& $schema->hasTable($this->dataset(self::INFO_DATASET)->targetTable())
&& $schema->hasTable($this->dataset(self::ITEM_DATASET)->targetTable());
&& collect([self::INFO_DATASET, self::ITEM_DATASET, self::SUPPLEMENT_DATASET, self::SUPPLEMENT_ITEM_DATASET, self::SHOP_DATASET])
->every(fn (string $key): bool => $schema->hasTable($this->dataset($key)->targetTable()));
}
public function ready(): bool
{
return $this->mirror->ready($this->dataset(self::INFO_DATASET))
&& $this->mirror->ready($this->dataset(self::ITEM_DATASET));
return collect([self::INFO_DATASET, self::ITEM_DATASET, self::SUPPLEMENT_DATASET, self::SUPPLEMENT_ITEM_DATASET, self::SHOP_DATASET])
->every(fn (string $key): bool => $this->mirror->ready($this->dataset($key)));
}
public function queryReady(): bool
@ -66,7 +79,13 @@ class NonJingxiRefundMirrorRepository
return false;
}
$indexes = collect($schema->getIndexes($table))
$tableIndexes = collect($schema->getIndexes($table));
if ($datasetKey === self::SUPPLEMENT_DATASET) {
$this->supplementDateIndex = $tableIndexes->first(
static fn (array $index): bool => array_map('strtolower', $index['columns']) === ['as_date', 'as_id']
)['name'] ?? null;
}
$indexes = $tableIndexes
->map(static fn (array $index): array => array_map(
'strtolower',
(array) ($index['columns'] ?? []),
@ -90,6 +109,9 @@ class NonJingxiRefundMirrorRepository
return [
self::INFO_DATASET => $this->mirror->state($this->dataset(self::INFO_DATASET)),
self::ITEM_DATASET => $this->mirror->state($this->dataset(self::ITEM_DATASET)),
self::SUPPLEMENT_DATASET => $this->mirror->state($this->dataset(self::SUPPLEMENT_DATASET)),
self::SUPPLEMENT_ITEM_DATASET => $this->mirror->state($this->dataset(self::SUPPLEMENT_ITEM_DATASET)),
self::SHOP_DATASET => $this->mirror->state($this->dataset(self::SHOP_DATASET)),
];
}
@ -100,7 +122,7 @@ class NonJingxiRefundMirrorRepository
public function syncStatus(?array $states = null, ?CarbonInterface $at = null): array
{
$states ??= $this->states();
if (count(array_filter($states, 'is_array')) !== 2) {
if (count(array_filter($states, 'is_array')) !== 5) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
@ -143,19 +165,10 @@ class NonJingxiRefundMirrorRepository
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$total = (clone $query)->count();
$rows = $query
->select($this->selectColumns())
->orderByDesc('r.as_date')
->orderByDesc('r.Id')
->orderByDesc('i.Id')
->forPage($page, $perPage)
->get();
$this->hydratePage($rows);
$total = $this->sourceQuery($filters, false)->count()
+ $this->sourceQuery($filters, true)->count();
$keys = $this->orderedKeys($filters, null, $page * $perPage)->forPage($page, $perPage)->get();
$rows = $this->loadRows($keys);
return ['data' => $rows->all(), 'total' => $total];
}
@ -174,38 +187,24 @@ class NonJingxiRefundMirrorRepository
}
$chunkSize = min(max($chunkSize, 1), 100);
$query = $this->baseQuery();
$this->applyFilters($query, $filters);
$page = 1;
$cursor = null;
do {
$rows = (clone $query)
->select($this->selectColumns())
->orderByDesc('r.as_date')
->orderByDesc('r.Id')
->orderByDesc('i.Id')
->forPage($page, $chunkSize)
->get();
if ($rows->isEmpty()) {
$keys = $this->orderedKeys($filters, $cursor, $chunkSize)->limit($chunkSize)->get();
if ($keys->isEmpty()) {
break;
}
$this->hydratePage($rows);
foreach ($rows as $row) {
foreach ($this->loadRows($keys) as $row) {
yield $row;
}
$page++;
} while ($rows->count() === $chunkSize);
$cursor = $keys->last();
} while ($keys->count() === $chunkSize);
}
/** @return list<string> */
private function selectColumns(): array
{
return [
'i.Id as Id', 'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id',
'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id',
'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name',
'r.shop_site as platform', 'r.refund as totalRefundMoney',
'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id', 'i.qty',
@ -216,17 +215,223 @@ class NonJingxiRefundMirrorRepository
];
}
private function baseQuery(): Builder
private function sourceQuery(array $filters, bool $supplement, bool $withItems = true): Builder
{
return $this->connection->table('ops_jst_return_refund_info as r')
->join('ops_jst_return_refund_item as i', 'i.as_id', '=', 'r.as_id')
->where(function (Builder $query): void {
$query->whereNull('r.shop_site')
->orWhere(function (Builder $platform): void {
$platform->where('r.shop_site', 'not like', '%京喜%')
->whereRaw('LOWER(r.shop_site) NOT LIKE ?', ['%jingxi%']);
});
$table = $supplement ? 'ops_jst_qmcust_refunds' : 'ops_jst_return_refund_info';
$query = $this->connection->table($table . ' as r');
if ($supplement && $filters['order_no'] === '' && $this->supplementDateIndex !== null
&& $this->connection->getDriverName() === 'mysql') {
// The legacy text payload makes a full table scan very expensive;
// keep date windows on the verified shared-mirror lookup index.
$query->forceIndex($this->supplementDateIndex);
}
if ($filters['order_no'] !== '') {
$needle = trim($filters['order_no']);
$key = $supplement ? 'as_id' : 'Id';
$matches = $this->connection->table($table)->select($key . ' as matched_id')->where('so_id', $needle)
->union($this->connection->table($table)->select($key . ' as matched_id')->where('outer_as_id', $needle));
if (ctype_digit($needle)) {
$matches->union($this->connection->table($table)->select($key . ' as matched_id')->where('o_id', $needle));
}
// Separate indexed lookups: MariaDB cannot reliably index-merge
// OR predicates across the replica's long text identifier fields.
$query->joinSub($matches, 'matching_refund', 'matching_refund.matched_id', '=', 'r.' . $key);
}
$itemTable = $supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item';
if ($withItems) {
$query->leftJoin($itemTable . ' as i', 'i.as_id', '=', 'r.as_id');
}
$platform = 'r.shop_site';
if ($supplement) {
// A shop may have several historical rows. An ambiguous platform is
// left unknown, and must never multiply the refund rows.
$this->excludedShopIds ??= $this->connection->table('ops_jst_shop_info')
->get(['shop_id', 'shop_site'])->groupBy('shop_id')
->filter(function ($rows): bool {
$sites = $rows->pluck('shop_site')->filter()->unique();
$site = (string) $sites->first();
return $sites->count() === 1 && (str_contains($site, '京喜') || str_contains(strtolower($site), 'jingxi'));
})->keys()->map('strval')->all();
if ($this->excludedShopIds !== []) {
$query->where(fn (Builder $q) => $q->whereNull('r.shop_id')->orWhereNotIn('r.shop_id', $this->excludedShopIds));
}
$query->whereNotExists(function (Builder $existing): void {
$existing->selectRaw('1')->from('ops_jst_return_refund_info as existing')
->whereColumn('existing.as_id', 'r.as_id');
})
->whereNotExists(function (Builder $existing): void {
$existing->selectRaw('1')->from('ops_jst_return_refund_info as existing')
->whereNotNull('r.outer_as_id')->where('r.outer_as_id', '!=', '')
->whereNotNull('r.shop_id')->where('r.shop_id', '!=', '')
->whereColumn('existing.shop_id', 'r.shop_id')
->whereColumn('existing.outer_as_id', 'r.outer_as_id');
});
$platform = null;
}
if ($platform !== null) {
$query->where(function (Builder $query) use ($platform): void {
$query->whereNull($platform)->orWhere(function (Builder $query) use ($platform): void {
$query->where($platform, 'not like', '%京喜%')
->whereRaw('LOWER(' . $platform . ') NOT LIKE ?', ['%jingxi%']);
});
});
}
$this->applyFilters($query, $filters);
if ($filters['sku_id'] !== '') {
if ($withItems) {
$this->applySkuFilter($query, $filters['sku_id']);
} else {
$query->whereExists(function (Builder $items) use ($itemTable, $filters): void {
$items->selectRaw('1')->from($itemTable . ' as i')->whereColumn('i.as_id', 'r.as_id');
$this->applySkuFilter($items, $filters['sku_id']);
});
}
}
return $query;
}
private const SORT_COLUMNS = ['refundDate', '_source_rank', '_head_id', '_source_id', '_item_id'];
private function orderedKeys(array $filters, ?object $cursor, int $limit): Builder
{
$branches = [];
foreach ([false, true] as $supplement) {
$rank = $supplement ? 1 : 0;
$headColumn = $supplement ? 'r.as_id' : 'r.Id';
$heads = $this->sourceQuery($filters, $supplement, false)
->select(['r.as_date', 'r.as_id'])->when(! $supplement, fn (Builder $q) => $q->addSelect('r.Id'));
if ($cursor !== null) {
$heads->where(function (Builder $after) use ($cursor, $rank, $headColumn, $supplement, $filters): void {
$after->where('r.as_date', '<', $cursor->refundDate);
if ($rank <= (int) $cursor->_source_rank) {
$after->orWhere(function (Builder $sameDate) use ($cursor, $rank, $headColumn, $supplement, $filters): void {
$sameDate->where('r.as_date', $cursor->refundDate);
if ($rank === (int) $cursor->_source_rank) {
$headValue = $supplement ? $cursor->_source_id : $cursor->_head_id;
$sameDate->where(function (Builder $head) use ($headColumn, $headValue, $cursor, $supplement, $filters): void {
$head->where($headColumn, '<', $headValue)
->orWhere(function (Builder $remaining) use ($headColumn, $headValue, $cursor, $supplement, $filters): void {
$remaining->where($headColumn, $headValue)->whereExists(function (Builder $items) use ($cursor, $supplement, $filters): void {
$items->selectRaw('1')->from(($supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item') . ' as i')
->whereColumn('i.as_id', 'r.as_id')
->where($supplement ? 'i.asi_id' : 'i.Id', '<', $cursor->_item_id);
if ($filters['sku_id'] !== '') {
$this->applySkuFilter($items, $filters['sku_id']);
}
});
});
});
}
});
}
});
}
$heads->orderByDesc('r.as_date')->orderByDesc($headColumn)->limit($limit);
// Each eligible header contributes at least one row. The newest N
// headers therefore contain the newest N matching detail rows.
$source = $this->connection->query()->fromSub($heads, 'r')
->leftJoin(($supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item') . ' as i', 'i.as_id', '=', 'r.as_id');
if ($filters['sku_id'] !== '') {
$this->applySkuFilter($source, $filters['sku_id']);
}
$source->selectRaw($supplement
? 'r.as_date AS refundDate, 1 AS _source_rank, 0 AS _head_id, COALESCE(i.asi_id, 0) AS _item_id, r.as_id AS _source_id'
: "r.as_date AS refundDate, 0 AS _source_rank, r.Id AS _head_id, COALESCE(i.Id, 0) AS _item_id, '' AS _source_id");
$branch = $this->connection->query()->fromSub($source, 'source_keys');
if ($cursor !== null) {
$branch->where(function (Builder $after) use ($cursor): void {
foreach (self::SORT_COLUMNS as $position => $column) {
$after->orWhere(function (Builder $part) use ($cursor, $position, $column): void {
foreach (array_slice(self::SORT_COLUMNS, 0, $position) as $equal) {
$part->where($equal, '=', $cursor->$equal);
}
$part->where($column, '<', $cursor->$column);
});
}
});
}
foreach (self::SORT_COLUMNS as $column) {
$branch->orderByDesc($column);
}
$branches[] = $this->connection->query()->fromSub($branch->limit($limit), 'bounded_keys');
}
// Bound both branches before the final merge, including cursor exports.
$query = $this->connection->query()->fromSub($branches[0]->unionAll($branches[1]), 'refund_keys');
foreach (self::SORT_COLUMNS as $column) {
$query->orderByDesc($column);
}
return $query;
}
private function loadRows($keys)
{
$loaded = collect();
foreach ([false, true] as $supplement) {
$sourceKeys = $keys->filter(fn (object $key): bool => (bool) $key->_source_rank === $supplement);
if ($sourceKeys->isEmpty()) {
continue;
}
$table = $supplement ? 'ops_jst_qmcust_refunds' : 'ops_jst_return_refund_info';
$itemTable = $supplement ? 'ops_jst_qmcust_refund_items' : 'ops_jst_return_refund_item';
$itemKey = $supplement ? 'i.asi_id' : 'i.Id';
$query = $this->connection->table($table . ' as r')
->leftJoin($itemTable . ' as i', 'i.as_id', '=', 'r.as_id')
->whereIn($supplement ? 'r.as_id' : 'r.Id', $sourceKeys->pluck($supplement ? '_source_id' : '_head_id'))
->where(function (Builder $items) use ($sourceKeys, $itemKey): void {
$items->whereIn($itemKey, $sourceKeys->pluck('_item_id'));
if ($sourceKeys->contains(fn (object $key): bool => (int) $key->_item_id === 0)) {
$items->orWhereNull($itemKey);
}
});
if ($supplement) {
$query->select([
'r.as_date as refundDate', 'r.as_id', 'r.outer_as_id',
'r.o_id', 'r.so_id', 'r.type', 'r.node', 'r.shop_type', 'r.shop_name',
'r.refund as totalRefundMoney', 'i.outer_oi_id', 'i.shop_sku_id', 'i.i_id', 'i.sku_id',
'i.qty', 'i.shop_amount as detailRefundMoney', 'i.type as refundType',
'i.r_qty as refundQty', 'r.buyer_apply_refund as AfSalesMoney',
'r.local_created as createTime', 'r.status', 'r.shop_status', 'r.created', 'r.confirm_date',
'r.shop_id',
])->selectRaw("COALESCE(i.asi_id, r.as_id) AS Id, NULL AS RefundMoneyIncome, '退款清单' AS dataSource, 1 AS _source_rank, 0 AS _head_id, COALESCE(i.asi_id, 0) AS _item_id, r.as_id AS _source_id");
} else {
$query->select($this->selectColumns())
->selectRaw("COALESCE(i.Id, r.Id) AS Id, '售后资料' AS dataSource, 0 AS _source_rank, r.Id AS _head_id, COALESCE(i.Id, 0) AS _item_id, '' AS _source_id");
}
$loaded = $loaded->concat($query->get());
}
$byIdentity = $loaded->keyBy(fn (object $row): string => $this->identity($row));
$rows = $keys->map(fn (object $key) => $byIdentity->get($this->identity($key)))->filter()->values();
$shopIds = $rows->where('_source_rank', 1)->pluck('shop_id')->unique();
$platforms = $shopIds->isEmpty() ? collect() : $this->connection->table('ops_jst_shop_info')
->whereIn('shop_id', $shopIds)->get(['shop_id', 'shop_site'])->groupBy('shop_id');
foreach ($rows as $row) {
if ((int) $row->_source_rank === 1) {
$sites = ($platforms->get($row->shop_id) ?? collect())->pluck('shop_site')->filter()->unique()->values();
$row->platform = $sites->count() === 1 ? $sites->first() : '待确认';
unset($row->shop_id);
}
$row->detailStatus = (int) $row->_item_id === 0 ? '暂无商品明细' : '已关联';
foreach (['totalRefundMoney', 'detailRefundMoney', 'RefundMoneyIncome', 'AfSalesMoney'] as $money) {
if (! is_numeric($row->$money)) {
$row->$money = null;
}
}
foreach (array_slice(self::SORT_COLUMNS, 1) as $column) {
unset($row->$column);
}
}
$this->hydratePage($rows);
return $rows;
}
private function identity(object $row): string
{
return implode('|', [$row->_source_rank, $row->_head_id, $row->_item_id, $row->_source_id]);
}
/** @param array{from:string,to:string,order_no:string,shop_name:string,sku_id:string} $filters */
@ -235,27 +440,19 @@ class NonJingxiRefundMirrorRepository
$query->where('r.as_date', '>=', $filters['from'] . ' 00:00:00')
->where('r.as_date', '<', Carbon::parse($filters['to'])->addDay()->format('Y-m-d 00:00:00'));
if ($filters['order_no'] !== '') {
$needle = $this->contains($filters['order_no']);
$query->where(function (Builder $order) use ($needle): void {
$order->whereRaw("CAST(r.o_id AS CHAR) LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("r.so_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("r.outer_as_id LIKE ? ESCAPE '!'", [$needle]);
});
}
if ($filters['shop_name'] !== '') {
$query->whereRaw("r.shop_name LIKE ? ESCAPE '!'", [$this->contains($filters['shop_name'])]);
}
}
if ($filters['sku_id'] !== '') {
$needle = $this->contains($filters['sku_id']);
$query->where(function (Builder $sku) use ($needle): void {
$sku->whereRaw("i.sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.shop_sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]);
});
}
private function applySkuFilter(Builder $query, string $value): void
{
$needle = $this->contains($value);
$query->where(function (Builder $sku) use ($needle): void {
$sku->whereRaw("i.sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.shop_sku_id LIKE ? ESCAPE '!'", [$needle])
->orWhereRaw("i.i_id LIKE ? ESCAPE '!'", [$needle]);
});
}
/** @param \Illuminate\Support\Collection<int,object> $rows */
@ -265,13 +462,14 @@ class NonJingxiRefundMirrorRepository
return;
}
$orderIds = $rows->pluck('o_id')->filter()->unique()->values()->all();
$primaryRows = $rows->where('dataSource', '售后资料');
$orderIds = $primaryRows->pluck('o_id')->filter()->unique()->values()->all();
$skuIds = $rows->pluck('sku_id')->filter()->unique()->values()->all();
$shopNames = $rows->pluck('shop_name')->filter()->unique()->values()->all();
$orderItems = $this->auxiliaryReady(self::AUXILIARY_DATASETS[1])
? $this->connection->table('ops_jst_order_item')
->whereIn('o_id', $orderIds)
->whereIn('outer_oi_id', $rows->pluck('outer_oi_id')->filter()->unique()->all())
->whereIn('outer_oi_id', $primaryRows->pluck('outer_oi_id')->filter()->unique()->all())
->get(['o_id', 'outer_oi_id', 'shop_i_id', 'shop_sku_id', 'i_id', 'sku_id', 'buyer_paid_amount'])
->keyBy(static fn (object $row): string => $row->o_id . '|' . $row->outer_oi_id)
: collect();
@ -291,14 +489,14 @@ class NonJingxiRefundMirrorRepository
: collect();
foreach ($rows as $row) {
$orderItem = $orderItems->get($row->o_id . '|' . $row->outer_oi_id);
$orderItem = $row->dataSource === '售后资料' ? $orderItems->get($row->o_id . '|' . $row->outer_oi_id) : null;
$skuId = $row->sku_id ?: ($orderItem->sku_id ?? null);
$unitCost = isset($skus[$skuId]) ? (float) $skus[$skuId]->cost_price : null;
$refundQuantity = $row->refundQty === null ? null : (float) $row->refundQty;
$row->isZC = str_contains((string) $row->shop_type, '自营') ? '是' : '否';
$row->responsibleDepartment = $shops[$row->shop_name]->responsibleDepartment ?? null;
$row->originPostcode = $orders[$row->o_id]->receiver_zip ?? null;
$row->originPostcode = $row->dataSource === '售后资料' ? ($orders[$row->o_id]->receiver_zip ?? null) : null;
$row->shop_i_id = $orderItem->shop_i_id ?? null;
$row->shop_sku_id = $row->shop_sku_id ?: ($orderItem->shop_sku_id ?? null);
$row->i_id = $row->i_id ?: ($orderItem->i_id ?? null);

View File

@ -495,7 +495,8 @@ class OrderDataMirrorRepository
$shopNames = $this->selectedShopNames($filters);
if ($shopNames !== []) {
$known = (array) Cache::get(self::SHOP_NAMES_CACHE_KEY, []);
// A release clears the complete cache; the small shop directory remains valid.
$known = $this->shopNames();
if (! empty($filters['shop_names_exact']) && array_diff($shopNames, $known) === []) {
$query->whereIn('o.shop_name', $shopNames);
} else {

View File

@ -141,6 +141,7 @@ class ProductCatalogMirrorRepository
$this->attachWorkPlatformPrices($rows, $workPlatformPriceSyncStatus['initialized']);
$jushuitanCostPriceHistorySyncStatus = $this->jushuitanCostPriceHistorySyncStatus();
$this->attachJushuitanCostPriceHistory($rows, $jushuitanCostPriceHistorySyncStatus['initialized']);
app(JushuitanCostPriceObservationRepository::class)->attach($rows);
return [
'data' => $rows,
@ -571,6 +572,7 @@ class ProductCatalogMirrorRepository
$query = $this->connection->table($productTable)
->where('sku_type', 'normal')
->select([
'_mirror_key',
'autoid',
'i_id',
'sku_id',

View File

@ -17,7 +17,7 @@ use RuntimeException;
*
* 两列金额口径不同,页面分别展示:
* 销量(period_sales / sales_NN):业绩明细(店铺收款匹配订单明细)的
* 「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按「店铺名称 + 发生日期」
* 「收入金额 + 优惠金额 + 冲减金额 + 违约赔付(冲减收入) + 退款金额」合计,按「店铺名称 + 发生日期」
* 预汇总到 store_report_paid_sales_rows,再按发生日期落到主体期间;
* 收入(rolling_12m_income / income_NN):平台账单日汇总 store_report_daily_rows 的收入,
* 供主体维度经营报表按主体汇总使用。

View File

@ -88,6 +88,63 @@ class SupplierManagementMirrorRepository
];
}
public function supplierById(int $id): ?object
{
if (! $this->ready() || $id <= 0) {
return null;
}
return $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable())
->where('Id', $id)
->first(['Id', 'scmcode', 'scmname']);
}
/** @param list<string> $codes
* @return list<object>
*/
public function suppliersByCodes(array $codes): array
{
$codes = array_values(array_unique(array_filter(
array_map('trim', $codes),
static fn (string $code): bool => $code !== '',
)));
if ($codes === []) {
return [];
}
if (! $this->ready()) {
throw new RuntimeException('supplier_management_mirror_not_ready');
}
return $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable())
->whereIn('scmcode', $codes)
->orderBy('Id')
->get(['Id', 'scmcode', 'scmname'])
->all();
}
/** @param list<string> $names
* @return list<object>
*/
public function suppliersByNames(array $names): array
{
$names = array_values(array_unique(array_filter(
array_map('trim', $names),
static fn (string $name): bool => $name !== '',
)));
if ($names === []) {
return [];
}
if (! $this->ready()) {
throw new RuntimeException('supplier_management_mirror_not_ready');
}
return $this->mirror->connection()->table($this->datasets->get(self::DATASET)->targetTable())
->whereIn('scmname', $names)
->orderBy('Id')
->get(['Id', 'scmcode', 'scmname'])
->all();
}
/** 全部筛选结果,按上游稳定键小批读取,保留编码/账号文本。 */
public function exportRows(array $filters): \Generator
{

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