Compare commits

...

74 Commits

Author SHA1 Message Date
5633ed2430 安全整合存量报价计费规则与独立成本价观测 (#410) 2026-10-20 19:08:47 +08:00
d68548646d 修复多人发布并发覆盖及旧客户端绕过保护 (#409) 2026-10-20 18:13:01 +08:00
chenxi
0ec6f703fe 商品成本:移除两个筛选复选框,默认全部数据展示
- 去掉「只看未匹配商品单价」「包含-不看仓」两个复选框
- 页面与导出默认展示全部行:已匹配/未匹配单价、含「-不看」仓全部纳入
- 汇总卡片按全部口径统计,移除「已隐藏的-不看仓行数」
2026-10-20 17:46:34 +08:00
a317ab9dea 供应商对账:比对汇总改按我方商品编码匹配 (#408)
对照我方货款对账的商品编码匹配厂家条码,页面、CSV导出和钉钉通知统一切换。
2026-10-20 17:45:55 +08:00
db08b2c264 供应商对账:新增财务比对汇总导出 (#407)
财务端比对明细新增CSV导出,包含筛选汇总和完整比对结果。
2026-10-20 17:29:46 +08:00
chenxi
32cd0d0a15 商品成本:常州简尚家居有限公司的仓库简称对应映射表「配件单仓」行
业务确认:仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 简称=配件单仓-不看。
仓库简称匹配增加这一档:主仓行 → 同名仓库 → 配件单仓行(仅在没有前两者时使用)。
2026-10-20 17:15:49 +08:00
8162afa969 供应商对账:财务比对筛选与分页优化 (#406)
财务端增加供应商精确搜索、比对明细吸顶表头和每页15条分页。
2026-10-20 17:03:27 +08:00
chenxi
bcb29aac1c 商品成本:仓库简称只认映射表里「仓储方 + 主仓」那条
发货仓库是聚水潭仓储方级别的名称,映射表里同一仓储方下还有次品仓/销退仓/配件单仓等行,
之前无主仓行时会兜底取到这些行的简称(如常州简尚家居有限公司→退货报废-不看),
把主仓发货误判成不看仓。改为:仓储方匹配仓库名含「主仓」的行的简称;
没有主仓行时只接受「仓库名=仓储方名」的同名仓库,不再兜底次品/销退行。
2026-10-20 15:19:32 +08:00
d1b751c79c 业绩明细:补齐拼多多退款支付立减备注并延长联动缓存 2026-10-20 15:14:55 +08:00
chenxi
fce31f0c09 商品成本:种菜金额有数据的账单行整条排除
账单侧按业绩明细取数时,除「收入金额有数据且不为 0」外,再排除 planting_amount > 0 的账单行
(种菜订单整条不参与商品成本匹配),页面口径说明同步补充。
2026-10-20 14:12:19 +08:00
acad1f4577 修复业绩明细日期前缀串单并增加防碰撞匹配 2026-10-20 14:01:26 +08:00
ad8da1264d 供应商对账:按采购单号日期条码核对数量金额 (#405)
厂家账单改为按采购单号+日期+条码匹配,逐项比较双方数量、单价和金额,页面与钉钉通知明确展示厂家多算或少算。
2026-10-20 13:44:00 +08:00
6470346392 修复账单覆盖校验日期边界 2026-10-20 13:26:20 +08:00
f08c600961 合并京东钱包每日账单同步修复 2026-10-20 13:19:37 +08:00
144937bfa3 补齐京东钱包每日账单同步并增加覆盖校验 2026-10-20 12:20:13 +08:00
chenxi
5fe92bd05c 商品成本:订单侧不取套餐/服务占位编码 TCXJ、FUWU
订单原始数据里的 TCXJ(套餐)、FUWU(服务)不是实物商品,商品成本同步时直接剔除
(SQL 层过滤 + 组合装拆出的组件里同样剔除),页面口径说明同步补充。
2026-10-20 11:53:03 +08:00
81bf2bd779 供应商对账:厂家端隐藏上传记录并调整内部位置 (#404)
厂家门户隐藏最近上传摘要;内部厂家端上传记录保留并移动到页面底部。
2026-10-20 11:39:20 +08:00
cad8369fdb 业绩明细:新增1688红包补贴备注并优化筛选统计 2026-10-20 11:28:53 +08:00
2a2df7451e 供应商对账:退货表格改为十条并固定表头 (#403)
退货表格调整为每页十条,并增加固定高度滚动区和吸顶表头。
2026-10-20 11:21:22 +08:00
chenxi
5e05a8728e 商品成本:未匹配单价统计排除「数量与单价口径不一致」行,避免两处重复计数 2026-10-20 11:19:53 +08:00
chenxi
6b044e727b 商品成本:单价增加「普通商品资料成本价」第三档兜底
取价顺序改为:商品月加权平均单价(仓库简称+商品编码)→ 线上代发价格(采购单价/钉钉代发单价)
→ 普通商品资料(operations_replica.rpt.jst_sku)里的商品成本价(sku_type=normal 且成本价>0)。
页面汇总新增「其中用商品资料成本价兜底」,同步命令日志同步输出三档兜底行数。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
2026-10-19 09:31:17 +08:00
179 changed files with 16458 additions and 1010 deletions

View File

@ -64,6 +64,7 @@
## 8. 构建与生产部署
- 发布脚本会拒绝覆盖“不在本次版本里”的线上提交(防止互相冲掉已上线的改动)。热修复必须基于线上当前版本构建,发布后立即合入 `main`;遇到拦截先 `git fetch` 并合入最新 `main` 或线上热修复分支,不得随手加 `-AllowRollback`(`--allow-rollback`),撤回线上发布需负责人明确决定。
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,macOS 开发机使用 `bash scripts/deploy-from-mac.sh`;两者流程一致(本机构建前端与镜像、迁移拦截、增量推送服务器本机 Registry、激活与健康检查、双版本保留)。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,9 @@ class SyncStoreReceiptProductCost extends Command
/** 虚拟条码(信誉订单/虚拟订单)不是真实商品,不计入商品成本。 */
private const VIRTUAL_SKU_PREFIX = 'X0000';
/** 套餐 / 服务占位编码:不是实物商品,商品成本不取数。 */
private const EXCLUDED_SKUS = ['TCXJ', 'FUWU'];
private const REMARK_NO_WAREHOUSE = '未匹配仓库简称';
private const REMARK_NO_PRICE = '未匹配商品单价';
@ -54,6 +57,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';
@ -114,10 +120,15 @@ class SyncStoreReceiptProductCost extends Command
// 账单侧取数:业绩明细里本月(可按平台限定)的账单行,一行 = 一个店铺的一个订单。
// 只取「收入金额有数据且不为 0」的账单行:收入为 0 的行是补出来的优惠/冲减/无收款账单行,
// 没有对应的收款商品,不参与商品成本。
// 「种菜金额」有数据(>0)的账单行整条排除,不参与匹配。
$billQuery = $mirror->table(self::SOURCE_TABLE)
->where('report_month', $reportMonth)
->whereNotNull('income_amount')
->where('income_amount', '!=', 0);
->where('income_amount', '!=', 0)
->where(static function ($query): void {
$query->whereNull('planting_amount')
->orWhere('planting_amount', '<=', 0);
});
if ($platform !== '') {
$billQuery->where('platform', $platform);
}
@ -166,20 +177,24 @@ class SyncStoreReceiptProductCost extends Command
}
$this->info('订单侧候选单号:' . count($candidates));
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string}>> $lines */
/** @var array<string,list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}>> $lines */
$lines = [];
$excludedSkuSql = "'" . implode("','", self::EXCLUDED_SKUS) . "'";
foreach (self::SOURCES as $source) {
try {
$rows = $mirror->select(
"SELECT 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, "
. " CONCAT(o.o_id, '-', i.oi_id) as row_key, "
. " i.sku_id as sku_id, i.qty as qty, i.amount as amount
FROM tmp_cost_order_nos t
STRAIGHT_JOIN {$source['orders']} o ON o.so_id = t.no
STRAIGHT_JOIN {$source['items']} i ON i.o_id = o.o_id
LEFT JOIN ops_jst_warehouse_info wh ON wh.wms_co_id = o.wms_co_id
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'"
WHERE COALESCE(i.sku_id, '') NOT LIKE 'X0000%'
AND UPPER(TRIM(COALESCE(i.sku_id, ''))) NOT IN ({$excludedSkuSql})"
);
} catch (Throwable $exception) {
$this->error('订单明细查询失败(' . $source['items'] . '):' . $exception->getMessage());
@ -210,13 +225,22 @@ class SyncStoreReceiptProductCost extends Command
$warehouse = '';
}
}
$lines[$core][] = [
// 月加权平均单价表里的商品编码统一大写,订单侧可能小写,统一成大写再比。
'sku' => mb_strtoupper($sku),
'qty' => $qty,
'amount' => $amount,
'warehouse' => $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,
];
}
}
}
$this->info('订单侧命中核心单号:' . count($lines));
@ -225,11 +249,14 @@ class SyncStoreReceiptProductCost extends Command
$prices = $this->weightedAverageUnitPrices($mirror, $reportMonth);
// 月加权平均单价匹配不到时,用「线上代发价格」(采购单价 / 钉钉代发单价)兜底。
$fallbackPrices = $this->onlineDaifaUnitPrices($mirror);
// 代发价格也没有时,再用普通商品资料里的商品成本价兜底。
$catalogPrices = $this->catalogCostPrices($mirror);
$this->info(sprintf(
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d',
'仓库映射条目:%d,月加权单价条目:%d,线上代发价格条目:%d,商品资料成本价条目:%d',
count($shortNames['by_provider']),
count($prices),
count($fallbackPrices)
count($fallbackPrices),
count($catalogPrices)
));
$this->pruneTarget($connection, $reportMonth, $platform);
@ -243,13 +270,12 @@ class SyncStoreReceiptProductCost extends Command
$missingPriceRows = 0;
$missingWarehouseRows = 0;
$fallbackPriceRows = 0;
$catalogPriceRows = 0;
$mismatchRows = 0;
foreach ($billByCore as $core => $bill) {
foreach ($this->costLines($lines[$core] ?? []) as $line) {
$short = $shortNames['by_warehouse'][$line['warehouse']]
?? $shortNames['by_provider'][$line['warehouse']]
?? '';
$short = $shortNames['by_provider'][$line['warehouse']] ?? '';
$price = $short !== '' ? ($prices[$short . "\0" . $line['sku']] ?? null) : null;
$priceSource = $price === null ? null : self::PRICE_SOURCE_WEIGHTED;
// 月加权平均单价按「仓库简称 + 商品编码」匹配不到时,退回线上代发价格(按商品编码)。
@ -261,6 +287,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);
@ -331,9 +366,10 @@ 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
));
@ -342,11 +378,15 @@ class SyncStoreReceiptProductCost extends Command
}
/**
* 拆单去重:聚水潭的拆单子单会把主单的商品行再列一遍,同一批商品只算一次;
* 同一商品在同一订单里既有真实发货仓库行、又有无仓库的占位行时,丢掉占位行,
* 这样「同一单分多仓发货」会按子单的实际仓库分行。
* 拆单取数:聚水潭拆单后,主单(link_o_id 为空)保留的是原始下单行,
* 子单(link_o_id 指向主单)才是实际发货行。一个订单只要出现子单,就只取子单,
* 否则主单和子单会被各算一遍(主单的原始商品 + 子单的实际发货商品)。
* 没有子单的订单才用主单行。
*
* @param list<array{sku:string,qty:float,amount:float,warehouse:string}> $rows
* 同一订单的多条子单(不同仓库 / 分批发货)按「发货仓库 + 商品编码」汇总,数量相加,
* 这样「一个订单有几个发货仓库、商品编码、数量」会分开列。
*
* @param list<array{sku:string,qty:float,amount:float,warehouse:string,is_main:bool,row_key:string}> $rows
* @return list<array{sku:string,qty:float,amount:float,warehouse:string}>
*/
private function costLines(array $rows): array
@ -355,25 +395,23 @@ class SyncStoreReceiptProductCost extends Command
return [];
}
// 1) 完全相同的行(商品 + 数量 + 金额 + 仓库)只保留一条。
// 1) 先按订单明细主键去掉上游的完全重复行(同一 o_id + oi_id 只算一次)。
$unique = [];
foreach ($rows as $row) {
$key = $row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount'] . "\0" . $row['warehouse'];
$key = $row['row_key'] . "\0" . $row['sku'];
$unique[$key] = $row;
}
// 2) 有真实仓库的同一(商品 + 数量 + 金额),优先于无仓库的占位行。
$withWarehouse = [];
// 2) 有子单就只取子单(实际发货行);整单没有子单才用主单行。
$hasSubOrder = false;
foreach ($unique as $row) {
if ($row['warehouse'] !== '') {
$withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']] = true;
if (! $row['is_main']) {
$hasSubOrder = true;
break;
}
}
foreach ($unique as $key => $row) {
if ($row['warehouse'] === ''
&& isset($withWarehouse[$row['sku'] . "\0" . $row['qty'] . "\0" . $row['amount']])) {
unset($unique[$key]);
}
if ($hasSubOrder) {
$unique = array_filter($unique, static fn (array $row): bool => ! $row['is_main']);
}
// 3) 汇总到「发货仓库 + 商品编码」。
@ -391,6 +429,54 @@ class SyncStoreReceiptProductCost extends Command
return array_values($aggregated);
}
/**
* 组合装条码拆组件:聚水潭的组合装 sku 形如
* ZH-<条码>-<数量>[-<条码>-<数量>...]
* 条码本身含横杠,所以按「纯数字段」识别数量,再按组件展开。
*
* 返回 [条码, 数量, 是否组件];非组合装或解析失败时返回原条码 / 原数量。
*
* @return list<array{0:string,1:float,2:bool}>
*/
private static function expandComboSku(string $sku, float $quantity): array
{
if (! str_starts_with(mb_strtoupper($sku), 'ZH-')) {
return [[$sku, $quantity, false]];
}
$parts = explode('-', substr($sku, 3));
$components = [];
$buffer = [];
foreach ($parts as $part) {
if ($part === '') {
return [[$sku, $quantity, false]];
}
if (preg_match('/^\d+(\.\d+)?$/', $part) === 1) {
if ($buffer === []) {
return [[$sku, $quantity, false]];
}
$components[] = [implode('-', $buffer), (float) $part];
$buffer = [];
continue;
}
$buffer[] = $part;
}
if ($buffer !== [] || $components === []) {
return [[$sku, $quantity, false]];
}
$expanded = [];
foreach ($components as [$code, $multiplier]) {
// 组合装里如果拆出套餐/服务占位编码,同样不取数。
if (in_array(mb_strtoupper(trim($code)), self::EXCLUDED_SKUS, true)) {
continue;
}
$expanded[] = [$code, $quantity * $multiplier, true];
}
return $expanded === [] ? [] : $expanded;
}
/**
* 仓库映射表:发货仓库 → 仓库简称。
*
@ -402,7 +488,6 @@ class SyncStoreReceiptProductCost extends Command
private function warehouseShortNames(): array
{
$byProvider = [];
$byWarehouse = [];
foreach (DB::table(self::WAREHOUSE_MAPPING_TABLE)
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']) as $mapping) {
$provider = trim((string) $mapping->warehouse_provider);
@ -411,19 +496,30 @@ class SyncStoreReceiptProductCost extends Command
if ($short === '') {
continue;
}
if ($warehouse !== '' && ! isset($byWarehouse[$warehouse])) {
$byWarehouse[$warehouse] = $short;
}
if ($provider === '') {
continue;
}
// 只认「仓储方 + 仓库含主仓」的那条:发货仓库是仓储方级别的名称,
// 次品仓 / 销退仓 / 客退仓这些行的简称(多为「-不看」)不能套到主仓发货上。
$isMain = str_contains($warehouse, '主仓');
if ($isMain || ! isset($byProvider[$provider])) {
if ($isMain) {
$byProvider[$provider] = $short;
continue;
}
// 映射表没有主仓行时,仅接受「仓库名 = 仓储方名」这种同名仓库(如重庆渝北仓),
// 其余(次品仓、配件单仓等)不再兜底,避免把主仓订单误判成不看仓。
if ($warehouse === $provider && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
continue;
}
// 业务确认:常州简尚家居有限公司的发货仓对应映射表里的「配件单仓」行
// (仓储方=常州简尚家居有限公司 / 仓库=配件单仓 → 配件单仓-不看)。
if (str_contains($warehouse, '配件单仓') && ! isset($byProvider[$provider])) {
$byProvider[$provider] = $short;
}
}
return ['by_provider' => $byProvider, 'by_warehouse' => $byWarehouse];
return ['by_provider' => $byProvider];
}
/**
@ -532,6 +628,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

@ -7,7 +7,8 @@ use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
use Throwable;
/**
* 店铺维度报表的「销量」汇总:业绩明细「订单实付/实收金额」按「店铺名称 + 发生日期」预汇总。
* 店铺维度报表的「销量」汇总:业绩明细「收入金额 + 优惠金额 + 冲减金额 + 退款金额」
* 按「店铺名称 + 发生日期」预汇总。
*
* 业绩明细同步(store-receipt:order-match:sync)结束后会自动刷新一次;
* 这个命令用于单独重跑(例如补数据或按最新代码强制重建)。
@ -17,7 +18,7 @@ class SyncStoreReportPaidSales extends Command
protected $signature = 'business-mirror:sync-store-report-paid-sales
{--force : 忽略来源指纹,强制重建销量汇总}';
protected $description = '把业绩明细的订单实付/实收金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
protected $description = '把业绩明细的收入/优惠/冲减/退款金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -107,8 +107,11 @@ class Login extends Controller
// Redirect to landing page if is user
$url = route($user->landing_page, ['company_id' => $company->id]);
// 登录后固定回到首页(财务驾驶舱),不再回跳上次打开/被拦截的业务页面。
$request->session()->forget('url.intended');
if (! $request->expectsJson()) {
return redirect()->intended($url);
return redirect()->to($url);
}
return response()->json([
@ -117,7 +120,7 @@ class Login extends Controller
'error' => false,
'message' => trans('auth.login_redirect'),
'data' => null,
'redirect' => redirect()->intended($url)->getTargetUrl(),
'redirect' => url($url),
]);
}

View File

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

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use App\Utilities\FinanceLedgerAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
class FinanceLedger extends Controller
{
public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。');
$data = $request->validate([
'account_short' => ['nullable', 'string', 'max:100'],
]);
$account = trim((string) ($data['account_short'] ?? ''));
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$row = $connection->table('fin_account_map')->where('id', $id)->first();
abort_if($row === null, 404);
if ($account !== '') {
$exists = $connection->table('fin_accounts')->where('account_short', $account)->exists();
abort_unless($exists, 422, '账户简称不存在,请从账户总表中选择。');
}
$old = (string) ($row->account_short ?? '');
$connection->table('fin_account_map')->where('id', $id)->update([
'account_short' => $account !== '' ? $account : null,
'updated_by' => (string) user()->name,
'updated_at' => now(),
]);
$connection->table('fin_account_map_events')->insert([
'source' => (string) $row->source,
'source_value' => (string) $row->source_value,
'old_account_short' => $old !== '' ? $old : null,
'new_account_short' => $account !== '' ? $account : null,
'user_id' => (int) user()->id,
'user_name' => (string) user()->name,
'created_at' => now(),
]);
if ($request->boolean('recalculate', true)) {
$calculator->recalculate();
}
$mapped = $connection->table('fin_account_map')
->whereNotNull('account_short')->where('account_short', '<>', '')->count();
return response()->json([
'ok' => true,
'account_short' => $account,
'mapped' => $mapped,
'total' => $connection->table('fin_account_map')->count(),
], 200, ['Cache-Control' => 'private, no-store']);
}
}

View File

@ -105,11 +105,15 @@ class Pages extends Controller
'拼多多',
'京东账单',
'京喜账单',
'京东网银钱包',
'抖音',
'得物',
];
/** 业绩明细不纳入的收款账户:京东网银钱包的收、支均不取。 */
private const RECEIPT_MATCH_EXCLUDED_PLATFORMS = [
'京东网银钱包',
];
/**
* 京东「直营补贴款」备注适用的京喜店铺(前缀)。
*
@ -136,6 +140,12 @@ class Pages extends Controller
'未匹配',
];
/** 差额按实收/应付口径兜底时使用的备注;这部分行才需要判断复杂差额原因。 */
private const RECEIPT_BASIS_FALLBACK_REMARKS = [
'订单实付按卖家实收核对',
'订单实付按应付/已付核对',
];
/** 业绩明细「异常标注」筛选项,取值与同步命令写入的标注一致。 */
private const RECEIPT_REMARK_OPTIONS = [
'账单无单号',
@ -247,28 +257,53 @@ class Pages extends Controller
private static function receiptJingxiSupplyEvidenceSql(): array
{
$guard = "platform = '京喜账单' AND COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) > 0.005";
// 京喜账单按「活动代」保留多代历史导入:只认与本月重叠的活动代(与同步命令同一口径)。
// 不按代过滤时,旧代 + 新代会把同一笔直营服务费算两遍(例如 3,432 被算成 6,864),
// 供货价规则整月对不上,全部落进「差额待核实」。没有活动代登记的月份保持原口径。
$generation = "AND (
NOT EXISTS (SELECT 1 FROM jd_daily_bill_periods p
WHERE p.period_start < DATE_ADD(store_receipt_order_match_rows.report_month, INTERVAL 1 MONTH)
AND p.period_end > store_receipt_order_match_rows.report_month)
OR b.generation IN (SELECT p.active_generation FROM jd_daily_bill_periods p
WHERE p.period_start < DATE_ADD(store_receipt_order_match_rows.report_month, INTERVAL 1 MONTH)
AND p.period_end > store_receipt_order_match_rows.report_month)
)";
return [
'guard' => $guard,
'supply_fee' => "(SELECT GREATEST(COALESCE(SUM(b.income), 0), COALESCE(SUM(b.expense), 0))
FROM jd_daily_bill_rows b
WHERE b.order_no = store_receipt_order_match_rows.online_order_no
AND b.bill_item = '直营服务费')",
AND b.bill_item = '直营服务费'
{$generation})",
'supply_net' => "(SELECT ABS(COALESCE(SUM(b.income), 0) - COALESCE(SUM(b.expense), 0))
FROM jd_daily_bill_rows b
WHERE b.order_no = store_receipt_order_match_rows.online_order_no
AND b.bill_item = '直营服务费')",
AND b.bill_item = '直营服务费'
{$generation})",
'gross_up_fee' => "(SELECT COALESCE(SUM(b.expense), 0)
FROM jd_daily_bill_rows b
WHERE b.order_no = store_receipt_order_match_rows.online_order_no
AND b.bill_item = '直营服务费'
{$generation})",
];
}
private static function receiptReasonNotBasisMarkedSql(): string
{
return "COALESCE(remark, '') NOT IN ('订单实付按卖家实收核对', '订单实付按应付/已付核对')";
}
/**
* 「差额异常备注」原因(不落库):说明差额为什么产生,一行最多一个,按顺序取第一条命中。
* 未匹配行 → 订单实付未匹配(细分原因见「未匹配原因」列);已匹配但基准金额=0 → 订单实付取数为0;
* 基准金额没有减掉优惠/冲减时,差额正好等于对应项。差额按有效值判定(未匹配按基准金额=0)。
* 「差额异常备注」基础原因(不落库),按页面优先级排列。
*
* @return array<string,string> 备注文字 => SQL 条件(已按顺序排除前面命中的行)
* 这里保留可重叠的原始条件;需要“一行只取第一个原因”时走
* receiptDifferenceRemarkIndexSql(),由单次 CASE 按顺序命中,避免把
* 前序大段条件重复拼进后续每一条,拖慢筛选联动。
*
* @return array<string,string> 备注文字 => 原始 SQL 条件
*/
private static function receiptDifferenceRemarkConditions(): array
private static function receiptDifferenceRemarkBaseRules(): array
{
$basis = self::differenceBasis();
$column = $basis['column'];
@ -294,14 +329,29 @@ class Pages extends Controller
. ' AND (ABS(' . $effective . ' - (' . $jingxiEvidence['supply_fee'] . ')) < 0.005'
. ' OR ABS(' . $effective . ' - (' . $jingxiEvidence['supply_net'] . ')) < 0.005)) THEN 1 ELSE 0 END = 1';
// 京喜正差额:账单「货款(收)」把直营服务费一起计入收入(货款 = 商品金额 + 直营服务费),
// 聚水潭「订单实付」是买家实付;差额 = 直营服务费 −(实付−商品金额)。
// 宜自然 44 行逐单对平,全京喜同形态近千行(2026-10-19 核)。
$jingxiGrossUp = "platform = '京喜账单' AND " . $effective . ' > 0.005'
. ' AND COALESCE(product_amount, 0) > 0.005'
. ' AND (' . $jingxiEvidence['gross_up_fee'] . ') > 0.005'
. ' AND ABS((COALESCE(income_amount, 0) - COALESCE(product_amount, 0)) - (' . $jingxiEvidence['gross_up_fee'] . ')) < 0.02';
$rules['差额=货款含直营服务费(实付低于商品金额)'] = $jingxiGrossUp
. ' AND COALESCE(paid_amount, 0) < COALESCE(product_amount, 0) - 0.03';
$rules['差额=货款含直营服务费(拆单/合并尾差)'] = $jingxiGrossUp;
// 京喜自营工厂店负差额:差额=京东「直营补贴款」(大店售价低于供货价的差价,见京麦费用项 50241),
// 无退款的行走到结算单开票核销后收回;订单已退款的行走「取消退款」,补贴款已随退款冲回(无应收)。
// 店铺范围:出类拔萃 + 9 家已逐店核验的京喜自营工厂店(见 JINGXI_SUBSIDY_STORE_PREFIXES)。
// 店铺范围:① 出类拔萃 + 9 家已逐店核验的店铺(JINGXI_SUBSIDY_STORE_PREFIXES);
// ② 其余京喜自营工厂店按「实付≈商品金额(供货价口径)」特征自动识别(含个别非「工厂店」命名的自营店),
// 避免未提供后台账号的店铺/新店再次落进「差额待核实」。
$jingxiSubsidyStoreLikes = implode(' OR ', array_map(
static fn (string $prefix): string => "store_name LIKE '" . $prefix . "%'",
self::JINGXI_SUBSIDY_STORE_PREFIXES
));
$jingxiSubsidyGuard = "platform = '京喜账单' AND (" . $jingxiSubsidyStoreLikes . ') AND ' . $effective . ' < -0.005';
$jingxiSubsidyFactoryStores = "(store_name LIKE '京喜-%工厂店%' OR store_name LIKE '京喜-香幽竹%' OR store_name LIKE '京喜-清幽竹香%')";
$jingxiSubsidySignature = 'ABS(COALESCE(paid_amount, 0) - COALESCE(product_amount, 0)) < 0.02';
$jingxiSubsidyGuard = "platform = '京喜账单' AND ((" . $jingxiSubsidyStoreLikes . ') OR (' . $jingxiSubsidyFactoryStores . ' AND ' . $jingxiSubsidySignature . ')) AND ' . $effective . ' < -0.005';
$rules['差额=直营补贴款(待开票结算)'] = $jingxiSubsidyGuard . ' AND ABS(COALESCE(refund_amount, 0)) < 0.005';
$rules['差额=直营补贴款(订单退款已冲回)'] = $jingxiSubsidyGuard . ' AND ABS(COALESCE(refund_amount, 0)) >= 0.005';
@ -322,9 +372,13 @@ class Pages extends Controller
$jdSofiaGuobu = '(' . $jdSofiaPay . ' - COALESCE(income_amount, 0)) > 0.005';
$rules['差额=订单实付包含商家承担优惠和以旧换新国补'] = $jdSofiaGuard . ' AND ' . $jdSofiaGuobu;
$rules['差额=订单实付包含商家承担优惠'] = $jdSofiaGuard . ' AND NOT (' . $jdSofiaGuobu . ')';
// 拼多多全额退款:先于通用「未减优惠/冲减」判定,避免抵减项把退款单误标成其他原因。
$pddFullRefundHit = "platform = '拼多多'"
. ' AND ABS(COALESCE(income_amount, 0) + COALESCE(refund_amount, 0)) <= 0.02';
$rules['订单实付含已退款部分'] = $pddFullRefundHit;
// C 方案:这三条是"通用等式档"——口径(卖家实收 / 应付已付)能对上账单的行让位给口径兜底
// (归零 + 口径标识),只有口径也对不上时才显示这三档,避免把口径差标成一堆"未减优惠/冲减"。
$notBasisMarked = "COALESCE(remark, '') NOT IN ('订单实付按卖家实收核对', '订单实付按应付/已付核对')";
$notBasisMarked = self::receiptReasonNotBasisMarkedSql();
$rules[$label . '未减冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(discount_amount, 0))) < 0.005 AND " . $notBasisMarked;
$rules[$label . '未减优惠'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - (COALESCE(income_amount, 0) + COALESCE(offset_amount, 0))) < 0.005 AND " . $notBasisMarked;
$rules[$label . '未减优惠和冲减'] = "match_status = '已匹配' AND ABS(COALESCE($column, 0) - COALESCE(income_amount, 0)) < 0.005 AND " . $notBasisMarked;
@ -360,26 +414,66 @@ class Pages extends Controller
. ' OR ' . $refundGapHit($refundEvidence['cancelled_sum'])
. ' OR ' . $refundGapNetHit($refundEvidence['refund_sum'])
. ' OR ' . $refundFullHit($refundEvidence['refund_sum'])
. ') ELSE 0 END = 1) OR ' . $taoRefundHit;
. ') ELSE 0 END = 1) OR ' . $taoRefundHit . ' OR ' . $pddFullRefundHit;
$conditions = [];
$seen = [];
foreach ($rules as $label => $condition) {
$condition = 'ABS(' . $effective . ') > 0.005 AND (' . $condition . ')';
$conditions[$label] = $seen === [] ? $condition : $condition . ' AND NOT (' . implode(' OR ', $seen) . ')';
$seen[] = '(' . $condition . ')';
// 拼多多部分退款:账单收入高于聚水潭买家实付,且扣除优惠/冲减后仍有正向小额差额,
// 与「多多支付立减」等平台支付类补贴一致;只标注原因,不改差额和收入。
$pddPaymentDiscount = '(' . $effective . ' - COALESCE(discount_amount, 0) - COALESCE(offset_amount, 0))';
$rules['差额=平台支付立减'] = "platform = '拼多多'"
. " AND match_status = '已匹配'"
. ' AND ' . $effective . ' > 0.005'
. ' AND COALESCE(refund_amount, 0) < -0.005'
. ' AND ' . $pddPaymentDiscount . ' > 0.005'
. ' AND ' . $pddPaymentDiscount . ' <= 20.005'
. ' AND ' . $notBasisMarked;
// 1688 先采后付订单的订单实付包含平台红包补贴,但支付宝实际到账是扣除补贴后的现金;
// 仅对已匹配、负差额、无优惠/冲减/退款且当前没有口径标识的行标注,不改差额金额。
$rules['订单实付含平台红包补贴'] = "platform = '淘系支付宝'"
. " AND (store_name LIKE '新佰广%' OR store_name LIKE '阿里巴巴%' OR store_name LIKE '1688%')"
. ' AND match_status = \'已匹配\''
. ' AND ' . $effective . ' < -0.005'
. ' AND ABS(COALESCE(discount_amount, 0)) < 0.005'
. ' AND ABS(COALESCE(offset_amount, 0)) < 0.005'
. ' AND ABS(COALESCE(refund_amount, 0)) < 0.005'
. ' AND COALESCE(paid_amount, 0) > COALESCE(income_amount, 0) + 0.005'
. ' AND ' . $notBasisMarked;
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
$rules['差额=分位四舍五入尾差'] = "platform = '京喜账单' AND ABS(" . $effective . ') <= 0.0301 AND ' . $notBasisMarked;
return $rules;
}
/** @return list<string> 差额异常备注的下拉顺序(含兜底档) */
private static function receiptDifferenceRemarkLabels(): array
{
return array_merge(array_keys(self::receiptDifferenceRemarkBaseRules()), ['差额待核实']);
}
/**
* 差额异常备注的优先级序号(1 开始;0=无备注)。
*
* 单次 CASE 命中后即停止,筛选统计与备注筛选共用同一口径。相比把
* 18 条条件逐条带上前序 NOT 再分别 SUM,避免重复计算退款等关联子查询。
*/
private static function receiptDifferenceRemarkIndexSql(): string
{
$effective = 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
$sql = 'CASE WHEN ABS(' . $effective . ') <= 0.005 THEN 0';
$index = 1;
foreach (self::receiptDifferenceRemarkBaseRules() as $condition) {
$sql .= ' WHEN (' . $condition . ') THEN ' . $index;
$index++;
}
// 兜底档:有差额但没命中任何原因 →「差额待核实」(与页面渲染的兜底备注保持一致)。
$conditions['差额待核实'] = 'ABS(' . $effective . ') > 0.005 AND NOT (' . implode(' OR ', $seen) . ') AND ' . $notBasisMarked;
return $conditions;
return $sql . ' WHEN ' . self::receiptReasonNotBasisMarkedSql() . ' THEN ' . $index . ' ELSE 0 END';
}
/**
* 该单的聚水潭应付(主单优先 pay_amount)是否大于账单收入(= 含国补/补贴类差额的口径)。
*
* 与 receiptDifferenceRemarkConditions() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
* 与 receiptDifferenceRemarkBaseRules() 里的聚水潭应付款子查询同口径;按单号缓存应付金额,
* 页面渲染与导出逐行调用时不会对同一单重复查询。
*/
private static function receiptJdSubsidyPayExceedsIncome(string $orderNo, float $income): bool
@ -430,16 +524,31 @@ class Pages extends Controller
return $cache[$orderNo];
}
/** 该店铺是否适用京喜「直营补贴款」备注(前缀匹配,范围见 JINGXI_SUBSIDY_STORE_PREFIXES)。 */
private static function isJingxiSubsidyStore(string $storeName): bool
/** 京喜「直营补贴款」备注是否适用于该行(10 家核验店 + 工厂店按实付≈商品金额特征,与筛选条件同口径)。 */
private static function jingxiSubsidyApplies(object $row): bool
{
$storeName = (string) ($row->store_name ?? '');
foreach (self::JINGXI_SUBSIDY_STORE_PREFIXES as $prefix) {
if (str_starts_with($storeName, $prefix)) {
return true;
}
}
return false;
$factoryStore = str_contains($storeName, '工厂店')
|| str_starts_with($storeName, '京喜-香幽竹')
|| str_starts_with($storeName, '京喜-清幽竹香');
if (! $factoryStore) {
return false;
}
return abs((float) ($row->paid_amount ?? 0) - (float) ($row->product_amount ?? 0)) < 0.02;
}
private static function is1688StoreName(string $storeName): bool
{
return str_starts_with($storeName, '新佰广')
|| str_starts_with($storeName, '阿里巴巴')
|| str_starts_with($storeName, '1688');
}
/** 单行的「差额异常备注」(与筛选条件同一套口径,页面与导出共用)。 */
@ -449,18 +558,36 @@ class Pages extends Controller
*/
private static function receiptEffectiveDifferenceSql(): string
{
// 注意:要把「差额待核实」这条兜底规则排除掉,否则它本身就是"都没命中",
// 会让这里永远不成立(口径兜底行就归不了零)。
$rules = self::receiptDifferenceRemarkConditions();
unset($rules['差额待核实']);
$ruleUnion = '(' . implode(' OR ', array_map(
static fn (string $condition): string => '(' . $condition . ')',
$rules
)) . ')';
// 备注序号 > 0 表示命中了明确原因;只有口径兜底且没有明确原因时才归零。
$indexSql = self::receiptDifferenceRemarkIndexSql();
return "CASE WHEN remark IN (" . self::receiptBasisFallbackSqlList() . ')'
. " AND COALESCE({$indexSql}, 0) = 0 THEN 0"
. ' ELSE ' . self::receiptDefaultDifferenceSql() . ' END';
}
return "CASE WHEN remark IN ('订单实付按卖家实收核对','订单实付按应付/已付核对')"
. " AND NOT COALESCE({$ruleUnion}, 0) THEN 0"
. ' ELSE COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0)) END';
/** 未命中复杂原因时,差额的默认口径。 */
private static function receiptDefaultDifferenceSql(): string
{
return 'COALESCE(difference, COALESCE(income_amount, 0) + COALESCE(discount_amount, 0) + COALESCE(offset_amount, 0))';
}
/**
* 口径兜底行的差额修正量:只对「实收/应付核对」行计算复杂规则,
* 避免把京东、京喜、得物、淘系退款等关联子查询放进整月汇总的每一行。
*/
private static function receiptFallbackDifferenceAdjustmentSql(): string
{
return 'CASE WHEN COALESCE(' . self::receiptDifferenceRemarkIndexSql() . ', 0) = 0 THEN -('
. self::receiptDefaultDifferenceSql() . ') ELSE 0 END';
}
/** @return string SQL 的字符串字面量列表 */
private static function receiptBasisFallbackSqlList(): string
{
return implode(', ', array_map(
static fn (string $remark): string => "'" . str_replace("'", "''", $remark) . "'",
self::RECEIPT_BASIS_FALLBACK_REMARKS
));
}
private static function receiptDifferenceRemark(object $row): ?string
@ -489,6 +616,11 @@ class Pages extends Controller
if (abs($product) < 0.005) {
return ($row->planting_amount ?? null) !== null ? $label . '取数为0(种菜)' : $label . '取数为0';
}
// 拼多多全额退款优先于其它抵减等式:退款后实际净收款为 0,不应再落成优惠/冲减异常。
if (($row->platform ?? '') === '拼多多'
&& abs($income + (float) ($row->refund_amount ?? 0)) <= 0.02) {
return '订单实付含已退款部分';
}
// 得物负差额:差额与联合营销费未让利尾数吻合(营销费支出 + 差额恰为整数)→「订单实付未减联合营销费」。
// 放在通用「未减冲减/优惠」等式之前:得物这类行的差额常恰好等于冲减(营销费)金额,需优先给出具体项备注。
if (($row->platform ?? '') === '得物' && $difference < -0.005) {
@ -509,9 +641,21 @@ class Pages extends Controller
return '订单实付取供货价';
}
}
// 京喜正差额:账单货款含直营服务费(货款 = 商品金额 + 直营服务费),
// 差额 = 直营服务费 −(实付−商品金额)。实付低于商品金额的单独标注。
if (($row->platform ?? '') === '京喜账单' && $difference > 0.005) {
$productAmount = (float) ($row->product_amount ?? 0);
$supplyFee = (float) ($row->order_jingxi_gross_up_fee ?? 0);
if ($productAmount > 0.005 && $supplyFee > 0.005
&& abs(($income - $productAmount) - $supplyFee) < 0.02) {
return (float) ($row->paid_amount ?? 0) < $productAmount - 0.03
? '差额=货款含直营服务费(实付低于商品金额)'
: '差额=货款含直营服务费(拆单/合并尾差)';
}
}
// 京喜自营工厂店负差额:差额=京东「直营补贴款」(无退款→待开票结算;订单已退款→补贴款已随退款冲回)。
if (($row->platform ?? '') === '京喜账单' && $difference < -0.005
&& self::isJingxiSubsidyStore((string) ($row->store_name ?? ''))) {
&& self::jingxiSubsidyApplies($row)) {
return abs((float) ($row->refund_amount ?? 0)) >= 0.005
? '差额=直营补贴款(订单退款已冲回)'
: '差额=直营补贴款(待开票结算)';
@ -583,6 +727,17 @@ class Pages extends Controller
}
}
// 拼多多部分退款:差额扣除优惠/冲减后,剩余部分与平台支付立减一致。
$pddPaymentDiscount = $difference - $discount - $offset;
if (($row->platform ?? '') === '拼多多'
&& ! $basisMarked
&& $difference > 0.005
&& (float) ($row->refund_amount ?? 0) < -0.005
&& $pddPaymentDiscount > 0.005
&& $pddPaymentDiscount <= 20.005) {
return '差额=平台支付立减';
}
// 原因优先:上面的规则都没命中时,如果这行的口径(卖家实收 / 应付已付)能对上账单,
// 就按口径兜底——差额按 0 展示、口径标识在「匹配原因」列,不再当异常。
$rawRemark = trim((string) ($row->remark ?? ''));
@ -590,6 +745,23 @@ class Pages extends Controller
return null;
}
// 1688 先采后付红包补贴:订单实付包含平台补贴,支付宝到账只含现金部分。
// 先只作为已知原因标注,不改变差额金额或税务收入。
if (($row->platform ?? '') === '淘系支付宝'
&& $difference < -0.005
&& self::is1688StoreName((string) ($row->store_name ?? ''))
&& abs($discount) < 0.005
&& abs($offset) < 0.005
&& abs((float) ($row->refund_amount ?? 0)) < 0.005
&& (float) ($row->paid_amount ?? 0) > $income + 0.005) {
return '订单实付含平台红包补贴';
}
// 京喜分位尾差:具体原因都没命中时,1–3 分钱按四舍五入尾差标注。
if (($row->platform ?? '') === '京喜账单' && abs($difference) <= 0.0301) {
return '差额=分位四舍五入尾差';
}
// 有差额但没命中任何已知原因:标「差额待核实」,页面上不再出现"有差额但备注空白"的行。
return '差额待核实';
}
@ -652,24 +824,18 @@ class Pages extends Controller
public function home()
{
$section = collect(SidebarPermissions::navigationFor(user()))->first();
$groups = \App\Utilities\SidebarMenu::groups(user());
$sections = \App\Utilities\SidebarMenu::sections(user());
if (empty($section)) {
if ($groups === [] && $sections === []) {
return view('business.access-pending');
}
$page = collect($section['pages'] ?? [])->first();
$parameters = ['section' => $section['slug']];
if ($page) {
$parameters['page'] = $page['slug'];
if (! empty($page['tabs'])) {
$parameters['tab'] = $page['tabs'][0]['slug'];
}
}
return redirect()->route('business.pages.show', $parameters);
// 登录后先落到首页导航,不再直接跳进第一个业务页面。
return view('business.pages.home', [
'groups' => $groups,
'sections' => $sections,
]);
}
public function show(string $section, ?string $page = null, ?string $tab = null)
@ -1158,13 +1324,15 @@ class Pages extends Controller
// 镜像表本身就是「一个店铺 + 一个订单号 + 一个商品金额」的行口径,直接统计即可。
$base = static fn () => DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
->where('report_month', $month)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
// 计数与合计合并为一次全表扫描:重筛选项(如按备注筛选)下少一次全表重算。
$aggQuery = $base();
$applyFilters($aggQuery);
// 口径兜底行(备注为空、口径对上)的差额按 0 计——与页面行展示、差额筛选同一套判定。
$differenceExpr = self::receiptEffectiveDifferenceSql();
$differenceBaseExpr = self::receiptDefaultDifferenceSql();
$fallbackSql = self::receiptBasisFallbackSqlList();
$agg = $aggQuery->selectRaw("
COUNT(*) as total,
SUM(payment_count) as payments,
@ -1175,13 +1343,24 @@ class Pages extends Controller
SUM(planting_amount) as planting_total,
SUM(product_amount) as goods_total,
SUM(paid_amount) as paid_total,
SUM({$differenceExpr}) as difference_total,
SUM({$differenceBaseExpr}) as difference_base,
SUM(CASE WHEN remark IN ({$fallbackSql}) THEN 1 ELSE 0 END) as basis_fallback_rows,
SUM(CASE WHEN payment_count > 1 THEN 1 ELSE 0 END) as multi_pay,
SUM(CASE WHEN income_amount = 0 THEN 1 ELSE 0 END) as zero_income,
SUM(CASE WHEN match_status = '已匹配' THEN 1 ELSE 0 END) as matched,
SUM(CASE WHEN match_status = '未匹配' THEN 1 ELSE 0 END) as unmatched
")->first();
$total = (int) ($agg->total ?? 0);
$differenceTotal = (float) ($agg->difference_base ?? 0);
if ((int) ($agg->basis_fallback_rows ?? 0) > 0) {
$adjustmentQuery = $base();
$applyFilters($adjustmentQuery);
$adjustment = $adjustmentQuery
->whereIn('remark', self::RECEIPT_BASIS_FALLBACK_REMARKS)
->selectRaw('SUM(' . self::receiptFallbackDifferenceAdjustmentSql() . ') as adjustment')
->first();
$differenceTotal += (float) ($adjustment->adjustment ?? 0);
}
$stats = [
'month' => $month,
@ -1200,7 +1379,7 @@ class Pages extends Controller
'planting_total' => round((float) ($agg->planting_total ?? 0), 2),
'goods_total' => round((float) ($agg->goods_total ?? 0), 2),
'paid_total' => round((float) ($agg->paid_total ?? 0), 2),
'difference_total' => round((float) ($agg->difference_total ?? 0), 2),
'difference_total' => round($differenceTotal, 2),
'per_page' => $limit,
];
@ -1211,7 +1390,8 @@ class Pages extends Controller
now()->addHours(6),
static function () use ($connection, $month): array {
$maker = static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
->where('report_month', $month);
->where('report_month', $month)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
return [
'platforms' => $maker()->whereNotNull('platform')->distinct()->orderBy('platform')->pluck('platform')->all(),
@ -1222,17 +1402,20 @@ class Pages extends Controller
);
$stats['selected_platforms'] = $platformFilter;
$stats['selected_stores'] = $storeFilter;
$stats['platform_options'] = array_values(array_unique(array_merge(
self::RECEIPT_PLATFORM_OPTIONS,
array_map('strval', $filterOptions['platforms'] ?? []),
)));
$stats['platform_options'] = array_values(array_diff(
array_unique(array_merge(
self::RECEIPT_PLATFORM_OPTIONS,
array_map('strval', $filterOptions['platforms'] ?? []),
)),
self::RECEIPT_MATCH_EXCLUDED_PLATFORMS,
));
$stats['store_options'] = array_values(array_unique(array_merge(
$storeFilter,
array_map('strval', $filterOptions['stores'] ?? []),
)));
$stats['status_options'] = self::RECEIPT_STATUS_OPTIONS;
// 差额异常备注下拉:只保留「差额为什么产生」的原因备注(按差额公式判定)
$stats['remark_options'] = array_values(array_keys(self::receiptDifferenceRemarkConditions()));
$stats['remark_options'] = self::receiptDifferenceRemarkLabels();
// 未匹配原因下拉:未匹配行在库里的原因标注
$stats['unmatched_reason_options'] = self::unmatchedReasonOptions();
@ -1253,6 +1436,7 @@ class Pages extends Controller
->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['gross_up_fee'] . ' END AS order_jingxi_gross_up_fee')
->orderBy('occurred_date')
->orderBy('store_name')
->orderBy('online_order_no')
@ -1304,20 +1488,21 @@ class Pages extends Controller
return;
}
$differenceConditions = self::receiptDifferenceRemarkConditions();
$differenceLabels = array_values(array_intersect($remarkFilter, array_keys($differenceConditions)));
if ($differenceLabels === []) {
$labels = self::receiptDifferenceRemarkLabels();
$indexes = [];
foreach ($remarkFilter as $label) {
$index = array_search($label, $labels, true);
if ($index !== false) {
$indexes[] = (int) $index + 1;
}
}
if ($indexes === []) {
return;
}
$query->where(function ($group) use ($differenceLabels, $differenceConditions): void {
foreach ($differenceLabels as $index => $label) {
$method = $index === 0 ? 'where' : 'orWhere';
$group->{$method}(function ($sub) use ($label, $differenceConditions): void {
$sub->whereRaw($differenceConditions[$label]);
});
}
});
$query->whereRaw(
self::receiptDifferenceRemarkIndexSql() . ' IN (' . implode(', ', array_unique($indexes)) . ')'
);
}
/**
@ -1376,7 +1561,8 @@ class Pages extends Controller
{
$query = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('store_receipt_order_match_rows')
->where('report_month', $month);
->where('report_month', $month)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS);
foreach ($filters as $key => $values) {
if ($key === 'difference_state' || $values === [] || $values === '') {
@ -1442,6 +1628,7 @@ class Pages extends Controller
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$months = DB::connection($connection)->table('store_receipt_order_match_rows')
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->select('report_month')
->distinct()
->orderByDesc('report_month')
@ -1505,22 +1692,196 @@ class Pages extends Controller
// 联动统计要跑几次全表聚合,短时间内重复请求(页面加载 + 下拉刷新)直接吃缓存
$cacheKey = 'performance-details-facets:' . md5(serialize([$month, $filters, $orderNo]));
// 数据按天同步,缓存 10 分钟:大幅降低大平台(拼多多等)的联动等待。
return response()->json(cache()->remember($cacheKey, now()->addSeconds(600), function () use ($month, $filters, $orderNo) {
// 数据按天同步;联动统计会扫全月,缓存 1 小时,避免相同筛选反复等待。
return response()->json(cache()->remember($cacheKey, now()->addHour(), function () use ($month, $filters, $orderNo) {
return [
'month' => substr($month, 0, 7),
'facets' => [
'platform' => $this->receiptOrderMatchFacet($month, 'platform', $filters, $orderNo, self::RECEIPT_PLATFORM_OPTIONS),
'store_name' => $this->receiptOrderMatchFacet($month, 'store_name', $filters, $orderNo),
'match_status' => $this->receiptOrderMatchFacet($month, 'match_status', $filters, $orderNo, self::RECEIPT_STATUS_OPTIONS),
'remark' => $this->receiptOrderMatchFacet($month, 'remark', $filters, $orderNo, array_keys(self::receiptDifferenceRemarkConditions())),
'unmatched_reason' => $this->receiptOrderMatchFacet($month, 'unmatched_reason', $filters, $orderNo, self::unmatchedReasonOptions()),
'difference_state' => $this->receiptDifferenceStateFacet($month, $filters, $orderNo),
],
'facets' => $this->receiptOrderMatchFacets($month, $filters, $orderNo),
];
}));
}
/**
* 一次请求生成全部筛选联动计数。
*
* 多个筛选项如果共用同一组“其它条件”,只查一次再一起聚合。页面当前先按
* 差额备注筛选时,原先 6 次重复全表条件判断会合并成 2 次查询。
*
* @param array<string,list<string>> $filters
* @return array<string,array{values:list<string>,counts:array<string,int>}>
*/
private function receiptOrderMatchFacets(string $month, array $filters, string $orderNo): array
{
$facetOrder = [
'platform' => self::RECEIPT_PLATFORM_OPTIONS,
'store_name' => [],
'match_status' => self::RECEIPT_STATUS_OPTIONS,
'remark' => self::receiptDifferenceRemarkLabels(),
'unmatched_reason' => self::unmatchedReasonOptions(),
'difference_state' => ['有差额', '无差额'],
];
// “统计某个筛选本身”时排除该筛选;条件完全相同的筛选项合并成一个查询。
$groups = [];
foreach (array_keys($facetOrder) as $column) {
$signature = $filters;
unset($signature[$column]);
$signature = array_filter(
$signature,
static fn ($values): bool => $values !== [] && $values !== ''
);
ksort($signature);
$key = md5(serialize([$orderNo, $signature]));
$groups[$key]['filters'] = $signature;
$groups[$key]['columns'][] = $column;
}
$facets = [];
foreach ($groups as $group) {
if ($group['filters'] === []) {
foreach ($group['columns'] as $column) {
$facets[$column] = $column === 'difference_state'
? $this->receiptDifferenceStateFacet($month, $filters, $orderNo)
: $this->receiptOrderMatchFacet($month, $column, $filters, $orderNo, $facetOrder[$column]);
}
continue;
}
$counts = $this->receiptOrderMatchFilteredFacetCounts(
$month,
$group['filters'],
$orderNo,
$group['columns']
);
foreach ($group['columns'] as $column) {
$columnCounts = $counts[$column] ?? [];
$ordered = array_values(array_intersect($facetOrder[$column], array_keys($columnCounts)));
$rest = array_values(array_diff(array_keys($columnCounts), $ordered));
sort($rest, SORT_NATURAL | SORT_FLAG_CASE);
$facets[$column] = [
'values' => array_merge($ordered, $rest),
'counts' => $columnCounts,
];
}
}
return array_replace(array_fill_keys(array_keys($facetOrder), ['values' => [], 'counts' => []]), $facets);
}
/**
* 共用同一组筛选条件时,一次分组查询算出多个筛选项的计数。
*
* @param array<string,list<string>> $filters
* @param list<string> $columns
* @return array<string,array<string,int>>
*/
private function receiptOrderMatchFilteredFacetCounts(string $month, array $filters, string $orderNo, array $columns): array
{
$labels = self::receiptDifferenceRemarkLabels();
$basisMarks = ['订单实付按卖家实收核对', '订单实付按应付/已付核对'];
$remarkIndexes = [];
foreach ((array) ($filters['remark'] ?? []) as $label) {
$index = array_search($label, $labels, true);
if ($index !== false) {
$remarkIndexes[] = (int) $index + 1;
}
}
$differenceValues = array_map('strval', (array) ($filters['difference_state'] ?? []));
$wantDifference = in_array('有差额', $differenceValues, true);
$wantNoDifference = in_array('无差额', $differenceValues, true);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$inner = DB::connection($connection)
->table('store_receipt_order_match_rows')
->where('report_month', $month)
->select(['platform', 'store_name', 'match_status', 'remark'])
->selectRaw(self::receiptDefaultDifferenceSql() . ' AS base_difference')
->selectRaw(self::receiptDifferenceRemarkIndexSql() . ' AS reason_index');
foreach ($filters as $key => $values) {
if ($key === 'remark' || $key === 'difference_state' || $values === []) {
continue;
}
if ($key === 'unmatched_reason') {
$this->applyReceiptUnmatchedReasonFilter($inner, (array) $values);
continue;
}
$inner->whereIn($key, $values);
}
if (trim($orderNo) !== '') {
$inner->where('online_order_no', 'like', '%' . addcslashes(trim($orderNo), '%_\\') . '%');
}
$outer = DB::connection($connection)
->query()
->fromSub($inner, 'facet_rows');
if ($remarkIndexes !== []) {
$outer->whereIn('reason_index', array_values(array_unique($remarkIndexes)));
}
if ($wantDifference !== $wantNoDifference) {
$effective = 'CASE WHEN remark IN ('
. implode(', ', array_map(static fn (string $mark): string => "'" . $mark . "'", $basisMarks))
. ') AND reason_index = 0 THEN 0 ELSE base_difference END';
$outer->whereRaw('ABS(' . $effective . ')' . ($wantDifference ? ' > 0.005' : ' <= 0.005'));
}
$rows = $outer
->select(['platform', 'store_name', 'match_status', 'remark', 'reason_index', 'base_difference'])
->selectRaw('COUNT(*) AS total')
->groupBy(['platform', 'store_name', 'match_status', 'remark', 'reason_index', 'base_difference'])
->get();
$counts = [
'platform' => [],
'store_name' => [],
'match_status' => [],
'remark' => [],
'unmatched_reason' => [],
'difference_state' => [],
];
$wanted = array_fill_keys($columns, true);
foreach ($rows as $row) {
$total = (int) ($row->total ?? 0);
$platform = (string) ($row->platform ?? '');
$store = (string) ($row->store_name ?? '');
$status = (string) ($row->match_status ?? '');
$rawRemark = trim((string) ($row->remark ?? ''));
$reasonIndex = (int) ($row->reason_index ?? 0);
$reasonLabel = $reasonIndex > 0 ? ($labels[$reasonIndex - 1] ?? null) : null;
if (isset($wanted['platform']) && $platform !== '') {
$counts['platform'][$platform] = ($counts['platform'][$platform] ?? 0) + $total;
}
if (isset($wanted['store_name']) && $store !== '') {
$counts['store_name'][$store] = ($counts['store_name'][$store] ?? 0) + $total;
}
if (isset($wanted['match_status']) && $status !== '') {
$counts['match_status'][$status] = ($counts['match_status'][$status] ?? 0) + $total;
}
if (isset($wanted['remark']) && $reasonLabel !== null) {
$counts['remark'][$reasonLabel] = ($counts['remark'][$reasonLabel] ?? 0) + $total;
}
if (isset($wanted['unmatched_reason'])) {
$unmatchedReason = ($status === '未匹配' && $rawRemark !== '')
? $rawRemark
: (in_array($rawRemark, $basisMarks, true) ? $rawRemark : null);
if ($unmatchedReason !== null) {
$counts['unmatched_reason'][$unmatchedReason] = ($counts['unmatched_reason'][$unmatchedReason] ?? 0) + $total;
}
}
if (isset($wanted['difference_state'])) {
$difference = (float) ($row->base_difference ?? 0);
if ($reasonIndex === 0 && in_array($rawRemark, $basisMarks, true)) {
$difference = 0.0;
}
$differenceLabel = abs($difference) > 0.005 ? '有差额' : '无差额';
$counts['difference_state'][$differenceLabel] = ($counts['difference_state'][$differenceLabel] ?? 0) + $total;
}
}
return $counts;
}
/**
* 单个筛选项的可选值与行数:应用除该筛选项以外的所有条件。
*
@ -1559,23 +1920,24 @@ class Pages extends Controller
$query->where('online_order_no', 'like', '%' . addcslashes($orderNo, '%_\\') . '%');
}
// 差额异常备注联动:5 条原因一次查询算完(原来是 5 次全表 count,下拉要等 1 秒多)
$differenceCounts = [];
$differenceLabels = array_keys(self::receiptDifferenceRemarkConditions());
if ($column === 'remark') {
$expressions = [];
foreach (array_values(self::receiptDifferenceRemarkConditions()) as $index => $condition) {
$expressions[] = 'SUM(CASE WHEN (' . $condition . ') THEN 1 ELSE 0 END) as c' . $index;
}
$row = (clone $query)->selectRaw(implode(', ', $expressions))->first();
foreach ($differenceLabels as $index => $label) {
$differenceCounts[$label] = (int) ($row->{'c' . $index} ?? 0);
}
}
$counts = [];
// 未匹配原因是页面级判定(只统计未匹配行,按库里的 remark 分组)
if ($column === 'unmatched_reason') {
if ($column === 'remark') {
// 一行只按优先级命中一次,再按序号分组,避免 18 条规则各跑一遍全表。
$indexSql = self::receiptDifferenceRemarkIndexSql();
$rows = (clone $query)
->selectRaw($indexSql . ' AS reason_index')
->selectRaw('COUNT(*) AS total')
->groupByRaw('reason_index')
->pluck('total', 'reason_index');
$labels = self::receiptDifferenceRemarkLabels();
foreach ($rows as $index => $total) {
$label = $labels[(int) $index - 1] ?? null;
if ($label !== null && (int) $total > 0) {
$counts[$label] = (int) $total;
}
}
} elseif ($column === 'unmatched_reason') {
// 未匹配原因是页面级判定(只统计未匹配行,按库里的 remark 分组)
$base = clone $query;
$rows = $query->where('match_status', '未匹配')
->select('remark')
@ -1594,8 +1956,7 @@ class Pages extends Controller
$counts[$basisMark] = (int) $marker;
}
}
} elseif ($column !== 'remark') {
// 差额异常备注是页面级判定,不再按库里的标注列分组
} else {
$rows = $query->select($column)
->selectRaw('count(*) as total')
->whereNotNull($column)
@ -1606,11 +1967,6 @@ class Pages extends Controller
$counts[(string) $value] = (int) $total;
}
}
foreach ($differenceCounts as $label => $total) {
if ((int) $total > 0) {
$counts[$label] = (int) $total;
}
}
$ordered = array_values(array_intersect($order, array_keys($counts)));
$rest = array_values(array_diff(array_keys($counts), $ordered));
@ -1692,6 +2048,8 @@ class Pages extends Controller
'offset_amount' => ['offset_amount', '冲减金额', 'money'],
'refund_amount' => ['refund_amount', '退款金额', 'money'],
'planting_amount' => ['planting_amount', '种菜金额', 'money'],
'planting_reimbursement_amount' => ['planting_reimbursement_amount', '种菜报销', 'money'],
'planting_unreimbursed_amount' => ['planting_unreimbursed_amount', '种菜未报销', 'money'],
'product_amount' => ['product_amount', '商品金额', 'money'],
'paid_amount' => ['paid_amount', '订单实付金额', 'money'],
'difference' => ['difference', '差额', 'money'],
@ -1702,6 +2060,7 @@ class Pages extends Controller
[, $month] = $this->receiptOrderMatchMonths();
$query = DB::connection($connection)->table('store_receipt_order_match_rows')
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->when($month !== null, static fn ($builder) => $builder->where('report_month', $month));
$platforms = $this->receiptOrderMatchFilterValues($request->query('platform'));
@ -1744,7 +2103,8 @@ class Pages extends Controller
->selectRaw('CASE WHEN (' . $refundEvidence['guard'] . ') THEN ' . $refundEvidence['cancelled_sum'] . ' END AS order_cancelled_sum')
->selectRaw('CASE WHEN (' . $dewuEvidence['guard'] . ') THEN ' . $dewuEvidence['marketing_expense'] . ' END AS order_dewu_marketing_expense')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_fee'] . ' END AS order_jingxi_supply_fee')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net');
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['supply_net'] . ' END AS order_jingxi_supply_net')
->selectRaw('CASE WHEN (' . $jingxiEvidence['guard'] . ') THEN ' . $jingxiEvidence['gross_up_fee'] . ' END AS order_jingxi_gross_up_fee');
$selected = array_values(array_intersect(
array_filter(explode(',', (string) $request->query('columns', ''))),
@ -1849,8 +2209,6 @@ class Pages extends Controller
'online_order_no' => trim((string) request('online_order_no', '')),
'product_code' => trim((string) request('product_code', '')),
'warehouse_short_name' => trim((string) request('warehouse_short_name', '')),
'missing_price' => request()->boolean('missing_price') ? '1' : '',
'include_excluded' => request()->boolean('include_excluded') ? '1' : '',
];
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
@ -1870,7 +2228,9 @@ class Pages extends Controller
];
}
$applyFilters = static function ($query, bool $excludedWarehouseOnly = false) use ($filters): void {
// 页面不再提供「只看未匹配单价 / 包含-不看仓」两个开关:默认全部数据展示,
// 已匹配与未匹配单价、所有仓库(含「-不看」仓)都纳入统计与列表。
$applyFilters = static function ($query) use ($filters): void {
$query->where('report_month', $filters['month'] . '-01');
if ($filters['platform'] !== '') {
$query->where('platform', $filters['platform']);
@ -1883,41 +2243,22 @@ class Pages extends Controller
if ($filters['warehouse_short_name'] !== '') {
$query->where('warehouse_short_name', $filters['warehouse_short_name']);
}
if ($filters['missing_price'] === '1') {
$query->whereNull('unit_price');
}
// 仓库映射表里标「不看」的仓(次品 / 销退 / 虚拟 / 信誉 / 京东云仓等)默认不展示:
// 这些仓在商品月加权平均单价里本来就没有价,展示出来只会让「未匹配单价」虚高。
if ($excludedWarehouseOnly) {
$query->where('warehouse_short_name', 'like', '%-不看%');
return;
}
if ($filters['include_excluded'] !== '1') {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
};
$countQuery = $base();
$applyFilters($countQuery);
$total = (int) $countQuery->count();
$excludedQuery = $base();
$applyFilters($excludedQuery, true);
$excludedRows = (int) $excludedQuery->count();
$aggQuery = $base();
$applyFilters($aggQuery);
$agg = $aggQuery->selectRaw('
SUM(CASE WHEN unit_price IS NOT NULL THEN product_quantity ELSE 0 END) as quantity_total,
SUM(CASE WHEN unit_price IS NOT NULL THEN product_cost ELSE 0 END) as cost_total,
SUM(CASE WHEN unit_price IS NULL THEN 1 ELSE 0 END) as missing_price_rows,
SUM(CASE WHEN unit_price IS NULL THEN product_quantity ELSE 0 END) as missing_price_quantity,
SUM(CASE WHEN unit_price IS NULL AND (remark IS NULL OR remark NOT LIKE \'%口径不一致%\') THEN 1 ELSE 0 END) as missing_price_rows,
SUM(CASE WHEN unit_price IS NULL AND (remark IS NULL OR remark NOT LIKE \'%口径不一致%\') THEN product_quantity ELSE 0 END) as missing_price_quantity,
SUM(CASE WHEN warehouse_short_name IS NULL THEN 1 ELSE 0 END) as missing_warehouse_rows,
SUM(CASE WHEN price_source LIKE \'线上代发价格%\' THEN 1 ELSE 0 END) as fallback_price_rows,
SUM(CASE WHEN price_source = \'普通商品资料成本价\' THEN 1 ELSE 0 END) as catalog_price_rows,
SUM(CASE WHEN remark LIKE \'%口径不一致%\' THEN 1 ELSE 0 END) as unit_mismatch_rows
')->first();
@ -1961,8 +2302,8 @@ class Pages extends Controller
'missing_price_rows' => (int) ($agg->missing_price_rows ?? 0),
'missing_price_quantity' => round((float) ($agg->missing_price_quantity ?? 0), 2),
'missing_warehouse_rows' => (int) ($agg->missing_warehouse_rows ?? 0),
'excluded_rows' => $excludedRows,
'fallback_price_rows' => (int) ($agg->fallback_price_rows ?? 0),
'catalog_price_rows' => (int) ($agg->catalog_price_rows ?? 0),
'unit_mismatch_rows' => (int) ($agg->unit_mismatch_rows ?? 0),
],
'productCostMonths' => $months,
@ -2100,6 +2441,7 @@ class Pages extends Controller
'financial-analysis-months-v1',
now()->addMinutes(10),
static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->distinct()->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($value): string => substr((string) $value, 0, 7))->all(),
);
@ -2126,6 +2468,7 @@ class Pages extends Controller
$rows = [];
$incomeRows = DB::connection($connection)->table('store_receipt_order_match_rows')
->where('report_month', $reportMonth)
->whereNotIn('platform', self::RECEIPT_MATCH_EXCLUDED_PLATFORMS)
->selectRaw('platform, store_name, SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)) as income')
->groupBy('platform', 'store_name')
@ -2240,15 +2583,7 @@ class Pages extends Controller
if ($warehouse !== '') {
$query->where('warehouse_short_name', $warehouse);
}
if ($request->boolean('missing_price')) {
$query->whereNull('unit_price');
}
if (! $request->boolean('include_excluded')) {
$query->where(static function ($inner): void {
$inner->whereNull('warehouse_short_name')
->orWhere('warehouse_short_name', 'not like', '%-不看%');
});
}
// 导出与页面一致:全部数据(含未匹配单价与「-不看」仓)。
$filename = '商品成本_' . ($month === null ? 'all' : substr($month, 0, 7)) . '.csv';
@ -2634,7 +2969,8 @@ class Pages extends Controller
}));
}
$perPage = 50;
// 货款/退货明细较宽,固定每页 15 行,减少纵向滚动并保持横向核对区稳定。
$perPage = in_array($dataset, ['payment-reconciliation', 'return-reconciliation'], true) ? 15 : 50;
$currentPage = max((int) request('page', 1), 1);
return [
@ -3280,11 +3616,14 @@ class Pages extends Controller
'withdrawal-management' => $this->contentForWithdrawalManagement(),
'bill-offset' => app(\Modules\EcommerceFinance\View\BillOffsetPageData::class)->page(),
'account-mapping' => $this->contentForAccountMapping(),
'ledger' => app(\Modules\EcommerceFinance\View\FinanceLedgerPageData::class)->page($tab),
'billing-details' => app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class)->page(),
'store-violation-alert' => $this->contentForStoreViolationAlerts(),
'preview-statistics' => $this->contentForPreviewStatistics(),
'planting-details' => $this->contentForPlantingDetails(),
'jst-order-labels' => $this->contentForJstOrderLabels(),
'planting-reimbursements' => app(\Modules\EcommerceFinance\View\PlantingReimbursementPageData::class)->page(),
'planting-loss' => app(\Modules\EcommerceFinance\View\PlantingLossPageData::class)->page(),
'finance-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->financeData(),
'supplier-data' => app(\Modules\EcommerceFinance\View\SupplierReconciliationPageData::class)->supplierData(),
'store-report-data-logic' => $this->prepareStoreReportLogic(),
@ -4731,7 +5070,13 @@ class Pages extends Controller
$amountByRow[(int) ($pair['row_number'] ?? 0)] = $pair['amount'] ?? null;
}
$results = app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs);
$verificationKey = 'courier-surcharge:verify:' . company_id() . ':' . auth()->id() . ':'
. hash('sha256', json_encode($pairs, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
$results = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock($verificationKey, 300)
->get(fn () => app(CourierSurchargeSettlementMirrorRepository::class)->decideImportRows($pairs));
if ($results === false) {
return back()->with('error', '同一份数据正在核验,请等待本次核验完成后再提交。');
}
return response()->streamDownload(function () use ($results, $amountByRow): void {
$writer = new \App\Support\StreamingXlsxWriter('导入核验');
@ -4798,9 +5143,9 @@ class Pages extends Controller
return response()->streamDownload(function (): void {
$writer = new \App\Support\StreamingXlsxWriter('导入模板');
$writer->appendRow(['单号', '附加费类型', '快递名称', '金额(可选)']);
$writer->appendRow(['DPK317138931411', '退回费', '曹县德邦小件', 20]);
$writer->appendRow(['DPK379220912936', '超长费', '常州德邦', null]);
$writer->appendRow(['单号', '附加费类型', '金额(可选)']);
$writer->appendRow(['DPK317138931411', '退回费', 20]);
$writer->appendRow(['DPK379220912936', '超长费', null]);
$output = fopen('php://output', 'wb');

View File

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

View File

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

View File

@ -6,6 +6,7 @@ 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 +30,7 @@ class SupplierReconciliation extends Controller
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
) {
$this->middleware('auth');
$this->middleware('company.identify');
@ -105,6 +107,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 +150,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 +165,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 = [];
@ -214,9 +236,10 @@ class SupplierReconciliation extends Controller
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

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

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

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

View File

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

View File

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

View File

@ -68,6 +68,7 @@ services:
volumes:
- caiwu_clickhouse_database:/var/lib/clickhouse
- caiwu_clickhouse_logs:/var/log/clickhouse-server
- ./clickhouse-resource-policy.xml:/etc/clickhouse-server/config.d/caiwu-resource-policy.xml:ro
mem_limit: 4g
cpus: "2.00"
ulimits:
@ -100,6 +101,7 @@ services:
condition: service_healthy
environment:
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-0}
FINANCE_BATCH_WORKER_ENABLED: "1"
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
JST_DB_CONNECTION: business_mirror
env_file:
@ -121,6 +123,35 @@ services:
retries: 10
start_period: 45s
# Heavy CLI jobs use a separate cgroup, not Apache's unbounded app container.
batch:
image: caiwu_akaunting:${IMAGE_TAG:?IMAGE_TAG is required}
pull_policy: never
profiles: [batch]
user: www-data
entrypoint: [php, artisan]
command: [list]
restart: "no"
environment:
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: business_mirror
JST_DB_CONNECTION: business_mirror
env_file:
- ./runtime/qiniu-storage.env
- ./runtime/clickhouse.env
volumes:
- ./runtime/.env:/var/www/html/.env:ro
- caiwu_storage:/var/www/html/storage
cpus: "1.00"
mem_limit: 3g
memswap_limit: 3g
healthcheck:
disable: true
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
caiwu_database:
name: caiwu_database

View File

@ -180,7 +180,7 @@ return [
'store_report_paid_sales_rows' => [
'role' => 'aggregate',
'target_store' => 'mariadb',
'consumer' => '店铺维度报表销量(业绩明细订单实付/实收金额)',
'consumer' => '店铺维度报表销量(业绩明细收入+优惠+冲减+退款金额合计)',
],
],
];

View File

@ -252,7 +252,7 @@ $navigation = [
'directory' => [
'icon' => 'query_stats',
'status' => '共享镜像',
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「订单实付/实收金额」,按店铺和发生时间汇总后落到主体期间。',
'description' => '店铺资料、主体生效区间统一读取团队共享业务镜像;销量取业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按店铺和发生日期逐日汇总后落到主体期间。',
'empty' => '店铺维度报表尚未完成共享数据导入。',
'fields' => [
['name' => 'erp_store_name', 'label' => 'ERP系统店铺名称'],
@ -540,6 +540,8 @@ $navigation = [
'pages' => [
['slug' => 'planting-details', 'title' => '种菜明细表'],
['slug' => 'jst-order-labels', 'title' => '聚水潭订单标签明细'],
['slug' => 'planting-reimbursements', 'title' => '种菜报销'],
['slug' => 'planting-loss', 'title' => '种菜损耗'],
],
],
[
@ -820,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 计入差额,只反映真正对不上的部分。挂不到订单行的优惠/冲减会补成一行(收入/商品留空)。「差额异常备注」只说明差额为什么产生,按顺序自动判定:订单实付未匹配(未匹配到订单实付金额,具体原因看「匹配原因」列);订单实付取数为0(种菜)(订单实付=0 且是种菜单)、订单实付取数为0(订单实付=0 但不是种菜单)、订单实付未减联合营销费(仅得物负差额:联合营销费未让利尾数对上才标注)、订单实付取供货价(仅京喜正差额:差额=该单直营服务费(供货模式净收)时标注)、差额=直营补贴款(待开票结算)(仅京喜账单·京喜自营工厂店负差额且无退款(出类拔萃等 10 家已逐店核验,其余工厂店按实付≈商品金额的供货价特征识别):京东直营补贴款待开票结算时标注)、差额=直营补贴款(订单退款已冲回)(同批店负差额但订单已退款:补贴款已随退款冲回)、差额=订单实付包含商家承担优惠和以旧换新国补(仅京东账单·索菲亚客厅/办公/书房/功能、亿家达全屋、朴美家居店负差额、且该单含国补(聚水潭应付−账单收入>0):差额为商家承担优惠(店铺东券/单品折扣等)与以旧换新国补之和,已用营销对账逐单核实)、差额=订单实付包含商家承担优惠(同店负差额但不含国补的单:差额即商家承担优惠)、订单实付未减冲减(订单实付=收入+优惠 → 差额=冲减金额)、订单实付未减优惠(订单实付=收入+冲减 → 差额=优惠金额)、订单实付未减优惠和冲减(订单实付=收入 → 差额=优惠+冲减)(以上三档等式对带口径标识的行让位给口径兜底)、订单实付含已退款部分(抖音负差额:聚水潭退款单/取消子单金额≈差额,或退款合计+账单退款净额≈实付−收入,或整单全退(退款合计≈实付)对上才标注;淘系支付宝/微信负差额:差额=退款合计、或退款合计=实付−收入(收款缺口)、或整单全退(退款合计≈实付)时同样标注(退款取聚水潭已确认记录));都不满足时,若「卖家实收」或「应付/已付」能对上账单,差额按 0 计并在「匹配原因」列标注口径核对;其余标「差额待核实」。「匹配原因」单独一列,取值:账单无单号 / 账单无单号(采销收款)/ 单号格式异常 / 订单表无此单 / 1688采销收款(非销售单)/ 订单无实付金额 / 无收款账单(「采销收款」两档专指「支付宝-1688采销专用」账户:该账户收的是采销/供货结算款,不是店铺销售单收款,不按丢单处理)。',
'empty' => '店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。',
'fields' => [
['name' => 'platform', 'label' => '平台'],
@ -832,6 +834,8 @@ $navigation = [
['name' => 'offset_amount', 'label' => '冲减金额', 'kind' => 'number'],
['name' => 'refund_amount', 'label' => '退款金额', 'kind' => 'number'],
['name' => 'planting_amount', 'label' => '种菜金额', 'kind' => 'number'],
['name' => 'planting_reimbursement_amount', 'label' => '种菜报销', 'kind' => 'number'],
['name' => 'planting_unreimbursed_amount', 'label' => '种菜未报销', 'kind' => 'number'],
['name' => 'product_amount', 'label' => '商品金额', 'kind' => 'number'],
['name' => 'paid_amount', 'label' => '订单实付/实收金额', 'kind' => 'number'],
['name' => 'difference', 'label' => '差额', 'kind' => 'number'],
@ -888,7 +892,7 @@ $navigation = [
['slug' => 'preview-statistics', 'title' => '预览统计'],
[
'slug' => 'process-statistics',
'title' => '流程统计',
'title' => '店铺流程',
'directory' => [
'icon' => 'timeline',
'status' => '共享镜像',
@ -939,6 +943,14 @@ $navigation = [
['slug' => 'account-mapping', 'title' => '科目映射表'],
],
],
[
'slug' => 'financial-ledger',
'title' => '财务台账',
'icon' => 'menu_book',
'pages' => [
['slug' => 'ledger', 'title' => '财务台账'],
],
],
[
'slug' => 'data-monitoring',
'title' => '数据监控',

26
config/finance_ledger.php Normal file
View File

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

16
config/sidebar_links.php Normal file
View File

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

View File

@ -193,6 +193,13 @@ return [
*/
'table_sync' => [
'batch_size' => (int) env('BUSINESS_MIRROR_TABLE_BATCH_SIZE', 500),
// These wide SQL Server views time out when TOP/pagination is planned
// together with every payload column. Select the bounded keys first,
// then look up only those rows in the same read-only statement.
'key_first_datasets' => [
'operations_replica.rpt.jst_Orders',
'operations_replica.rpt.jst_originalBillInfo',
],
'max_batches_per_run' => (int) env('BUSINESS_MIRROR_TABLE_MAX_BATCHES', 2),
'max_datasets_per_run' => (int) env('BUSINESS_MIRROR_MAX_DATASETS', 1),
'overlap_hours' => (int) env('BUSINESS_MIRROR_OVERLAP_HOURS', 48),

View File

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

View File

@ -0,0 +1,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,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 供应商应付「厂家记账」配置:按供应商记录厂家记账方式(正常记账 / 仅代发仓发错货记账 / 不记账)及生效期。
* 与聚工单镜像一样不区分公司;建表后导入财务提供的存量配置(resources/data/supplier-bookkeeping-configs.json)。
*/
return new class extends Migration {
public function up(): void
{
Schema::create('business_supplier_bookkeeping_configs', function (Blueprint $table): void {
$table->increments('id');
$table->string('supplier_name', 191)->unique('supplier_bookkeeping_config_supplier_unique');
$table->string('mode', 32);
$table->date('effective_from')->nullable();
$table->date('effective_to')->nullable();
$table->string('source_text', 255)->default('');
$table->string('creator', 191);
$table->timestamps();
$table->softDeletes();
});
Schema::create('business_supplier_bookkeeping_config_events', function (Blueprint $table): void {
$table->increments('id');
$table->unsignedInteger('config_id')->nullable()->index();
$table->string('actor', 191);
$table->string('action', 32);
$table->text('description');
$table->longText('details');
$table->dateTime('created_at');
});
$payload = json_decode((string) file_get_contents(resource_path('data/supplier-bookkeeping-configs.json')), true, 16, JSON_THROW_ON_ERROR);
$now = now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
$rows = array_map(static fn (array $row): array => [
'supplier_name' => $row['supplier_name'],
'mode' => $row['mode'],
'effective_from' => $row['effective_from'],
'effective_to' => $row['effective_to'],
'source_text' => $row['source_text'],
'creator' => '存量配置导入',
'created_at' => $now,
'updated_at' => $now,
], $payload['configs']);
foreach (array_chunk($rows, 100) as $chunk) {
DB::table('business_supplier_bookkeeping_configs')->insert($chunk);
}
DB::table('business_supplier_bookkeeping_config_events')->insert([
'config_id' => null,
'actor' => '存量配置导入',
'action' => 'import',
'description' => '导入存量厂家记账配置:新增' . count($rows) . '条',
'details' => json_encode(['added' => count($rows), 'source' => $payload['source']], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'created_at' => $now,
]);
}
public function down(): void
{
Schema::dropIfExists('business_supplier_bookkeeping_config_events');
Schema::dropIfExists('business_supplier_bookkeeping_configs');
}
};

View File

@ -0,0 +1,22 @@
<?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::table('ec_account_mapping_rules', function (Blueprint $table): void {
$table->string('fee_memo', 191)->default('')->after('category');
});
}
public function down(): void
{
Schema::table('ec_account_mapping_rules', function (Blueprint $table): void {
$table->dropColumn('fee_memo');
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 科目映射表变更后,自动重跑业绩明细的待办队列。
*
* 一条 = 一个「同步平台 × 月份」,由后台命令 store-receipt:auto-resync 逐个消费。
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('ec_account_mapping_resync_jobs', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('platform', 64);
$table->string('month', 7);
$table->string('status', 16)->default('pending');
$table->unsignedInteger('attempts')->default(0);
$table->text('message')->nullable();
$table->timestamps();
$table->unique(['platform', 'month'], 'ec_account_mapping_resync_unique');
});
}
public function down(): void
{
Schema::dropIfExists('ec_account_mapping_resync_jobs');
}
};

View File

@ -0,0 +1,201 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 财务台账:账户、店铺、公司、期初、动账明细、每日流水和账户映射。
*
* 财务台账是财务系统自己的业务主数据和派生数据,不是上游镜像,
* 因此统一落在 caiwu_business_mirror;不注册到 upstream_mirrors。
*/
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->create('fin_accounts', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('company_name', 100);
$table->string('account_short', 100);
$table->string('region', 50)->nullable();
$table->string('account_no', 50)->nullable();
$table->string('bank_name', 200)->nullable();
$table->string('bank_city_branch', 100)->nullable();
$table->string('account_type', 50)->nullable();
$table->string('company_plan', 50)->nullable();
$table->string('account_last4', 50)->nullable();
$table->string('single_limit', 50)->nullable();
$table->string('daily_limit', 50)->nullable();
$table->string('monthly_limit', 50)->nullable();
$table->string('yearly_limit', 50)->nullable();
$table->decimal('current_balance', 18, 2)->default(0);
$table->decimal('blocked_amount', 18, 2)->default(0);
$table->decimal('daily_available', 18, 2)->default(0);
$table->decimal('clearing_days', 10, 2)->nullable();
$table->string('raise_target', 50)->nullable();
$table->string('raise_status', 50)->nullable();
$table->string('raise_actions', 500)->nullable();
$table->string('done_actions', 500)->nullable();
$table->string('next_action', 200)->nullable();
$table->string('remark', 500)->nullable();
$table->string('owner', 50)->nullable();
$table->timestamps();
$table->index(['company_name'], 'fin_accounts_company_index');
$table->index(['account_short'], 'fin_accounts_short_index');
$table->index(['owner'], 'fin_accounts_owner_index');
});
}
if (! $schema->hasTable('fin_shops')) {
$schema->create('fin_shops', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('shop_name', 200);
$table->string('platform', 50)->nullable();
$table->string('company_name', 100)->nullable();
$table->string('account_short', 100)->nullable();
$table->string('status', 50)->nullable();
$table->string('payout_account', 100)->nullable();
$table->decimal('shop_balance', 18, 2)->default(0);
$table->string('remark', 500)->nullable();
$table->timestamps();
$table->unique(['shop_name'], 'fin_shops_name_unique');
$table->index(['company_name'], 'fin_shops_company_index');
$table->index(['platform'], 'fin_shops_platform_index');
});
}
if (! $schema->hasTable('fin_companies')) {
$schema->create('fin_companies', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('name', 200);
$table->string('register_place', 200)->nullable();
$table->string('business_type', 50)->nullable();
$table->string('tax_type', 50)->nullable();
$table->string('status', 20)->nullable();
$table->string('basic_bank', 200)->nullable();
$table->string('basic_account_last4', 50)->nullable();
$table->string('remark', 500)->nullable();
$table->timestamps();
$table->unique(['name'], 'fin_companies_name_unique');
});
}
if (! $schema->hasTable('fin_account_opening')) {
$schema->create('fin_account_opening', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100);
$table->date('opening_date');
$table->decimal('opening_balance', 18, 2)->default(0);
$table->string('remark', 200)->nullable();
$table->timestamps();
$table->unique(['account_short', 'opening_date'], 'fin_opening_account_date_unique');
$table->index(['opening_date'], 'fin_opening_date_index');
});
}
if (! $schema->hasTable('fin_transactions')) {
$schema->create('fin_transactions', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->date('txn_date');
$table->string('account_short', 100)->nullable();
$table->text('detail')->nullable();
$table->decimal('income', 18, 2)->default(0);
$table->decimal('expense', 18, 2)->default(0);
$table->string('approval_no', 100)->nullable();
$table->string('source', 20);
$table->string('source_value', 200)->nullable();
$table->string('process_type', 50)->nullable();
$table->string('row_key', 64);
$table->timestamp('synced_at')->nullable();
$table->timestamps();
$table->unique(['row_key'], 'fin_transactions_row_key_unique');
$table->index(['txn_date'], 'fin_transactions_date_index');
$table->index(['account_short'], 'fin_transactions_account_index');
$table->index(['source', 'source_value'], 'fin_transactions_source_index');
$table->index(['approval_no'], 'fin_transactions_approval_index');
});
}
if (! $schema->hasTable('fin_daily_flows')) {
$schema->create('fin_daily_flows', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->date('flow_date');
$table->string('company_name', 100)->nullable();
$table->string('account_short', 100);
$table->decimal('opening_balance', 18, 2)->default(0);
$table->decimal('inflow', 18, 2)->default(0);
$table->decimal('outflow', 18, 2)->default(0);
$table->decimal('closing_balance', 18, 2)->default(0);
$table->string('remark', 200)->nullable();
$table->timestamps();
$table->unique(['flow_date', 'account_short'], 'fin_flows_date_account_unique');
$table->index(['account_short'], 'fin_flows_account_index');
});
}
if (! $schema->hasTable('fin_account_map')) {
$schema->create('fin_account_map', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('source', 20);
$table->string('source_value', 200);
$table->string('account_short', 100)->nullable();
$table->unsignedInteger('txn_count')->default(0);
$table->decimal('income_sum', 18, 2)->default(0);
$table->decimal('expense_sum', 18, 2)->default(0);
$table->string('updated_by', 50)->nullable();
$table->timestamps();
$table->unique(['source', 'source_value'], 'fin_account_map_source_unique');
$table->index(['account_short'], 'fin_account_map_account_index');
});
}
if (! $schema->hasTable('fin_account_map_events')) {
$schema->create('fin_account_map_events', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('source', 20);
$table->string('source_value', 200);
$table->string('old_account_short', 100)->nullable();
$table->string('new_account_short', 100)->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('user_name', 100)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['source', 'source_value'], 'fin_map_events_source_index');
});
}
}
public function down(): void
{
$schema = Schema::connection($this->connection());
foreach ([
'fin_account_map_events',
'fin_account_map',
'fin_daily_flows',
'fin_transactions',
'fin_account_opening',
'fin_companies',
'fin_shops',
'fin_accounts',
] as $table) {
$schema->dropIfExists($table);
}
}
};

View File

@ -0,0 +1,38 @@
<?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('work_order_waybill_lookup')) {
$schema->create('work_order_waybill_lookup', function (Blueprint $table): void {
$table->char('mirror_key', 64)->primary();
$table->string('waybill', 191);
$table->unsignedBigInteger('source_id');
$table->index(['waybill', 'source_id'], 'work_order_waybill_source_idx');
});
}
if (! $schema->hasTable('work_order_waybill_lookup_states')) {
$schema->create('work_order_waybill_lookup_states', function (Blueprint $table): void {
$table->unsignedTinyInteger('id')->primary();
$table->char('cursor', 64)->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('updated_at')->nullable();
});
}
}
public function down(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$schema->dropIfExists('work_order_waybill_lookup_states');
$schema->dropIfExists('work_order_waybill_lookup');
}
};

View File

@ -0,0 +1,50 @@
<?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('store_report_paid_sales_dirty_dates')) {
$schema->create('store_report_paid_sales_dirty_dates', function (Blueprint $table): void {
$table->date('occurred_on')->primary();
$table->uuid('revision');
});
}
if ($schema->hasTable('store_receipt_order_match_rows')) {
$indexed = collect($schema->getIndexes('store_receipt_order_match_rows'))
->contains(fn (array $index) => ($index['columns'][0] ?? null) === 'occurred_date');
if (! $indexed) {
$connection = $schema->getConnection();
if ($connection->getDriverName() === 'mysql') {
// Fail quickly on metadata contention and require an online index build.
$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('store_receipt_order_match_rows');
$connection->statement('ALTER TABLE ' . $table . ' ADD INDEX store_receipt_paid_sales_date_index (occurred_date), ALGORITHM=INPLACE, LOCK=NONE');
} finally {
$connection->statement('SET SESSION lock_wait_timeout=' . $wait);
}
} else {
$schema->table('store_receipt_order_match_rows', fn (Blueprint $table) => $table->index('occurred_date', 'store_receipt_paid_sales_date_index'));
}
}
}
}
public function down(): void
{
if (config('system_database.mode', 'bootstrap') === 'bootstrap') return;
$schema = Schema::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$schema->dropIfExists('store_report_paid_sales_dirty_dates');
if ($schema->hasTable('store_receipt_order_match_rows') && collect($schema->getIndexes('store_receipt_order_match_rows'))->contains('name', 'store_receipt_paid_sales_date_index')) {
$schema->table('store_receipt_order_match_rows', fn (Blueprint $table) => $table->dropIndex('store_receipt_paid_sales_date_index'));
}
}
};

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

@ -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
@ -189,13 +203,15 @@ CRON_TZ=Asia/Shanghai
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /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 upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
# 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。
7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1
# 订单售后费用明细的店铺候选:完整列表要扫两张百万级订单宽表(约 2 分钟),放定时任务预热;
# 页面冷缓存时先用毫秒级的店铺目录兜底,避免请求一直不结束、页面一直转圈。
# 店铺候选平时仅读镜像变更;夜间全量核对历史名称。冷页面不扫描订单宽表。
45 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan order-data:warm-shop-candidates --quiet >/dev/null 2>&1
45 2 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh order-data:warm-shop-candidates --full --quiet >/dev/null 2>&1
# 页面字段自动跟随上游:先刷新数据字典,再把上游新增字段补进镜像并建列,页面自动多出这一列。
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
# 店铺违规信息预警:每天 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
@ -205,7 +221,12 @@ CRON_TZ=Asia/Shanghai
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
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
28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/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
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * 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
28 1 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh jushuitan:sync-shipping --days=3 >/dev/null 2>&1
# 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。
30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --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
@ -193,13 +207,15 @@ CRON_TZ=Asia/Shanghai
* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /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 upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1
# 店铺经营报表按 15 分钟刷新;单轮覆盖 50000 行回看窗口,避免空闲时持续重读千万级账单。
7,22,37,52 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-store-report --max-batches=12 --quiet >/dev/null 2>&1
# 订单售后费用明细的店铺候选:完整列表要扫两张百万级订单宽表(约 2 分钟),放定时任务预热;
# 页面冷缓存时先用毫秒级的店铺目录兜底,避免请求一直不结束、页面一直转圈。
# 店铺候选平时仅读镜像变更;夜间全量核对历史名称。冷页面不扫描订单宽表。
45 * * * * root /usr/bin/flock -w 600 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan order-data:warm-shop-candidates --quiet >/dev/null 2>&1
45 2 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh order-data:warm-shop-candidates --full --quiet >/dev/null 2>&1
# 页面字段自动跟随上游:先刷新数据字典,再把上游新增字段补进镜像并建列,页面自动多出这一列。
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
# 店铺违规信息预警:每天 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
@ -209,7 +225,12 @@ CRON_TZ=Asia/Shanghai
# 供应商管理每天北京时间 08:15 完整校准档案、合同、联系人与打款资料。
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
28 1 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan jushuitan:sync-shipping --days=3 >/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
# 科目映射保存后保留自动重跑队列,每分钟在受限容器内串行消费。
* * * * * 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
28 1 * * * root /bin/sh /opt/caiwuxitong/run-finance-batch.sh jushuitan:sync-shipping --days=3 >/dev/null 2>&1
# 头条放心购(店铺收款账户账单 › 抖音账户)资金流水:每天 09:30 定点把该渠道所用
# 原始账单镜像追平一次,保证上班前当日/前一日流水已进系统,而不是等每分钟轮转慢慢走。
30 9 * * * root /usr/bin/flock -w 1800 /run/caiwu-upstream-sync.lock /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables operations_replica.rpt.jst_originalBillInfo --max-batches=20 --quiet >/dev/null 2>&1

View File

@ -0,0 +1,32 @@
<clickhouse>
<!-- Keep diagnostics bounded on the shared 8-core / 16GB host. -->
<logger><level>information</level></logger>
<background_schedule_pool_size>32</background_schedule_pool_size>
<total_memory_profiler_step>67108864</total_memory_profiler_step>
<text_log>
<level>information</level>
<flush_interval_milliseconds>30000</flush_interval_milliseconds>
<max_size_rows>16384</max_size_rows>
<reserved_size_rows>1024</reserved_size_rows>
<buffer_size_rows_flush_threshold>8192</buffer_size_rows_flush_threshold>
<ttl>event_date + INTERVAL 7 DAY</ttl>
</text_log>
<trace_log>
<flush_interval_milliseconds>30000</flush_interval_milliseconds>
<max_size_rows>16384</max_size_rows>
<reserved_size_rows>1024</reserved_size_rows>
<buffer_size_rows_flush_threshold>8192</buffer_size_rows_flush_threshold>
<ttl>event_date + INTERVAL 7 DAY</ttl>
</trace_log>
<metric_log>
<collect_interval_milliseconds>10000</collect_interval_milliseconds>
<flush_interval_milliseconds>60000</flush_interval_milliseconds>
<ttl>event_date + INTERVAL 7 DAY</ttl>
</metric_log>
<asynchronous_metric_log>
<flush_interval_milliseconds>60000</flush_interval_milliseconds>
<ttl>event_date + INTERVAL 7 DAY</ttl>
</asynchronous_metric_log>
<part_log><ttl>event_date + INTERVAL 7 DAY</ttl></part_log>
<query_log><ttl>event_date + INTERVAL 14 DAY</ttl></query_log>
</clickhouse>

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

@ -0,0 +1,45 @@
#!/bin/sh
set -eu
REMOTE_DIR="${CAIWU_REMOTE_DIR:-/opt/caiwuxitong}"
LOCK_FILE="${CAIWU_UPSTREAM_LOCK:-/run/caiwu-upstream-sync.lock}"
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) ;;
*) echo 'Unsupported finance batch command' >&2; exit 64 ;;
esac
cd "$REMOTE_DIR"
if [ "$1" = 'account-bills:exports:run' ]; then
storage_root="$(docker inspect caiwu_app --format '{{range .Mounts}}{{if eq .Destination "/var/www/html/storage"}}{{.Source}}{{end}}{{end}}')"
[ -n "$storage_root" ] && [ -d "$storage_root" ] || exit 65
# Check only tiny task metadata; do not start an idle container every minute.
if ! python3 - "$storage_root" <<'PY'
import json,pathlib,sys
for path in pathlib.Path(sys.argv[1]).glob('app/account-bills-exports/*/*.json'):
try:
task = json.loads(path.read_text())
if isinstance(task, dict) and task.get('status') == 'queued':
sys.exit(0)
except (OSError,ValueError):
continue
sys.exit(1)
PY
then
exit 0
fi
fi
if [ "$1" != 'account-bills:exports:run' ]; then
exec 9>"$LOCK_FILE"
if ! flock -w "${CAIWU_BATCH_LOCK_WAIT:-1800}" 9; then
echo 'Upstream synchronization is busy; no batch container started' >&2
exit 75
fi
fi
image="$(docker inspect caiwu_app --format '{{.Config.Image}}')"
case "$image" in
caiwu_akaunting:*) IMAGE_TAG="${image#*:}" ;;
*) echo 'Unsupported active finance image; no batch container started' >&2; exit 65 ;;
esac
RUN_MIGRATIONS=0
export IMAGE_TAG RUN_MIGRATIONS
# Mutating jobs hold the upstream lock; read-only exports use their own single-worker lock.
docker compose -f compose.jiangling.yml --profile batch run --rm -T --no-deps batch "$@"

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`

View File

@ -129,11 +129,22 @@ 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` 启动即报“拒绝访问”。
@ -269,6 +280,23 @@ php artisan business-mirror:sync-tables
首次全量、历史回补和镜像校准由任务 Codex 自主执行:先备份受影响的系统配置、镜像表及同步状态,再在生产同步节点内使用现有上游互斥锁,按稳定游标分批执行或续传 `--full`,直至完成并核对源表/镜像和实际页面。无需负责人另行授权或代跑命令;开发机驱动不支持时直接通过 SSH 使用生产容器,不以本机不能同步为由停止任务。后续无参数命令只执行或继续受控增量,不对无关数据集批量全量。
重型发货回填、业绩明细重算、商品成本重算和店铺候选全量核对,统一从生产节点使用受限任务入口:
```bash
sh /opt/caiwuxitong/run-finance-batch.sh jushuitan:sync-shipping --from=YYYY-MM-DD --to=YYYY-MM-DD
sh /opt/caiwuxitong/run-finance-batch.sh store-receipt:order-match:sync YYYY-MM --platform=taobao-alipay
```
入口持有现有上游同步锁,使用 `batch` Profile 的独立容器(1 核、3GiB 内存、不使用 Swap、无 Web 健康检查);正常发布不启动该 Profile。先备份再执行回补,失败时保留原有未完成状态,不重复并行启动任务。店铺候选平时按镜像同步时间增量刷新,02:45 使用 `--full` 全量核对。ClickHouse 系统日志的保留期由 `deploy/clickhouse-resource-policy.xml` 管理;业务表不受日志策略影响。
查件运单索引 `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` 全量重建派生汇总;普通刷新无需全表指纹扫描。科目重算每轮默认处理一个平台月份,再释放同步锁,剩余任务由下一轮继续。
账单导出保存在原有文件任务队列,每分钟由 `account-bills:exports:run` 消费一个任务,任一时刻只有一个重型导出。该只读任务使用同一受限批 Profile 和独立导出锁,不长期持有上游写同步锁;空队列不启动容器。状态、取消、下载和七天文件保留沿用现有逻辑。
生产临时排查先使用元数据和 `EXPLAIN`,业务读取须设置合理的 SQL 执行时限,并使用已有索引缩小范围。不要在网页容器内对归档大表进行无时限计数、模糊查找或循环重试;需要全表核对时先安排受控批处理窗口。
### 8.3 开发者需要尚未接入的上游数据时
系统数据库保存团队配置、数据字典和同步状态,共享业务库保存已接入业务行;二者都不是 SQL Server 网络代理。开发探索与正式页面的读取方式不同:

View File

@ -0,0 +1,93 @@
# 财务台账开发说明
财务台账的账户、店铺、公司、期初、动账明细、每日流水和账户映射统一落在
`caiwu_business_mirror`,不放在遗留库 `caiwu_db`,也不放进系统配置库
`caiwu_system_config`。
## 表
| 表 | 说明 |
|---|---|
| `fin_accounts` | 账户总表 |
| `fin_shops` | 店铺台账 |
| `fin_companies` | 公司台账 |
| `fin_account_opening` | 账户期初余额 |
| `fin_transactions` | 动账明细 |
| `fin_daily_flows` | 每日资金流水 |
| `fin_account_map` | 来源值 → 账户简称 |
| `fin_account_map_events` | 映射修改审计 |
迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`。
## 本地导入
基础数据和期初来自财务维护的《银行当前余额-*.xlsx》。先把文件复制进应用容器:
```bash
docker cp "/path/银行当前余额-20261018-3.xlsx" jiangling_app:/tmp/finance-ledger-base.xlsx
docker exec -u root jiangling_app chmod 644 /tmp/finance-ledger-base.xlsx
docker compose -f docker-compose.local.yml exec -u www-data app \
php artisan finance-ledger:import-base /tmp/finance-ledger-base.xlsx --recalculate
```
动账明细从 AI 库 `JSReportReplica.rpt` 读取:
```bash
docker compose -f docker-compose.local.yml exec -u www-data app \
php artisan finance-ledger:sync-rpt
```
本地尚未接入共享系统数据库时,可在 `runtime/.env` 临时启用
`UPSTREAM_LOCAL_CONFIG_ENABLED=true` 并配置 `DB_OPERATIONS_REPLICA_*`;
该文件已被 Git 忽略。生产环境使用共享系统数据库中的只读连接,不需要
在本机或服务器保存上游密码。
## 校验与重算
```bash
docker compose -f docker-compose.local.yml exec -u www-data app \
php artisan finance-ledger:recalculate
docker compose -f docker-compose.local.yml exec -u www-data app \
php artisan finance-ledger:verify
```
`finance-ledger:verify` 检查账户/店铺/公司/期初/明细/映射数量、期初与余额合计、
以及“期末 = 期初 + 入账 − 出账”的勾稽差异。
## 页面
- 路由:`/workspace/financial-ledger/ledger`
- 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源
- 写权限:账户映射修改仅系统管理员;其他账号只读
- 调度:`finance-ledger:sync-rpt` 每天 02:30(Asia/Shanghai)
生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行
`schedule:run` 的容器,调度登记不能替代宿主机 cron。
## 数据安全
真实期初、账户余额和动账明细不得提交到 Git。本地导入文件放在仓库外的
私有目录,或使用被忽略的 `runtime/private/`;测试使用合成数据。
## 上线前检查
1. 备份 `caiwu_system_config` 和 `caiwu_business_mirror`;
2. 发布新镜像并执行 `fin_*` 迁移(本次只新增表,不改已有表);
3. 首次在生产执行:
```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: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
```
5. 在「系统设置 → 权限」把「财务台账」页面分配给财务角色;账户映射修改允许超级管理员和「财务台账管理员」角色(可用 `FINANCE_LEDGER_MAPPING_EDITOR_ROLES` 覆盖)。
生产环境不设置 `FINANCE_LEDGER_WINDOW_END` 时会同步到当天;本地为了复现 12,037 笔规格数据,在 `runtime/.env` 固定为 `2026-10-18`。

View File

@ -38,6 +38,9 @@ class CourierSurchargeSettlementMirrorRepository
/** 导入核验时最多回查多少张扣单工单,避免大表扫描过重。 */
private const IMPORT_RETURN_CONTEXT_LIMIT = 100;
/** 导入核验时最多按单号回查多少张销售出库单(超长费)。 */
private const IMPORT_OVERLENGTH_CONTEXT_LIMIT = 200;
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
@ -276,9 +279,26 @@ class CourierSurchargeSettlementMirrorRepository
}
}
$contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT;
$contextComplete = count($unmatchedDeductWaybills) <= self::IMPORT_RETURN_CONTEXT_LIMIT
|| app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class)->ready();
$returnContext = $this->deductOrderContext(array_keys($unmatchedDeductWaybills));
$ruleModes = $this->activeRuleModes();
$quoteRules = $this->importQuoteRules();
// 超长费没有结算行时,按单号回销售出库单查快递/尺寸并按启用的报价核算。
$unmatchedOverlengthWaybills = [];
foreach ($normalized as $pair) {
if ($pair['waybill'] === '' || $pair['surcharge_type'] !== '超长费') {
continue;
}
if ($this->matchImportRow($rowsByWaybill[$pair['waybill']] ?? [], $pair['surcharge_type'], $pair['courier_name']) === null) {
$unmatchedOverlengthWaybills[$pair['waybill']] = true;
}
}
$overlengthContext = $this->importOverlengthContext(array_keys($unmatchedOverlengthWaybills));
$fallbackRows = [];
@ -323,7 +343,16 @@ class CourierSurchargeSettlementMirrorRepository
$results[] = $matched !== null
? $this->importMatchedRow($pair, $matched)
: $this->importMissingRow(
: ($this->importQuoteResult(
$pair,
$fallbackRows[$pair['waybill']] ?? null,
$returnContext,
$ruleModes,
$quoteRules,
$overlengthContext,
$contextWeights,
$contextReturns,
) ?? $this->importMissingRow(
$pair,
$returnContext,
$ruleModes,
@ -332,7 +361,7 @@ class CourierSurchargeSettlementMirrorRepository
$fallbackRows[$pair['waybill']] ?? null,
$contextWeights,
$contextReturns,
);
));
}
return $results;
@ -422,7 +451,7 @@ class CourierSurchargeSettlementMirrorRepository
* @param array<string,array<string,mixed>> $returnContext
* @param array<string,array<string,list<string>>> $ruleModes
* @param array<string,float> $contextWeights
* @param array<int,string> $contextReturns
* @param array<int,list<array<string,mixed>>> $contextReturns
* @return array<string,mixed>
*/
private function importMissingRow(
@ -474,7 +503,9 @@ class CourierSurchargeSettlementMirrorRepository
$onlineOrderNo = trim((string) ($context['online_order_no'] ?? ''));
$inspectionType = trim((string) ($context['inspection_type'] ?? '')) ?: null;
$inspectionReason = trim((string) ($context['inspection_reason'] ?? ''));
$returnWaybillNo = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? null;
$orderReturns = $contextReturns[(int) ($context['o_id'] ?? 0)] ?? [];
$returnWaybills = array_values(array_unique(array_filter(array_column($orderReturns, 'l_id'))));
$returnWaybillNo = $returnWaybills === [] ? null : implode(',', $returnWaybills);
$settlementWeight = $this->importWeightSum($context['items'] ?? [], $contextWeights);
$sendTime = trim((string) ($context['send_time'] ?? ''));
$shippedAt = $sendTime !== '' ? $sendTime : null;
@ -517,6 +548,591 @@ class CourierSurchargeSettlementMirrorRepository
];
}
/**
* 导入核验的「按报价核算」:结算表里没有该运单+类型的结算行时,
* 用单号回销售出库单(超长费)或查件扣单(退回费/拦截费)取上下文,
* 再按启用的报价规则计算应结金额。返回 null 表示该单号取不到上下文,
* 交给 importMissingRow 输出兜底的不结原因。
*
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
* @param array<string,array<string,mixed>> $returnContext
* @param array<string,array<string,list<string>>> $ruleModes
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
* @param array<string,array<string,mixed>> $overlengthContext
* @param array<string,float> $contextWeights
* @param array<int,list<array<string,mixed>>> $contextReturns
* @return array<string,mixed>|null
*/
private function importQuoteResult(
array $pair,
?object $fallbackRow,
array $returnContext,
array $ruleModes,
array $quoteRules,
array $overlengthContext,
array $contextWeights,
array $contextReturns,
): ?array {
$type = $pair['surcharge_type'];
if ($type === '超长费') {
$context = $overlengthContext[$pair['waybill']] ?? null;
return $context === null
? null
: $this->importOverlengthQuoteResult($pair, $context, $quoteRules['overlength'] ?? []);
}
if (! in_array($type, ['退回费', '拦截费'], true)) {
return null;
}
$context = $returnContext[$pair['waybill']] ?? null;
return $context === null
? null
: $this->importDeductQuoteResult($pair, $context, $fallbackRow, $ruleModes, $quoteRules, $contextWeights, $contextReturns);
}
/**
* 按单号回销售出库单(JianSun)取快递、明细、尺寸与平台商品ID。
*
* @param list<string> $waybills
* @return array<string,array<string,mixed>>
*/
private function importOverlengthContext(array $waybills): array
{
if ($waybills === [] || count($waybills) > self::IMPORT_OVERLENGTH_CONTEXT_LIMIT) {
return [];
}
try {
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
} catch (Throwable) {
return [];
}
$orders = [];
$orderByWaybill = [];
foreach (array_chunk($waybills, 100) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT Id, io_id, o_id, l_id, so_id, shop_name, '
. "CONVERT(varchar(19), io_date, 120) AS io_date, logistics_company "
. 'FROM dbo.jst_salesOutboundInfo WHERE l_id IN (' . $placeholders . ') ORDER BY Id DESC',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$waybill = trim((string) ($row['l_id'] ?? ''));
if ($waybill === '' || isset($orderByWaybill[$waybill])) {
continue;
}
$orderByWaybill[$waybill] = $row;
$orders[] = $row;
}
}
if ($orders === []) {
return [];
}
$ioIds = array_values(array_unique(array_filter(array_map(
static fn (array $order): int => (int) ($order['io_id'] ?? 0),
$orders,
))));
$items = [];
$skuIds = [];
foreach (array_chunk($ioIds, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT io_id, sku_id, i_id, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$ioId = (int) ($row['io_id'] ?? 0);
$skuId = trim((string) ($row['sku_id'] ?? ''));
if ($ioId <= 0) {
continue;
}
$items[$ioId][] = $row;
if ($skuId !== '') {
$skuIds[$skuId] = true;
}
}
}
$dimensions = $this->importDimensions(array_keys($skuIds));
$platform = [];
foreach (array_chunk($ioIds, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT io_id, sku_id, shop_i_id FROM dbo.Jst_SendDataDetail_View WHERE io_id IN (' . $placeholders . ')'
. ' AND LEN(shop_i_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$ioId = (int) ($row['io_id'] ?? 0);
$shopIId = trim((string) ($row['shop_i_id'] ?? ''));
$skuId = trim((string) ($row['sku_id'] ?? ''));
if ($ioId <= 0 || $shopIId === '') {
continue;
}
if ($skuId !== '') {
$platform[$ioId . '|' . $skuId] = $shopIId;
}
$platform[$ioId . '|'] ??= $shopIId;
}
}
$contexts = [];
foreach ($orderByWaybill as $waybill => $order) {
$ioId = (int) ($order['io_id'] ?? 0);
$contexts[$waybill] = [
'order' => $order,
'items' => $items[$ioId] ?? [],
'dimensions' => $dimensions,
'platform' => $platform,
];
}
return $contexts;
}
/**
* @param array<string,mixed> $context
* @param list<array{id:int,courier:string,threshold:float,amount:float}> $rules
* @return array<string,mixed>
*/
private function importOverlengthQuoteResult(array $pair, array $context, array $rules): array
{
$order = $context['order'];
$actualCourier = trim((string) ($order['logistics_company'] ?? ''));
$importedCourier = $pair['courier_name'];
$courier = $importedCourier !== '' ? $importedCourier : $actualCourier;
$ioId = (int) ($order['io_id'] ?? 0);
$best = null;
foreach ($context['items'] as $item) {
$skuId = trim((string) ($item['sku_id'] ?? ''));
$dimension = $context['dimensions'][$skuId] ?? null;
if ($dimension === null) {
continue;
}
$maxSide = max($dimension['l'], $dimension['w'], $dimension['h']);
if ($best === null || $maxSide > $best['max_side']) {
$best = [
'max_side' => $maxSide,
'sku_id' => $skuId,
'i_id' => trim((string) ($item['i_id'] ?? '')),
'length' => $dimension['l'],
'width' => $dimension['w'],
'height' => $dimension['h'],
'platform_product_id' => $context['platform'][$ioId . '|' . $skuId]
?? ($context['platform'][$ioId . '|'] ?? null),
];
}
}
$fields = [
'courier_name' => $courier !== '' ? $courier : null,
'shipped_at' => trim((string) ($order['io_date'] ?? '')) ?: null,
'platform_product_id' => $best['platform_product_id'] ?? null,
'product_code' => $best === null
? null
: ($best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null)),
'shop_name' => trim((string) ($order['shop_name'] ?? '')) ?: null,
'online_order_no' => trim((string) ($order['so_id'] ?? '')) ?: null,
'length' => $best['length'] ?? null,
'width' => $best['width'] ?? null,
'height' => $best['height'] ?? null,
'volume' => $best === null ? null : round($best['length'] * $best['width'] * $best['height'], 2),
'inspection_type' => null,
'inspection_reason' => null,
'return_waybill_no' => null,
'settlement_weight' => null,
];
if ($importedCourier !== '' && $actualCourier !== '' && $importedCourier !== $actualCourier) {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:该运单实际快递为「' . $actualCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
}
$rule = null;
foreach ($rules as $candidate) {
if ($candidate['courier'] === $actualCourier) {
$rule = $candidate;
break;
}
}
if ($rule === null) {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:快递「' . ($actualCourier !== '' ? $actualCourier : '未知') . '」没有启用的「超长费」报价规则', null);
}
if ($best === null) {
return $this->importQuoteRow($pair, $fields, null, '待核价',
'待核价:商品资料缺少长宽高,暂无法核算超长费', '销售出库单已匹配,但条形码在商品资料中没有长宽高');
}
$evidence = mb_substr(sprintf(
'规则 #%d;条形码 %s 尺寸 %s×%s×%s,最长边 %scm %s %scm',
$rule['id'],
$best['sku_id'] !== '' ? $best['sku_id'] : '(无)',
$this->trimNumber($best['length']),
$this->trimNumber($best['width']),
$this->trimNumber($best['height']),
$this->trimNumber($best['max_side']),
$best['max_side'] > $rule['threshold'] ? '>' : '≤',
$this->trimNumber($rule['threshold']),
), 0, 500);
if ($best['max_side'] <= $rule['threshold']) {
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
'不结:最长边 ' . $this->trimNumber($best['max_side']) . 'cm 未超过报价阈值 ' . $this->trimNumber($rule['threshold']) . 'cm',
$evidence);
}
return $this->importQuoteRow($pair, $fields, (float) $rule['amount'], '已计算', '', $evidence);
}
/**
* @param array<string,mixed> $context
* @param array<string,array<string,list<string>>> $ruleModes
* @param array{overlength:list<array<string,mixed>>,return:list<array<string,mixed>>,intercept:list<array<string,mixed>>} $quoteRules
* @param array<string,float> $contextWeights
* @param array<int,list<array<string,mixed>>> $contextReturns
* @return array<string,mixed>
*/
private function importDeductQuoteResult(
array $pair,
array $context,
?object $fallbackRow,
array $ruleModes,
array $quoteRules,
array $contextWeights,
array $contextReturns,
): array {
$type = $pair['surcharge_type'];
$importedCourier = $pair['courier_name'];
$contextCourier = trim((string) ($context['courier'] ?? ''));
$fallbackCourier = trim((string) ($fallbackRow->courier_name ?? ''));
$courier = $importedCourier !== '' ? $importedCourier : ($contextCourier !== '' ? $contextCourier : $fallbackCourier);
$inspectionType = trim((string) ($context['inspection_type'] ?? ''));
$items = $context['items'] ?? [];
$primary = $this->primaryImportItem($items);
$orderWeight = $this->importWeightSum($items, $contextWeights) ?? 0.0;
$oId = (int) ($context['o_id'] ?? 0);
$returnWaybills = [];
$hasReturn = false;
$returnedWeight = 0.0;
$hasReturnWeight = false;
foreach ($contextReturns[$oId] ?? [] as $return) {
$rQty = (float) ($return['r_qty'] ?? 0);
$receiveDate = trim((string) ($return['receive_date'] ?? ''));
if ($rQty <= 0 || $receiveDate === '') {
continue;
}
$hasReturn = true;
$returnWaybill = trim((string) ($return['l_id'] ?? ''));
if ($returnWaybill !== '') {
$returnWaybills[$returnWaybill] = true;
}
$skuId = strtoupper(trim((string) ($return['sku_id'] ?? '')));
$weight = $skuId !== '' ? ($contextWeights[$skuId] ?? null) : null;
if ($weight !== null) {
$returnedWeight += $rQty * (float) $weight;
$hasReturnWeight = true;
}
}
$settlementWeight = $hasReturn
? ($hasReturnWeight && $returnedWeight > 0 ? $returnedWeight : $orderWeight)
: $orderWeight;
$fields = [
'courier_name' => $courier !== '' ? $courier : null,
'shipped_at' => trim((string) ($context['send_time'] ?? '')) ?: null,
'platform_product_id' => $primary['platform_product_id'] !== '' ? $primary['platform_product_id'] : null,
'product_code' => $primary['sku_id'] !== '' ? $primary['sku_id'] : ($primary['i_id'] !== '' ? $primary['i_id'] : null),
'shop_name' => trim((string) ($context['shop_name'] ?? '')) ?: null,
'online_order_no' => trim((string) ($context['online_order_no'] ?? '')) ?: null,
'length' => null,
'width' => null,
'height' => null,
'volume' => null,
'inspection_type' => $inspectionType !== '' ? $inspectionType : null,
'inspection_reason' => trim((string) ($context['inspection_reason'] ?? '')) ?: null,
'return_waybill_no' => $returnWaybills === [] ? null : mb_substr((string) array_key_first($returnWaybills), 0, 191),
'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null,
];
if ($importedCourier !== '' && $contextCourier !== '' && $importedCourier !== $contextCourier) {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:该运单实际快递为「' . $contextCourier . '」,与导入的「' . $importedCourier . '」不一致', null);
}
if ($inspectionType !== '扣单') {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:该运单在查件工单里不是「扣单」类型', null);
}
$rules = $type === '拦截费' ? ($quoteRules['intercept'] ?? []) : ($quoteRules['return'] ?? []);
$rule = null;
foreach ($rules as $candidate) {
if ($candidate['courier'] === $courier) {
$rule = $candidate;
break;
}
}
if ($rule === null) {
$modes = array_values(array_unique($ruleModes[$courier][$type] ?? []));
if ($modes !== []) {
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:快递「' . $courier . '」的' . $type . '报价规则(' . implode('、', $modes) . ')暂不支持自动核算', null);
}
return $this->importQuoteRow($pair, $fields, null, '未匹配报价',
'不结:快递「' . ($courier !== '' ? $courier : '未知') . '」没有启用的「' . $type . '」报价规则', null);
}
if ($type === '拦截费') {
return $this->importQuoteRow($pair, $fields, (float) $rule['value'], '已计算', '',
'规则 #' . $rule['id'] . ';查件扣单按固定单价 ' . $this->trimNumber((float) $rule['value']) . ' 元/单');
}
$evidenceParts = ['规则 #' . $rule['id']];
if ($hasReturn) {
$evidenceParts[] = '售后退货退款已进仓,结算重量 ' . $this->trimNumber($settlementWeight) . 'kg';
} else {
$evidenceParts[] = '售后退货退款无进仓时间/实退数量';
}
if ($rule['mode'] === '固定单价') {
return $this->importQuoteRow(
$pair,
$fields,
$hasReturn ? (float) $rule['value'] : 0.0,
'已计算',
$hasReturn ? '' : '不结:售后退货退款无进仓时间/实退数量',
implode(';', $evidenceParts),
);
}
if (! $hasReturn) {
return $this->importQuoteRow($pair, $fields, 0.0, '已计算',
'不结:售后退货退款无进仓时间/实退数量', implode(';', $evidenceParts));
}
if ($settlementWeight <= 0) {
return $this->importQuoteRow($pair, $fields, null, '待核价',
'待核价:商品资料缺少重量,暂无法按重量核算退回费', implode(';', $evidenceParts));
}
return $this->importQuoteRow(
$pair,
$fields,
round($settlementWeight * (float) $rule['value'], 2),
'已计算',
'',
implode(';', $evidenceParts) . ';' . $this->trimNumber($settlementWeight) . 'kg × '
. $this->trimNumber((float) $rule['value']) . ' 元/kg',
);
}
/**
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
* @param array<string,mixed> $fields
* @return array<string,mixed>
*/
private function importQuoteRow(
array $pair,
array $fields,
?float $amount,
string $status,
string $reason,
?string $evidence,
): array {
$decision = $amount !== null && $amount > 0 ? '正常结' : '不结';
return array_merge([
'row_number' => $pair['row_number'],
'waybill' => $pair['waybill'],
'surcharge_type' => $pair['surcharge_type'],
'decision' => $decision,
'reason' => $decision === '正常结'
? ''
: ($reason !== '' ? $reason : '结算金额为 0'),
], $fields, [
'settlement_amount' => $amount,
'settlement_status' => $status,
'evidence' => $evidence,
]);
}
/**
* 当前启用的报价规则里,可用于导入核算的部分。
*
* @return array{overlength:list<array{id:int,courier:string,threshold:float,amount:float}>,
* return:list<array{id:int,courier:string,mode:string,value:float}>,
* intercept:list<array{id:int,courier:string,value:float}>}
*/
private function importQuoteRules(): array
{
$rules = ['overlength' => [], 'return' => [], 'intercept' => []];
try {
$rows = DB::table('business_courier_surcharge_quotes')
->where('status', 'active')
->orderBy('id')
->get();
} catch (Throwable) {
return $rules;
}
foreach ($rows as $rule) {
$types = json_decode((string) $rule->types, true) ?: [];
$courier = trim((string) $rule->courier);
$mode = trim((string) $rule->mode);
$condition = (string) $rule->condition;
$standard = (string) $rule->standard;
if ($courier === '') {
continue;
}
if (in_array('超长费', $types, true)
&& (str_contains($mode, '按尺寸') || str_contains($mode, '最长边'))
&& preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $threshold) === 1
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $price) === 1) {
$rules['overlength'][] = [
'id' => (int) $rule->id,
'courier' => $courier,
'threshold' => (float) $threshold[1],
'amount' => (float) $price[1],
];
}
if (! str_contains($condition, '扣单')) {
continue;
}
if (in_array('拦截费', $types, true)
&& $mode === '固定单价'
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
$rules['intercept'][] = [
'id' => (int) $rule->id,
'courier' => $courier,
'value' => (float) $price[1],
];
}
if (in_array('退回费', $types, true)) {
if ($mode === '固定单价'
&& preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $price) === 1) {
$rules['return'][] = [
'id' => (int) $rule->id,
'courier' => $courier,
'mode' => '固定单价',
'value' => (float) $price[1],
];
} elseif ($mode === '按重量'
&& preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $price) === 1) {
$rules['return'][] = [
'id' => (int) $rule->id,
'courier' => $courier,
'mode' => '按重量',
'value' => (float) $price[1],
];
}
}
}
return $rules;
}
/**
* @param list<string> $skuIds
* @return array<string,array{l:float,w:float,h:float}>
*/
private function importDimensions(array $skuIds): array
{
$map = [];
foreach (array_chunk(array_values(array_unique(array_filter($skuIds))), 500) as $chunk) {
try {
$rows = $this->connection->table('ops_jst_sku')
->whereIn('sku_id', $chunk)
->get(['sku_id', 'l', 'w', 'h']);
} catch (Throwable) {
return $map;
}
foreach ($rows as $row) {
$length = (float) $row->l;
$width = (float) $row->w;
$height = (float) $row->h;
if ($length > 0 && $width > 0 && $height > 0) {
$map[trim((string) $row->sku_id)] = ['l' => $length, 'w' => $width, 'h' => $height];
}
}
}
return $map;
}
private function trimNumber(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
/**
* @param array{row_number:int,waybill:string,surcharge_type:string,courier_name:string} $pair
* @param array<string,array{courier:string}> $returnContext
@ -677,23 +1293,33 @@ class CourierSurchargeSettlementMirrorRepository
static fn (string $waybill): bool => $waybill !== '',
)));
if ($waybills === [] || count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT) {
$lookup = app(\Modules\EcommerceFinance\Services\WorkOrderWaybillLookup::class);
$indexed = $lookup->ready();
if ($waybills === [] || (! $indexed && count($waybills) > self::IMPORT_RETURN_CONTEXT_LIMIT)) {
return [];
}
$context = [];
foreach (array_chunk($waybills, 50) as $chunk) {
foreach (array_chunk($waybills, $indexed ? 300 : 50) as $chunk) {
try {
$rows = $this->connection->table('js_jst_work_order_info')
->where('title', '查件')
->where(function ($query) use ($chunk): void {
foreach ($chunk as $waybill) {
$query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%');
}
})
->orderByDesc('Id')
->get(['businessFieldList']);
if ($indexed) {
$rows = $this->connection->table($lookup::TABLE . ' as lookup')
->join('js_jst_work_order_info as raw', 'raw._mirror_key', '=', 'lookup.mirror_key')
->whereIn('lookup.waybill', $chunk)
->orderByDesc('lookup.source_id')->get(['raw.businessFieldList']);
} else {
// Retain the existing path only until the resumable initial index is ready.
$rows = $this->connection->table('js_jst_work_order_info')
->where('title', '查件')
->where(function ($query) use ($chunk): void {
foreach ($chunk as $waybill) {
$query->orWhere('businessFieldList', 'like', '%' . str_replace(['%', '_'], '', $waybill) . '%');
}
})
->orderByDesc('Id')
->get(['businessFieldList']);
}
} catch (Throwable) {
continue;
}
@ -851,7 +1477,7 @@ class CourierSurchargeSettlementMirrorRepository
/**
* @param array<string,array<string,mixed>> $returnContext
* @return array<int,string>
* @return array<int,list<array{o_id:int,l_id:string,sku_id:string,r_qty:float,receive_date:string}>>
*/
private function importReturns(array $returnContext): array
{
@ -882,7 +1508,8 @@ class CourierSurchargeSettlementMirrorRepository
try {
$rows = $upstream->select(
'SELECT a.o_id, a.l_id, ri.r_qty, CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
'SELECT a.o_id, a.l_id, ri.sku_id, ri.r_qty, '
. 'CONVERT(varchar(19), ri.receive_date, 120) AS receive_date '
. 'FROM rpt.jst_returnAndRefundInfo a '
. 'INNER JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = a.as_id '
. 'WHERE a.o_id IN (' . $placeholders . ')',
@ -904,17 +1531,17 @@ class CourierSurchargeSettlementMirrorRepository
continue;
}
$byOrder[$oId][$waybill] = true;
$byOrder[$oId][] = [
'o_id' => $oId,
'l_id' => $waybill,
'sku_id' => trim((string) ($row['sku_id'] ?? '')),
'r_qty' => (float) ($row['r_qty'] ?? 0),
'receive_date' => trim((string) ($row['receive_date'] ?? '')),
];
}
}
$map = [];
foreach ($byOrder as $oId => $waybills) {
$map[(int) $oId] = implode(',', array_keys($waybills));
}
return $map;
return $byOrder;
}
/** @return array<string,array<string,list<string>>> */

View File

@ -6,6 +6,7 @@ use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\WorkOrderWaybillLookup;
use RuntimeException;
class GenericBusinessMirrorRepository
@ -161,6 +162,9 @@ class GenericBusinessMirrorRepository
foreach (array_chunk($mapped, $chunkSize) as $chunk) {
$this->connection->table($dataset->targetTable())->upsert($chunk, ['_mirror_key'], $updates);
}
if ($dataset->targetTable() === WorkOrderWaybillLookup::SOURCE) {
app(WorkOrderWaybillLookup::class)->storeBatch($mapped);
}
$this->connection->table('business_mirror_table_states')
->where('dataset_key', $dataset->key())
@ -197,6 +201,9 @@ class GenericBusinessMirrorRepository
->orWhere('_mirror_generation', '!=', $generation);
})
->delete();
if ($dataset->targetTable() === WorkOrderWaybillLookup::SOURCE) {
app(WorkOrderWaybillLookup::class)->prune();
}
}
$countState = $this->supportsExactCount()

View File

@ -4,6 +4,7 @@ namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\JdBillPlatform;
use Modules\EcommerceFinance\Services\LedgerRemarkFilter;
use RuntimeException;
@ -28,7 +29,8 @@ class JdDailyBillRepository
{
$periods = $this->periods();
$query = $this->query($filters, $periods);
$summary = (clone $query)->selectRaw('COUNT(*) AS count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first();
$summary = Cache::remember($this->cacheKey('summary', $periods, $filters), 3600,
fn () => (clone $query)->selectRaw('COUNT(*) AS count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')->first());
$total = (int) $summary->count;
$page = max(1, $page);
$perPage = min(100, max(1, $perPage));
@ -64,11 +66,24 @@ class JdDailyBillRepository
public function filterOptions(): array
{
$q = $this->query([], $this->periods());
return [
'shops' => (clone $q)->distinct()->orderBy('store_name')->pluck('store_name')->all(),
'bill_items' => (clone $q)->distinct()->orderBy('bill_item')->pluck('bill_item')->all(),
];
$periods = $this->periods();
return Cache::remember($this->cacheKey('options', $periods), 3600, function () use ($periods): array {
$q = $this->query([], $periods);
return [
'shops' => (clone $q)->distinct()->orderBy('store_name')->pluck('store_name')->all(),
'bill_items' => (clone $q)->distinct()->orderBy('bill_item')->pluck('bill_item')->all(),
];
});
}
/** Imported generations are immutable; replacing a month invalidates both caches. */
private function cacheKey(string $scope, \Illuminate\Support\Collection $periods, array $filters = []): string
{
ksort($filters);
return 'jd-daily-bill:' . $scope . ':v1:' . hash('sha256', json_encode([
$this->db()->getDatabaseName(), $this->platform,
$periods->pluck('active_generation')->sort()->values()->all(), $filters,
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
private function periods(): \Illuminate\Support\Collection

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

@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Repositories;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Carbon;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Throwable;
@ -13,6 +14,8 @@ class OrderDataMirrorRepository
/** 完整店铺候选的缓存键(页面店铺多选使用)。 */
public const SHOP_NAMES_CACHE_KEY = 'order-data.shop-candidates:v2';
public const SHOP_NAMES_CURSORS_KEY = 'order-data.shop-candidates:cursors:v1';
/** 订单号搜索一次最多解析出多少候选订单,超过就退回模糊匹配。 */
private const ORDER_NUMBER_CANDIDATE_LIMIT = 500;
@ -67,21 +70,26 @@ class OrderDataMirrorRepository
/**
* 重新计算并缓存完整店铺候选(店铺目录 ∪ 订单镜像里出现过的店铺)。
*
* 只允许定时任务调用:单次要扫两张百万级宽表。
* 按镜像同步时间增量补充;夜间 --full 对账移除已不存在的候选。
*
* @return array<int,string>
*/
public function refreshShopNamesCache(): array
public function refreshShopNamesCache(bool $full = false): array
{
$cached = Cache::get(self::SHOP_NAMES_CACHE_KEY);
$incremental = ! $full && is_array($cached);
$cursors = $incremental ? (array) Cache::get(self::SHOP_NAMES_CURSORS_KEY, []) : [];
[$changed, $nextCursors] = $this->computedShopNames($incremental, $cursors);
$names = array_values(array_unique(array_merge(
$this->computedShopNames(),
$incremental ? $cached : [],
$changed,
$this->shopDirectoryNames(),
)));
sort($names, SORT_STRING);
if ($names !== []) {
Cache::put(self::SHOP_NAMES_CACHE_KEY, $names, now()->addHours(24));
}
// Empty is also a valid full reconciliation result.
Cache::put(self::SHOP_NAMES_CACHE_KEY, $names, now()->addHours(24));
Cache::put(self::SHOP_NAMES_CURSORS_KEY, $nextCursors, now()->addHours(24));
return $names;
}
@ -184,22 +192,34 @@ class OrderDataMirrorRepository
/**
* 非淘系 + 淘系两个来源合并去重(淘系店铺名形如"天猫-xx/淘宝-xx")。
*
* 分开各自 DISTINCT 再在 PHP 合并:UNION 会让 MariaDB 把两个上百万行的结果
* 一起物化去重再排序,单次要两三分钟。只在定时预热时执行。
*
* @return array<int,string>
* @param array<string,string|null> $cursors
* @return array{0:list<string>,1:array<string,string|null>}
*/
private function computedShopNames(): array
private function computedShopNames(bool $incremental, array $cursors): array
{
$names = [];
$nextCursors = [];
foreach (['ops_jst_orders', 'ops_jst_qmcust_orders'] as $table) {
$rows = $this->connection->table($table)
$hasTimestamp = $this->connection->getSchemaBuilder()->hasColumn($table, '_mirror_synced_at');
$high = $hasTimestamp ? $this->connection->table($table)->max('_mirror_synced_at') : null;
$since = $cursors[$table] ?? null;
if ($incremental && $since === null && $high !== null) {
// Upgrade the existing 24-hour complete cache without another full scan.
$since = Carbon::parse($high)->subDay()->format('Y-m-d H:i:s');
}
$query = $this->connection->table($table)
->select('shop_name')
->distinct()
->whereNotNull('shop_name')
->where('shop_name', '<>', '')
->pluck('shop_name');
->where('shop_name', '<>', '');
if ($incremental && $hasTimestamp && $since !== null && $high !== null) {
// Inclusive lower bound retains writes sharing the same timestamp.
$query->where('_mirror_synced_at', '>=', $since)
->where('_mirror_synced_at', '<=', $high);
}
$rows = $query->pluck('shop_name');
$nextCursors[$table] = $high;
foreach ($this->normalizeShopNames($rows) as $name) {
$names[$name] = true;
@ -209,7 +229,7 @@ class OrderDataMirrorRepository
$names = array_keys($names);
sort($names, SORT_STRING);
return $names;
return [$names, $nextCursors];
}
/** @param iterable<mixed> $rows @return array<int,string> */
@ -475,12 +495,18 @@ class OrderDataMirrorRepository
$shopNames = $this->selectedShopNames($filters);
if ($shopNames !== []) {
// 多店铺:每个店铺做一个模糊匹配再取并集,选中的是完整店铺名时等同于精确匹配。
$query->where(function (Builder $shop) use ($shopNames): void {
foreach ($shopNames as $name) {
$shop->orWhereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains($name)]);
}
});
// 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 {
// Free text and legacy callers keep their existing contains semantics.
$query->where(function (Builder $shop) use ($shopNames): void {
foreach ($shopNames as $name) {
$shop->orWhereRaw("o.shop_name LIKE ? ESCAPE '!'", [$this->contains($name)]);
}
});
}
}
if (($filters['sku_id'] ?? '') !== '') {
// 商品编码同样支持批量:每个编码一组"商品/款式/店铺编码"匹配,多组取并集。

View File

@ -0,0 +1,367 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use InvalidArgumentException;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
use Throwable;
/**
* 聚水潭聚工单「打款工单 → 种菜报销」:只读工单镜像 jiansun.dbo.jst_workOrderInfo。
*
* 口径(2026-10-19 财务确认):工单类型“种菜报销”、工单状态“已完成”,按完成时间筛选,
* 自 2026-09-02 00:00 起接入。种菜报销都是当天报销前一天的明细,所以 M 月种菜报销明细
* = 完成时间 [M 月 2 日 00:00, M+1 月 2 日 00:00)。同一工单号只取最新一版。
*/
class PlantingReimbursementRepository
{
public const DATASET = 'jiansun.dbo.jst_workOrderInfo';
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
public const STATUS_CONFIRMED = 'CONFIRMED';
public const START_AT = '2026-09-02 00:00:00';
public const FIRST_MONTH = '2026-09';
/** 聚工单只返回 fieldId;对应关系由历史种菜明细逐列对撞及打款工单列表核对得出。 */
public const FIELDS = [
'online_order_no' => 'orderField',
'internal_order_no' => 'o_id',
'platform' => 'platform',
'shop_name' => 'shopField',
'actual_paid_amount' => 'actualPaidAmountField',
'task_remark' => 'TEXTField1413856620251113150546',
'seller_remark' => 'businessRemarkField',
'task_payment' => 'NUMBERField1413856620250924131615',
'task_freight' => 'NUMBERField1413856620251113150537',
'task_commission' => 'NUMBERField1413856620251230154457',
'jingxi_price_diff' => 'NUMBERField1413856620250924131623',
'task_deduction' => 'NUMBERField1413856620251113150612',
'task_empty_package_fee' => 'NUMBERField1413856620251113145620',
'reimbursement_amount' => 'NUMBERField1413856620251117101250',
'payee_name' => 'TEXTField1413856620251117180931',
'payee_account' => 'TEXTField1413856620251117113451',
'payee_bank' => 'TEXTField1413856620251117112729',
'payee_mobile' => 'TEXTField1413856620260618171005',
'erp_order_status' => 'erpOrderStatus',
'platform_order_status' => 'platformOrderStatus',
'paid_at' => 'payDateField',
];
public const MONEY_FIELDS = [
'actual_paid_amount', 'task_payment', 'task_freight', 'task_commission', 'jingxi_price_diff',
'task_deduction', 'task_empty_package_fee', 'reimbursement_amount',
];
private const ERP_ORDER_STATUS = [
'Sent' => '已发货', 'Merged' => '被合并', 'Cancelled' => '已取消', 'Split' => '被拆分',
'WaitConfirm' => '待审核', 'WaitFConfirm' => '待财审', 'Delivering' => '发货中',
'Question' => '异常', 'WaitOuterSent' => '等供销商发货',
];
private const ROWS_CACHE_SECONDS = 600;
private const QUERY_TIMEOUT_SECONDS = 90;
private readonly ConnectionInterface $connection;
public function __construct(
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {
$this->connection = $mirror->connection();
}
/**
* 报销月份 → 完成时间窗口 [M-02 00:00, M+1-02 00:00)。
*
* @return array{from:string,to:string}
*/
public static function window(string $month): array
{
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) !== 1 || $month < self::FIRST_MONTH) {
throw new InvalidArgumentException('报销月份无效。');
}
$start = CarbonImmutable::createFromFormat('!Y-m-d', $month . '-02');
return ['from' => $start->format('Y-m-d H:i:s'), 'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s')];
}
/** 默认月份:完成时间窗口已开始的最近一个报销月(每月 1 日仍算上月)。 */
public static function defaultMonth(?CarbonImmutable $now = null): string
{
$now ??= CarbonImmutable::now();
$month = $now->subDay()->format('Y-m');
return max($month, self::FIRST_MONTH);
}
/** @return list<string> 可选报销月份(新到旧) */
public static function months(?CarbonImmutable $now = null): array
{
$months = [];
$cursor = CarbonImmutable::createFromFormat('!Y-m', self::defaultMonth($now));
while ($cursor->format('Y-m') >= self::FIRST_MONTH) {
$months[] = $cursor->format('Y-m');
$cursor = $cursor->subMonthNoOverflow();
}
return $months;
}
/** @return array{code:string,last_succeeded_at:?string} */
public function syncStatus(): array
{
try {
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable('business_mirror_table_states') || ! $schema->hasTable($this->dataset()->targetTable())) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$state = $this->mirror->state($this->dataset());
} catch (Throwable) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
if (! is_array($state)) {
return ['code' => 'unavailable', 'last_succeeded_at' => null];
}
$last = isset($state['last_succeeded_at']) ? (string) $state['last_succeeded_at'] : null;
if (($state['initial_completed_at'] ?? null) === null) {
return ['code' => ($state['status'] ?? '') === 'failed' ? 'initial_failed' : 'initializing', 'last_succeeded_at' => $last];
}
return ['code' => ($state['status'] ?? '') === 'failed' ? 'failed' : 'ready', 'last_succeeded_at' => $last];
}
/**
* 某报销月份的全部种菜报销(已完成,每个工单号取最新一版),按完成时间倒序。
* 结果按月份缓存 10 分钟;单月约 1.5~2 万行。
*
* @return list<array<string,mixed>>
*/
public function rows(string $month): array
{
$window = self::window($month);
$status = $this->syncStatus();
if (! in_array($status['code'], ['ready', 'failed'], true)) {
throw new RuntimeException('business_mirror_dataset_not_ready');
}
// 镜像每几分钟增量一次,按版本做键会让缓存几乎每次失效;固定按月份缓存 10 分钟。
$key = 'planting-reimbursements:rows:v2:' . $month;
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$rows = $this->load($window['from'], $window['to']);
try {
Cache::put($key, $rows, self::ROWS_CACHE_SECONDS);
} catch (Throwable $exception) {
report($exception);
}
return $rows;
}
/** @return list<array<string,mixed>> */
private function load(string $from, string $to): array
{
$table = $this->dataset()->targetTable();
$query = $this->connection->table($table)
->where('woTypeId', self::WORK_TYPE_ID)
->where('completeTime', '>=', $from)
->where('completeTime', '<', $to)
->select([
'Id', 'woNo', 'status', 'createby', 'executors', 'completer',
'completeTime', 'created', 'businessFieldList',
])
->orderByDesc('completeTime')
->orderByDesc('woNo');
/** @var array<string,object> $latest 窗口内每个工单号的最新一版 */
$latest = [];
foreach ($this->boundedGet($query) as $record) {
$woNo = (string) $record->woNo;
if (! isset($latest[$woNo]) || (int) $record->Id > (int) $latest[$woNo]->Id) {
$latest[$woNo] = $record;
}
}
// 同一工单若有更新的一版(可能已改完成时间或被作废)落在窗口外,以最新一版为准,这里剔除。
$newestIds = [];
foreach (array_chunk(array_map('strval', array_keys($latest)), 1000) as $chunk) {
$versions = $this->connection->table($table)
->where('woTypeId', self::WORK_TYPE_ID)
->whereIn('woNo', $chunk)
->groupBy('woNo')
->selectRaw('woNo, MAX(Id) AS newest_id')
->get();
foreach ($versions as $version) {
$newestIds[(string) $version->woNo] = (int) $version->newest_id;
}
}
$rows = [];
foreach ($latest as $woNo => $record) {
if (($newestIds[(string) $woNo] ?? (int) $record->Id) > (int) $record->Id) {
continue;
}
// 最新一版已不是“已完成”(如完成后被作废)的工单不计入。
if ((string) $record->status !== self::STATUS_CONFIRMED) {
continue;
}
$rows[] = $this->decorate($record);
}
return $rows;
}
/** @return array<string,mixed> */
private function decorate(object $record): array
{
$fields = $this->fields((string) ($record->businessFieldList ?? ''));
$row = [
'work_order_no' => (string) $record->woNo,
'started_at' => $this->time($record->created ?? null),
'completed_at' => $this->time($record->completeTime ?? null),
'status' => '已完成',
'initiator' => (string) ($record->createby ?? ''),
'executor' => (string) ($record->executors ?? ''),
'completer' => (string) ($record->completer ?? ''),
];
foreach (self::FIELDS as $key => $fieldId) {
$row[$key] = $this->display($fields[$fieldId] ?? null);
}
foreach (self::MONEY_FIELDS as $key) {
$row[$key] = $this->money($row[$key]);
}
$row['erp_order_status'] = self::ERP_ORDER_STATUS[$row['erp_order_status']] ?? $row['erp_order_status'];
$items = $this->items($fields['itemInfo']['raw'] ?? null);
$row['product_code'] = implode("\n", array_filter(array_column($items, 'skuId')));
$row['product_name'] = implode("\n", array_filter(array_column($items, 'name')));
$row['product_quantity'] = implode("\n", array_filter(array_column($items, 'qty')));
$row['platform_product_id'] = implode("\n", array_filter(array_column($items, 'platformItemId')));
return $row;
}
/** @return array<string,array{value:mixed,label:mixed,raw:array<mixed>}> */
private function fields(string $json): array
{
if (trim($json) === '') {
return [];
}
try {
$decoded = json_decode($json, true, 64, JSON_THROW_ON_ERROR);
} catch (Throwable) {
return [];
}
if (isset($decoded['businessField']) && is_array($decoded['businessField'])) {
$decoded = $decoded['businessField'];
}
$fields = [];
foreach (is_array($decoded) ? $decoded : [] as $item) {
if (is_array($item) && isset($item['fieldId'])) {
$fields[(string) $item['fieldId']] = [
'value' => $item['value'] ?? ($item['valueList'] ?? null),
'label' => $item['valueLabel'] ?? null,
'raw' => $item,
];
}
}
return $fields;
}
/** @param array{value:mixed,label:mixed}|null $field */
private function display(?array $field): string
{
if ($field === null) {
return '';
}
foreach ([$field['label'], $field['value']] as $value) {
if (is_array($value)) {
$value = implode('、', array_filter(array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $value)));
}
if (is_scalar($value) && trim((string) $value) !== '') {
return trim((string) $value);
}
}
return '';
}
/**
* @param array<mixed>|null $raw
* @return list<array<string,string>>
*/
private function items(?array $raw): array
{
$items = [];
foreach ((array) ($raw['childFieldList'] ?? []) as $childRow) {
$item = [];
foreach ((array) $childRow as $child) {
if (is_array($child) && isset($child['fieldId'])) {
$v = $child['valueLabel'] ?? $child['value'] ?? '';
$item[(string) $child['fieldId']] = is_scalar($v) ? trim((string) $v) : '';
}
}
if ($item !== []) {
$items[] = $item;
}
}
foreach ((array) ($raw['value'] ?? []) as $item) {
if (is_array($item) && ! array_is_list($item)) {
$items[] = array_map(static fn (mixed $v): string => is_scalar($v) ? trim((string) $v) : '', $item);
}
}
return $items;
}
private function money(string $value): ?string
{
$normalized = str_replace([',', ',', '¥', '¥', '元', ' '], '', trim($value));
if (! preg_match('/^-?\d+(\.\d+)?$/', $normalized)) {
return null;
}
return number_format(round((float) $normalized, 2), 2, '.', '');
}
private function time(mixed $value): string
{
return substr(trim((string) $value), 0, 19);
}
/** @return iterable<int,object> */
private function boundedGet(Builder $query): iterable
{
$connection = $query->getConnection();
if ($connection->getDriverName() !== 'mysql') {
return $query->get();
}
return $connection->select(
'SET STATEMENT max_statement_time=' . self::QUERY_TIMEOUT_SECONDS . ' FOR ' . $query->toSql(),
$query->getBindings(),
);
}
private function dataset(): BusinessMirrorDataset
{
return $this->datasets->get(self::DATASET);
}
}

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

@ -16,8 +16,9 @@ use RuntimeException;
* 失效时间为变更日前一天、新主体从变更日当天生效,收入按实际日期归集,不重复统计。
*
* 两列金额口径不同,页面分别展示:
* 销量(period_sales / sales_NN):业绩明细(店铺收款匹配订单明细)的「订单实付/实收金额」,
* 按「店铺名称 + 发生时间」预汇总到 store_report_paid_sales_rows,再按发生日期落到主体期间;
* 销量(period_sales / sales_NN):业绩明细(店铺收款匹配订单明细)的
* 「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按「店铺名称 + 发生日期」
* 预汇总到 store_report_paid_sales_rows,再按发生日期落到主体期间;
* 收入(rolling_12m_income / income_NN):平台账单日汇总 store_report_daily_rows 的收入,
* 供主体维度经营报表按主体汇总使用。
*/
@ -28,7 +29,7 @@ class StoreReportRepository
public const LIFECYCLES_DATASET = 'store-report.store-lifecycles';
/** 平台账单日汇总:主体维度经营报表的「收入」列仍按这里取数。 */
public const DAILY_DATASET = 'store-report.daily-sales';
/** 销量数据集:业绩明细「订单实付/实收金额」按「店铺名称 + 发生日期」预汇总。 */
/** 销量数据集:业绩明细四项金额合计按「店铺名称 + 发生日期」预汇总。 */
public const PAID_SALES_DATASET = 'store-report.paid-sales';
/** 销量汇总表;由业绩明细表汇总而来,页面按店铺名与发生日期关联主体期间。 */
public const PAID_SALES_TABLE = 'store_report_paid_sales_rows';
@ -36,7 +37,8 @@ class StoreReportRepository
public const PAID_SALES_SOURCE_TABLE = 'store_receipt_order_match_rows';
/**
* 销量来源:业绩明细的「订单实付/实收金额」(按店铺 + 发生日期预汇总)。
* 销量来源:收入+优惠+冲减+退款四项合计(按店铺 + 发生日期预汇总)。
* 列名沿用历史命名 paid_amount,避免共享镜像表结构变更影响线上旧版本。
*
* @var array{table:string,amount:string}
*/

View File

@ -11,7 +11,7 @@ use Throwable;
* 店铺收款账户账单的「导出任务」:后台生成 CSV 落盘,页面按任务列表查看状态并下载。
*
* 任务与文件都存在 storage/app/account-bills-exports/{userId}/ 下(JSON 记录状态 + CSV 文件),
* 不依赖浏览器保持连接,也不需要队列表:请求里用 detach 的 php-cli 进程跑导出。
* 不依赖浏览器保持连接;生产由受限批处理容器逐个消费,本地由单个 PHP 进程消费。
*/
class AccountBillsExportTasks
{
@ -29,7 +29,12 @@ class AccountBillsExportTasks
public function baseDir(int $userId): string
{
return storage_path('app/account-bills-exports/' . $userId);
return $this->rootDir() . '/' . $userId;
}
public function rootDir(): string
{
return storage_path('app/account-bills-exports');
}
public function jsonPath(array $task): string
@ -167,6 +172,8 @@ class AccountBillsExportTasks
/** 后台执行导出(脱离当前请求)。 */
public function dispatch(array $task): void
{
// Production consumes the durable file queue from the limited batch profile.
if (config('upstream_pipeline.batch_worker_enabled', false)) return;
$php = PHP_BINDIR . '/php';
if (! is_file($php)) {
$php = 'php';
@ -174,7 +181,7 @@ class AccountBillsExportTasks
$artisan = base_path('artisan');
$command = escapeshellcmd($php) . ' ' . escapeshellarg($artisan)
. ' account-bills:export-run ' . escapeshellarg((string) $task['token'])
. ' account-bills:exports:run --max-jobs=0'
. ' > /dev/null 2>&1 &';
try {
@ -185,4 +192,15 @@ class AccountBillsExportTasks
// 起不了后台进程时保持 queued,页面会提示重试。
}
}
public function nextQueued(): ?array
{
$next = null;
foreach (glob($this->rootDir() . '/*/*.json') ?: [] as $path) {
$task = json_decode((string) file_get_contents($path), true);
if (! is_array($task) || ($task['status'] ?? '') !== 'queued') continue;
if ($next === null || strcmp((string) $task['planned_at'], (string) $next['planned_at']) < 0) $next = $task;
}
return $next;
}
}

View File

@ -0,0 +1,128 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
use Throwable;
/**
* 科目映射表变更后,把受影响的「业绩明细」平台×月份放进后台重跑队列。
*
* 业绩明细的优惠/冲减/退款金额是同步时按映射表算好落库的,改映射必须重跑对应
* 平台×月份才会更新;这里把重跑做成后台自动执行,页面保存后无需人工执行命令。
*/
class AccountMappingResyncDispatcher
{
public const JOBS_TABLE = 'ec_account_mapping_resync_jobs';
/**
* 科目映射表的平台名 → [同步命令 platform 键, 业绩明细平台名]。
* 只保留已接入业绩同步的平台;其它平台(如唯品会JX)没有同步任务。
*/
private const PLATFORM_MAP = [
'淘宝' => [['taobao-alipay', '淘系支付宝'], ['taobao-wechat', '淘系微信']],
'天猫' => [['taobao-alipay', '淘系支付宝'], ['taobao-wechat', '淘系微信']],
'阿里巴巴' => [['taobao-alipay', '淘系支付宝']],
'拼多多' => [['pinduoduo', '拼多多']],
'京喜' => [['jingxi', '京喜账单']],
'京东' => [['jd-transactions', '京东账单'], ['jd-wallet', '京东网银钱包']],
'抖音' => [['douyin', '抖音']],
'得物' => [['dewu', '得物']],
];
/**
* @param list<string> $mappingPlatforms 科目映射表里的平台名
* @return array{jobs:int}
*/
public function dispatchFor(array $mappingPlatforms): array
{
$targets = [];
foreach ($mappingPlatforms as $platform) {
foreach (self::PLATFORM_MAP[trim((string) $platform)] ?? [] as [$key, $label]) {
$targets[$key] = $label;
}
}
if ($targets === []) {
return ['jobs' => 0];
}
$mirror = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$periods = $mirror->table('store_receipt_order_match_rows')
->whereIn('platform', array_values($targets))
->select(['platform', 'report_month'])
->distinct()
->orderBy('report_month')
->get()->groupBy('platform');
if ($periods->isEmpty()) {
return ['jobs' => 0];
}
$now = now();
$jobs = 0;
foreach ($targets as $key => $label) {
$months = $periods->get($label, collect())->map(fn ($row) => substr((string) $row->report_month, 0, 7))->unique();
foreach ($months as $month) {
// running 时再次保存映射,会把状态改回 pending;当前进程完成时发现状态已变,
// 不会覆盖成 done,于是会紧接着再跑一遍,避免漏掉刚保存的新规则。
$affected = DB::table(self::JOBS_TABLE)->updateOrInsert(
['platform' => (string) $key, 'month' => (string) $month],
[
'status' => 'pending',
'message' => null,
'updated_at' => $now,
'created_at' => $now,
]
);
if ($affected) {
$jobs++;
}
}
}
if ($jobs > 0) {
$this->spawnWorker();
}
return ['jobs' => $jobs];
}
/** @return array{jobs:int} 所有已接入平台一起重跑(用于「恢复初始 112 条」) */
public function dispatchAll(): array
{
return $this->dispatchFor(array_keys(self::PLATFORM_MAP));
}
/**
* 起一个后台进程处理队列。
*
* 已有进程在跑时新进程会拿不到锁直接退出;正在跑的进程处理完当前任务后会继续取新任务。
*/
public function spawnWorker(): void
{
// Production cron consumes the durable queue in the resource-limited batch profile.
if (config('upstream_pipeline.batch_worker_enabled', false)) {
return;
}
$this->launchWorker();
}
protected function launchWorker(): void
{
try {
$php = escapeshellarg(PHP_BINARY ?: 'php');
$command = 'cd ' . escapeshellarg(base_path())
. ' && nohup ' . $php . ' artisan store-receipt:auto-resync >/dev/null 2>&1 &';
Process::fromShellCommandline($command, base_path())
->disableOutput()
->setTimeout(10)
->run();
} catch (Throwable $exception) {
// 后台进程起不来时任务仍留在队列里,可手动执行 store-receipt:auto-resync 补跑。
Log::warning('科目映射表自动重跑进程启动失败:' . $exception->getMessage());
}
}
}

View File

@ -34,7 +34,7 @@ class AccountMappingStore
public const EVENTS_TABLE = 'ec_account_mapping_events';
/** 可维护字段(顺序即导出/模板列顺序) */
public const FIELDS = ['platform', 'item', 'memo', 'goods', 'invoice', 'category', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'];
public const FIELDS = ['platform', 'item', 'memo', 'goods', 'invoice', 'category', 'feeMemo', 'dAcct', 'dSub', 'cAcct', 'cSub', 'basis'];
public const LABELS = [
'platform' => '平台',
@ -43,6 +43,7 @@ class AccountMappingStore
'goods' => '商品名称关键词',
'invoice' => '票据口径',
'category' => '财务类型',
'feeMemo' => '费用备注',
'dAcct' => '借方科目',
'dSub' => '借方二级',
'cAcct' => '贷方科目',
@ -55,6 +56,14 @@ class AccountMappingStore
public const BASISES = [self::BASIS_OUT, self::BASIS_IN];
/** 进程内规则缓存(写入时清空;CLI 批量任务可避免逐行重查规则表)。 */
private static ?array $allMemo = null;
private static function forgetMemo(): void
{
self::$allMemo = null;
}
/** 大类 → 预期方向基准(用于校验「基准与大类是否一致」) */
public const CATEGORY_BASIS = [
'费用' => self::BASIS_OUT,
@ -101,13 +110,19 @@ class AccountMappingStore
/** @return array<int,array<string,mixed>> 按 id 升序的全部规则(共享数据库优先,表不存在时退回旧文件) */
public function all(): array
{
// 进程内缓存:业绩明细等批量任务会逐行调用匹配器,而匹配器每次都走 all(),
// 不缓存时每个账单行都要重查一次规则表(36k 行会拖到几十分钟)。写入方法会清空缓存。
if (self::$allMemo !== null) {
return self::$allMemo;
}
if ($this->rulesTableReady()) {
$out = [];
foreach (DB::table(self::RULES_TABLE)->orderBy('id')->get() as $row) {
$out[] = $this->fromRow($row);
}
return $out;
return self::$allMemo = $out;
}
// 兼容:迁移前的环境(或回滚场景)继续读文件
@ -119,7 +134,7 @@ class AccountMappingStore
$rules = array_values(array_filter($rules, static fn (mixed $r): bool => is_array($r) && isset($r['id'])));
usort($rules, static fn (array $a, array $b): int => ((int) $a['id']) <=> ((int) $b['id']));
return $rules;
return self::$allMemo = $rules;
}
public function find(int $id): ?array
@ -136,6 +151,7 @@ class AccountMappingStore
/** 新增一条,返回新规则 */
public function create(array $values, ?int $userId = null): array
{
self::forgetMemo();
$rules = $this->all();
$id = 1;
foreach ($rules as $rule) {
@ -153,6 +169,7 @@ class AccountMappingStore
/** 修改一条,返回改后的规则 */
public function update(int $id, array $values, ?int $userId = null): array
{
self::forgetMemo();
$rules = $this->all();
$updated = null;
$before = null;
@ -171,12 +188,24 @@ class AccountMappingStore
throw new RuntimeException('未找到要修改的规则。');
}
$this->persist($rules);
$this->logEvent('update', $id, $before, $updated, $userId);
if ($before !== $updated) {
$this->persist($rules);
$this->logEvent('update', $id, $before, $updated, $userId);
}
return $updated;
}
/** Fee memo is display-only; all other fields retain conservative invalidation. */
public static function requiresReceiptResync(?array $before, ?array $after): bool
{
foreach (self::FIELDS as $field) {
if ($field === 'feeMemo') continue;
if (trim((string) ($before[$field] ?? '')) !== trim((string) ($after[$field] ?? ''))) return true;
}
return false;
}
public function destroy(int $id, ?int $userId = null): bool
{
$rules = $this->all();
@ -205,10 +234,11 @@ class AccountMappingStore
* 导入:按「平台 + 账单项目 + 财务备注」判重。
*
* @param array<int,array<string,mixed>> $rows
* @return array{created:int,updated:int,skipped:int,duplicates:array<int,string>,total:int}
* @return array{created:int,updated:int,skipped:int,duplicates:array<int,string>,total:int,resync_platforms:list<string>}
*/
public function importRows(array $rows, bool $overwrite, ?int $userId = null): array
{
self::forgetMemo();
$rules = $this->all();
$byKey = [];
foreach ($rules as $index => $rule) {
@ -224,6 +254,7 @@ class AccountMappingStore
$skipped = 0;
$duplicates = [];
$resyncPlatforms = [];
foreach ($rows as $row) {
$values = $this->normalize($row);
if ($this->isEmptyRow($values)) {
@ -238,7 +269,16 @@ class AccountMappingStore
continue;
}
$index = $byKey[$key];
$rules[$index] = ['id' => $rules[$index]['id']] + $values;
$replacement = ['id' => $rules[$index]['id']] + $values;
if ($rules[$index] === $replacement) {
$skipped++;
continue;
}
if (self::requiresReceiptResync($rules[$index], $replacement)) {
$resyncPlatforms[$rules[$index]['platform']] = true;
$resyncPlatforms[$replacement['platform']] = true;
}
$rules[$index] = $replacement;
$updated++;
continue;
}
@ -247,6 +287,7 @@ class AccountMappingStore
$byKey[$key] = count($rules) - 1;
$nextId++;
$created++;
$resyncPlatforms[$values['platform']] = true;
}
if ($created + $updated > 0) {
@ -264,6 +305,7 @@ class AccountMappingStore
'skipped' => $skipped,
'duplicates' => array_slice($duplicates, 0, 20),
'total' => count($rows),
'resync_platforms' => array_keys($resyncPlatforms),
];
}
@ -492,6 +534,7 @@ class AccountMappingStore
/** @param array<int,array<string,mixed>> $rules */
private function persist(array $rules): void
{
self::forgetMemo();
if (! $this->rulesTableReady()) {
$this->persistFile($rules);
@ -609,6 +652,7 @@ class AccountMappingStore
'goods' => (string) $row->goods,
'invoice' => (string) $row->invoice,
'category' => (string) $row->category,
'feeMemo' => (string) ($row->fee_memo ?? ''),
'dAcct' => (string) $row->d_acct,
'dSub' => (string) $row->d_sub,
'cAcct' => (string) $row->c_acct,
@ -631,6 +675,7 @@ class AccountMappingStore
'goods' => $rule['goods'],
'invoice' => $rule['invoice'],
'category' => $rule['category'],
'fee_memo' => $rule['feeMemo'],
'd_acct' => $rule['dAcct'],
'd_sub' => $rule['dSub'],
'c_acct' => $rule['cAcct'],

View File

@ -26,6 +26,7 @@ class AccountMappingWorkbookImporter
'goods' => ['商品名称关键词', '商品名称', '商品描述', '货品名称'],
'invoice' => ['票据口径', '票据', '票据类型', '发票'],
'category' => ['财务类型', '大类', '业务性质', '科目大类', '分类'],
'feeMemo' => ['费用备注', '费用说明', '费用备注说明'],
'basis' => ['方向基准', '基准'],
'dAcct' => ['借方科目', '借/贷(借)', '借/贷(借)', '借方'],
'dSub' => ['借方二级'],

View File

@ -3,9 +3,11 @@
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use PDOException;
use RuntimeException;
use Throwable;
@ -64,9 +66,12 @@ class BusinessMirrorSynchronizer
$batches = 0;
$deadline = microtime(true) + max((int) config('upstream_pipeline.sync.dataset_seconds', 8), 1);
$rows = (int) ($state['last_upserted_rows'] ?? 0);
$started = microtime(true);
$stage = 'read_upstream';
try {
while (true) {
$stage = 'read_upstream';
$batch = $this->readBatch(
$dataset,
$columns,
@ -79,17 +84,24 @@ class BusinessMirrorSynchronizer
);
if ($batch === []) {
$stage = 'complete_mirror';
$this->mirror->complete($dataset, $mode, $generation, $cursor ?? $highWatermark, $rows);
return $this->result($dataset, 'succeeded', $mode, $batches, $rows);
}
$stage = 'normalize_rows';
$cursor = $this->cursor($dataset, $batch[array_key_last($batch)]);
$rows += count($batch);
$batchRows = count($batch);
$rows += $batchRows;
$batches++;
$this->mirror->storeBatch($dataset, $columns, $this->normalize($columns, $batch), $cursor, $generation, $rows);
$batch = $this->normalize($columns, $batch);
$stage = 'write_mirror';
$this->mirror->storeBatch($dataset, $columns, $batch, $cursor, $generation, $rows);
unset($batch);
if (count($batch) < $batchSize) {
if ($batchRows < $batchSize) {
$stage = 'complete_mirror';
$this->mirror->complete($dataset, $mode, $generation, $cursor, $rows);
return $this->result($dataset, 'succeeded', $mode, $batches, $rows);
@ -100,6 +112,28 @@ class BusinessMirrorSynchronizer
}
}
} catch (Throwable $exception) {
// PDOException alone cannot distinguish an upstream timeout from a
// local transaction failure. Keep the stage and numeric diagnostics,
// never the exception message, SQL, bindings or connection details.
$sqlState = null;
$driverCode = null;
for ($cause = $exception; $cause !== null; $cause = $cause->getPrevious()) {
if ($cause instanceof PDOException) {
$code = (string) ($cause->errorInfo[0] ?? $cause->getCode());
$sqlState = preg_match('/^[A-Z0-9]{5}$/D', $code) === 1 ? $code : null;
$native = $cause->errorInfo[1] ?? null;
$driverCode = is_numeric($native) ? (int) $native : null;
}
}
Log::warning('共享业务镜像同步阶段失败。', [
'dataset' => $dataset->key(),
'source' => $dataset->sourceKey(),
'stage' => $stage,
'sql_state' => $sqlState,
'driver_code' => $driverCode,
'elapsed_seconds' => round(microtime(true) - $started, 3),
'exception' => $exception::class,
]);
$this->mirror->fail($dataset, $exception::class);
throw $exception;
}
@ -157,33 +191,49 @@ class BusinessMirrorSynchronizer
implode(', ', array_map(fn (array $column): string => $this->selectExpression($column), $columns)), $tail, $limit,
);
if ($this->driver === 'sqlsrv' && $mode === 'incremental'
&& in_array($dataset->key(), (array) config('upstream_mirrors.table_sync.key_first_datasets', []), true)) {
$keys = (new UpstreamSqlDialect($this->driver))->select(
implode(', ', array_map(fn (string $name): string => $this->quote($name), $order)), $tail, $limit,
);
$keyColumn = fn (string $name): string => $this->quote('mirror_keys') . '.' . $this->quote($name);
$rowColumn = fn (string $name): string => $this->quote('mirror_rows') . '.' . $this->quote($name);
$sql = 'SELECT ' . implode(', ', array_map(fn (array $column): string => $this->selectExpression($column, 'mirror_rows'), $columns))
. ' FROM (' . $keys . ') AS ' . $this->quote('mirror_keys')
. ' INNER LOOP JOIN ' . $this->quote($dataset->sourceSchema()) . '.' . $this->quote($dataset->sourceObject())
. ' AS ' . $this->quote('mirror_rows')
. ' ON ' . implode(' AND ', array_map(fn (string $name): string => $rowColumn($name) . ' = ' . $keyColumn($name), $order))
. ' ORDER BY ' . implode(', ', array_map($keyColumn, $order));
}
return $this->queries->forSource($dataset->sourceKey())->select($sql, $bindings);
}
/** @param array<string,mixed> $column */
private function selectExpression(array $column): string
private function selectExpression(array $column, ?string $alias = null): string
{
$name = (string) $column['column_name'];
$quoted = $this->quote($name);
$reference = $alias === null ? $quoted : $this->quote($alias) . '.' . $quoted;
if ($this->driver !== 'sqlsrv') {
if ($this->driver === 'pgsql' && ($column['data_type'] ?? '') === 'timestamp with time zone') {
// Store timezone-aware PostgreSQL instants as UTC in the shared mirror.
return "to_char({$quoted} AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') AS {$quoted}";
return "to_char({$reference} AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') AS {$quoted}";
}
if ($this->driver === 'pgsql' && ($column['data_type'] ?? '') === 'bytea') {
return "encode({$quoted}, 'hex') AS {$quoted}";
return "encode({$reference}, 'hex') AS {$quoted}";
}
return $quoted;
return $reference;
}
// FreeTDS may return SQL Server date values using the connection locale
// (for example "Jan 1 2026"). Convert temporal columns at the source so
// every mirror receives deterministic ISO values that MariaDB accepts.
return match (strtolower((string) ($column['data_type'] ?? ''))) {
'date' => 'CONVERT(varchar(10), ' . $quoted . ', 23) AS ' . $quoted,
'datetime', 'datetime2', 'smalldatetime' => 'CONVERT(varchar(27), ' . $quoted . ', 126) AS ' . $quoted,
default => $quoted,
'date' => 'CONVERT(varchar(10), ' . $reference . ', 23) AS ' . $quoted,
'datetime', 'datetime2', 'smalldatetime' => 'CONVERT(varchar(27), ' . $reference . ', 126) AS ' . $quoted,
default => $reference,
};
}

View File

@ -0,0 +1,289 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use RuntimeException;
/**
* 导入财务台账的基础数据:账户、店铺、公司和期初余额。
*
* 源文件是财务维护的《银行当前余额-*.xlsx》。这里只做结构校验和全量
* 覆盖,不推断业务口径;导入完成后由 FinanceLedgerCalculator 重算流水。
*/
class FinanceLedgerBaseImporter
{
public function import(string $path, ?string $openingDate = null): array
{
if (! is_file($path)) {
throw new RuntimeException('基础数据文件不存在:' . $path);
}
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($path);
try {
$accounts = $this->accounts($spreadsheet->getSheetByName('账户总表'));
$shops = $this->shops($spreadsheet->getSheetByName('店铺台账'));
$companies = $this->companies($spreadsheet->getSheetByName('公司台账'));
$openingDate = $openingDate ?: (string) config('finance_ledger.opening_date', '2026-10-17');
$opening = $this->opening($spreadsheet->getSheetByName('每日资金流水'), $openingDate);
} finally {
$spreadsheet->disconnectWorksheets();
}
if ($accounts === []) {
throw new RuntimeException('账户总表没有可导入的数据。');
}
$accountKeys = array_fill_keys(array_map(
static fn (array $row): string => (string) $row['account_short'],
$accounts,
), true);
$missing = [];
foreach ($opening as $row) {
if (! isset($accountKeys[$row['account_short']])) {
$missing[] = $row['account_short'];
}
}
if ($missing !== []) {
throw new RuntimeException('期初账户在账户总表中不存在:' . implode('、', array_slice(array_unique($missing), 0, 10)));
}
$accountBalance = round(array_sum(array_column($accounts, 'current_balance')), 2);
$openingBalance = round(array_sum(array_column($opening, 'opening_balance')), 2);
if (abs($accountBalance - $openingBalance) > 0.01) {
throw new RuntimeException(sprintf(
'期初合计 %.2f 与账户余额合计 %.2f 不一致。',
$openingBalance,
$accountBalance,
));
}
$now = now();
$connection = $this->connection();
$connection->transaction(function () use ($connection, $accounts, $shops, $companies, $opening, $now): void {
$connection->table('fin_account_opening')->delete();
$connection->table('fin_shops')->delete();
$connection->table('fin_companies')->delete();
$connection->table('fin_accounts')->delete();
$this->insert($connection, 'fin_accounts', $accounts, $now);
$this->insert($connection, 'fin_shops', $shops, $now);
$this->insert($connection, 'fin_companies', $companies, $now);
$this->insert($connection, 'fin_account_opening', $opening, $now);
});
return [
'accounts' => count($accounts),
'shops' => count($shops),
'companies' => count($companies),
'opening_rows' => count($opening),
'opening_sum' => $openingBalance,
'account_balance_sum' => $accountBalance,
'opening_date' => $openingDate,
];
}
private function accounts(?Worksheet $sheet): array
{
if (! $sheet) {
throw new RuntimeException('缺少「账户总表」工作表。');
}
$rows = [];
foreach ($this->rows($sheet, 2, 23) as $row) {
$company = $this->text($row[0] ?? null);
$short = $this->text($row[1] ?? null);
if ($company === '' || $short === '') {
continue;
}
$rows[] = [
'company_name' => $company,
'account_short' => $short,
'region' => null,
'account_no' => $this->text($row[2] ?? null),
'bank_name' => $this->text($row[3] ?? null),
'bank_city_branch' => $this->text($row[4] ?? null),
'account_type' => $this->text($row[5] ?? null),
'company_plan' => $this->text($row[6] ?? null),
'account_last4' => $this->text($row[7] ?? null),
'single_limit' => $this->text($row[8] ?? null),
'daily_limit' => $this->text($row[9] ?? null),
'monthly_limit' => $this->text($row[10] ?? null),
'yearly_limit' => $this->text($row[11] ?? null),
'current_balance' => $this->number($row[12] ?? null),
'blocked_amount' => $this->number($row[13] ?? null),
'daily_available' => $this->number($row[14] ?? null),
'clearing_days' => $this->nullableNumber($row[15] ?? null),
'raise_target' => $this->text($row[16] ?? null),
'raise_status' => $this->text($row[17] ?? null),
'raise_actions' => $this->text($row[18] ?? null),
'done_actions' => $this->text($row[19] ?? null),
'next_action' => $this->text($row[20] ?? null),
'remark' => $this->text($row[21] ?? null),
'owner' => $this->text($row[22] ?? null),
];
}
return $rows;
}
private function shops(?Worksheet $sheet): array
{
if (! $sheet) {
throw new RuntimeException('缺少「店铺台账」工作表。');
}
$statusMap = ['正常' => '在营', '关闭中' => '暂停', '已关闭' => '已注销'];
$rows = [];
foreach ($this->rows($sheet, 6, 8) as $row) {
$name = $this->text($row[0] ?? null);
if ($name === '') {
continue;
}
$status = $this->text($row[4] ?? null);
$rows[] = [
'shop_name' => $name,
'platform' => $this->text($row[1] ?? null),
'company_name' => $this->text($row[2] ?? null),
'account_short' => $this->text($row[3] ?? null),
'status' => $statusMap[$status] ?? $status,
'payout_account' => $this->text($row[5] ?? null),
'shop_balance' => $this->number($row[6] ?? null),
'remark' => $this->text($row[7] ?? null),
];
}
return $rows;
}
private function companies(?Worksheet $sheet): array
{
if (! $sheet) {
throw new RuntimeException('缺少「公司台账」工作表。');
}
$rows = [];
foreach ($this->rows($sheet, 6, 10) as $row) {
$name = $this->text($row[0] ?? null);
if ($name === '') {
continue;
}
$rows[] = [
'name' => $name,
'register_place' => $this->text($row[1] ?? null),
'business_type' => $this->text($row[2] ?? null),
'tax_type' => $this->text($row[3] ?? null),
'status' => $this->text($row[4] ?? null),
'basic_bank' => $this->text($row[5] ?? null),
'basic_account_last4' => $this->text($row[6] ?? null),
'remark' => $this->text($row[9] ?? null),
];
}
return $rows;
}
private function opening(?Worksheet $sheet, string $openingDate): array
{
if (! $sheet) {
throw new RuntimeException('缺少「每日资金流水」工作表。');
}
$rows = [];
foreach ($this->rows($sheet, 6, 8) as $row) {
$short = $this->text($row[2] ?? null);
if ($short === '') {
continue;
}
$rows[] = [
'account_short' => $short,
'opening_date' => $openingDate,
'opening_balance' => $this->number($row[3] ?? null),
'remark' => $this->text($row[7] ?? null),
];
}
return $rows;
}
/** @return array<int,array<int,mixed>> */
private function rows(Worksheet $sheet, int $startRow, int $columnCount): array
{
$lastRow = $sheet->getHighestDataRow();
if ($lastRow < $startRow) {
return [];
}
return $sheet->rangeToArray(
'A' . $startRow . ':' . \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($columnCount) . $lastRow,
null,
false,
false,
false,
);
}
private function insert($connection, string $table, array $rows, $now): void
{
foreach ($rows as &$row) {
$row['created_at'] = $now;
$row['updated_at'] = $now;
}
unset($row);
foreach (array_chunk($rows, 500) as $chunk) {
$connection->table($table)->insert($chunk);
}
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
private function text(mixed $value): string
{
if ($value === null) {
return '';
}
$text = trim((string) $value);
if (in_array($text, ['#N/A', '#DIV/0!', '#VALUE!', '#REF!', 'None'], true)) {
return '';
}
return preg_replace('/\s+/u', ' ', $text) ?? $text;
}
private function number(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
if (is_int($value) || is_float($value)) {
return round((float) $value, 2);
}
$text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) $value));
if ($text === '' || $text === '-' || $text === '/') {
return 0.0;
}
return is_numeric($text) ? round((float) $text, 2) : 0.0;
}
private function nullableNumber(mixed $value): ?float
{
if ($value === null || trim((string) $value) === '' || trim((string) $value) === '-') {
return null;
}
return $this->number($value);
}
}

View File

@ -0,0 +1,182 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\DB;
/**
* 按账户滚动计算每日资金流水,并回写账户当前余额、每日可出金额和店铺余额。
*
* 计算口径固定在服务端,页面只读结果:
* 期末 = 期初 + 当日入账 - 当日出账
* 账户当前余额 = 最新一条流水期末
* 每日可出金额 = 当前余额 - 卡住金额
* 店铺余额 = 店铺所属公司名下账户当前余额合计(待财务确认)
*/
class FinanceLedgerCalculator
{
public function recalculate(): array
{
$connection = $this->connection();
$openingDate = (string) config('finance_ledger.opening_date', '2026-10-17');
$now = now();
$opening = $connection->table('fin_account_opening')
->where('opening_date', '<=', $openingDate)
->orderBy('opening_date')
->get()
->groupBy('account_short')
->map(static fn ($rows) => $rows->last());
$accountCompanies = $connection->table('fin_accounts')
->select(['account_short', 'company_name'])
->get()
->groupBy('account_short')
->map(static fn ($rows) => (string) ($rows->first()->company_name ?? ''));
$mapping = $connection->table('fin_account_map')
->whereNotNull('account_short')
->where('account_short', '<>', '')
->get(['source', 'source_value', 'account_short'])
->keyBy(static fn ($row): string => $row->source . "\0" . $row->source_value);
$transactions = $connection->table('fin_transactions')
->where('txn_date', '>=', $openingDate)
->orderBy('txn_date')
->orderBy('id')
->get(['txn_date', 'account_short', 'source', 'source_value', 'income', 'expense']);
$daily = [];
$balances = [];
foreach ($transactions as $transaction) {
$mapKey = $transaction->source . "\0" . $transaction->source_value;
$account = (string) ($mapping->get($mapKey)->account_short ?? '');
if ($account === '') {
$account = trim((string) $transaction->account_short);
}
if ($account === '' || ! $opening->has($account)) {
continue;
}
$date = (string) $transaction->txn_date;
$key = $account . "\0" . $date;
$daily[$key] ??= [
'account_short' => $account,
'flow_date' => $date,
'inflow' => 0.0,
'outflow' => 0.0,
];
$daily[$key]['inflow'] += (float) $transaction->income;
$daily[$key]['outflow'] += (float) $transaction->expense;
}
$flows = [];
$latest = [];
foreach ($opening as $account => $openingRow) {
$account = (string) $account;
$balance = (float) $openingRow->opening_balance;
$company = $accountCompanies->get($account, '');
$start = max((string) $openingRow->opening_date, $openingDate);
$rows = array_values(array_filter(
$daily,
static fn (array $row): bool => $row['account_short'] === $account,
));
usort($rows, static fn (array $a, array $b): int => strcmp($a['flow_date'], $b['flow_date']));
$openingFlow = [
'flow_date' => $start,
'company_name' => $company,
'account_short' => $account,
'opening_balance' => round($balance, 2),
'inflow' => 0.0,
'outflow' => 0.0,
'closing_balance' => round($balance, 2),
'remark' => '期初',
'created_at' => $now,
'updated_at' => $now,
];
$flows[] = $openingFlow;
$openingIndex = array_key_last($flows);
foreach ($rows as $row) {
if ($row['flow_date'] < $start) {
continue;
}
$inflow = round((float) $row['inflow'], 2);
$outflow = round((float) $row['outflow'], 2);
if (abs($inflow) < 0.005 && abs($outflow) < 0.005) {
continue;
}
if ($row['flow_date'] === $start) {
// 期初日发生动账时合并到同一行,避免唯一键冲突。
$balance = round($balance + $inflow - $outflow, 2);
$flows[$openingIndex]['inflow'] = $inflow;
$flows[$openingIndex]['outflow'] = $outflow;
$flows[$openingIndex]['closing_balance'] = $balance;
$flows[$openingIndex]['remark'] = null;
continue;
}
$balance = round($balance + $inflow - $outflow, 2);
$flows[] = [
'flow_date' => $row['flow_date'],
'company_name' => $company,
'account_short' => $account,
'opening_balance' => round($balance - $inflow + $outflow, 2),
'inflow' => $inflow,
'outflow' => $outflow,
'closing_balance' => $balance,
'remark' => null,
'created_at' => $now,
'updated_at' => $now,
];
}
$latest[$account] = $balance;
}
$connection->transaction(function () use ($connection, $flows, $latest, $now): void {
$connection->table('fin_daily_flows')->delete();
foreach (array_chunk($flows, 500) as $chunk) {
$connection->table('fin_daily_flows')->insert($chunk);
}
foreach ($latest as $account => $balance) {
$connection->table('fin_accounts')
->where('account_short', $account)
->update([
'current_balance' => round((float) $balance, 2),
'daily_available' => $connection->raw('ROUND(current_balance - blocked_amount, 2)'),
'updated_at' => $now,
]);
}
$companies = $connection->table('fin_accounts')
->selectRaw('company_name, SUM(current_balance) AS balance')
->groupBy('company_name')
->pluck('balance', 'company_name');
foreach ($companies as $company => $balance) {
$connection->table('fin_shops')
->where('company_name', (string) $company)
->update(['shop_balance' => round((float) $balance, 2), 'updated_at' => $now]);
}
});
return [
'accounts' => count($latest),
'flows' => count($flows),
'opening_date' => $openingDate,
'balance_sum' => round(array_sum($latest), 2),
];
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
}

View File

@ -0,0 +1,675 @@
<?php
namespace Modules\EcommerceFinance\Services;
use RuntimeException;
/**
* 从 AI 库 JSReportReplica.rpt 读取财务台账动账明细。
*
* 数据口径以《财务台账模块开发规格.md》第 3 章为准;所有 SQL 都是固定、
* 参数化的 SELECT,不读取 base,也不写上游。同步结果交给
* FinanceLedgerTransactionWriter 全量替换并重算流水。
*/
class FinanceLedgerRptSynchronizer
{
private const SOURCE = 'operations_replica';
private const DING_SINCE = '2025-10-18';
private const JST_WORK_ORDER_TYPE = '9E5A531F48D94AF4B8611FA6918ADA29';
private const FIXED_ASSET_PROCESS = 'PROC-6407E98B-040A-4C96-AA7D-696650FC1A25';
private const DETAIL_FIELDS = [
'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' => '固定资产报废或出售出售明细',
];
private const CHANNEL_NAMES = [
'alipayCSV' => '支付宝',
'JDWallet' => '京东钱包',
'PinduoduoImport' => '拼多多',
'TouTiaoFXGZJ' => '抖音',
'WeChatPay' => '微信',
'CommonBillTGCPay' => '腾讯广告',
'PddMarketingImport' => '拼多多营销',
'XiaohsSeller' => '小红书',
];
public function __construct(
private readonly UpstreamReadOnlyQueryFactory $queries,
private readonly FinanceLedgerTransactionWriter $writer,
) {
}
public function sync(bool $recalculate = true, ?string $from = null, ?string $to = null): array
{
$from = trim((string) ($from ?? config('finance_ledger.window_start', '2026-04-18')));
$to = trim((string) ($to ?? config('finance_ledger.window_end', ''))) ?: now()->toDateString();
$transactions = array_merge(
$this->dingTalkRows(),
$this->workOrderRows(),
$this->payableRows($from),
$this->storeOpeningRows($from),
$this->withdrawalRows($from),
);
$transactions = array_values(array_filter(
$transactions,
static fn (array $row): bool => $row['txn_date'] >= $from && $row['txn_date'] <= $to,
));
if ($transactions === []) {
throw new RuntimeException('rpt 没有返回窗口内的动账明细。');
}
$result = $this->writer->replace($transactions, $recalculate);
$result['source_counts'] = array_count_values(array_column($transactions, 'process_type'));
return $result;
}
/** @return array<int,array<string,mixed>> */
private function dingTalkRows(): array
{
$processCodes = array_keys((array) config('finance_ledger.dingtalk_process_codes', []));
if ($processCodes === []) {
throw new RuntimeException('未配置钉钉审批流程编码。');
}
$placeholders = implode(',', array_fill(0, count($processCodes), '?'));
$query = $this->queries->forSource(self::SOURCE);
$workflows = $query->select(
'SELECT processInstanceId, processCode, businessId, title, status, originatorUserId, '
. 'originatorDeptName, joinAt, finishTime FROM rpt.ding_Workflow '
. 'WHERE joinAt >= ? AND processCode IN (' . $placeholders . ')',
[self::DING_SINCE, ...$processCodes],
);
if ($workflows === []) {
return [];
}
$schemes = $query->select(
'SELECT s.id, s.processInstanceId, s.name, CAST(s.value AS VARCHAR(MAX)) AS value '
. 'FROM rpt.ding_WorkflowScheme s JOIN rpt.ding_Workflow w ON w.processInstanceId = s.processInstanceId '
. 'WHERE w.joinAt >= ? AND w.processCode IN (' . $placeholders . ') '
. "AND s.name IN ('付款明细','收款费用明细','应付明细','固定资产报废或出售明细','收款合计','付款金额合计') "
. 'ORDER BY s.id',
[self::DING_SINCE, ...$processCodes],
);
$employees = [];
foreach ($query->select('SELECT userid, name FROM rpt.hr_employee') as $employee) {
$employees[(string) $employee['userid']] = trim((string) ($employee['name'] ?? ''));
}
$byInstance = [];
foreach ($schemes as $scheme) {
$pid = (string) $scheme['processInstanceId'];
$name = (string) $scheme['name'];
$byInstance[$pid][$name] = (string) $scheme['value'];
}
$rows = [];
foreach ($workflows as $workflow) {
$pid = (string) $workflow['processInstanceId'];
$code = (string) $workflow['processCode'];
$processName = (string) (config('finance_ledger.dingtalk_process_codes.' . $code) ?: '钉钉审批');
if ((string) $workflow['status'] !== 'COMPLETED') {
continue;
}
$applicant = $employees[(string) ($workflow['originatorUserId'] ?? '')] ?? '';
if ($applicant === '') {
if (preg_match('/^(?:\d{2}-\d{2}\+?)?(.+?)提交的/u', (string) ($workflow['title'] ?? ''), $matches) === 1) {
$applicant = trim($matches[1]);
}
}
$dept = trim((string) ($workflow['originatorDeptName'] ?? ''));
$fields = $byInstance[$pid] ?? [];
$approval = trim((string) ($workflow['businessId'] ?? ''));
if ($code === self::FIXED_ASSET_PROCESS) {
foreach ($this->detailRows($fields['固定资产报废或出售明细'] ?? '', $code) as $detail) {
$text = $this->joinParts([
$applicant,
$dept,
$detail['use'],
$detail['asset_name'] !== '' ? '资产:' . $detail['asset_name'] : '',
]);
$rows[] = $this->row([
'date' => $detail['pay_time'],
'account_short' => '',
'source_value' => '银行存款_个人账户_招商银行-朱俊祥',
'source' => 'dingtalk',
'process_type' => $processName,
'approval_no' => $approval,
'detail' => $text,
'income' => $detail['sale_price'],
'expense' => 0,
'pid' => $pid,
'seq' => $detail['seq'],
'acct_code' => '1002.02.046',
'acct_name' => '银行存款_个人账户_招商银行-朱俊祥',
]);
}
continue;
}
$payments = $this->paymentRows($fields['付款明细'] ?? '');
$details = $this->detailRows($fields[self::DETAIL_FIELDS[$code] ?? ''] ?? '', $code);
$detailMap = [];
foreach ($details as $detail) {
foreach ($this->seqTokens($detail['seq']) ?: [''] as $token) {
$detailMap[$token][] = $detail;
}
}
if (count($details) === 1 && $detailMap === []) {
$detailMap['_all'] = $details;
}
foreach ($payments as $payment) {
if ($payment['amount'] == 0.0 && $payment['code'] === '') {
continue;
}
$tokens = $this->seqTokens($payment['seq']) ?: ['_all'];
$matched = [];
foreach ($tokens as $token) {
$matched = array_merge($matched, $detailMap[$token] ?? []);
}
if ($matched === [] && isset($detailMap['_all'])) {
$matched = $detailMap['_all'];
}
if ($matched === [] && count($details) === 1) {
$matched = $details;
}
$uses = $payees = $suppliers = $shops = $items = [];
foreach ($matched as $detail) {
if ($detail['use'] !== '') {
$uses[] = $detail['use'];
}
if ($detail['payee'] !== '') {
$payees[] = $detail['payee'];
}
if ($detail['supplier'] !== '') {
$suppliers[] = $detail['supplier'];
}
if ($detail['shop'] !== '') {
$shops[] = $detail['shop'];
}
if ($detail['item'] !== '') {
$items[] = $detail['item'];
}
}
if ($code === 'PROC-39DD70DE-DAB2-4A5F-B8F1-0B0B68BD0C88') {
$express = $projects = [];
foreach ($matched as $detail) {
if ($detail['express'] !== '') {
$express[] = $detail['express'];
}
if ($detail['project'] !== '') {
$projects[] = $detail['project'];
}
}
$payeeText = $this->unique($payees) ?: ($this->unique($projects) ?: $this->unique($express));
$supplierText = $this->unique($suppliers) ?: ($this->unique($express) ?: $this->unique($projects));
$text = $this->joinParts([$applicant, $dept, $payeeText, $supplierText]);
} else {
$extra = '';
if ($uses === [] && ($shops !== [] || $items !== [])) {
$extra = $this->unique($shops) . ($items !== [] ? '/' . $this->unique($items) : '');
$diff = array_sum(array_map(static fn (array $detail): float => (float) $detail['diff'], $matched));
if ($diff != 0.0) {
$extra .= ';差价' . number_format($diff, 2, '.', '');
}
}
$text = $this->joinParts([$applicant, $dept, $this->unique($uses) ?: $extra]);
}
$rows[] = $this->row([
'date' => $payment['date'],
'account_short' => '',
'source_value' => $payment['name'],
'source' => 'dingtalk',
'process_type' => $processName,
'approval_no' => $approval,
'detail' => $text,
'income' => 0,
'expense' => $payment['amount'],
'pid' => $pid,
'seq' => $payment['seq'],
'acct_code' => $payment['code'],
'acct_name' => $payment['name'],
]);
}
}
return $rows;
}
/** @return array<int,array<string,mixed>> */
private function workOrderRows(): array
{
$rows = $this->queries->forSource(self::SOURCE)->select(
'SELECT Id, woNo, createby, created, completeTime, businessFieldList FROM rpt.jst_workOrderInfo '
. 'WHERE woTypeId = ? AND status = ?',
[self::JST_WORK_ORDER_TYPE, 'CONFIRMED'],
);
$result = [];
foreach ($rows as $row) {
$fields = json_decode((string) ($row['businessFieldList'] ?? ''), true);
if (! is_array($fields)) {
continue;
}
$map = [];
foreach ($fields as $field) {
$id = (string) ($field['fieldId'] ?? '');
$map[substr($id, -6)] = $field['value'] ?? null;
}
$amount = $this->toNumber($map['091551'] ?? null);
$kind = trim((string) ($map['101125'] ?? ''));
$state = trim((string) ($map['101747'] ?? ''));
if ($amount <= 0 || $kind !== '入账' || ! in_array($state, ['已到账', '已收到'], true)) {
continue;
}
$result[] = $this->row([
'date' => $this->date($map['101432'] ?? null),
'account_short' => trim((string) ($map['101730'] ?? '')),
'source_value' => trim((string) ($map['101730'] ?? '')),
'source' => 'workorder',
'process_type' => '聚工单查账流程',
'approval_no' => trim((string) ($row['woNo'] ?? '')),
'detail' => $this->joinParts([trim((string) ($row['createby'] ?? '')), '查账工单', $state]),
'income' => $amount,
'expense' => 0,
'pid' => 'JST-' . (string) ($row['Id'] ?? ''),
]);
}
return $result;
}
/** @return array<int,array<string,mixed>> */
private function payableRows(string $from): array
{
$sql = 'WITH paid AS ('
. 'SELECT r.DetailId, SUM(r.amountLower) AS paid_amount FROM rpt.biz_bank_receipt_detail r '
. "WHERE r.DetailId IS NOT NULL AND r.BindStatus = N'已绑定' AND r.MatchStatus = N'匹配成功' AND r.amountLower > 0 "
. 'GROUP BY r.DetailId'
. '), detail AS ('
. 'SELECT d.id, d.bill_period, d.business_no, d.third_short_name, d.our_subsidiary_name, '
. 'd.third_subsidiary_name, d.deduct_amount, ISNULL(p.paid_amount,0) AS paid_amount, '
. "CASE WHEN ISNULL(p.paid_amount,0) <= 0 THEN N'等待打款' "
. "WHEN ISNULL(p.paid_amount,0) >= d.deduct_amount THEN N'打款完成' ELSE N'打款中' END AS receipt_pay_status "
. 'FROM rpt.biz_quota_deduct_detail d LEFT JOIN paid p ON p.DetailId = d.id WHERE d.is_del = 0'
. ') SELECT d.id, d.bill_period, d.business_no, d.third_short_name, d.our_subsidiary_name, '
. 'd.third_subsidiary_name, d.deduct_amount, d.paid_amount, d.receipt_pay_status, '
. 'r.Id, r.payerAccountName, r.UploadTime, r.amountLower, r.transactionDate, r.bookingDate '
. 'FROM detail d JOIN rpt.biz_bank_receipt_detail r ON r.DetailId = d.id AND r.TaskId = d.business_no '
. "WHERE d.receipt_pay_status IN (N'打款中',N'打款完成')";
$rows = $this->queries->forSource(self::SOURCE)->select($sql);
$result = [];
foreach ($rows as $row) {
$amount = $this->toNumber($row['amountLower'] ?? null);
if ($amount <= 0) {
continue;
}
$date = $this->date($row['transactionDate'] ?? null)
?: $this->date($row['bookingDate'] ?? null)
?: $this->date($row['UploadTime'] ?? null);
$result[] = $this->row([
'date' => $date,
'account_short' => trim((string) ($row['payerAccountName'] ?? '')),
'source_value' => trim((string) ($row['payerAccountName'] ?? '')),
'source' => 'payable',
'process_type' => '供应商应付',
'approval_no' => trim((string) ($row['business_no'] ?? '')),
'detail' => $this->joinParts([
trim((string) ($row['third_short_name'] ?? '')),
trim((string) ($row['our_subsidiary_name'] ?? '')),
trim((string) ($row['third_subsidiary_name'] ?? '')),
trim((string) ($row['bill_period'] ?? '')),
]),
'income' => 0,
'expense' => $amount,
'pid' => 'PAY-' . (string) ($row['id'] ?? '') . '-' . (string) ($row['Id'] ?? ''),
]);
}
return $result;
}
/** @return array<int,array<string,mixed>> */
private function storeOpeningRows(string $from): array
{
$sql = '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.paymentTime >= ? AND a.isPaymentMade = 1 AND a.money > 0';
$rows = $this->queries->forSource(self::SOURCE)->select($sql, [$from]);
$seen = [];
$result = [];
foreach ($rows as $row) {
$key = implode('|', [
(string) ($row['flowId'] ?? ''),
(string) ($row['costType'] ?? ''),
(string) ($row['money'] ?? ''),
(string) ($row['paymentTime'] ?? ''),
trim((string) ($row['paymentAccount'] ?? '')),
]);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$amount = $this->toNumber($row['money'] ?? null);
$note = '';
if ($amount > 10_000_000) {
$note = '原值异常已按申请单修正为 200.00';
$amount = 200.0;
}
$account = trim((string) ($row['paymentAccount'] ?? ''));
if (in_array($account, ['无', '-'], true)) {
$account = '';
}
$result[] = $this->row([
'date' => $this->date($row['paymentTime'] ?? null),
'account_short' => $account,
'source_value' => $account,
'source' => 'store',
'process_type' => '店铺开店费用',
'approval_no' => trim((string) ($row['flowId'] ?? '')),
'detail' => $this->joinParts([
trim((string) ($row['applyUser'] ?? '')),
trim((string) ($row['applyDepartment'] ?? '')),
trim((string) ($row['erpShopName'] ?? '')),
trim((string) ($row['platform'] ?? '')),
trim((string) ($row['costType'] ?? '')),
]),
'income' => 0,
'expense' => $amount,
'pid' => 'SHOP-' . (string) ($row['flowId'] ?? '') . '-' . (string) ($row['costType'] ?? '') . '-' . (string) ($row['money'] ?? ''),
'note' => $note,
]);
}
return $result;
}
/** @return array<int,array<string,mixed>> */
private function withdrawalRows(string $from): array
{
$sql = 'WITH d AS ('
. 'SELECT a.Id, a.shop_id, a.channel, a.fee_time, a.fee_name, a.inout_type, a.amount, a.primary_key, '
. 'ROW_NUMBER() OVER (PARTITION BY a.primary_key ORDER BY a.Id) AS rn '
. "FROM rpt.jst_originalBillInfo a WHERE a.fee_name LIKE N'%提现%' AND a.fee_time >= ?"
. ') SELECT d.Id, d.channel, d.fee_time, d.fee_name, d.amount, b.shop_name, d.inout_type '
. 'FROM d LEFT JOIN rpt.jst_shopInfo b ON d.shop_id = b.shop_id WHERE d.rn = 1';
$rows = $this->queries->forSource(self::SOURCE)->select($sql, [$from]);
$result = [];
foreach ($rows as $row) {
if ((string) ($row['inout_type'] ?? '') !== '-1') {
continue;
}
$shop = trim((string) ($row['shop_name'] ?? ''));
$channel = self::CHANNEL_NAMES[(string) ($row['channel'] ?? '')] ?? (string) ($row['channel'] ?? '');
$result[] = $this->row([
'date' => $this->date($row['fee_time'] ?? null),
'account_short' => $shop,
'source_value' => $shop,
'source' => 'shop',
'process_type' => '店铺提现',
'approval_no' => (string) ($row['Id'] ?? ''),
'detail' => $this->joinParts([$shop, $channel, trim((string) ($row['fee_name'] ?? ''))]),
'income' => $this->toNumber($row['amount'] ?? null),
'expense' => 0,
'pid' => 'BILL-' . (string) ($row['Id'] ?? ''),
]);
}
return $result;
}
private function row(array $data): array
{
return [
'txn_date' => $data['date'] ?? '',
'account_short' => (string) ($data['account_short'] ?? ''),
'source' => (string) ($data['source'] ?? ''),
'source_value' => (string) ($data['source_value'] ?? ''),
'process_type' => (string) ($data['process_type'] ?? ''),
'approval_no' => (string) ($data['approval_no'] ?? ''),
'detail' => (string) ($data['detail'] ?? ''),
'income' => round((float) ($data['income'] ?? 0), 2),
'expense' => round((float) ($data['expense'] ?? 0), 2),
'pid' => (string) ($data['pid'] ?? ''),
'seq' => (string) ($data['seq'] ?? ''),
'acct_code' => (string) ($data['acct_code'] ?? ''),
'acct_name' => (string) ($data['acct_name'] ?? ''),
];
}
/** @return array<int,array<string,mixed>> */
private function paymentRows(?string $value): array
{
$rows = $this->parseRows($value);
if ($rows !== null) {
$result = [];
foreach ($rows as $row) {
$result[] = [
'seq' => trim((string) ($row['对应序号'] ?? '')),
'amount' => $this->toNumber($row['付款金额'] ?? $row['付款总金额'] ?? null),
'code' => trim((string) ($row['科目编码'] ?? '')),
'name' => trim((string) ($row['科目名称'] ?? '')),
'date' => $this->date($row['打款时间'] ?? null),
];
}
return $result;
}
$result = [];
foreach ($this->corruptRows((string) $value) as $row) {
$values = $row['values'];
if ($values === []) {
continue;
}
$code = '';
$date = '';
foreach ($values as $candidate) {
if ($code === '' && preg_match('/^(1001|1002|1012)(\.\d{2,3})+$/', $candidate) === 1) {
$code = $candidate;
}
if ($date === '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $candidate) === 1) {
$date = substr($candidate, 0, 10);
}
}
$result[] = [
'seq' => trim((string) ($values[0] ?? '')),
'amount' => $this->toNumber($values[2] ?? null),
'code' => $code,
'name' => trim((string) ($values[4] ?? '')),
'date' => $date,
];
}
return $result;
}
/** @return array<int,array<string,mixed>> */
private function detailRows(?string $value, string $processCode): array
{
$rows = $this->parseRows($value);
if ($rows !== null) {
$result = [];
foreach ($rows as $row) {
$result[] = [
'seq' => trim((string) ($row['序号'] ?? '')),
'use' => trim((string) ($row['用途'] ?? '')),
'payee' => trim((string) ($row['收款人全称'] ?? '')),
'supplier' => trim((string) ($row['供应商'] ?? '')),
'shop' => trim((string) ($row['店铺名称'] ?? '')),
'item' => trim((string) ($row['商品ID'] ?? '')),
'diff' => $this->toNumber($row['差价'] ?? null),
'asset_name' => trim((string) ($row['固定资产名称'] ?? '')),
'express' => trim((string) ($row['快递'] ?? '')),
'project' => trim((string) ($row['核算项目'] ?? '')),
'sale_price' => $this->toNumber($row['出售价格'] ?? null),
'pay_time' => $this->date($row['付款时间'] ?? null),
];
}
return $result;
}
$result = [];
foreach ($this->corruptRows((string) $value) as $row) {
$fields = $row['fields'];
$result[] = [
'seq' => trim((string) ($fields['序号'] ?? ($row['values'][0] ?? ''))),
'use' => trim((string) ($fields['用途'] ?? '')),
'payee' => trim((string) ($fields['收款人全称'] ?? '')),
'supplier' => trim((string) ($fields['供应商'] ?? '')),
'shop' => trim((string) ($fields['店铺名称'] ?? '')),
'item' => trim((string) ($fields['商品ID'] ?? '')),
'diff' => $this->toNumber($fields['差价'] ?? null),
'asset_name' => trim((string) ($fields['固定资产名称'] ?? '')),
'express' => trim((string) ($fields['快递'] ?? '')),
'project' => trim((string) ($fields['核算项目'] ?? '')),
'sale_price' => $this->toNumber($fields['出售价格'] ?? null),
'pay_time' => $this->date($fields['付款时间'] ?? null),
];
}
return $result;
}
private function parseRows(?string $value): ?array
{
$text = trim((string) $value);
if ($text === '' || $text === '[]') {
return [];
}
$data = json_decode($text, true);
if (! is_array($data)) {
return null;
}
$rows = [];
foreach ($data as $object) {
$row = [];
foreach ((array) ($object['rowValue'] ?? []) as $cell) {
$label = trim((string) ($cell['label'] ?? ''));
$val = $cell['value'] ?? null;
if (($val === null || $val === '') && is_array($cell['extendValue'] ?? null)) {
$val = $cell['extendValue']['label'] ?? null;
}
$row[$label] = $val;
}
$rows[] = $row;
}
return $rows;
}
/** @return array<int,array{fields:array<string,string>,values:array<int,string>}> */
private function corruptRows(string $value): array
{
if (trim($value) === '' || trim($value) === '[]') {
return [];
}
$chunks = explode('{"rowValue":', $value);
array_shift($chunks);
$rows = [];
foreach ($chunks as $chunk) {
$fields = [];
if (preg_match_all('/"label":"([^"]{1,40})"/u', $chunk, $labels, PREG_OFFSET_CAPTURE) !== false) {
foreach ($labels[1] as $label) {
$tail = substr($chunk, $label[1] + strlen($label[0]) + 2, 400);
if (preg_match('/"value":"([^",}]*)/u', $tail, $match) === 1) {
$fields[$label[0]] = $match[1];
}
}
}
preg_match_all('/"value":"([^",}]*)/u', $chunk, $values);
$flat = $values[1] ?? [];
if ($flat !== []) {
$fields['序号'] ??= $flat[0];
}
$rows[] = ['fields' => $fields, 'values' => $flat];
}
return $rows;
}
private function date(mixed $value): string
{
$text = trim((string) ($value ?? ''));
if (preg_match('/^(\d{4})[-\/年](\d{1,2})[-\/月](\d{1,2})/u', $text, $matches) !== 1) {
return '';
}
return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]);
}
private function toNumber(mixed $value): float
{
if (is_int($value) || is_float($value)) {
return round((float) $value, 2);
}
$text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) ($value ?? '')));
if ($text === '' || $text === '-' || $text === '/') {
return 0.0;
}
return is_numeric($text) ? round((float) $text, 2) : 0.0;
}
/** @return array<int,string> */
private function seqTokens(string $seq): array
{
return array_values(array_filter(preg_split('/[-,,、\s]+/u', $seq) ?: []));
}
private function unique(array $values): string
{
$unique = [];
foreach ($values as $value) {
$value = trim((string) $value);
if ($value !== '' && ! in_array($value, $unique, true)) {
$unique[] = $value;
}
}
return implode('、', $unique);
}
private function joinParts(array $parts): string
{
$cleaned = [];
foreach ($parts as $part) {
$part = trim((string) $part);
if ($part !== '' && ($cleaned === [] || end($cleaned) !== $part)) {
$cleaned[] = $part;
}
}
return implode(';', $cleaned);
}
}

View File

@ -0,0 +1,188 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* 将已核对的财务台账快照(ledger_raw.json)导入 fin_transactions。
*
* 该入口用于本地开发和生产首次建账;日常刷新由 rpt 同步命令负责。
* 快照本身不进入 Git,导入前必须确认文件摘要和行数。
*/
class FinanceLedgerSnapshotImporter
{
private const SOURCES = [
'日常费用报销流程' => 'dingtalk',
'种菜报销流程' => 'dingtalk',
'应付申请流程' => 'dingtalk',
'快递面单应付流程' => 'dingtalk',
'线上代发业务报销' => 'dingtalk',
'固定资产报废或出售流程' => 'dingtalk',
'聚工单查账流程' => 'workorder',
'供应商应付' => 'payable',
'店铺开店费用' => 'store',
'店铺提现' => 'shop',
];
public function import(string $path, bool $recalculate = true): array
{
if (! is_file($path)) {
throw new RuntimeException('台账快照文件不存在:' . $path);
}
$payload = json_decode((string) file_get_contents($path), true);
if (! is_array($payload) || $payload === []) {
throw new RuntimeException('台账快照不是有效的 JSON 数组。');
}
$connection = $this->connection();
$now = now();
$rows = [];
$occurrences = [];
foreach ($payload as $index => $item) {
if (! is_array($item)) {
continue;
}
$process = trim((string) ($item['process'] ?? ''));
$source = self::SOURCES[$process] ?? null;
if ($source === null) {
throw new RuntimeException('未知的台账流程:' . $process);
}
$date = $this->date($item['date'] ?? null);
if ($date === null) {
continue;
}
$approvalNo = $this->text($item['process_no'] ?? '');
$rawAccount = $this->text($item['account_short'] ?? '');
$accountName = $this->text($item['acct_name'] ?? '');
$sourceValue = $source === 'dingtalk' ? $accountName : $rawAccount;
$income = round($this->number($item['income'] ?? 0), 2);
$expense = round($this->number($item['expense'] ?? 0), 2);
$detail = $this->text($item['detail'] ?? '', 4000);
$canonical = implode("\0", [
$source,
$this->text($item['pid'] ?? ''),
$this->text($item['seq'] ?? ''),
$approvalNo,
$this->text($item['acct_code'] ?? ''),
$accountName,
number_format($income, 2, '.', ''),
number_format($expense, 2, '.', ''),
$detail,
]);
$occurrences[$canonical] = ($occurrences[$canonical] ?? 0) + 1;
$rows[] = [
'txn_date' => $date,
'account_short' => $rawAccount,
'detail' => $detail,
'income' => $income,
'expense' => $expense,
'approval_no' => $approvalNo,
'source' => $source,
'source_value' => $sourceValue,
'process_type' => $process,
'row_key' => substr(hash('sha256', $canonical), 0, 56) . ':' . $occurrences[$canonical],
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
if ($rows === []) {
throw new RuntimeException('台账快照没有可导入的动账明细。');
}
$connection->transaction(function () use ($connection, $rows, $now): void {
$connection->table('fin_transactions')->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$connection->table('fin_transactions')->insert($chunk);
}
$this->refreshMappingStats($connection, $now);
});
$result = [
'rows' => count($rows),
'income' => round(array_sum(array_column($rows, 'income')), 2),
'expense' => round(array_sum(array_column($rows, 'expense')), 2),
'from' => min(array_column($rows, 'txn_date')),
'to' => max(array_column($rows, 'txn_date')),
];
if ($recalculate) {
$result['recalculated'] = app(FinanceLedgerCalculator::class)->recalculate();
}
return $result;
}
private function refreshMappingStats($connection, $now): void
{
$stats = $connection->table('fin_transactions')
->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum')
->whereNotNull('source_value')
->where('source_value', '<>', '')
->groupBy('source', 'source_value')
->get();
$rows = [];
foreach ($stats as $stat) {
$rows[] = [
'source' => (string) $stat->source,
'source_value' => mb_substr((string) $stat->source_value, 0, 200),
'txn_count' => (int) $stat->txn_count,
'income_sum' => round((float) $stat->income_sum, 2),
'expense_sum' => round((float) $stat->expense_sum, 2),
'created_at' => $now,
'updated_at' => $now,
];
}
foreach (array_chunk($rows, 500) as $chunk) {
$connection->table('fin_account_map')->upsert(
$chunk,
['source', 'source_value'],
['txn_count', 'income_sum', 'expense_sum', 'updated_at'],
);
}
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
private function text(mixed $value, int $max = 200): string
{
$text = trim((string) ($value ?? ''));
$text = preg_replace('/\s+/u', ' ', $text) ?? $text;
return mb_substr($text, 0, $max);
}
private function number(mixed $value): float
{
if (is_int($value) || is_float($value)) {
return (float) $value;
}
$text = str_replace([',', ',', '¥', '¥', ' '], '', trim((string) ($value ?? '')));
return is_numeric($text) ? (float) $text : 0.0;
}
private function date(mixed $value): ?string
{
$text = trim((string) ($value ?? ''));
if (preg_match('/^(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})/', $text, $matches) !== 1) {
return null;
}
return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]);
}
}

View File

@ -0,0 +1,168 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* 动账明细写入器:全量替换 fin_transactions,刷新映射统计并重算流水。
*
* 同一笔业务在相同输入下生成稳定的 row_key;完全重复的行保留出现序号,
* 以保持规格书 12,037 笔的口径。
*/
class FinanceLedgerTransactionWriter
{
public function replace(array $transactions, bool $recalculate = true): array
{
if ($transactions === []) {
throw new RuntimeException('没有可写入的动账明细。');
}
$connection = $this->connection();
$now = now();
$rows = [];
$occurrences = [];
foreach ($transactions as $item) {
$date = $this->date($item['txn_date'] ?? null);
if ($date === null) {
continue;
}
$source = (string) ($item['source'] ?? '');
if ($source === '') {
throw new RuntimeException('动账明细缺少 source。');
}
$income = round((float) ($item['income'] ?? 0), 2);
$expense = round((float) ($item['expense'] ?? 0), 2);
$detail = mb_substr($this->normalizeText($item['detail'] ?? ''), 0, 4000);
$approvalNo = mb_substr(trim((string) ($item['approval_no'] ?? '')), 0, 100);
$sourceValue = mb_substr($this->normalizeText($item['source_value'] ?? ''), 0, 200);
$rawAccount = mb_substr($this->normalizeText($item['account_short'] ?? ''), 0, 100);
$processType = mb_substr(trim((string) ($item['process_type'] ?? '')), 0, 50);
$pid = trim((string) ($item['pid'] ?? ''));
$seq = trim((string) ($item['seq'] ?? ''));
$acctCode = trim((string) ($item['acct_code'] ?? ''));
$acctName = trim((string) ($item['acct_name'] ?? ''));
$canonical = implode("\0", [
$source, $processType, $pid, $seq, $approvalNo, $acctCode, $acctName,
number_format($income, 2, '.', ''), number_format($expense, 2, '.', ''), $detail,
]);
$occurrences[$canonical] = ($occurrences[$canonical] ?? 0) + 1;
$rows[] = [
'txn_date' => $date,
'account_short' => $rawAccount,
'detail' => $detail,
'income' => $income,
'expense' => $expense,
'approval_no' => $approvalNo,
'source' => $source,
'source_value' => $sourceValue,
'process_type' => $processType,
'row_key' => substr(hash('sha256', $canonical), 0, 56) . ':' . $occurrences[$canonical],
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
if ($rows === []) {
throw new RuntimeException('动账明细没有有效日期。');
}
$connection->transaction(function () use ($connection, $rows, $now): void {
$connection->table('fin_transactions')->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$connection->table('fin_transactions')->insert($chunk);
}
$this->refreshMappingStats($connection, $now);
});
$result = [
'rows' => count($rows),
'income' => round(array_sum(array_column($rows, 'income')), 2),
'expense' => round(array_sum(array_column($rows, 'expense')), 2),
'from' => min(array_column($rows, 'txn_date')),
'to' => max(array_column($rows, 'txn_date')),
];
if ($recalculate) {
$result['recalculated'] = app(FinanceLedgerCalculator::class)->recalculate();
}
return $result;
}
private function refreshMappingStats($connection, $now): void
{
$stats = $connection->table('fin_transactions')
->selectRaw('source, source_value, COUNT(*) AS txn_count, SUM(income) AS income_sum, SUM(expense) AS expense_sum')
->whereNotNull('source_value')
->where('source_value', '<>', '')
->groupBy('source', 'source_value')
->get();
$rows = [];
$keys = [];
foreach ($stats as $stat) {
$source = (string) $stat->source;
$value = mb_substr((string) $stat->source_value, 0, 200);
$keys[$source . "\0" . $value] = true;
$rows[] = [
'source' => $source,
'source_value' => $value,
'txn_count' => (int) $stat->txn_count,
'income_sum' => round((float) $stat->income_sum, 2),
'expense_sum' => round((float) $stat->expense_sum, 2),
'created_at' => $now,
'updated_at' => $now,
];
}
// 上游删除或改写的来源值不能继续留在映射表里;只清理本次未出现的行,
// 保留已有 account_short,避免重同步覆盖人工映射。
$stale = [];
foreach ($connection->table('fin_account_map')->get(['id', 'source', 'source_value']) as $existing) {
if (! isset($keys[(string) $existing->source . "\0" . (string) $existing->source_value])) {
$stale[] = (int) $existing->id;
}
}
foreach (array_chunk($stale, 500) as $ids) {
$connection->table('fin_account_map')->whereIn('id', $ids)->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
$connection->table('fin_account_map')->upsert(
$chunk,
['source', 'source_value'],
['txn_count', 'income_sum', 'expense_sum', 'updated_at'],
);
}
}
private function normalizeText(mixed $value): string
{
$text = trim((string) ($value ?? ''));
return preg_replace('/\s+/u', ' ', $text) ?? $text;
}
private function connection()
{
return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
private function date(mixed $value): ?string
{
$text = trim((string) ($value ?? ''));
if (preg_match('/^(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})/', $text, $matches) !== 1) {
return null;
}
return sprintf('%04d-%02d-%02d', $matches[1], $matches[2], $matches[3]);
}
}

View File

@ -73,6 +73,8 @@ class ShippingReplicaSynchronizer
private int $packagesRetainedForUnmatchedAmount = 0;
private int $staleMirrorPackages = 0;
private int $supersededRemoved = 0;
private int $outboundScanned = 0;
private int $purchaseScanned = 0;
/** @var array<int,true> */
private array $seenSourceIds = [];
@ -183,6 +185,8 @@ class ShippingReplicaSynchronizer
$this->packagesRetainedForUnmatchedAmount = 0;
$this->staleMirrorPackages = 0;
$this->supersededRemoved = 0;
$this->outboundScanned = 0;
$this->purchaseScanned = 0;
$this->seenSourceIds = [];
$this->expressOrderIds = [];
$this->dwsFailedDays = [];
@ -857,6 +861,7 @@ class ShippingReplicaSynchronizer
[$a, $a + $step, $fromS, $toS]
);
$scanned += count($rows);
$this->outboundScanned += count($rows);
$pick = [];
foreach ($rows as $r) {
$io = (string) ($r['io_id'] ?? '');
@ -959,6 +964,7 @@ class ShippingReplicaSynchronizer
. 'receiver_address, receiver_name, receiver_mobile FROM ' . self::PURCHASE_OUT . ' WHERE io_date >= ? AND io_date < ?',
[$fromS, $toS]
);
$this->purchaseScanned += count($rows);
$pick = [];
$skip = ['done' => 0, 'cancel' => 0, 'noitem' => 0];
foreach ($rows as $r) {
@ -1286,15 +1292,16 @@ class ShippingReplicaSynchronizer
}
/**
* 工作平台的快递名称/仓库即发货数据视图的 expressName / wareHoursName(抽样 14/14 一致):
* 视图有值时以视图为准;视图无该出库单时保留出库单/发货明细推断的值。
* 仓库以发货数据视图的 wareHoursName 为准(工作平台口径);快递名称以出库单/发货明细当前值为准,视图仅兜底。
*/
private function withViewCarrier(string $io, array $pkg, int $pkgId): array
{
if ($io === '') {
return $pkg;
}
if (! empty($this->ioExpress[$io])) {
// 快递名称以出库单/发货明细的当前值为准:聚水潭换单、改快递公司后出库单与发货明细会更新,工作平台跟着变,
// 发货数据视图的 expressName 却停在旧值(如 崔桥顺丰干配→常州德邦)。视图只在出库单/发货明细没有快递名称时兜底。
if (trim((string) ($pkg['express_name1'] ?? '')) === '' && ! empty($this->ioExpress[$io])) {
$pkg['express_name1'] = $this->ioExpress[$io];
}
if (! empty($this->ioWare[$io])) {
@ -2389,6 +2396,12 @@ class ShippingReplicaSynchronizer
}
// 凌晨按自动发货仓入库、当时还没有快递单号的旧包裹:订单之后有了发货明细(改走发货明细来源键),
// 或同一交易单号本次已由其它包裹吐出 → 视为被取代
// 出库单兜底/采购退货出库:本次已完整扫描窗口内的出库单与采购退货单,未再出现说明上游已作废、换单或不再收录(工作平台也不再显示)
if (($id >= self::PURCHASE_OUT_OFFSET && $this->purchaseScanned > 0)
|| ($id >= self::OUTBOUND_OFFSET && $id < self::PURCHASE_OUT_OFFSET && $this->outboundScanned > 0)) {
$superseded[] = $id;
continue;
}
$so = trim((string) ($row->trade_no ?? ''));
$isAuto = $id >= self::AUTOSHIP_OFFSET && $id < self::OUTBOUND_OFFSET;
if (($isAuto && isset($this->expressOrderIds[(string) ($id - self::AUTOSHIP_OFFSET)]))

View File

@ -0,0 +1,137 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Throwable;
/** 当前镜像的观测记录;不写正式操作日志,也不推测真实改价时间或历史价格。 */
class JushuitanCostPriceSnapshotDiffer
{
public const DATASET_KEY = 'jushuitan.product-cost-price-observations';
public const SNAPSHOTS = 'jushuitan_cost_price_observation_baselines';
public const OBSERVATIONS = 'jushuitan_cost_price_observations';
public const STATES = 'business_mirror_table_states';
public function __construct(
private readonly GenericBusinessMirrorRepository $mirror,
private readonly BusinessMirrorDatasetRegistry $datasets,
) {}
public function sync(int $batchSize = 500, int $maxBatches = 20): array
{
$lock = Cache::lock('jushuitan-cost-price-observations', 1800);
if (! $lock->get()) {
return ['status' => 'locked', 'rows' => 0, 'changes' => 0];
}
try {
return $this->run(max(1, min(1000, $batchSize)), max(1, min(2000, $maxBatches)));
} finally {
$lock->release();
}
}
private function run(int $batchSize, int $maxBatches): array
{
$db = $this->mirror->connection();
$schema = Schema::connection($db->getName());
$dataset = $this->datasets->get(ProductCatalogMirrorRepository::DATASET_KEY);
if (! $this->mirror->ready($dataset)
|| ! $schema->hasTable(self::SNAPSHOTS) || ! $schema->hasTable(self::OBSERVATIONS)) {
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
}
$source = $this->mirror->state($dataset);
if (($source['status'] ?? '') !== 'succeeded') {
return ['status' => 'source_not_ready', 'rows' => 0, 'changes' => 0];
}
$state = $db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->first();
$resume = $state && in_array($state->status, ['running', 'failed'], true);
$cursor = $resume ? json_decode($state->cursor ?? 'null', true) : null;
// 固定窗口、两分钟重叠,兼容源镜像批次时间精度;重复读取不重复记变价。
$from = $resume ? $state->window_started_at
: ($state?->window_ends_at ? Carbon::parse($state->window_ends_at)->subMinutes(2)->format('Y-m-d H:i:s.u') : null);
$until = $resume ? $state->window_ends_at : now()->subSeconds(2)->format('Y-m-d H:i:s.u');
$db->table(self::STATES)->updateOrInsert(['dataset_key' => self::DATASET_KEY], [
'source_key' => 'operations_replica', 'source_object' => 'mirror.' . $dataset->targetTable(),
'target_table' => self::OBSERVATIONS, 'status' => 'running',
'run_mode' => $state?->initial_completed_at ? 'incremental' : 'baseline',
'generation' => $resume ? $state->generation : (string) Str::uuid(),
'window_started_at' => $from, 'window_ends_at' => $until,
'cursor' => $cursor ? json_encode($cursor) : null,
'last_attempted_at' => now(), 'last_error_code' => null, 'updated_at' => now(),
]);
$scanned = $changes = 0;
try {
for ($batch = 0; $batch < $maxBatches; $batch++) {
$query = $db->table($dataset->targetTable())
->select(['_mirror_key', '_mirror_synced_at', 'sku_id', 'cost_price', 'modified'])
->where('sku_type', 'normal')->whereNotNull('cost_price')
->where('_mirror_synced_at', '<=', $until);
if ($from !== null) {
$query->where('_mirror_synced_at', '>=', $from);
}
if ($cursor) {
$query->where(static fn ($q) => $q->where('_mirror_synced_at', '>', $cursor['at'])
->orWhere(static fn ($q) => $q->where('_mirror_synced_at', $cursor['at'])->where('_mirror_key', '>', $cursor['key'])));
}
$rows = $query->orderBy('_mirror_synced_at')->orderBy('_mirror_key')->limit($batchSize)->get();
if ($rows->isEmpty()) {
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
'status' => 'succeeded', 'cursor' => null, 'high_watermark' => json_encode(['at' => $until]),
'initial_completed_at' => $state?->initial_completed_at ?? now(),
'last_succeeded_at' => now(), 'updated_at' => now(),
]);
return ['status' => 'succeeded', 'rows' => $scanned, 'changes' => $changes];
}
$previous = $db->table(self::SNAPSHOTS)->whereIn('source_key', $rows->pluck('_mirror_key'))->get()->keyBy('source_key');
$snapshots = $events = [];
$observedAt = now()->format('Y-m-d H:i:s.u');
foreach ($rows as $row) {
$sku = trim((string) $row->sku_id);
if ($sku === '' || ! is_numeric($row->cost_price)) {
continue;
}
$price = number_format((float) $row->cost_price, 4, '.', '');
$old = $previous->get($row->_mirror_key);
if ($old && $old->sku_id === $sku && number_format((float) $old->cost_price, 4, '.', '') !== $price) {
$events[] = [
'id' => hash('sha256', $row->_mirror_key . '|' . $old->captured_at . '|' . $observedAt . '|' . $price),
'source_key' => $row->_mirror_key, 'sku_id' => $sku,
'before_price' => $old->cost_price, 'after_price' => $price,
'previous_observed_at' => $old->captured_at, 'observed_at' => $observedAt,
'source_modified_at' => $row->modified,
];
}
$snapshots[] = ['source_key' => $row->_mirror_key, 'sku_id' => $sku, 'cost_price' => $price, 'captured_at' => $observedAt];
}
$last = $rows->last();
$cursor = ['at' => $last->_mirror_synced_at, 'key' => $last->_mirror_key];
// 价格基线、观测事件和游标同一事务;中断续传不会漏写或重复记账。
$db->transaction(function () use ($db, $snapshots, $events, $cursor): void {
if ($snapshots) {
$db->table(self::SNAPSHOTS)->upsert($snapshots, ['source_key'], ['sku_id', 'cost_price', 'captured_at']);
}
if ($events) {
$db->table(self::OBSERVATIONS)->insert($events);
}
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
'cursor' => json_encode($cursor), 'updated_at' => now(),
]);
});
$scanned += $rows->count();
$changes += count($events);
}
return ['status' => 'running', 'rows' => $scanned, 'changes' => $changes];
} catch (Throwable $exception) {
$db->table(self::STATES)->where('dataset_key', self::DATASET_KEY)->update([
'status' => 'failed', 'last_error_code' => 'snapshot_comparison_failed', 'updated_at' => now(),
]);
throw $exception;
}
}
}

View File

@ -76,10 +76,13 @@ final class LedgerRemarkFilter
if ($plan['prefixes'] !== [] || $plan['excludes'] !== []) {
$sub->where(function (Builder $scope) use ($plan, $storeLike, $storeNotLike): void {
if ($plan['prefixes'] !== []) {
// 本平台前缀的店铺
// 同平台可能有多个店铺名前缀(如阿里巴巴 = 阿里巴巴 / 1688 / 新佰广),
// 任意一个前缀命中即可,不能把多个 LIKE 条件用 AND 串起来。
$scope->orWhere(function (Builder $own) use ($plan, $storeLike): void {
foreach ($plan['prefixes'] as $prefix) {
$storeLike($own, $prefix);
$own->orWhere(function (Builder $one) use ($storeLike, $prefix): void {
$storeLike($one, $prefix);
});
}
});
}

View File

@ -0,0 +1,336 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\PlantingReimbursementRepository;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
/**
* 种菜损耗(2026-10-19 财务确认的口径):
* 1. 取 M 月种菜报销明细(完成时间 [M-02, M+1-02));
* 2. 用线上订单号去匹配发货数据(jst_oms_shipping)中 M-1、M 两个月发货的交易单号,匹配到的保留;
* 3. 任务备注或卖家备注写了“拍A发B”或“拍A发A”的不扣款,不论是否匹配到发货都单独列出发给运营;
* 4. 实际只发了「信誉订单虚拟条码」「透明挂钩」这类商品的不扣款;
* 5. 其余匹配到的作为扣款项,扣款金额 = Σ 发货商品成本价 × 数量 + 该订单在发货数据中的运费(2026-10-19 更正),
* 成本价按发货条形码(=商品编码)取普通商品资料。未匹配到发货的不计入种菜损耗。
*/
class PlantingLossCalculator
{
public const RESULT_DEDUCT = '扣款';
public const RESULT_EXEMPT = '不扣款(拍A发B/拍A发A)';
public const RESULT_FREE_GOODS = '不扣款(虚拟条码/透明挂钩)';
public const RESULT_UNMATCHED = '未匹配发货';
public const RESULTS = [self::RESULT_DEDUCT, self::RESULT_EXEMPT, self::RESULT_FREE_GOODS, self::RESULT_UNMATCHED];
/** 发货商品名称或条码含这些字样的不计扣款。 */
public const FREE_GOODS_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩', '无实物'];
/**
* 信誉订单虚拟条码(种菜单自身的虚拟商品)。发货数据里同一交易单号下若出现它,说明这单本身是按虚拟条码走的,
* 同单号下的其它商品是合并发货的别的订单,整单不扣款(2026-10-19 财务确认:信誉订单虚拟条码不扣款)。
* 透明挂钩同理:同单号下出现透明挂钩即整单不扣款(2026-10-20 财务确认)。
*/
public const VIRTUAL_ORDER_KEYWORDS = ['信誉订单虚拟条码', '虚拟条码', '透明挂钩'];
public const VIRTUAL_ORDER_BARCODES = ['X00006', 'X00002'];
public const SKU_DATASET = 'operations_replica.rpt.jst_sku';
private const CHUNK = 1000;
private const CACHE_SECONDS = 600;
public function __construct(
private readonly PlantingReimbursementRepository $reimbursements,
private readonly ShippingOrderRepository $shipping,
private readonly BusinessMirrorDatasetRegistry $datasets,
private readonly GenericBusinessMirrorRepository $mirror,
) {}
/** 发货数据同步状态(与「发货数据」页面同一来源)。 */
public function shippingStatus(): array
{
return $this->shipping->syncStatus();
}
/**
* 发货数据匹配窗口:报销月份的上月 1 日 00:00 至下月 1 日 00:00。
*
* @return array{from:string,to:string}
*/
public static function shippingWindow(string $month): array
{
PlantingReimbursementRepository::window($month);
$start = CarbonImmutable::createFromFormat('!Y-m', $month);
return [
'from' => $start->subMonthNoOverflow()->format('Y-m-d H:i:s'),
'to' => $start->addMonthNoOverflow()->format('Y-m-d H:i:s'),
];
}
/** 例:2026-09 → “2026-08~2026-09”。 */
public static function shippingLabel(string $month): string
{
$window = self::shippingWindow($month);
return substr($window['from'], 0, 7) . '~' . $month;
}
/** 命中的免扣字样(拍A发B / 拍A发A),未命中返回 null。全角、大小写、空格均兼容。 */
public static function exemptionKeyword(string ...$remarks): ?string
{
foreach ($remarks as $remark) {
$text = mb_strtoupper((string) preg_replace('/\s+/u', '', mb_convert_kana($remark, 'as')));
if (preg_match('/拍A发([AB])/u', $text, $match) === 1) {
return '拍A发' . $match[1];
}
}
return null;
}
public static function isFreeGoods(string $barcode, string $productName): bool
{
foreach (self::FREE_GOODS_KEYWORDS as $keyword) {
if (str_contains($productName, $keyword) || str_contains($barcode, $keyword)) {
return true;
}
}
return false;
}
public static function isVirtualOrderItem(string $barcode, string $productName): bool
{
$code = strtoupper(trim($barcode));
// 透明挂钩条形码以 GG 开头(如 GG003-SZ / GG-xxx),2026-10-20 财务确认。
if (in_array($code, self::VIRTUAL_ORDER_BARCODES, true) || str_starts_with($code, 'GG')) {
return true;
}
foreach (self::VIRTUAL_ORDER_KEYWORDS as $keyword) {
if (str_contains($productName, $keyword)) {
return true;
}
}
return false;
}
/** 成本价(元,字符串)× 数量 → 分。 */
public static function deductionCents(string $costPrice, int $qty): int
{
return (int) round((float) $costPrice * 100 * $qty);
}
/**
* 当月全部种菜报销及其发货匹配、扣款判定(含未匹配,页面按结果筛选)。
*
* @return list<array<string,mixed>>
*/
public function rows(string $month): array
{
$key = 'planting-loss:rows:v3:' . $month;
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$reimbursements = $this->reimbursements->rows($month);
$window = self::shippingWindow($month);
$shipments = $this->shipments(array_column($reimbursements, 'online_order_no'), $window['from'], $window['to']);
$costs = $this->costPrices(array_merge(...array_map(
static fn (array $shipment): array => array_column($shipment['lines'], 'barcode'),
array_values($shipments),
) ?: [[]]));
$rows = [];
foreach ($reimbursements as $row) {
$rows[] = $this->judge($row, $shipments[$row['online_order_no']] ?? null, $costs, $month);
}
try {
Cache::put($key, $rows, self::CACHE_SECONDS);
} catch (\Throwable $exception) {
report($exception);
}
return $rows;
}
/**
* @param array<string,mixed> $row
* @param array<string,mixed>|null $shipment
* @param array<string,string> $costs 条码 => 成本价
* @return array<string,mixed>
*/
private function judge(array $row, ?array $shipment, array $costs, string $month): array
{
$deductLines = [];
$missingCost = [];
$cents = 0;
$virtualOrder = false;
foreach ($shipment['lines'] ?? [] as $line) {
if (self::isVirtualOrderItem($line['barcode'], $line['product_name'])) {
$virtualOrder = true;
}
}
foreach ($virtualOrder ? [] : ($shipment['lines'] ?? []) as $line) {
if (self::isFreeGoods($line['barcode'], $line['product_name'])) {
continue;
}
$cost = $costs[$line['barcode']] ?? null;
if ($cost === null) {
$missingCost[] = $line['barcode'] !== '' ? $line['barcode'] : '(无条码)';
$deductLines[] = $line['barcode'] . ' × ' . $line['qty'] . ' 成本价缺失';
continue;
}
$lineCents = self::deductionCents($cost, $line['qty']);
$cents += $lineCents;
$deductLines[] = sprintf('%s × %d × %s = %s', $line['barcode'], $line['qty'], $cost, number_format($lineCents / 100, 2, '.', ''));
}
$freight = $shipment['freight'] ?? null;
if ($deductLines !== []) {
if ($freight === null) {
$deductLines[] = '运费缺失';
} else {
$cents += (int) round((float) $freight * 100);
$deductLines[] = '运费 ' . $freight;
}
}
$missing = array_values(array_unique($missingCost));
$missingText = implode(';', array_filter([
$missing === [] ? '' : '成本价缺失:' . implode('、', $missing),
$deductLines !== [] && $freight === null ? '运费缺失' : '',
]));
// 拍A发B/拍A发A 不论是否匹配到发货都单独列出发给运营(2026-10-20 财务确认);匹配发货只用于挑出真实发货的单。
$keyword = self::exemptionKeyword((string) $row['task_remark'], (string) $row['seller_remark']);
[$result, $basis] = match (true) {
$keyword !== null => [self::RESULT_EXEMPT, '备注含“' . $keyword . '”' . ($shipment === null ? '(未匹配到发货)' : '')],
$shipment === null => [self::RESULT_UNMATCHED, self::shippingLabel($month) . ' 发货数据中无此交易单号'],
$virtualOrder => [self::RESULT_FREE_GOODS, '发货含信誉订单虚拟条码/透明挂钩,整单不扣款'],
$deductLines === [] => [self::RESULT_FREE_GOODS, '只发了虚拟条码/透明挂钩/无实物商品'],
$missingText !== '' => [self::RESULT_DEDUCT, $missingText . ',扣款金额未含缺失部分'],
default => [self::RESULT_DEDUCT, '已匹配发货,按成本价×数量+运费扣款'],
};
$row['loss_result'] = $result;
$row['loss_basis'] = $basis;
$row['deduction_amount'] = $result === self::RESULT_DEDUCT ? number_format($cents / 100, 2, '.', '') : null;
$row['deduction_detail'] = $result === self::RESULT_DEDUCT ? implode("\n", $deductLines) : '';
$row['cost_missing'] = $result === self::RESULT_DEDUCT && $missingText !== '';
$row['shipped_at'] = $shipment['shipped_at'] ?? '';
$row['express_no'] = $shipment['express_no'] ?? '';
$row['warehouse_name'] = $shipment['warehouse_name'] ?? '';
$row['shipped_barcode'] = implode("\n", array_column($shipment['lines'] ?? [], 'barcode'));
$row['shipped_product_name'] = implode("\n", array_column($shipment['lines'] ?? [], 'product_name'));
$row['shipped_qty'] = $shipment === null ? '' : (string) array_sum(array_column($shipment['lines'], 'qty'));
$row['shipped_amount'] = $shipment['amount'] ?? null;
$row['shipped_freight'] = $shipment['freight'] ?? null;
return $row;
}
/**
* @param list<string> $tradeNos
* @return array<string,array{shipped_at:string,express_no:string,warehouse_name:string,amount:?string,freight:?string,lines:list<array{barcode:string,product_name:string,qty:int}>}>
*/
private function shipments(array $tradeNos, string $from, string $to): array
{
$tradeNos = array_values(array_unique(array_filter(array_map('trim', $tradeNos), static fn (string $v): bool => $v !== '')));
$db = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
$acc = [];
foreach (array_chunk($tradeNos, self::CHUNK) as $chunk) {
$records = $db->table('jst_oms_shipping')
->whereIn('trade_no', $chunk)
->where('ship_time', '>=', $from)
->where('ship_time', '<', $to)
->orderBy('id')
->get(['trade_no', 'ship_time', 'express_no', 'warehouse_name', 'barcode', 'product_name', 'qty', 'amount', 'freight']);
foreach ($records as $record) {
$trade = (string) $record->trade_no;
$item = &$acc[$trade];
$shipTime = substr((string) $record->ship_time, 0, 19);
$item['shipped_at'] = isset($item['shipped_at']) ? min($item['shipped_at'], $shipTime) : $shipTime;
foreach (['express_no', 'warehouse_name'] as $key) {
$value = trim((string) $record->{$key});
if ($value !== '') {
$item[$key][$value] = true;
}
}
foreach (['amount', 'freight'] as $key) {
if ($record->{$key} !== null) {
$item[$key] = ($item[$key] ?? 0) + (int) round((float) $record->{$key} * 100);
}
}
$barcode = trim((string) $record->barcode);
$lineKey = $barcode . '|' . trim((string) $record->product_name);
$item['lines'][$lineKey] ??= ['barcode' => $barcode, 'product_name' => trim((string) $record->product_name), 'qty' => 0];
$item['lines'][$lineKey]['qty'] += max(1, (int) $record->qty);
unset($item);
}
}
$result = [];
foreach ($acc as $trade => $item) {
$result[$trade] = [
'shipped_at' => $item['shipped_at'],
'express_no' => implode("\n", array_keys($item['express_no'] ?? [])),
'warehouse_name' => implode("\n", array_keys($item['warehouse_name'] ?? [])),
'amount' => isset($item['amount']) ? number_format($item['amount'] / 100, 2, '.', '') : null,
'freight' => isset($item['freight']) ? number_format($item['freight'] / 100, 2, '.', '') : null,
'lines' => array_values($item['lines']),
];
}
return $result;
}
/**
* 普通商品资料的成本价:条形码(商品编码)= ops_jst_sku.sku_id,同编码取最新一条。
*
* @param list<string> $barcodes
* @return array<string,string>
*/
private function costPrices(array $barcodes): array
{
$barcodes = array_values(array_unique(array_filter($barcodes, static fn (string $v): bool => $v !== '')));
if ($barcodes === []) {
return [];
}
$connection = $this->mirror->connection();
$table = $this->datasets->get(self::SKU_DATASET)->targetTable();
$costs = [];
foreach (array_chunk($barcodes, self::CHUNK) as $chunk) {
$records = $connection->table($table)
->whereIn('sku_id', $chunk)
->where('sku_type', 'normal')
->orderByDesc('autoid')
->get(['sku_id', 'cost_price']);
foreach ($records as $record) {
$sku = (string) $record->sku_id;
if (isset($costs[$sku])) {
continue;
}
// 成本价为空或 0 视为缺失,避免把未维护成本的商品算成 0 元扣款。
if (is_numeric($record->cost_price) && (float) $record->cost_price > 0) {
$costs[$sku] = rtrim(rtrim(number_format((float) $record->cost_price, 4, '.', ''), '0'), '.');
}
}
}
return $costs;
}
}

View File

@ -80,7 +80,7 @@ class StoreReportLogic
[
'code' => 'R9',
'object' => '销量口径',
'logic' => '销量=业绩明细(店铺收款匹配订单明细)的「订单实付/实收金额」,按「店铺名称 + 发生时间」汇总;订单实付为空的未匹配行不计入。收入列仍按平台账单日汇总,两个口径分开统计、不互相覆盖。',
'logic' => '销量=业绩明细(店铺收款匹配订单明细)的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」合计,按「店铺名称 + 发生日期」逐日汇总;退款、冲减按账单净额(负数)计入,不再使用订单实付/实收金额。收入列仍按平台账单日汇总,两个口径分开统计、不互相覆盖。',
'source' => 'store_receipt_order_match_rows → store_report_paid_sales_rows',
'priority' => '与业绩明细同口径',
],

View File

@ -447,7 +447,8 @@ class StoreReportMirrorSynchronizer
}
/**
* 店铺维度报表的「销量」:把业绩明细的「订单实付/实收金额」按「店铺名称 + 发生日期」预汇总。
* 店铺维度报表的「销量」:把业绩明细的
* 「收入金额 + 优惠金额 + 冲减金额 + 退款金额」按「店铺名称 + 发生日期」预汇总。
*
* 业绩明细由 store-receipt:order-match:sync 按「账期 + 平台」整体重写,这里用
* 「行数 + 最近写入时间 + 金额合计」的指纹判断是否变化:没变化就复用当前版本,
@ -456,6 +457,15 @@ class StoreReportMirrorSynchronizer
* @return array{status:string,source_rows:int,stored_rows:int,changed:bool}
*/
public function syncPaidSales(bool $force = false): array
{
$result = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('store-report:paid-sales', 600)
->get(fn () => $this->syncPaidSalesUnlocked($force));
return $result === false
? ['status' => 'succeeded', 'source_rows' => 0, 'stored_rows' => 0, 'changed' => false]
: $result;
}
private function syncPaidSalesUnlocked(bool $force): array
{
if (! Schema::connection($this->connection->getName())
->hasTable(StoreReportRepository::PAID_SALES_SOURCE_TABLE)) {
@ -463,53 +473,66 @@ class StoreReportMirrorSynchronizer
}
$state = $this->state(StoreReportRepository::PAID_SALES_DATASET);
$changes = app(StoreReportPaidSalesChanges::class);
try {
$fingerprint = $this->paidSalesFingerprint();
$active = trim((string) ($state->active_generation ?? ''));
if (! $force && $active !== '' && ! empty($state->initial_completed_at)
&& (string) ($state->source_fingerprint ?? '') === $fingerprint) {
return [
'status' => 'succeeded',
'source_rows' => 0,
'stored_rows' => (int) ($state->row_count ?? 0),
'changed' => false,
];
if (! $force && $changes->available() && ! empty($state->active_generation)
&& ! empty($state->initial_completed_at)
&& str_starts_with((string) ($state->source_fingerprint ?? ''), StoreReportPaidSalesChanges::VERSION)) {
return $this->syncPaidSalesDates($state, $changes);
}
return $this->connection->transaction(function () use ($state, $force, $changes): array {
$pending = $changes->available() ? $changes->pending() : [];
$fingerprint = $changes->available()
? StoreReportPaidSalesChanges::VERSION . Str::uuid() : $this->paidSalesFingerprint();
$active = trim((string) ($state->active_generation ?? ''));
if (! $force && $active !== '' && ! empty($state->initial_completed_at)
&& (string) ($state->source_fingerprint ?? '') === $fingerprint) {
return [
'status' => 'succeeded',
'source_rows' => 0,
'stored_rows' => (int) ($state->row_count ?? 0),
'changed' => false,
];
}
$now = now();
$generation = (string) Str::uuid();
$this->connection->statement(
'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
. ' SELECT ?, store_name, occurred_date, SUM(COALESCE(paid_amount, 0)), COUNT(paid_amount), ?'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IS NOT NULL"
. ' GROUP BY store_name, occurred_date',
[$generation, $now->format('Y-m-d H:i:s')],
);
$stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', $generation)->count();
$sourceRows = (int) ($this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->count());
$this->connection->table('store_report_import_states')
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)
->update([
'status' => 'succeeded',
'run_mode' => 'full',
'active_generation' => $generation,
'pending_generation' => null,
'row_count' => $stored,
'source_fingerprint' => $fingerprint,
'initial_completed_at' => $state->initial_completed_at ?? $now,
'last_attempted_at' => $now,
'last_succeeded_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
]);
// 版本切换成功后再清理上一版,避免中途失败时页面读到被清空的当前版本。
$this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', '<>', $generation)->delete();
$now = now();
$generation = (string) Str::uuid();
$this->connection->statement(
'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
. ' SELECT ?, store_name, occurred_date,'
. ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IS NOT NULL"
. ' GROUP BY store_name, occurred_date',
[$generation, $now->format('Y-m-d H:i:s')],
);
$stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', $generation)->count();
$sourceRows = (int) ($this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->count());
$this->connection->table('store_report_import_states')
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)
->update([
'status' => 'succeeded',
'run_mode' => 'full',
'active_generation' => $generation,
'pending_generation' => null,
'row_count' => $stored,
'source_fingerprint' => $fingerprint,
'initial_completed_at' => $state->initial_completed_at ?? $now,
'last_attempted_at' => $now,
'last_succeeded_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
]);
// 版本切换成功后再清理上一版,避免中途失败时页面读到被清空的当前版本。
$this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', '<>', $generation)->delete();
if ($changes->available()) $changes->acknowledge($pending);
return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true];
return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true];
});
} catch (Throwable $exception) {
$this->connection->table('store_report_import_states')
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)
@ -529,18 +552,55 @@ class StoreReportMirrorSynchronizer
}
}
/** 业绩明细是否变化的指纹:行数 + 最近写入时间 + 订单实付合计。 */
private function syncPaidSalesDates(object $state, StoreReportPaidSalesChanges $changes): array
{
$pending = $changes->pending();
if ($pending === []) {
return ['status' => 'succeeded', 'source_rows' => 0, 'stored_rows' => (int) $state->row_count, 'changed' => false];
}
$dates = array_column($pending, 'occurred_on');
$generation = (string) $state->active_generation;
return $this->connection->transaction(function () use ($changes, $pending, $dates, $generation): array {
$this->connection->table(StoreReportRepository::PAID_SALES_TABLE)
->where('generation', $generation)->whereIn('occurred_on', $dates)->delete();
$this->connection->statement(
'INSERT INTO ' . StoreReportRepository::PAID_SALES_TABLE
. ' (generation, store_name, occurred_on, paid_amount, order_count, updated_at)'
. ' SELECT ?, store_name, occurred_date,'
. ' SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), COUNT(*), ?'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE
. " WHERE store_name IS NOT NULL AND store_name <> '' AND occurred_date IN ("
. implode(',', array_fill(0, count($dates), '?')) . ') GROUP BY store_name, occurred_date',
[$generation, now()->format('Y-m-d H:i:s'), ...$dates],
);
$stored = (int) $this->connection->table(StoreReportRepository::PAID_SALES_TABLE)->where('generation', $generation)->count();
$sourceRows = (int) $this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)->whereIn('occurred_date', $dates)->count();
$this->connection->table('store_report_import_states')
->where('dataset_key', StoreReportRepository::PAID_SALES_DATASET)->update([
'status' => 'succeeded', 'run_mode' => 'incremental', 'row_count' => $stored,
'source_fingerprint' => StoreReportPaidSalesChanges::VERSION . Str::uuid(),
'last_attempted_at' => now(), 'last_succeeded_at' => now(), 'last_error_code' => null, 'updated_at' => now(),
]);
$changes->acknowledge($pending);
return ['status' => 'succeeded', 'source_rows' => $sourceRows, 'stored_rows' => $stored, 'changed' => true];
});
}
/** 业绩明细是否变化的指纹:行数 + 最近写入时间 + 销量口径四项金额合计。 */
private function paidSalesFingerprint(): string
{
$row = $this->connection->selectOne(
'SELECT COUNT(*) AS rows_total, MAX(imported_at) AS last_imported_at,'
. ' COALESCE(SUM(paid_amount), 0) AS paid_total FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE,
. ' COALESCE(SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)), 0) AS sales_total'
. ' FROM ' . StoreReportRepository::PAID_SALES_SOURCE_TABLE,
);
return hash('sha256', implode('|', [
(string) ($row->rows_total ?? 0),
(string) ($row->last_imported_at ?? ''),
(string) ($row->paid_total ?? '0'),
(string) ($row->sales_total ?? '0'),
]));
}

View File

@ -0,0 +1,62 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
/** Persistent invalidation by affected date, including dates removed by a period replacement. */
class StoreReportPaidSalesChanges
{
public const TABLE = 'store_report_paid_sales_dirty_dates';
public const VERSION = 'dates:v1:';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $database)
{
$this->connection = $database->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
}
public function available(): bool
{
return $this->connection->getSchemaBuilder()->hasTable(self::TABLE);
}
public function markPeriod(string $reportMonth, string $platform): void
{
if (! $this->available()) return;
$dates = $this->connection->table(StoreReportRepository::PAID_SALES_SOURCE_TABLE)
->where('report_month', substr($reportMonth, 0, 7) . '-01')->where('platform', $platform)
->whereNotNull('occurred_date')->distinct()->pluck('occurred_date');
$this->markDates($dates->all());
}
public function markDates(array $dates): void
{
if (! $this->available()) return;
$revision = (string) Str::uuid();
$rows = array_map(fn ($date) => ['occurred_on' => $date, 'revision' => $revision], array_values(array_unique(array_filter($dates))));
foreach (array_chunk($rows, 500) as $chunk) {
$this->connection->table(self::TABLE)->upsert($chunk, ['occurred_on'], ['revision']);
}
}
public function pending(): array
{
return $this->connection->table(self::TABLE)->orderBy('occurred_on')->get()->map(fn ($row) => (array) $row)->all();
}
public function acknowledge(array $snapshot): void
{
foreach (array_chunk($snapshot, 100) as $chunk) {
// A write arriving during aggregation retains its newer revision for the next run.
$this->connection->table(self::TABLE)->where(function ($query) use ($chunk): void {
foreach ($chunk as $row) {
$query->orWhere(fn ($match) => $match->where('occurred_on', $row['occurred_on'])->where('revision', $row['revision']));
}
})->delete();
}
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
/**
* 读取厂家记账配置 Excel:
* - 财务原表:供应商、厂家是否记账(原文如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”);
* - 本系统导出表:供应商、记账方式、生效开始日期、生效结束日期、原始说明。
*/
class SupplierBookkeepingConfigImporter
{
/** @return list<array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text:string}> */
public function read(string $path): array
{
$reader = IOFactory::createReader('Xlsx');
$info = $reader->listWorksheetInfo($path);
if ($info === [] || $info[0]['totalRows'] > 5001 || $info[0]['totalColumns'] > 20) {
throw ValidationException::withMessages(['file' => '请使用第一个工作表存放配置,最多5000条。']);
}
$reader->setLoadSheetsOnly($info[0]['worksheetName']);
$book = $reader->load($path);
try {
$sheet = $book->getSheet(0);
$headers = [];
for ($column = 1; $column <= $info[0]['totalColumns']; $column++) {
$headers[$this->label($sheet->getCell([$column, 1])->getValue())] = $column;
}
$supplierColumn = $headers['供应商'] ?? $headers['供应商名称'] ?? null;
$ruleColumn = $headers['厂家是否记账'] ?? null;
$modeColumn = $headers['记账方式'] ?? null;
if ($supplierColumn === null || ($ruleColumn === null && $modeColumn === null)) {
throw ValidationException::withMessages(['file' => '缺少列:需要“供应商”和“厂家是否记账”(或“记账方式”)。']);
}
$rows = [];
$seen = [];
for ($number = 2; $number <= $sheet->getHighestDataRow(); $number++) {
$supplier = $this->text($sheet, $supplierColumn, $number);
$ruleText = $ruleColumn !== null ? $this->text($sheet, $ruleColumn, $number) : '';
$modeText = $modeColumn !== null ? $this->text($sheet, $modeColumn, $number) : '';
// 原表中间重复出现的表头行、空行跳过。
if (($supplier === '' && $ruleText === '' && $modeText === '') || $this->label($supplier) === '供应商') {
continue;
}
if ($supplier === '' || mb_strlen($supplier) > 191) {
throw ValidationException::withMessages(['file' => "第{$number}行供应商为空或超长。"]);
}
$key = mb_strtolower($supplier);
if (isset($seen[$key])) {
throw ValidationException::withMessages(['file' => "第{$number}行供应商“{$supplier}”与第{$seen[$key]}行重复。"]);
}
$seen[$key] = $number;
try {
$parsed = Rule::parseRuleText($ruleText !== '' ? $ruleText : $modeText);
} catch (InvalidArgumentException $exception) {
throw ValidationException::withMessages(['file' => "第{$number}行:" . $exception->getMessage()]);
}
foreach (['effective_from' => '生效开始日期', 'effective_to' => '生效结束日期'] as $field => $label) {
if (isset($headers[$label])) {
$date = $this->date($sheet, $headers[$label], $number);
if ($date !== null) {
$parsed[$field] = $date;
}
}
}
if ($parsed['effective_from'] !== null && $parsed['effective_to'] !== null && $parsed['effective_from'] > $parsed['effective_to']) {
throw ValidationException::withMessages(['file' => "第{$number}行生效结束日期早于开始日期。"]);
}
$source = isset($headers['原始说明']) ? $this->text($sheet, $headers['原始说明'], $number) : '';
$rows[] = ['supplier_name' => $supplier] + $parsed + ['source_text' => $ruleText !== '' ? $ruleText : ($source !== '' ? $source : $modeText)];
}
if ($rows === []) {
throw ValidationException::withMessages(['file' => '配置表没有有效记录。']);
}
return $rows;
} finally {
$book->disconnectWorksheets();
}
}
private function label(mixed $value): string
{
return str_replace([' ', ' ', "\n"], '', trim((string) $value));
}
private function text($sheet, int $column, int $row): string
{
$cell = $sheet->getCell([$column, $row]);
if ($cell->getDataType() === 'f') {
throw ValidationException::withMessages(['file' => "第{$row}行含公式,请使用实际配置值。"]);
}
return trim((string) $cell->getFormattedValue());
}
private function date($sheet, int $column, int $row): ?string
{
$cell = $sheet->getCell([$column, $row]);
$value = $cell->getValue();
if ($value === null || trim((string) $value) === '') {
return null;
}
if (is_numeric($value) && ExcelDate::isDateTime($cell)) {
return ExcelDate::excelToDateTimeObject($value)->format('Y-m-d');
}
$text = str_replace(['/', '.'], '-', trim((string) $cell->getFormattedValue()));
if (! preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $text, $m) || ! checkdate((int) $m[2], (int) $m[3], (int) $m[1])) {
throw ValidationException::withMessages(['file' => "第{$row}行日期无效,请使用 2026-01-31 格式。"]);
}
return sprintf('%04d-%02d-%02d', $m[1], $m[2], $m[3]);
}
}

View File

@ -0,0 +1,156 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
/** 供应商应付「厂家记账」配置:每个供应商一条记账方式,删除可恢复,操作留痕。 */
class SupplierBookkeepingConfigStore
{
public const TABLE = 'business_supplier_bookkeeping_configs';
public const EVENTS = 'business_supplier_bookkeeping_config_events';
private const FIELDS = ['mode', 'effective_from', 'effective_to', 'source_text'];
public function query()
{
return DB::table(self::TABLE);
}
/** @return array<string,array<string,mixed>> 供应商名称 => 生效中的配置(未删除) */
public function activeConfigs(): array
{
$result = [];
foreach ($this->query()->whereNull('deleted_at')->get() as $row) {
$result[trim($row->supplier_name)] = (array) $row;
}
return $result;
}
public function filtered(array $filters)
{
$query = $this->query();
$status = $filters['deleted_status'] ?? 'active';
if ($status === 'deleted') {
$query->whereNotNull('deleted_at');
} elseif ($status !== 'all') {
$query->whereNull('deleted_at');
}
if (($filters['supplier_name'] ?? '') !== '') {
$query->where('supplier_name', 'like', '%' . addcslashes($filters['supplier_name'], '\\%_') . '%');
}
if (($filters['mode'] ?? '') !== '') {
$query->where('mode', $filters['mode']);
}
return $query->orderBy('supplier_name')->orderBy('id');
}
/** @param array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text?:string} $data */
public function save(array $data, string $actor, ?int $id = null): int
{
return DB::transaction(function () use ($data, $actor, $id): int {
$before = $id === null ? null : $this->query()->where('id', $id)->whereNull('deleted_at')->lockForUpdate()->first();
abort_if($id !== null && $before === null, 404);
$name = trim($data['supplier_name']);
$duplicate = $this->query()->where('supplier_name', $name)->when($id !== null, fn ($q) => $q->where('id', '<>', $id))->exists();
if ($duplicate) {
throw ValidationException::withMessages(['supplier_name' => '该供应商已有配置(含已删除),请编辑或恢复原配置。']);
}
$time = $this->time();
$values = ['supplier_name' => $name, 'updated_at' => $time] + $this->values($data);
if ($id === null) {
$id = $this->query()->insertGetId($values + ['creator' => $actor, 'created_at' => $time]);
} else {
$this->query()->where('id', $id)->update($values);
}
$this->event($id, $actor, $before ? 'update' : 'create', ($before ? '修改' : '新增') . '厂家记账配置:' . $name . ' → ' . Rule::describe($values), ['before' => $before, 'after' => $values]);
return $id;
});
}
public function changeDeleted(int $id, bool $deleted, string $actor): void
{
DB::transaction(function () use ($id, $deleted, $actor): void {
$row = $this->query()->where('id', $id)->lockForUpdate()->first();
abort_if($row === null, 404);
if (($row->deleted_at !== null) === $deleted) {
return;
}
$this->query()->where('id', $id)->update(['deleted_at' => $deleted ? $this->time() : null, 'updated_at' => $this->time()]);
$this->event($id, $actor, $deleted ? 'delete' : 'restore', ($deleted ? '删除' : '恢复') . '厂家记账配置:' . $row->supplier_name, ['supplier_name' => $row->supplier_name, 'mode' => $row->mode]);
});
}
/**
* 整批导入:新供应商新增;已有供应商记账方式或生效期不同则按文件更新(已删除的一并恢复);相同则跳过。
*
* @param list<array{supplier_name:string,mode:string,effective_from:?string,effective_to:?string,source_text:string}> $rows
* @return array{added:int,updated:int,skipped:int}
*/
public function import(array $rows, string $actor, string $sourceHash): array
{
return DB::transaction(function () use ($rows, $actor, $sourceHash): array {
$added = $updated = $skipped = 0;
$time = $this->time();
foreach ($rows as $row) {
$name = trim($row['supplier_name']);
$values = $this->values($row);
$existing = $this->query()->where('supplier_name', $name)->lockForUpdate()->first();
if ($existing === null) {
$this->query()->insert(['supplier_name' => $name, 'creator' => $actor, 'created_at' => $time, 'updated_at' => $time] + $values);
$added++;
continue;
}
$same = $existing->deleted_at === null;
foreach (['mode', 'effective_from', 'effective_to'] as $field) {
$same = $same && (string) $existing->{$field} === (string) $values[$field];
}
if ($same) {
$skipped++;
continue;
}
$this->query()->where('id', $existing->id)->update($values + ['deleted_at' => null, 'updated_at' => $time]);
$updated++;
}
$this->event(null, $actor, 'import', "导入厂家记账配置:新增{$added}条,更新{$updated}条,未变化{$skipped}条", compact('added', 'updated', 'skipped') + ['source_sha256' => $sourceHash]);
return compact('added', 'updated', 'skipped');
});
}
public function events()
{
return DB::table(self::EVENTS)->orderByDesc('id');
}
private function values(array $data): array
{
$values = [];
foreach (self::FIELDS as $field) {
$value = $data[$field] ?? null;
$values[$field] = $field === 'source_text' ? mb_substr(trim((string) $value), 0, 255) : (($value === '' || $value === null) ? null : (string) $value);
}
abort_unless(isset(Rule::MODES[$values['mode']]), 422, '记账方式无效。');
if ($values['effective_from'] !== null && $values['effective_to'] !== null && $values['effective_from'] > $values['effective_to']) {
throw ValidationException::withMessages(['effective_to' => '生效结束日期不能早于开始日期。']);
}
return $values;
}
private function event(?int $id, string $actor, string $action, string $description, array $details): void
{
DB::table(self::EVENTS)->insert(['config_id' => $id, 'actor' => $actor, 'action' => $action, 'description' => mb_substr($description, 0, 1000), 'details' => json_encode($details, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'created_at' => $this->time()]);
}
private function time(): string
{
return Carbon::now(config('app.platform_timezone', 'Asia/Shanghai'))->format('Y-m-d H:i:s');
}
}

View File

@ -0,0 +1,178 @@
<?php
namespace Modules\EcommerceFinance\Services;
use InvalidArgumentException;
/**
* 厂家记账扣款判定(2026-10-19 由财务确认):
*
* - 记账原因一级为“厂家记账”:按供应商的厂家记账配置判定,仅“正常记账”的供应商作为扣款项;
* - 记账原因一级为“仓库记账”:只有订单发货仓库为“某某代发仓”且二级原因含“仓库发错货”时,
* 才可作为厂家记账扣款项(供应商配置为“不记账”时仍不扣);其余仓库记账一律不作为扣款项;
* - 配置表中没有的供应商(含配置不在生效期内)视同正常记账,作为可扣款项(2026-10-19 财务更正);
* - 工单未带供应商、或多件商品分属不同供应商且判定不一致的,标记“待确认”,不计入扣款。
*/
class SupplierBookkeepingDeductionRule
{
public const MODE_NONE = 'none';
public const MODE_DROPSHIP_WRONG = 'dropship_wrong';
public const MODE_NORMAL = 'normal';
public const MODES = [
self::MODE_NORMAL => '正常记账',
self::MODE_DROPSHIP_WRONG => '仅代发仓发错货记账',
self::MODE_NONE => '不记账',
];
public const STATUS_DEDUCT = '扣款';
public const STATUS_SKIP = '不扣款';
public const STATUS_REVIEW = '待确认';
public const STATUSES = [self::STATUS_DEDUCT, self::STATUS_SKIP, self::STATUS_REVIEW];
public const DROPSHIP_WAREHOUSE_KEYWORD = '代发仓';
public const WAREHOUSE_WRONG_KEYWORD = '仓库发错货';
/**
* 解析配置表中“厂家是否记账”原文,例如“代发仓发货错需记账,其他原因均不记账”“不记账(2025.3月-2027.12.30)”。
*
* @return array{mode:string,effective_from:?string,effective_to:?string}
*/
public static function parseRuleText(string $text): array
{
$normalized = str_replace(['(', ')', ' ', ' '], ['(', ')', '', ''], trim($text));
if ($normalized === '') {
throw new InvalidArgumentException('厂家是否记账为空。');
}
$label = array_search($normalized, self::MODES, true);
if (is_string($label)) {
return ['mode' => $label, 'effective_from' => null, 'effective_to' => null];
}
if (str_contains($normalized, '代发仓') && (str_contains($normalized, '发货错') || str_contains($normalized, '发错货'))) {
$mode = self::MODE_DROPSHIP_WRONG;
} elseif (str_starts_with($normalized, '正常记账')) {
$mode = self::MODE_NORMAL;
} elseif (str_starts_with($normalized, '不记账')) {
$mode = self::MODE_NONE;
} else {
throw new InvalidArgumentException('无法识别的记账方式:' . $text);
}
[$from, $to] = [null, null];
$date = '(\d{4})[.\/年](\d{1,2})月?(?:[.\/](\d{1,2})日?)?';
if (preg_match('/(' . $date . '[-—~至]+' . $date . ')/u', $normalized, $m)) {
$from = self::date((int) $m[1], (int) $m[2], ($m[3] ?? '') === '' ? 1 : (int) $m[3]);
$to = self::date((int) $m[4], (int) $m[5], ($m[6] ?? '') === '' ? null : (int) $m[6]);
if ($from > $to) {
throw new InvalidArgumentException('生效期开始晚于结束:' . $text);
}
} elseif (str_contains($normalized, '(')) {
throw new InvalidArgumentException('无法识别的生效期:' . $text);
}
return ['mode' => $mode, 'effective_from' => $from, 'effective_to' => $to];
}
/** 配置的展示文字,例如“不记账(2025-03-01 至 2026-02-28)”。 */
public static function describe(array|object $config): string
{
$config = (array) $config;
$label = self::MODES[$config['mode']] ?? (string) $config['mode'];
$from = $config['effective_from'] ?? null;
$to = $config['effective_to'] ?? null;
if ($from === null && $to === null) {
return $label;
}
return $label . '(' . ($from ?? '不限') . ' 至 ' . ($to ?? '不限') . ')';
}
/**
* @param array<string,mixed> $row 厂家记账明细行
* @param array<string,array<string,mixed>|object> $configs 供应商名称 => 生效中的配置
* @return array{supplier_config:string,deduction_status:string,deduction_basis:string}
*/
public static function decide(array $row, array $configs): array
{
$level1 = (string) ($row['reason_level_1'] ?? '');
$level2 = (string) ($row['reason_level_2'] ?? '');
$date = substr((string) ($row['started_at'] ?? ''), 0, 10);
$suppliers = array_values(array_unique(array_filter(array_map('trim', explode("\n", (string) ($row['supplier'] ?? '')))), SORT_STRING));
$found = [];
foreach ($suppliers as $supplier) {
$found[$supplier] = isset($configs[$supplier]) ? (array) $configs[$supplier] : null;
}
$configText = implode("\n", array_map(static fn (?array $config): string => $config === null ? '未在配置表(按正常记账)' : self::describe($config), $found));
if ($level1 === '仓库记账') {
$warehouse = (string) ($row['shipping_warehouse'] ?? '');
if (! str_contains($warehouse, self::DROPSHIP_WAREHOUSE_KEYWORD) || ! str_contains($level2, self::WAREHOUSE_WRONG_KEYWORD)) {
return self::result($configText, self::STATUS_SKIP, '仓库记账仅“代发仓 + 仓库发错货”作为厂家扣款,本单不扣');
}
} elseif ($level1 !== '厂家记账') {
return self::result($configText, self::STATUS_SKIP, '非厂家记账/仓库记账原因');
}
if ($suppliers === []) {
return self::result('', self::STATUS_REVIEW, '工单未带供应商,无法判定扣款,请人工确认');
}
$outcomes = [];
foreach ($found as $supplier => $config) {
$outcomes[$supplier] = self::outcome($level1, $config, $date);
}
$statuses = array_values(array_unique(array_column($outcomes, 0)));
if (count($statuses) > 1) {
$detail = implode(';', array_map(static fn (string $supplier, array $o): string => $supplier . ':' . $o[0], array_keys($outcomes), $outcomes));
return self::result($configText, self::STATUS_REVIEW, '多个供应商判定不一致,请人工确认(' . $detail . ')');
}
return self::result($configText, $statuses[0], reset($outcomes)[1]);
}
/** @return array{0:string,1:string} [判定, 说明] */
private static function outcome(string $level1, ?array $config, string $date): array
{
if ($config === null) {
return [self::STATUS_DEDUCT, $level1 === '厂家记账' ? '供应商不在配置表,按正常记账扣款' : '代发仓发错货,记厂家扣款(供应商不在配置表)'];
}
$from = $config['effective_from'] ?? null;
$to = $config['effective_to'] ?? null;
if (($from !== null && $date < $from) || ($to !== null && $date > $to)) {
return [self::STATUS_DEDUCT, '发起日期不在配置生效期内(' . self::describe($config) . '),按正常记账扣款'];
}
return match ($config['mode']) {
self::MODE_NONE => [self::STATUS_SKIP, '供应商配置为不记账'],
self::MODE_NORMAL => $level1 === '厂家记账'
? [self::STATUS_DEDUCT, '供应商正常记账']
: [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款'],
self::MODE_DROPSHIP_WRONG => $level1 === '仓库记账'
? [self::STATUS_DEDUCT, '代发仓发错货,记厂家扣款']
: [self::STATUS_SKIP, '供应商仅代发仓发错货记账,其他原因不记账'],
default => [self::STATUS_REVIEW, '无法识别的记账方式,请人工确认'],
};
}
private static function result(string $config, string $status, string $basis): array
{
return ['supplier_config' => $config, 'deduction_status' => $status, 'deduction_basis' => $basis];
}
private static function date(int $year, int $month, ?int $day): string
{
if ($month < 1 || $month > 12) {
throw new InvalidArgumentException('生效期月份无效。');
}
$last = (int) date('t', mktime(0, 0, 0, $month, 1, $year));
$day ??= $last;
if ($day < 1 || $day > $last) {
throw new InvalidArgumentException('生效期日期无效。');
}
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace Modules\EcommerceFinance\Services;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
/** 财务端货款对账比对汇总 CSV。 */
class SupplierComparisonCsvExporter
{
private const HEADERS = [
'采购单号', '日期', '我方商品编码', '厂家条码', '比对结果',
'我方数量', '我方单价', '我方金额',
'厂家数量', '厂家单价', '厂家金额',
'数量差异', '金额差异', '结算口径', '结算金额',
];
/**
* @param array<string,mixed> $detail
*/
public function download(
array $detail,
string $supplier,
string $month,
string $reconciler,
): StreamedResponse {
$safeSupplier = preg_replace('/[\\\\\\/:*?"<>|]+/u', '_', $supplier) ?: '厂家';
$period = $month !== '' ? preg_replace('/[^0-9-]+/u', '', $month) : '全部月份';
$filename = $safeSupplier . '_货款对账比对_' . $period . '_' . now()->format('Ymd_His') . '.csv';
$summary = (array) ($detail['summary'] ?? []);
$rows = (array) ($detail['rows'] ?? []);
return response()->streamDownload(function () use ($detail, $rows, $summary, $supplier, $month, $reconciler): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new RuntimeException('无法创建导出文件。');
}
fwrite($csv, "\xEF\xBB\xBF");
$this->write($csv, ['供应商', $supplier, '月份', $month !== '' ? $month : '全部月份', '对账人', $reconciler !== '' ? $reconciler : '全部']);
$this->write($csv, [
'我方数据行数', (int) ($detail['mineRows'] ?? 0),
'厂家数据行数', (int) ($detail['theirsRows'] ?? 0),
'比对结果条数', count($rows),
]);
$this->write($csv, [
'双方一致', (int) ($summary['match'] ?? 0),
'厂家多', (int) ($summary['supplierExtra'] ?? 0),
'数量/金额不一致', (int) ($summary['mismatch'] ?? 0),
'厂家少', (int) ($summary['supplierMissing'] ?? 0),
]);
$this->write($csv, [
'我方金额合计', $this->money($summary['mineAmount'] ?? 0),
'厂家金额合计', $this->money($summary['theirsAmount'] ?? 0),
'厂家多算金额', $this->money($summary['supplierOverAmount'] ?? 0),
'按口径结算合计', $this->money($summary['settleAmount'] ?? 0),
'差异金额', $this->money($summary['differenceAmount'] ?? 0),
]);
$this->write($csv, []);
$this->write($csv, self::HEADERS);
foreach ($rows as $row) {
$this->write($csv, [
$row['purchaseNo'] ?? '',
$row['date'] ?? '',
$row['barcode'] ?? '',
$row['supplierBarcode'] ?? '',
$row['type'] ?? '',
$this->number($row['mineQty'] ?? 0),
$this->money($row['mineUnitPrice'] ?? 0),
$this->money($row['mineAmount'] ?? 0),
$this->number($row['theirsQty'] ?? 0),
$this->money($row['theirsUnitPrice'] ?? 0),
$this->money($row['theirsAmount'] ?? 0),
$this->quantityDifference($row['qtyDifference'] ?? 0),
$this->amountDifference($row['amountDifference'] ?? 0),
$row['note'] ?? '',
$this->money($row['settleAmount'] ?? 0),
]);
}
fclose($csv);
}, $filename, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @param resource $csv @param list<mixed> $values */
private function write($csv, array $values): void
{
fputcsv($csv, array_map(fn (mixed $value): string => $this->csvCell($value), $values));
}
private function quantityDifference(mixed $value): string
{
$difference = (float) $value;
if (abs($difference) < 0.005) {
return '一致';
}
return '厂家' . ($difference > 0 ? '多 ' : '少 ') . $this->number(abs($difference));
}
private function amountDifference(mixed $value): string
{
$difference = (float) $value;
if (abs($difference) < 0.005) {
return '一致';
}
return '厂家' . ($difference > 0 ? '多 ¥' : '少 ¥') . $this->money(abs($difference));
}
private function number(mixed $value): string
{
return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.');
}
private function money(mixed $value): string
{
return number_format((float) $value, 2, '.', '');
}
private function csvCell(mixed $value): string
{
$text = trim((string) $value);
if (is_numeric(str_replace(',', '', $text))) {
return $text;
}
return preg_match('/^[=+\-@\t\r]/u', $text) === 1 ? "\t" . $text : $text;
}
}

View File

@ -165,7 +165,7 @@ class SupplierDingTalkNotifier
'- 对账月份:' . ($month !== '' ? $month : '全部月份'),
'- 对账人:' . ($reconciler !== '' ? $reconciler : '全部'),
'- 差异共 **' . $summary['count'] . '** 条(厂家多 ' . $summary['extra']
. ' / 金额数量不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
. ' / 数量金额不一致 ' . $summary['mismatch'] . ' / 厂家少 ' . $summary['missing'] . ')',
'- 差异金额:**¥' . number_format($summary['difference_amount'], 2) . '**',
];
@ -179,22 +179,29 @@ class SupplierDingTalkNotifier
$theirsQty = (float) ($row['theirsQty'] ?? 0);
$mineAmount = (float) ($row['mineAmount'] ?? 0);
$theirsAmount = (float) ($row['theirsAmount'] ?? 0);
$mineUnitPrice = (float) ($row['mineUnitPrice'] ?? $row['unitPrice'] ?? 0);
$theirsUnitPrice = (float) ($row['theirsUnitPrice'] ?? $row['unitPrice'] ?? 0);
$purchaseNo = trim((string) ($row['purchaseNo'] ?? ''));
$date = trim((string) ($row['date'] ?? ''));
$barcode = trim((string) ($row['barcode'] ?? ''));
$supplierBarcode = trim((string) ($row['supplierBarcode'] ?? ''));
$lines[] = sprintf(
'%d. **%s** `%s`%s 单价 ¥%s|我方 %s / ¥%s|厂家 %s / ¥%s|%s',
'%d. **%s**|采购单号:%s|日期:%s|商品编码:%s%s',
$rowOffset + $index + 1,
(string) ($row['type'] ?? '差异'),
$purchaseNo !== '' ? $purchaseNo : '—',
$date !== '' ? $date : '—',
$barcode,
$supplierBarcode !== '' ? '(厂家条码 ' . $supplierBarcode . ')' : '',
number_format((float) ($row['unitPrice'] ?? 0), 2),
$this->number($mineQty),
number_format($mineAmount, 2),
$this->number($theirsQty),
number_format($theirsAmount, 2),
(string) ($row['note'] ?? ''),
);
$lines[] = ' 我方:数量 ' . $this->number($mineQty)
. '|单价 ¥' . number_format($mineUnitPrice, 2)
. '|金额 ¥' . number_format($mineAmount, 2);
$lines[] = ' 厂家:数量 ' . $this->number($theirsQty)
. '|单价 ¥' . number_format($theirsUnitPrice, 2)
. '|金额 ¥' . number_format($theirsAmount, 2);
$lines[] = ' 处理:' . (string) ($row['note'] ?? '');
}
$lines[] = '';

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