Compare commits

...

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N5KQeRJaLrbJY9iKNC5veV
2026-10-18 22:27:16 +08:00
b2dd1e5e6b 报价管理:修复列设置后筛选翻页表头与内容错位 (#388) 2026-10-18 14:25:25 +08:00
e428cf5de2 报价历史:支持勾选并批量审核通过 (#387) 2026-10-18 14:05:40 +08:00
2d30aea624 报价管理:移除列表黄色分组说明行 (#386) 2026-10-18 13:29:18 +08:00
409da4cae8 feat: 提现管理余额日期改为下拉选择 2026-10-18 12:09:42 +08:00
da794c3ddb feat: 提现管理支持按余额日期筛选并长期保留每日余额 2026-10-18 11:52:02 +08:00
b49d23d090 供应商配件运费:外采快递不扣款 (#385) 2026-10-17 21:49:09 +08:00
23887059f1 多时期补录保留选定快递网点编号 (#384) 2026-10-17 21:09:11 +08:00
10d9c22e3e 报价执行期间冲突显示明确错误提示 (#383) 2026-10-17 21:02:31 +08:00
bd39a7fdda 存量报价支持多时期导入、快递分组及按日期查看 (#382) 2026-10-17 20:58:04 +08:00
077701d24a 供应商应付:新增新河北仓、曹县仓配件运费扣除明细 (#381) 2026-10-17 20:54:13 +08:00
d24eefb687 补全工作平台供应商管理字段同步和展示 (#380) 2026-10-17 13:12:52 +08:00
3638a8cee9 货款对账:差异支持勾选多条推送 (#379) 2026-10-17 13:05:10 +08:00
41f6946a2b 货款对账:差异支持按厂家钉钉机器人推送 (#378) 2026-10-17 12:54:30 +08:00
3fedeef59a 修复存量报价审核生效、原件预览与配件补发可扣款清单 (#377) 2026-10-17 12:49:39 +08:00
a0a6e9285a 供应商对账:新增厂家账号门户与独立退货视图 (#376) 2026-10-17 12:01:30 +08:00
18d03fe092 供应商对账:厂家退货支持查询导出并精简字段 (#375) 2026-10-17 11:13:18 +08:00
b0d7096241 供应商对账:厂家端退货表表头与退货明细完全一致 (#374)
厂家端退货表列头与退货明细一致(补齐月份/条形码/商品名称等),类型/操作在最后。
2026-10-17 10:53:13 +08:00
c69796b0d3 供应商对账:退货明细按导入顺序展示 (#373)
退货扣减确认取消金额排序,按导入顺序分页展示。
2026-10-17 10:24:25 +08:00
a8ffb6fc23 供应商对账:退货扣减确认展示全部退货行 (#372)
退货行按扣减金额从高到低分页展示(每页 15 行 + 页码),不再只显示前 15 行。
2026-10-17 10:05:08 +08:00
670faf2b80 供应商对账:表格改为卡片内横向滚动 (#371)
宽表不再撑开页面:卡片/滚动容器 min-width:0 + max-width:100%,表格在卡片内横向滚动。
2026-10-17 09:54:38 +08:00
feea2ed8b6 供应商对账:厂家上传的对账数据改为每页 15 行 + 页码翻页 (#370)
上传的对账数据表改为每页 15 行短表,底部总行数+页码(含上一页/下一页),翻页保留所选厂家。
2026-10-17 09:47:59 +08:00
2848c81a5e 供应商对账:退货明细改为「退货扣减确认」面板 (#369)
退货扣减确认:顶部汇总、逐行确认/异议、全部确认(不覆盖异议)、金额空按预估金额扣减,展示金额最高 15 行。
2026-10-17 09:40:54 +08:00
8aa7676853 供应商对账:查看比对改为独立明细视图 (#368)
财务端点「查看比对」进入只显示该厂家明细的视图(汇总+行级差异),带返回列表按钮并保留筛选。
2026-10-17 09:23:52 +08:00
3b4db0e273 供应商对账:单价/金额留空自动补齐 (#367)
单价留空按金额÷数量补齐、金额留空按数量×单价补齐、都空按 0(数量必填),未定价行厂家可直接上传。
2026-10-17 09:16:51 +08:00
2ffb49ca92 供应商对账:条码匹配去掉厂家编号/版本后缀,表头不符提示缺列 (#366)
比对键先去掉厂家编号+版本后缀再匹配(实测雪翔家居文件),页面显示厂家原始条码;表头校验失败列出缺少/多出的列名。
2026-10-17 09:07:16 +08:00
2a80f380e6 供应商对账:财务端数据在月份后新增「对账人」筛选 (#365)
对账人筛选取自终版货款对账的对账人列,按月过滤后生成选项;选择后供应商列表与比对明细同步过滤。
2026-10-17 08:57:14 +08:00
87eaf48c2c 供应商对账:应付款项下新增对账页面(财务端数据 / 厂家端数据) (#364)
新增供应商对账目录:财务端实时上传情况与行级差异(条码+单价匹配,四类口径),厂家端严格按模板上传(表头不一致/必填缺失/非数字/串厂一律失败);复用终版存储与流式读取。
2026-10-17 08:49:17 +08:00
82c79dadc3 feat: 店铺维度报表销量改取业绩明细的订单实付/实收金额 (#363) 2026-10-17 00:19:09 +08:00
bcf8ea8fb7 应付款终版导入:流式读取改用 zip:// 管道,解析内存 274MB→76MB (#362)
sharedStrings 与工作表 XML 改为 XMLReader 经 zip:// 流式解析;200k 行基准读取从 6.2s/274MB 降到 2.3s/76MB。
2026-10-17 00:05:05 +08:00
656c655c91 应付款终版导入:支持 20 万行(流式读取 + 分片导入 + 进度显示) (#361)
新增流式 xlsx/csv 读取器与分片导入:上传建行缓存,按片处理(2 万行/25 秒预算),批量 upsert + 批量审计事件,前端进度条自动续跑;上限 20 万行,上传 50MB。
2026-10-16 23:54:03 +08:00
7742163632 应付款终版导入:分块读取工作簿,避免大文件把进程撑爆导致 500 (#359)
按 2000 行分块读取(大文件不再整表载入内存),读满 1 万行立即提示拆分文件;php.ini 补 log_errors/error_log,致命错误可查。
2026-10-16 23:15:33 +08:00
8429ad0642 应付款终版货款对账:采购单号后新增「日期」列 (#358)
「月份」后依次为入库单号、采购单号、日期;导入模板/表格/导入/编辑/导出同一套字段,只作用于终版目录。
2026-10-16 22:34:01 +08:00
0a54775b3b 配件补发扣款:快递比例配置、Excel导入和归属选择 (#357) 2026-10-16 22:27:46 +08:00
b26ab9f85d 快递应付:首发信息拆列及明细字段筛选 (#356) 2026-10-16 22:17:52 +08:00
688ae30747 应付款终版退货对账:wms配货单号调整到标题后 (#355)
「标题」在前、「wms配货单号」在后(与基础页面一致),导入模板与页面/导出同步;其他页签不受影响。
2026-10-16 22:17:05 +08:00
b4eaa1e102 快递应付:新增配件补发扣款匹配界面 (#353) 2026-10-16 21:36:03 +08:00
e6f4b61761 应付款终版退货对账:创建人调整到对账人前 (#352)
「标记多标签」后补创建人、「对账人」保持末列,导入模板与页面/导出同步;其他页签不受影响。
2026-10-16 21:35:39 +08:00
3e1d211d1b 应付款终版退货对账:新增「wms配货单号/创建人」列并去掉「快递名称」 (#352)
「备注」后插入 wms配货单号,末尾补创建人,删除快递名称列;导入模板/表格/导入/编辑/导出同一套字段,只作用于终版目录。
2026-10-16 21:23:25 +08:00
7fbcd061af 应付款终版货款对账:新增「入库单号/采购单号/对账人」列 (#351)
「月份」后补入库单号、采购单号,末尾「未发出」后补对账人;导入模板/表格/导入/编辑/导出同一套字段,只作用于终版目录。
2026-10-16 20:58:42 +08:00
da2a26c485 应付款终版货款对账:导入模板与表格新增「未发出」列 (#350)
终版目录的货款对账页在现有字段后追加「未发出」(导入模板/表格/导入/编辑/导出同一套字段);基础目录的「未发出」是按来源数据计算的标记,保持不变。
2026-10-16 20:12:23 +08:00
37c11f4e8e fix: 提现管理店铺余额保留最近5天 (#349)
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-10-16 19:00:49 +08:00
2399d42d6d feat(存量报价): 支持按发货名称手填录入及无编号报价 2026-10-16 17:58:35 +08:00
chenxi
5b89692318 商品成本:收入为 0 的账单行不取数、单价加线上代发价格兜底
- 账单侧只取收入金额有数据且不为 0 的业绩明细账单行(收入为 0 的是补出来的优惠/冲减/无收款账单行)
- 商品单价按「仓库简称 + 商品编码」取月加权平均价,匹配不到时退回「线上代发价格」
  (采购单管理的采购单价优先,其次钉钉线上代发的代发订单金额 ÷ 发货数量),并记录 price_source
- 新增数量与单价口径校验:定制配件行把配件件数写进商品数量,乘成品单价会放大几百倍,
  成本单价高于该行明细单价 3 倍时成本留空、标注「数量与单价口径不一致」,不计入合计
- 页面与导出列改为业务口径的 9 列(发生时间/店铺名称/线上单号/发货仓库/仓库简称/商品编码/商品数量/商品单价/商品成本),
  汇总卡片新增「线上代发价格兜底」「数量与单价口径不一致」两项
2026-10-16 17:34:06 +08:00
c4a39ec265 科目映射表:存储改共享数据库(ec_account_mapping_rules / ec_account_mapping_events,本地与生产同一份,改完两边同时生效);旧文件保留为只读备份镜像;账单明细改读共享库;新增 account-mapping:import-files 导入命令 2026-10-16 17:19:37 +08:00
c50244544d 业绩明细备注:京喜「直营补贴款」两条规则扩到 10 家店(出类拔萃 + 胜思创博家具/久伴吾家具/悦家/蓄势待发/日进斗金/上彩/美栖家/荣屹/亿家达京喜自营,均已逐店在结算单核实) 2026-10-16 17:18:42 +08:00
f3df42f46b fix(存量报价): 修复批量保存被安全检查误拦截 (#347) 2026-10-16 16:28:18 +08:00
706b07b4ad 业绩明细:淘系支付宝/微信新增「订单实付含已退款部分」核实(差额/收款缺口/整单全退对上聚水潭已确认退款,与抖音同口径,EXISTS+HAVING 提速);店铺名优先展示线上真实店铺(修复「支付宝-公司」壳名);字段说明同步 2026-10-16 16:18:23 +08:00
9a25db91cc feat(报价管理): 存量报价整本批量录入及签署原图归档 (#346)
保留各家工作表报价原文、公式、独立执行日期及签署原件;审核后按执行日期管理版本。33项测试、336项断言通过。生产业务迁移已获明确授权,服务器私有备份已完成并逐条校验。
2026-10-16 14:56:44 +08:00
6c76a373a3 业绩明细:合单发货取数优先取原单行(merge_so_id 为空),修复 878 行误取合并汇总金额(7/8月实付合计修正 -11.17 万);商品金额/实付/实收/应付统一三档优先级;字段结构说明同步新取数与口径兜底/待核实顺序 2026-10-16 12:00:42 +08:00
8fbc9abd53 聚胜算每日账单导出:等待胜算标签页注册并提高导出重试次数
- export-daily.mjs:点击「胜算」后轮询等待 ss.erp321.com 标签页注册(最多 30s,中途补点一次),
  避免 targetInfos 尚未出现时直接读 targetId 抛 TypeError(2026-10-13 09:30 首次尝试即因此失败)。
- run-erp-daily.ps1:导出重试 2 次提高到 3 次(2026-10-12 两次全败后只能人工补跑)。
2026-10-16 10:17:58 +08:00
b0ea247ca1 聚胜算每日账单导出:修复授权过期弹窗卡住与日期面板点空导致的导出失败
1) 店铺授权过期提醒弹窗在无头 Edge 下会卡在 leave 动画,弹窗与遮罩一直可见,挡住日期面板和查询/导出按钮;补发 animationend/transitionend 让 antd 正常卸载,仍残留则退化为隐藏该弹窗自己的 portal 容器(并解除 body 滚动锁),只影响这一棵弹窗。
2) 日期面板有时只渲染在屏幕外(left/top=-9999):原逻辑按 getBoundingClientRect 拿到负坐标去点,点了空还当成选中,最终日期框没变(表现为「日期设置失败」或整月导出失败);现在视口内照旧坐标点击,视口外退回 DOM click。
3) 页签切换后筛选区偶尔未渲染,取「开始日期」输入框坐标会报 undefined;改为先等输入框出现,并在取不到时重试而不是抛错。

验证:7/8/9 月历史复核导出各 1 次成功(23771/25020/44702,与启用代次一致);10-01→10-11 当日导出首次即成功。
2026-10-16 10:17:57 +08:00
e064492a95 业绩明细备注:撤销「订单实付未减退款」(价保扣款类回归通用档「订单实付未减冲减」,该档 = 订单实付=收入+优惠 → 差额=冲减金额) 2026-10-16 10:02:57 +08:00
422497cfcc 业绩明细:C 方案——三条通用等式档(未减冲减/优惠/优惠和冲减)在口径标识行让位给口径兜底;差额待核实档排除口径标识行 2026-10-16 09:44:21 +08:00
74a9b5b81f fix(表格列设置): 修复面板被侧栏遮挡并适配视口边缘 (#345) 2026-10-15 22:02:06 +08:00
23b8087d35 fix(报价签署): 修复临时报价说明中文显示问号 (#344) 2026-10-15 21:21:15 +08:00
d1b14fc10d feat: 合作合同乙方签约地支持编辑 (#343) 2026-10-15 18:13:24 +08:00
5c20defc79 业绩明细:修正有效差额 SQL(排除「差额待核实」兜底档,口径兜底行才真正按 0 计入合计/筛选) 2026-10-15 17:56:03 +08:00
01abeb4124 业绩明细备注:京东价保扣款负差额(差额=账单「价保扣款」合计)新增标注「订单实付未减退款」(含索菲亚智能店 3587456013325741 等 20 单) 2026-10-15 17:58:11 +08:00
173ca7c2ec 业绩明细:差额判定改为「原因优先 → 实收/应付口径兜底 → 待核实」;口径命中不再抹差额、页面与合计/筛选/联动同一套判定 2026-10-15 17:51:01 +08:00
564eb1ef08 fix: 协议签署业务操作授权与按钮权限一致 (#342) 2026-10-15 17:38:17 +08:00
1706131d9c 业绩明细备注:索菲亚功能店并入「商家承担优惠」备注组(8 单已用营销对账逐单核实);索菲亚智能店负差为价保扣款、不属本类未纳入 2026-10-15 17:35:21 +08:00
f4a0014690 业绩明细备注:京东五店负差额按单拆分——含国补(聚水潭应付−账单收入>0)标注「订单实付包含商家承担优惠和以旧换新国补」,其余标注「订单实付包含商家承担优惠」 2026-10-15 17:19:26 +08:00
288184c8dc feat: 快递资料合同甲乙方与协议签署联动 (#341)
新增合同主体(乙方)与签订公司(甲方)只读展示和导出;协议签署自动带出唯一乙方,缺失或冲突留空核对。19项直接相关测试与实际浏览器联动验证通过。无权限、金额、数据库结构或配置改动。
2026-10-15 17:00:21 +08:00
60163f6c32 业绩明细备注:京东索菲亚书房/亿家达全屋/朴美家居店负差额标注「订单实付包含商家承担优惠和以旧换新国补」(营销对账逐单核实) 2026-10-15 16:54:07 +08:00
c30cff7d0d fix: 移除快递合同管理签署完成证书列 (#340)
删除合同管理中的证书列和对应入口,保留已签合同查看、下载及归档文件。9 项测试 / 71 断言及实际浏览器验证通过。
2026-10-15 15:55:41 +08:00
6518995e45 业绩明细备注:京东索菲亚客厅/办公店负差额标注「订单实付包含商家承担优惠和以旧换新国补」 2026-10-15 15:40:54 +08:00
3e222e0035 业绩明细:实收对不上账单时也退回按应付/已付核对(覆盖拆单主单实收为空、子单有值的情况) 2026-10-15 15:38:07 +08:00
f41a8fa6ba 钉钉登录:单独授权人员仅访问三个快递报价页面 (#339)
按精确企业身份开放受限登录,保留在职校验和普通角色;统一限制菜单、页面、导出与预览。59 项相关测试通过,无数据库迁移。
2026-10-15 15:21:05 +08:00
1214fc365e 业绩明细:「订单实付按应付/已付核对」标注在页面展示并纳入筛选与联动计数(此前只在库里有、页面不显示) 2026-10-15 14:59:44 +08:00
a4952dfa42 Revert "业绩明细:新增备注「订单退款未参与差额」;字段结构说明同步(差额待核实/匹配原因/三档新备注)"
This reverts commit 0d215fe20c22e2e2ba3dbe82411d760aa0d7bac2.
2026-10-15 14:27:47 +08:00
0d215fe20c 业绩明细:新增备注「订单退款未参与差额」;字段结构说明同步(差额待核实/匹配原因/三档新备注) 2026-10-15 14:23:10 +08:00
cccd321195 快递应付:清空运费对账页面并停用错误数据源 (#338) 2026-10-15 14:08:05 +08:00
1131d073ce 快递入驻:指定钉钉初复审人员,复审任一人通过 (#337) 2026-10-15 13:52:30 +08:00
72b3f43da5 业绩明细:实收缺失时退回按应付/已付核对;同步命令新增 --dry-run 开关 2026-10-15 13:21:36 +08:00
c314209705 业绩明细备注:京喜出类拔萃店负差额标注直营补贴款(待开票结算/订单退款已冲回) 2026-10-15 12:04:41 +08:00
4e38a1cf11 业绩明细:双口径兜底阈值放宽到 0.5 分(1 分尾差也按卖家实收核对归零) 2026-10-15 10:43:37 +08:00
fd4b155979 feat(快递资料): 归档合同管理与到期提醒 (#336) 2026-10-15 10:22:35 +08:00
8d7f6da49f feat(协议签署): 按需生成证件推送单并独立签署归档 (#335) 2026-10-15 08:19:32 +08:00
8ba7ac8327 feat(快递入驻/快递资料): 生成二维码时必填快递资料并对齐到快递资料;去掉「发起报价变更」按钮;自定义3 改名扣款方式
- 生成入驻二维码新增 11 个必填项(快递方看不到,放在入驻流程下方单独一块):快递类型、快递合作方式、快递属性、扣款方式、单号类型、快递邮箱、
  是否需要核对物流点、快递发货方式、是否已有工作平台账号、快递网点代码、网点所在地区;入驻列表、入驻详情可查看和修改
- 快递资料:扣款方式对齐到「扣款方式」列(工作平台没填时用入驻的),其余字段接在入驻信息列里;网点代码/地区进网点明细;导出同步
- 不再依赖钉钉「新建快递」流程字段,ERP 开通改为手动补录 ERP账户ID
- 快递资料「自定义3」列、筛选、编辑和导出表头改名为「扣款方式」(字段仍是 undefined3)
- 报价管理工具栏、报价历史页去掉「发起报价变更」:它默认拿第一份报价走老的改格子流程,对入驻快递会绕开后台变价;
  变价统一从报价列表每一行的「变价」进入;老报价编辑页新增 …/changes/new 入口,避免套餐限制中间件把 GET …/create 跳到用户页
- 新增迁移 2026_10_14_300000:business_courier_onboardings 加 11 个可空字段

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
2026-10-14 18:31:25 +08:00
c0aab60862 业绩明细:订单侧双口径核对(买家实付/卖家实收)+ 差额待核实 + 列名与筛选改名
- 同步:账单能对上卖家实收时按实收核对(差额归零、订单实付/实收列显示实收值),并写入标识「订单实付按卖家实收核对」
- 页面:新增兜底备注「差额待核实」(可筛选);该标识在「匹配原因」列展示并纳入筛选与联动计数
- 列名:「订单实付金额」→「订单实付/实收金额」;「未匹配原因」→「匹配原因」
- 新增 scripts/docker-fix-start.ps1(Docker 因僵尸 socket 起不来时一键清理并启动)
2026-10-14 17:54:56 +08:00
9c63f36944 fix(发货数据): 无快递单号的自动发货仓旧包裹也按被取代清除
- 10.11/10.12 剩余约 476 个省份空白包裹:凌晨同步时按自动发货仓入库、当时订单还没有快递单号;之后订单有了发货明细,
  改由发货明细来源键吐出,旧包裹因无单号未被识别为被取代而残留(重复且省份空白)。
- 窗口内未再出现的旧包裹,若为自动发货仓来源且该订单本次已有发货明细,或无单号但同一交易单号本次已由其它包裹吐出,视为被取代删除。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
2026-10-14 15:34:47 +08:00
afd8487cd4 部署工具:密码提示改为 -Command 方式调用脚本(修复 ssh 子环境下 -File 被系统拒绝导致登录失败) 2026-10-14 15:11:33 +08:00
71652c5a64 feat(快递入驻): 快递扫码填写入驻资料 + 两级审核 + 首版报价 + 签署开通
快递应付 → 快递报价 下新增「快递入驻」页:生成入驻二维码发给快递方,
对方免登录在手机上填写主体、联系人、e签宝签署人、结算账户、报价并上传资质,
内部对接人初审、管理员财务复审后生成首版报价,并自动生成报价确认单 PDF
推送到快递填写的 e签宝账号,签署归档后开通。

- 新增三张表 business_courier_onboardings / _files / _events(纯新增,不改旧表)
- 免登录填报页 /onboarding/courier/{token}:40 位随机 token,可暂存、提交、退回后补件,
  链接有效期可延长、可作废;银行账号加密存储;附件存 uploads 盘
- 新增 COURIER_ONBOARDING_PUBLIC_URL 配置二维码里的站点地址(留空用当前访问地址)
- 内置纯 PHP 二维码生成(App\Support\QrCode,SVG 输出,无新增依赖)
- 生成二维码时必须定好系统快递名称(唯一,快递方不可见);财务复审沿用该名称,快递所填报价写成报价管理的 CourierQuoteVersion,
  复用 CourierQuoteSignatureService 生成报价确认单并推送 e签宝(失败可重推/同步状态);初审人默认不能复审(COURIER_ONBOARDING_ALLOW_SAME_REVIEWER=true 可在测试环境放开)
- 支持公司 / 个人两种入驻:个人填姓名+身份证号(加密)、传身份证和银行卡,报价确认单按个人身份在 e签宝签署
- 扫码先选入驻方式;证件改为 营业执照 / 法人身份证正反面 / 银行卡正反面 各一张(个人营业执照选填),手机端上传前自动压缩,超限回到填报页提示
- 报价支持 4 种计费方式(面单+费率 / 首重+续重 / 按重量段一口价 / 小重量一口价+超出另算,费率可分段)+「其他」上传报价表;附加规则:计泡、起算重量、取整、每票加收(CourierQuoteStructure)
- 不再要求上传盖章报价单,至少填写一行在线报价
- 开通:报价确认单签署归档、或匹配到已签署协议、或填写线下签署说明
- 生成二维码前必填「快递公司代码」(自动大写、不可重复),入驻信息按代码对齐快递资料
- 公司入驻对公(营业执照主体)+ 对私(法定代表人)两个打款账户必填并自动核对;个人入驻对私必填、对公选填
- 变价:报价管理里后台改价 → 审核 → 自动推送快递 e签宝 → 回签;回签单导出 xlsx/zip;误发版本可作废
- 短期(临时)变价:变价单/报价版本加选填「结束日期」(end_date);按日期取价,期间用临时价,到期次日自动恢复原长期报价,
  不用重签;报价管理显示 生效中(临时)/ 暂停中 / 已到期,确认单写明执行期间和到期后恢复的版本,审核提醒期间重叠
- 快递资料分四类:快递资料 / 网点 / 打款信息 / 操作日志,支持查询、导出、重置、编辑;
  财务系统入驻的快递直接作为快递资料行,合同起止取协议签署里已签署的合作合同;
  点开一行在下方看该快递的网点 / 打款信息(完整账号)/ 操作日志
- 编辑存财务系统覆盖值(business_courier_profiles,不回写工作平台),改动记操作日志(business_courier_profile_events)
- 打款信息新增 / 变更(business_courier_payment_accounts,账号加密)保存后跳到协议签署,自动预填收款账户证明 / 收款账户变更,
  按签署状态显示 待推送协议 / 签署中 / 已生效;入口在快递资料明细和「打款信息」页签(表格每个账户可直接「变更」)
- ERP 开通:入驻开通后标「待 ERP 开通」,可手动补录(钉钉「新建快递」流程同步待接入)
- 新增 CourierOnboardingWorkflowTest(8 个用例),更新快递报价目录顺序断言

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gzfsnV3fJ89CoUy1D9V9g
2026-10-14 13:47:00 +08:00
1684902863 业绩明细取数:「买家实付」字段未同步改价/退款时,采用能与账单对平的「已付」金额(仅负向纠偏) 2026-10-14 13:47:25 +08:00
e26c1e467e 业绩明细取数:合并发货行不再当作单笔金额(实付与商品优先取本单非合并行) 2026-10-14 09:56:10 +08:00
f6497f5afc fix(发货数据): 重跑时清除被取代的兜底包裹,保留包裹也刷新省市区/快递/仓库
- 凌晨同步时发货明细常未到齐,包裹先按自动发货仓/出库单兜底入库(无省份、无商品);之后发货明细到齐换成发货明细来源键,
  旧兜底包裹因"上游未再出现"被保留,造成重复和空白省份(10 月重跑 2,226 个)。现在:旧包裹的快递单号已由本次其它包裹吐出时
  视为被取代并删除;无对应的仍保留待核对。
- 商品金额无法安全匹配(或新商品明细缺失)而保留的旧包裹(10 月 28,201 个),此前整包不更新;现在商品与金额不动,
  省市区、快递名称、仓库、店铺、平台、交易单号、付款时间按本次上游刷新。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
2026-10-14 08:54:45 +08:00
b4f8416794 店铺违规预警:已超时口径限定需提醒范围,字典导入命令兼容大维度文件 (#334) 2026-10-13 20:13:53 +08:00
0c04860d69 店铺违规预警:修复重算命令严格模式报错,新增违规字典导入命令 (#333) 2026-10-13 20:04:02 +08:00
ea80148dfc 店铺违规预警:级别/责任人/处理截止/超时/推送状态 + 处理状态四档 + 群推送接口 + 预览统计页 (#332) 2026-10-13 19:54:01 +08:00
530c9858ec 业绩明细:月份选完自动查询(月份选择器补值变更事件 + 筛选栏自动提交) 2026-10-13 17:57:28 +08:00
8a67c7d768 业绩明细:1688采销专用收款单独成档(未匹配原因新增两档)
- 同步逻辑:「支付宝-1688采销专用」账户的未匹配行写「账单无单号(采销收款)」「1688采销收款(非销售单)」,不再混进「订单表无此单」
- 页面筛选:未匹配原因下拉补上两档
- 字段结构说明同步更新
2026-10-13 17:46:18 +08:00
10d3067aee 业绩明细:京喜备注统一为「订单实付取供货价」(替换此前的宽判定版本,负责人确认)
同事此前以「差额=直营服务费(已计销售费用)」宽判定(京喜正差额即标,约 2% 与本单服务费对不上);经负责人确认,统一为「订单实付取供货价」:逐单核对差额=直营服务费规模或净额(部分退款场景),对不上不标。
2026-10-13 16:44:17 +08:00
d8d5196628 chore:.gitignore/.dockerignore 排除本地演示文件 2026-10-13 16:43:05 +08:00
5ab7786a90 业绩明细:京喜新增「订单实付取供货价」备注(差额=直营服务费规模或净额);联动缓存延至 10 分钟;计数与合计合并一次扫描
京喜工厂店/直营为供货模式:聚水潭订单实付=供货价(净收),账单按「应收−直营服务费=供货价」结算,正差额即直营服务费(含部分退款后的净额场景);逐单核对后标注,对不上不标。得物/京东补充索引已就绪。
2026-10-13 16:43:05 +08:00
7258d09238 业绩明细:列顺序统一「差额→差额异常备注→匹配状态→未匹配原因」;差额筛选并入统一多选组件(联动计数/导出/重置) 2026-10-13 16:43:04 +08:00
ae54706ceb 业绩明细:抖音「订单实付含已退款部分」再补充口径(退款合计≈实付 整单全退) 2026-10-13 16:37:45 +08:00
2be4d4700e 业绩明细:抖音「订单实付含已退款部分」补充口径(退款合计+账单退款净额≈实付−收入) 2026-10-13 16:08:38 +08:00
45a28aa76c 快递报价:工作区 iframe 内不再重复显示已打开页面标签栏
- 已打开页面标签栏只在最外层窗口渲染,iframe 子页面不再出现同一条标签栏,
  避免点标签时在 iframe 内部再套一层快递报价工作区(页面上出现两份同样的页签+标签行)
- 快递报价工作区注入的样式同时隐藏 .open-tabs,兜住浏览器缓存的旧子页面
2026-10-13 15:51:04 +08:00
1c00548396 标签栏:刷新后至少保留当前页,避免空着 2026-10-13 15:26:40 +08:00
a8a0c6d4c9 已打开页面标签栏改为按浏览器会话保留
之前存在 localStorage:跨浏览器重启也不会清,前几天点过的标签刷新后又出现
(用户反馈"没点过/刷新后其他标签又弹出来")。改成 sessionStorage:同一次浏览器
会话内刷新、来回切换都保留,关掉浏览器或另开窗口重新进来就是干净的;同时清掉
旧版本写在 localStorage 里的记录。
2026-10-13 15:24:26 +08:00
add787edf5 已打开页面标签栏上移到页面标题行上方
按使用习惯把标签栏放在内容区最上面,页面标题/操作那一行在它下面。
2026-10-13 15:18:37 +08:00
c5c71dab74 已打开页面标签栏:只收录从系统里点开的页面
之前只要在浏览器里加载过业务页面就会固定上去,包括浏览器恢复会话、另外标签页
静默加载的页面(用户没点过也会出现)。改成只在导航来自同源页面(即从系统内点击
进入)时才新增标签;已经在标签里的页面仍会更新标题和最近一次地址。
2026-10-13 15:13:14 +08:00
5970572274 业绩明细:抖音「订单实付含已退款部分」判定放宽(退款金额≈差额 或 ≈实付−收入 命中即可) 2026-10-13 15:11:42 +08:00
a884da7057 侧边栏菜单搜索 + 已打开页面标签栏
1)侧边栏菜单上方新增搜索框:按关键字过滤菜单项(如输「快递应付」直接定位),
   无匹配时给出提示,Esc 清空。
2)页面顶部新增「已打开页面」标签栏:点开过的业务页面并排固定在页面上方,
   点标签直接切回(回到最近一次带筛选的地址);同一张页面按路径去重;
   标签按账号存在浏览器本地(localStorage),没退出登录就一直保留;
   支持单个关闭和「清空(保留当前页)」,最多并排 15 个,超出横向滚动。
   钉钉单页入口模式下不显示这个标签栏。
2026-10-13 14:57:22 +08:00
cdcb1c6662 应付云仓车费:明细行点击高亮 (#331) 2026-10-13 14:29:57 +08:00
d3049686b4 应付云仓车费:移除「全部表单字段/付款明细/审批任务」三列 (#330) 2026-10-13 14:12:47 +08:00
8905192b78 应付云仓车费:附件支持点击下载 (#329) 2026-10-13 13:58:31 +08:00
26b83bd8e7 钉钉入口单页模式扩到整个「财务经营风险管控」目录
上一版只对店铺违规信息预警单页;在钉钉里点该目录的另一个页签(流程统计)又会
冒出整排菜单。改成:只要属于 financial-risk-control 目录、且来自钉钉容器
(或显式 ?focus=1),就隐藏全局菜单,只保留该目录自己的页签。
2026-10-13 13:53:49 +08:00
7a5f04287c 钉钉工作台入口:店铺违规信息预警整页展示(隐藏全局菜单)
钉钉工作台里点进来的这个入口,现在只显示这一张页面:不再渲染全局侧边栏,
内容区占满宽度,其余页面和网页端登录完全不受影响。

判断依据:当前页面是 financial-risk-control/store-violation-alert,且请求来自
钉钉容器(UA 含 DingTalk)或显式带 ?focus=1;路由参数取不到时按路径段兜底。
2026-10-13 13:28:59 +08:00
57f2dccfac 应付云仓车费:修复附件列为空 (#328) 2026-10-13 13:26:59 +08:00
b962c23e92 附加费结算:导入/明细头收紧、结果统计并入导入行右侧 (#327) 2026-10-13 13:13:14 +08:00
0fa52090f7 退货取件管理:同步条与筛选区收紧、列表区放大 (#326) 2026-10-13 12:10:58 +08:00
7223a2eb2e 应付云仓车费:表格数据区填满到底部 (#325) 2026-10-13 11:53:43 +08:00
6b6b97a375 应付云仓车费:筛选区收紧、表格区放大 (#324) 2026-10-13 11:32:28 +08:00
d47a72c175 店铺违规预警:新增违规原因与处置方式两列(页面/统一表/导入导出) (#323) 2026-10-13 11:32:27 +08:00
ac40c6d8a9 镜像索引:dewu_settlement_bill_rows 增加 (online_order_no, bill_item) 索引(业绩明细得物备注联动计数 69s 慢查询) 2026-10-13 11:31:50 +08:00
7e1ded5112 附加费结算:修复筛选设置面板左缘溢出被裁切 (#322) 2026-10-13 11:14:07 +08:00
f8a83cf684 业绩明细:京喜正差额新增「差额=直营服务费(已计销售费用)」备注 2026-10-13 11:15:21 +08:00
d1ffaf773c 业绩明细:得物负差额新增「订单实付未减联合营销费」备注;抖音备注改名「订单实付含已退款部分」
得物:负差额 = 联合营销费未让利尾数(该单营销费支出 + 差额恰为整数)时标注;规则排在通用「未减冲减/优惠」之前,避免被笼统备注抢先;证据子查询仅在得物负差额行计算。抖音:备注名统一加「订单」前缀。导航说明同步更新。
2026-10-13 11:07:55 +08:00
53b0821384 快递应付:仓储运费更名为仓储费 (#320) 2026-10-13 10:59:30 +08:00
8b62ba0504 附加费结算:筛选字段可自选显示(筛选设置) (#321) 2026-10-13 10:59:29 +08:00
bbf8c1f16c 附加费结算:分页改为退货取件管理同款紧凑样式 (#319) 2026-10-13 10:41:33 +08:00
03fea599fb 附加费结算:收紧顶部概览/筛选/导入区 (#318) 2026-10-13 10:29:37 +08:00
f663ddc51f 附加费结算:明细行点击高亮,方便核对时标记当前行 (#317) 2026-10-13 09:56:41 +08:00
7ecd5a59e6 fix(发货数据): 快递名称、仓库按发货数据视图取值,与工作平台一致
- 9 月核对剩余 256 条快递名称、44 条仓库不一致;抽样 14/14 工作平台显示的就是发货数据视图
  (Jst_SendData_View_extension) 的 expressName / wareHoursName。视图有值时以视图为准,
  视图没有该出库单时保留出库单/发货明细推断的值。
- 发货数据视图、出库单头按 300 个出库单一批查询(原 800),减少副本库超时导致整月重跑中断。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
2026-10-13 09:53:02 +08:00
63f9e2a418 店铺违规预警:同步 SQL 在源端转换 date 列,修复 FreeTDS 区域日期解析失败 (#316) 2026-10-13 09:40:53 +08:00
e61e3b50fb 店铺违规预警:补充生产每日同步定时任务(cron 遗漏) (#315) 2026-10-13 09:36:14 +08:00
c55c06026f 快递应付:报价维护只留报价页签 + 附加费结算定时同步(每日7天/每周90天) (#314) 2026-10-12 17:49:10 +08:00
fba094ce26 业绩明细:抖音负差额新增「实付已退款部分」备注(聚水潭退款单/取消子单金额≈差额时命中) 2026-10-12 17:27:09 +08:00
e883d262f0 快递应付:修复三方仓进销存加载超时 2026-10-12 17:22:17 +08:00
26f945ba29 本地预览:测试断言同步到已启用的 OPcache 配置
deploy/php-local.ini 已改为 OPcache 开启 + 每次请求校验时间戳,测试仍断言 opcache.enable=0 导致失败;更新为 enable=1 并校验时间戳。
2026-10-12 17:12:10 +08:00
a90736592a 业绩明细同步:订单实付命中即算订单存在,修复部分订单被误判「订单表无此单」
部分订单(退款流程 / 拆单 / 商品明细未同步)在订单侧只有订单头与实付、没有商品明细行,原匹配逻辑仅按商品明细判断订单存在性导致误判。改为实付命中同样计入订单存在性;商品金额仍只由商品明细构建(无明细时保持为空)。实跑修复 8 月 1791 行(得物 57 / 京喜 497 / 抖音 46 / 京东 153 / 拼多多 1038)。
2026-10-12 17:12:10 +08:00
20eccf616c 业绩明细:新增「未匹配原因」列与筛选联动;差额异常备注只保留原因说明
新增「未匹配原因」列(账单无单号 / 单号格式异常 / 订单表无此单 / 订单无实付金额 / 无收款账单),支持多选筛选与联动候选;「差额异常备注」对未匹配行统一显示「订单实付未匹配」,细分原因改看新列。
2026-10-12 17:12:09 +08:00
45b9feb8c4 快递应付:附加费结算增加独立页签入口 2026-10-12 16:59:51 +08:00
9c62f4364b 快递运费:固定列宽+收紧内边距,12 列表头一屏可见(超长内容省略+悬停提示) 2026-10-12 15:46:01 +08:00
66d6146c19 快递运费:导入兼容「店铺」列名(财务系统提供成本5-6.xlsx 用)
列名别名扩展:店铺名称/店铺;csv 与 xlsx 两条路径的用例同步覆盖。
2026-10-12 14:55:01 +08:00
0da349e51b 快递应付:仓储核算移为独立仓储运费 (#311) 2026-10-12 14:46:52 +08:00
c8eb6e2e5f fix(发货数据): 早上补跑一次发货同步,副本库超时重连改为逐次拉长等待
- 凌晨 01:28 同步时副本库最近一两天的订单/商品明细常未到齐,10-12 凌晨一轮写入 4,669 个只有单号、缺商品明细的包裹
  (10.10/10.11);工作平台导出为实时查询不受影响。新增每天 07:28 再拉最近 3 天,补齐商品明细。
- selectWithReconnect:副本库超时/游标失败的重试由 2 次(间隔 0.25 秒)改为 5 次,等待 2/5/10/20/30 秒,
  白天整月重跑不再因单次超时中断(单测环境不等待)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
2026-10-12 14:39:11 +08:00
9f6a5f6fde 退货取件管理:整行点击异步加载详情,本地 opcache 提速 2026-10-12 13:47:01 +08:00
3e24bc5111 快递应付:左侧增加快递报价入口并打开四个页面 (#309) 2026-10-12 13:26:22 +08:00
10c22a655d 退货取件管理:详情精简置底、当前行高亮、内部滚动与快递公司多选 2026-10-12 13:14:53 +08:00
f7547bc9a1 feat(快递应付): 四个报价相关模块归入快递报价子目录 (#307) 2026-10-12 12:17:13 +08:00
f3143e6959 快递运费:修复 XlsxSheetReader 可选列漏传闭包导致 xlsx 导入报未定义变量
补 xlsx 路径导入测试(csv/xlsx 是两条代码路径,此前只测了 csv)
2026-10-12 12:07:09 +08:00
f805920fda 快递运费:新增「店铺名称 / 快递单号 / 条形码」列,并按「财务系统运费整理.xlsx」重新导入
- 迁移:courier_freight_rows 增加 express_no / barcode / shop_name(可空)
- 导入器支持可选列(快递单号 / 条形码 / 店铺名称),老文件缺这三列也能导;XlsxSheetReader 支持可选列表头
- 页面与导出新增三列;运费差 / 结算运费 / 其他附加费汇总 / 退件费 表头与取数逻辑不变
- 测试:新增新列导入、老文件兼容、页面展示用例
2026-10-12 11:56:00 +08:00
50cfbf1c14 feat(协议签署): 接入供应商代发、代销和生产合作合同模板 (#306) 2026-10-12 10:48:04 +08:00
d8dfb33437 fix(发货数据): 仓库按实际出库单发货仓、补入无需物流虚拟发货、作废单号识别换单
- 仓库一律按实际出库单 wms_co_id 映射(从快递公司一致的明细行学习仓库名,样本>=3 且占比>=90% 才回填),
  修正发货明细写总仓而实际由工厂代发仓出库(如 710099981090 千优代发仓)的情况。
- 快递名称为"无需物流"(补差/信誉/运费等虚拟发货,如 X00006)的包裹仓库统一为"简尚-自动发货仓",与工作平台一致。
- 换单配对:快递明细单号只对应作废出库单时视为已换单,改用有效出库单的新单号(如 DPK366101313659 -> SF1549142274850)。
- 出库单兜底不再跳过"无需物流"的无单号出库单(信誉订单/补差价/运费专用等虚拟发货),按工作平台口径补入自动发货仓、快递单号为空;
  9 月核对工作平台有、线上无的 565 条虚拟链接,抽查确认为此原因(出库单无单号被跳过)。
- 换单配对查询(logistic_id 无索引)副本库超时时跳过本页,不再中断整轮同步;结果返回 tracking_pairing_skipped_pages。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
2026-10-12 09:58:05 +08:00
1e689d254e 附加费结算:超长费/退回费/拦截费拉取、导入核验与模板导出 2026-10-12 09:33:24 +08:00
chenxi
d9646b4ac9 财务经营分析表:按「时间-平台-店铺」聚合收入与成本
财务经营报表新增「财务经营分析表」页面,一行 = 一个月的一个平台的一个店铺,
表头:时间、平台、店铺、收入、成本、运费。
- 收入 = 业绩明细的(收入金额 + 优惠金额 + 冲减金额 + 退款金额)按各字段记账符号求和
- 成本 = 商品成本表按店铺汇总的商品成本
- 运费暂不取数,页面显示「—」、导出留空列
支持月份(含全部月份)/平台/店铺筛选、汇总卡片与 CSV 导出,聚合结果按月份缓存 5 分钟。
2026-10-11 22:04:54 +08:00
597104430e 协议签署:每条公司主体增加删除功能 (#304)
每条公司主体增加确认删除,保留历史快照,删除默认主体后选择剩余主体,全部删除回退当前公司。相关21项测试246条断言及隔离页面交互通过。
2026-10-11 14:26:31 +08:00
976db7e4f1 授权委托书三处往来方名称跟随我方签署主体
## 变更

- 授权委托书的“账单往来方”“借支协议相对方”“代收货款往来方”三项改为跟随“我方签署主体”。
- 快递公司和供应商共用授权委托书模板,因此两种合作方均同步生效。

## 验证

- `AgreementSigningPageTest.php`:14 tests, 197 assertions passed。
- 实际生成授权委托书页面确认三处字段初始值均来自当前我方主体。

仅涉及页面模板字段标记,不涉及金额、权限、数据库迁移或配置。
2026-10-11 14:18:36 +08:00
cbec6a8b62 供应商管理增加每天08:15完整同步 (#302) 2026-10-11 13:57:28 +08:00
ad7226081a 协议签署:供应商合作合同显示模板待提供
供应商合作合同使用待提供模板提示;授权委托书复用现有模板,快递合作合同正常。页面测试及浏览器切换和预览验证通过。
2026-10-11 13:47:19 +08:00
ad3961b82d 修正供应商易签宝账号并接入签署选择 (#300) 2026-10-11 13:39:03 +08:00
87e5ce0df2 协议签署:供应商增加合作合同和授权委托书选项
开放已有模板,适配供应商签署账号标签与乙方名称提示;14 项页面测试、193 条断言及浏览器模板切换和预览验证通过。
2026-10-11 13:30:14 +08:00
49c6e1ecd1 修复供应商管理资料镜像索引与存储分类 (#298) 2026-10-11 12:34:31 +08:00
665b8fafd2 接入供应商管理页面与共享镜像 (#297) 2026-10-11 12:11:15 +08:00
ad6d70a0a2 协议签署:增加合作方类型与供应商待接入展示
合作方类型与供应商待接入展示;53 项测试、825 条断言通过。
2026-10-11 11:55:11 +08:00
48553c001d 协议签署:每条任务支持自主删除
全部状态、两类任务支持确认软删除,保留文件及外部签署流程。相关72项测试通过,权限及公司隔离保持不变。
2026-10-11 11:24:30 +08:00
766a85b097 协议签署:待处理任务增加删除和确认 2026-10-11 10:37:28 +08:00
0e1feaf1e6 修复协议文件签字盖章区域过小 (#293) 2026-10-11 10:17:32 +08:00
118f030220 fix(协议签署): 修复授权委托书编号重叠 (#292) 2026-10-11 10:09:47 +08:00
4eb09a1fcc 协议签署:待处理与已归档视图、归档原因及文件下载 (#291) 2026-10-11 09:55:46 +08:00
22133450a6 业绩明细:种菜金额口径加上货号 X00006(与标签明细页同一规则)
- 种菜订单集合 = 种菜明细表 ∪ 标签明细里「是否种菜订单=是」;后者改为命中种菜标签映射或货号含信誉订单虚拟条码 X00006
- 种菜明细表部分不再依赖标签映射是否为空
2026-10-10 17:20:05 +08:00
29f801bd92 聚水潭订单标签明细:修复货号回填进度打印引用未定义变量导致中断的问题 2026-10-10 16:18:27 +08:00
0a89a24f28 聚水潭订单标签明细:修复货号回填把纯数字订单号绑成整数导致的 MariaDB 1292 报错
订单号改用值对列表传递、全程按字符串绑定,避免 PHP 数组键自动转 int 后与字符列 order_no 比较被强转数字
2026-10-10 16:11:52 +08:00
53cd0c3ce6 聚水潭订单标签明细:新增「货号」列,货号 X00006 的订单判为种菜订单
- 新增 jst_order_labels.item_code 迁移;导入时从商品明细镜像按 o_id 写入货号(被替换/取消的明细不计入)
- 「是否种菜订单」增加规则:货号含 X00006(信誉订单虚拟条码)直接判「是」
- 页面与导出新增货号列;新增 jst-order-labels:sync-item-codes 回填命令
- 测试:新增货号/种菜判定/回填命令用例,并修正导出表头过时断言
2026-10-10 16:06:02 +08:00
2da46a2a86 业绩明细:修复导出「差额」列把未匹配行写成 0 的问题
- 未匹配行的差额在库里是 NULL,导出取值用了 ?? '' 变成空字符串,原判断只认 null,
  这些行被导成 0.00,导出合计只剩已匹配行的差额(2026-07 只有 14.7 万)
- 现在空差额按「比对基准金额=0」补算 收入+优惠+冲减,与页面/统计卡口径一致
- 验证:2026-07 全部平台导出 300,707 行,差额列合计 1,354,719.44(与页面同值);
  只看未匹配 3,968 行,差额列合计 1,207,560.77
2026-10-10 15:38:31 +08:00
158c5e1b23 业绩明细:差额/匹配状态/备注改为与订单实付金额核对;科目映射表筛选改按财务备注
- 业绩明细「差额 / 匹配状态 / 差额异常备注」的比对基准由「订单商品金额」换成「订单实付金额」:
  差额=(收入金额+优惠金额+冲减金额)− 订单实付金额;匹配状态以订单侧能否取到订单实付为准;
  备注文案改为 订单实付未匹配(账单无单号/单号格式异常/订单表无此单/订单无实付金额/无收款账单)、
  订单实付取数为0(种菜)、订单实付取数为0、订单实付未减冲减、订单实付未减优惠、订单实付未减优惠和冲减
- 商品金额列保留展示、不再作为核对基准;退款金额仍单独列示、不参与差额
- 两个口径共用同一条取数链(同店同号 → 店铺名不一致 → 同号多条 → 空店铺兜底),
  差额口径做成开关:config/performance_details.php 的 difference_basis 填 product 即可切回旧口径
- 统计卡「金额构成」补「订单实付金额合计」,与差额公式对齐
- 科目映射表筛选:第二个筛选项由「账单项目」改为「财务备注」(候选按平台收窄、可搜索、多选);
  账单项目仍可用左上角关键词搜索
2026-10-10 15:14:06 +08:00
37d18612a6 修复合作合同 PDF 方框、文字重叠与签字区分页 (#290) 2026-10-10 14:34:13 +08:00
107c883eb2 业绩明细:新增「退款金额」列,按总账备注=退款取数
- 新增列「退款金额」:店铺收款账户账单里总账备注=退款的金额(收入侧 − 支出侧),
  与优惠金额/冲减金额同一口径,单独列示、不参与差额计算;统计卡、导出字段、
  字段结构说明同步补充
- 同步命令支持账单表的 expense 列(京东账单/京喜账单/京东网银钱包/抖音用 expense,
  淘系/拼多多/得物用 expense_amount),此前只认 expense_amount,
  导致这些平台支出侧的退款、扣回金额被当成 0
- 重建后自检改为「本月账单确有优惠/冲减/退款行、但三列一行都没写」才报错,
  并把退款金额纳入判定,避免无数据月份误报
- 新增迁移:store_receipt_order_match_rows.refund_amount
2026-10-10 13:41:03 +08:00
153b823ab8 修复已注销主体变更遗漏现公司签署 (#289) 2026-10-10 13:28:37 +08:00
0234bae83f 修正共用合同合作方及归属主体,支持个人实名签署 (#288) 2026-10-10 12:10:32 +08:00
951fde9c46 账单页签:总账备注下拉候选合并科目映射表的财务备注
- 之前候选只来自总账备注映射表,科目映射表里新加的备注在下拉里选不到
- 现在两个来源取并集,改完映射表下一次请求即可筛选
2026-10-10 11:20:18 +08:00
dd03037105 账单页签/账单明细:财务备注按科目映射表关键词取最具体规则
- 行内财务备注把 channel/business_type/order_type 等业务字段纳入关键词匹配(得物退款行可识别)
- 「总账备注/财务备注」筛选折算 SQL 时支持同一账单项目多条规则的关键词条件与兜底反向条件
- 与业绩明细、科目映射表口径对齐(得物退款行统一显示/筛选为退款)
2026-10-10 11:13:46 +08:00
c1431b8b38 协议签署支持个人签署及已注销公司原法人签署 (#287) 2026-10-10 11:12:13 +08:00
b92a3b0ed0 修复共用合同 E 签宝主体与实名认证字段 (#286) 2026-10-10 11:01:51 +08:00
2bf8d3538a 修复合作合同双方 E 签宝个人实名信息 (#285) 2026-10-10 10:48:09 +08:00
25cf3f8f0f 业绩明细:同步取财务备注时带上业务备注与商品名称
- 之前只按「平台+账单项目」匹配,退款行与正常行走同一条规则(得物卖家承担优惠券/折扣活动/联合营销费被算进冲减收入)
- 现在按行传入业务备注(custom_remark/business_type/order_type/channel 等)与商品名称,与店铺收款账户账单页同一套口径;账单查询同步补取这些列
- 配合科目映射表给得物这三个账单项目新增「退款」规则后:退款行→退款,正常扣款行→冲减收入
2026-10-10 10:30:06 +08:00
073e82ea32 业绩明细:差额异常备注改为「差额原因」口径
- 备注只由差额公式判定:商品金额未匹配(账单无单号/订单表无此单/无收款账单)、商品金额未加种菜、商品金额取数为0、商品金额未减冲减/未减优惠/未减优惠和冲减;命中不了留空
- 未匹配行按商品金额=0 计入差额,差额合计与导出同步
- 列名「异常标注」改「差额异常备注」,筛选、联动行数、导出表头同步
- 联动接口:5 条原因合并为 1 次统计 + 60 秒缓存(1.5s → 0.8s,缓存内 11ms)
2026-10-09 18:27:07 +08:00
chenxi
fe8c271fcf 商品成本:新增财务经营报表「商品成本」页面与同步命令
以业绩明细的账单行为基数,按「店铺 + 线上订单号」展开订单原始数据的商品行:
- 发货仓库取订单 wms_co_id 对应的聚水潭仓库名称,仓库简称查仓库映射表
- 商品单价按「仓库简称 + 商品编码」取商品月加权平均单价里的加权平均价
- 商品成本 = 商品数量 × 商品单价(四舍五入两位小数)
- 拆单子单重复列出的同一批商品只算一次;同一单分多仓发货按子单实际仓库分行
- 排除虚拟条码(X0000x)与数量金额都为 0 的占位行;默认隐藏仓库映射表里标「-不看」的仓
页面提供月份、平台、店铺、线上单号、商品编码、仓库简称筛选与汇总、CSV 导出。
2026-10-09 17:36:14 +08:00
41d0e5169f 快递运费:运费差后面新增「结算运费」列(= 提供成本运费 + 运费差)
- 列定义加在 运费差 与 其他附加费汇总 之间,金额列同样左对齐、不参与查询条件
- 行数据按交易单号取到运费差后,由页面计算 结算运费;两者都没有时显示 —
- 描述文字同步(前端导出按表格列生成,新列自动带上)
2026-10-09 17:36:33 +08:00
df9ec2cb1e 账套科目表:销售费用下新增二级科目「会员价保」(5601.05.10)
- 科目映射表里唯品会JX「会员价保」已挂 销售费用/会员价保,账套里原先没有这个明细科目
- 校验由「借方二级科目找不到」警告变为正常
2026-10-09 17:03:29 +08:00
217b953a03 唯品会JX账户:科目映射与总账备注按行内「账单项目」匹配
- AccountLedgerRemarkMap:JX 页签取 payload.second_remark 作为映射的账单项目,
  与「科目映射表」里按账单项目(处罚款/客服特殊退款-仅退款/盘盈盘亏…)维护的规则对齐
- AccountBills:总账备注筛选对 JX 改为按行内账单项目过滤,其余页签不变
- 测试同步(总账备注筛选按账单项目命中)
2026-10-09 16:57:28 +08:00
499a9e12a6 唯品会JX账户:「账单项目」筛选改为按行内账单项目过滤
- 筛选下拉与筛选条件都取 payload.second_remark(处罚款 / 客服特殊退款-仅退款 / 盘盈盘亏 …),与页面「账单项目」列同源
- 唯品会MP页签维持按账单项目列过滤;导出走同一套过滤逻辑
- 同步更新 JX 页签测试(筛选选项与导出断言)
2026-10-09 16:36:09 +08:00
f82856a6f7 唯品会JX账户:列名「二次备注」改为「账单项目」
- 与费用规则里「账单项目」的叫法统一(该列即账单项目/业务备注,用于匹配科目映射表)
- 同步更新列名断言
2026-10-09 16:29:18 +08:00
9ce9ee74eb 唯品会JX账户:表头改为「明细类型/二次备注/总账备注/订单号SO/收入/支出/合计」模板
- 按财务给的「唯品会JX汇总10.9」模板调整页签列:类型→明细类型、销售订单号→订单号/SO、
  含税金额→合计金额,总账备注与二次备注紧跟明细类型,收入/支出保持独立列
- 同步更新 VipshopJxReceivingBillsTest 的列名断言(原先还在断言已改名的「订单结算时间」)
2026-10-09 15:58:01 +08:00
74b2d3a086 Revert "业绩明细:新增「种菜订单」筛选(全部/只看种菜/排除种菜),页面统计与导出同步生效"
This reverts commit 9ba90667b148aa8da62fdf7d7dd20ed5cf12b03a.
2026-10-09 14:35:19 +08:00
9ba90667b1 业绩明细:新增「种菜订单」筛选(全部/只看种菜/排除种菜),页面统计与导出同步生效 2026-10-09 14:23:48 +08:00
ed89882661 业绩明细:重建后自检优惠/冲减列,映射有项目但两列为空时直接报错,避免静默丢列 2026-10-09 13:56:07 +08:00
chenxi
cb26d5da17 业绩明细:商品金额改为只取主单,拆单子单不再重复累加
聚水潭拆单子单会把主单的商品行再列一遍,子单的平台子订单号与主单不同时
原「子订单号 + 条码」去重键拦不住,同一批商品被计两次。
现在商品金额只取主单(link_o_id 为空/0),整单没有任何主单行时退化为取全部行,
与订单实付「主单优先、无主单退化」保持同一套规则。
2026-10-09 12:14:26 +08:00
7e335b19a9 账单明细:发生时间移到平台后面,筛选栏重新配列 2026-10-09 11:46:57 +08:00
a4899e440e 账单明细:去掉账单项目筛选(改用关键词搜索),筛选栏回到 平台/店铺/财务类型/财务备注 + 关键词 + 日期 2026-10-09 11:45:31 +08:00
35bd7cb7f0 账单明细:财务类型与关键词同一行,筛选栏按平台/店铺/财务备注 + 账单项目/财务类型/关键词 重排 2026-10-09 11:39:43 +08:00
3136f29d33 账单明细:账单项目挪到关键词同一行,筛选栏改 12 列栅格对齐 2026-10-09 11:26:41 +08:00
0f7f72be71 账单明细联动:行数为0的取值不展示,联动结果缓存放宽到5分钟 2026-10-09 11:19:21 +08:00
cfdda94da8 账单明细:财务备注/财务类型筛选按平台限定,修同项目跨平台串行(优惠收入带出冲减收入) 2026-10-09 10:36:27 +08:00
a0faaa116b Merge remote-tracking branch 'origin/main' into feature/yueyang-billing-details-filters 2026-10-09 10:33:35 +08:00
65112a2bff 店铺账号密码:状态映射缓存键升到 v4,强制刷新流程状态
换成 jiansun 实时 OA 流程表后,旧的 v3 缓存(30 分钟)里还留着 7 月之后
审批状态为空的结果,页面会继续显示横杠;升版缓存键让所有环境重新计算。
2026-10-09 10:32:00 +08:00
f56bd9d3ea 账单明细:新增财务备注筛选、筛选联动(行数为0的取值不展示)、行距放宽 2026-10-09 10:30:43 +08:00
473b6f4ebb 修复流程审批状态取不到:OA 流程表改用 jiansun 的实时副本
原来取的是中台库 dbo.flow_flow,那份数据停在 2026-07-16,7 月之后的申请
(比如 shp20260716165004772 京喜-清木居工厂店 关店流程)审批状态全是空的,
而工作平台显示「流程中」。

jiansun.dbo.flow_flow 是实时表(今天还在更新,10543 行),同名单据里
flowStatus=1、当前审批人=山时。已登记并完成首次全量 + 建 flowId 索引;
中台那份旧副本改为停止同步(数据保留)。

流程统计页与店铺账号密码的「流程类型 / 审批状态 / 流程状态」两处取值都跟着换成实时表。
2026-10-09 10:25:43 +08:00
b4821ca933 唯品会MP/JX账户:订单结算时间改为「账单期间」,筛选项也按账单期间过滤
1) 两列改名:AccountBillColumns 里唯品会两个页签的「订单结算时间」→「账单期间」(金额式账期月份,如 2026-07),列类型从 date 改 text 避免被当日期格式化;
2) 筛选标签:DATE_FILTER_LABELS 里 vipshop / vipshop-jx 从「结算时间」改成「账单期间」;
3) 筛选真正生效:这两个页签的 start/end 不再过滤 occurred_at,改为按 payload.settle_period(YYYY-MM)与所选区间取月份交集,页面列表与导出同步生效。
同步更新 AccountBillsTableTest 的断言。
2026-10-09 09:35:16 +08:00
chenxi
a334f65962 业绩明细:订单实付金额排除虚拟条码后的实付
订单实付=订单级实付 − 虚拟条码(X0000x)明细行实付,下限 0,
与商品金额保持同一口径;纯信誉/虚拟订单的实付归零。
同时更新页面口径说明。

影响:京东账单 2026-08 实付 5673.8 万 → 424.9 万(虚拟条码占 5248.9 万)。
2026-10-08 18:31:20 +08:00
0cca2aac70 业绩明细全量重跑:命令内自行放宽 memory_limit,免去手工传 -d memory_limit 参数 2026-10-08 17:41:05 +08:00
8207dc0d95 业绩明细:订单金额/优惠金额/冲减金额统一按「收入-支出」取数,只回填金额时同步刷新差额列 2026-10-08 17:07:11 +08:00
09077c3661 导出进度改为真实百分比(按发生时间流式取数),并修日期框在宽屏下被裁掉 2026-10-08 16:39:42 +08:00
03b728a0fc Merge remote-tracking branch 'origin/main' into fix/yueyang-offset-signed-amount
# Conflicts:
#	app/Console/Commands/SyncStoreReceiptOrderMatches.php
2026-10-08 16:24:38 +08:00
68d4466810 导出进度弹窗:关闭按钮点击区域放大到 32x32,行数进度列自动换行避免挤掉操作列 2026-10-08 15:56:34 +08:00
b7e4e1a556 快递运费:提供成本运费/运费差/其他附加费汇总/退件费四列表头与金额改为靠左
这四列是从来源表和快递应付带出来的金额,业务要求靠左展示;用页面范围的更高优先级选择器覆盖通用的 .directory-table .is-number 右对齐(其他页面的数字列不受影响),数字格式与千分位仍保留。
2026-10-08 15:51:03 +08:00
e0e197a38b 导出进度:取数阶段显示已等待秒数与预计总行数 2026-10-08 15:48:32 +08:00
ddbf32a0ee 快递运费:其他附加费汇总后面新增「退件费」列(按交易单号取快递应付的退件费结算金额)
退件费快照的表头跟另外两个不一样:单号列叫「交易订单」、金额列叫「结算金额」,所以 CourierPayableSummaryMap 的列定位改成按候选关键字找(先全等、再包含),三个汇总页共用一套逻辑。
2026-10-08 15:40:27 +08:00
d95abebf9c 导出进度:行数列显示取数等待秒数与已写行数/总行数百分比(两个页签组件统一) 2026-10-08 15:38:05 +08:00
chenxi
a0ce32ead1 Merge remote-tracking branch 'origin/main' 2026-10-08 15:37:18 +08:00
chenxi
f17a90c7a3 业绩明细:商品金额排除虚拟条码,新增「订单实付金额」列(方案3)
商品金额排除 X0000x 虚拟条码行;新增订单实付金额列(订单级实付,拆单取主单)。
2026-10-08 15:37:11 +08:00
6255179bf5 导出任务取消:点击后立即置为「已取消」,后台下一个检查点停止并删除半成品文件 2026-10-08 15:34:01 +08:00
ad65f0cc75 导出任务:新增「取消」(后台检测取消标记后停止并删半成品文件) 2026-10-08 15:31:10 +08:00
4d5b9bf440 导出任务:按自增顺序取数提速、时间统一北京时间、运行中显示「取数中」 2026-10-08 15:22:06 +08:00
0c84afb875 快递运费:运费差后面新增「其他附加费汇总」列(按交易单号取快递应付的附加费金额)
把上一版的 CourierFreightDifferenceMap 改成通用的 CourierPayableSummaryMap,同时支持「运费差」和「其他附加费汇总」两个汇总快照(都是上传在 uploads 盘的 JSON):运费差取「差异(结算运费-提供成本运费)」,附加费取「金额」,都按交易单号挂到快递运费页面对应行;快照文件时间戳进缓存键,财务重导后自动失效。
2026-10-08 15:21:25 +08:00
daf3fd64d8 店铺收款账户账单:导出进度改成科目映射表「操作记录」同款弹窗(撤掉独立页面) 2026-10-08 15:12:41 +08:00
d69a7d2ca7 店铺账号密码:把流程类型和审批状态两列都拉过来
上一版只带了「流程状态」(关店流程的审批状态)。改成按 ERP 店铺名取该店铺
最近一单申请流程(开店/变更/关店/主体注销自动),同时给出流程类型与审批状态,
列位置仍在「店铺状态」后面;两列都可搜索多选筛选、可排序。

线上:966 个账号店铺里 396 家最近一单是关店流程;状态文案与流程统计页一致。
状态映射缓存从 10 分钟放宽到 30 分钟(全量算一次要几秒,数据变化很慢)。
2026-10-08 15:12:41 +08:00
e01b575e74 店铺收款账户账单:导出改成独立「导出进度」页面(任务列表+下载),修正任务页签标签 2026-10-08 15:08:05 +08:00
9710c3baa7 店铺收款账户账单:默认账期改本月、日期不再自动查询、导出按钮与文案各页签统一 2026-10-08 14:59:56 +08:00
c88c0f165e 店铺账号密码:新增「流程状态」列(取店铺关店流程)
按 ERP 店铺名关联 jiansun.dbo.fys_shopInfo_flow 的关店流程(flowType=10),
同一店铺取最近一单,状态用 OA 流程表的审批状态文案(流程中/完成/拒绝/撤回…)。
列加在「店铺状态」后面,同时做成可搜索多选筛选,并支持按流程状态排序。

线上:966 个账号店铺里 486 个有关店流程(流程中 352 / 完成 46 / 撤回 14 / 拒绝 2 /
其余审批状态暂缺),列值缓存 10 分钟。
2026-10-08 14:51:44 +08:00
a8b086766b 快递运费:提供成本运费后面新增「运费差」列(按交易单号取快递应付的运费差)
运费差是「应付款项 › 快递应付 › 运费差」页面上传的汇总快照(JSON 文件,不是数据库表),这里新增 CourierFreightDifferenceMap 读它并转成 交易单号 ⇒ 差异 映射,页面按交易单号带出新列;快照文件的修改时间进缓存键,财务重导后自动失效。源表一格多单号(逗号分隔)会逐个挂同一个差异值。
2026-10-08 14:49:55 +08:00
30f69f2af9 店铺收款账户账单:导出改成后台任务 + 导出进度列表(状态/下载),不再依赖页面停留 2026-10-08 14:49:11 +08:00
18b4b9f0fb 修复导出进度:导出方法名与进度字段重名导致点击无反应,改用 exportedRows;去掉原生确认框 2026-10-08 14:38:55 +08:00
chenxi
203aea523a Merge remote-tracking branch 'origin/main' 2026-10-08 14:38:44 +08:00
885961084e 流程统计:接入店铺申请流程(开店/变更/关店)共享镜像
数据源:jiansun.dbo.fys_shopInfo_flow(申请单明细,4572 行,含店铺/平台/负责人/利润中心/商标/变更原因/关店原因等)
+ 中台库 dbo.flow_flow(按 flowId 关联审批状态与当前审批人),两个数据集已登记并完成首次全量。

- 新增 ShopInfoFlowMirrorRepository:左连接两套镜像,支持单据编号/申请人/申请日期/当前审批人/
  ERP店铺名称/店铺运营负责人/利润中心/商标号等文本筛选,流程类型、审批状态、平台为可搜索多选。
- 审批状态文案取自 OA 前端字典:0暂存/1流程中/2拒绝/3完成/4撤回/5待签署/6已签署/7待审批;
  流程类型:8店铺开店/9店铺变更/10店铺关店/12主体注销自动流程。
- 页面按目录表渲染(查询条件、筛选字段、导出),列与外部店铺申请页对齐。
- TableRegistry 登记为只读上游页,镜像未就绪时页面给提示而不是报错。
2026-10-08 14:38:16 +08:00
chenxi
3dd90dc52c 修复业绩明细商品金额重复计算(订单侧按子订单去重)
订单侧聚合改为先按「子订单号 + 条码」去重再求和,避免拆单/子单重复计入商品金额。
2026-10-08 14:33:48 +08:00
29defd2025 店铺收款账户账单导出:改成后台导出 + 页面进度条(带 token 轮询,页面可自由切换) 2026-10-08 14:31:32 +08:00
chenxi
12eb5d03d9 Merge remote-tracking branch 'origin/main' 2026-10-08 14:24:25 +08:00
chenxi
946d794e12 修复业绩明细补行插入列数不一致
补行的列集合与主行对齐(补 planting_amount),避免批量插入时列数不匹配导致同步失败。
2026-10-08 14:24:20 +08:00
39f7797a35 店铺收款账户账单导出:大账期先发文件头并禁止代理缓冲(下载立刻开始),页面加导出中提示与大账期确认 2026-10-08 14:16:12 +08:00
cddb5e6145 财务经营风险管控:新增「流程统计」页面
- 在 financial-risk-control 下新增 process-statistics 页面(目录式骨架,先占位:
  流程名称/总数量/已完成/待处理/期间),数据源和统计口径确认后再接入。
- 该目录有两个页面了,按目录内页签渲染(复用 section-page-tabs),可以互相切换。
- 侧边栏权限兼容:按页面键只授权过「店铺违规信息预警」的账号也会看到新页面
  (两页同属财务经营风险管控权限),并更新相关测试。
2026-10-08 14:07:06 +08:00
chenxi
c02ecb8b44 Merge remote-tracking branch 'origin/main' 2026-10-08 14:05:19 +08:00
7fe426211d 快递运费:列名改回「成品配件」(新表含成品运费/配件运费两类)
上一版把列名改成「成品运费」了,按业务要求改回「成品配件」;导入侧继续两种表头都认。
2026-10-08 14:06:22 +08:00
chenxi
085ea33ff4 业绩明细订单侧切换为「订单原始数据」口径(方案B)
订单侧来源改为企微客户订单 + 聚水潭订单(与「订单数据 → 原始数据」同一来源),
按「店铺 + 线上订单号」对商品金额求和;取数改用临时表 + STRAIGHT_JOIN 避免大表全扫。
2026-10-08 14:05:08 +08:00
8c626c65de 快递运费:查询字段靠左,列名「成品配件」改「成品运费」
1) 查询栏里的空提交表单没隐藏,会占掉栅格第一格,把筛选字段整体挤到第二列;改成 hidden 后字段从最左边开始(所有用这套查询栏的页面一起受益)。
2) 快递运费页把「成品配件」列名改成「成品运费」;导入时两种表头都认(成品配件 / 成品运费)。
2026-10-08 13:55:56 +08:00
9f1cd6f8c1 目录表重置:一并清掉多选筛选的 key[] 参数
多选提交的是 authorization_status[] / store_status[],原来的重置只删 key,
会把多选值留在 URL 上。
2026-10-08 13:51:41 +08:00
69f3d9228f 店铺账号密码:授权状态 / 店铺状态筛选改成可搜索多选
复用已有的 CaiWuMultiSelect 组件(按钮 + 复选面板 + 全选/清空),勾选结果提交为
authorization_status[] / store_status[],多个取值取并集;一个都不勾=全部。

- 目录表的筛选控件在 query 表单外(靠 form 属性关联),给多选组件补了 formId 选项,
  生成的隐藏 input 带上 form,保证能随查询表单提交。
- pageFilters 支持数组筛选值(去重、最多 50 个);仓库侧筛选统一按列表处理,
  单选(旧链接)和多选都兼容。
2026-10-08 13:48:51 +08:00
6d8fdb5dce 快递运费:页面加查询栏(来源下拉 + 交易单号搜索)
复用目录表的字段查询栏:来源用动态下拉(5/6/7月提供成本),交易单号支持模糊搜索;成品配件只有一个取值、金额没法查,这两项不做查询条件。
2026-10-08 13:37:11 +08:00
09ae442b87 快递运费:补 deleted_at(模型基类带软删除,页面查询报 Unknown column)
建表迁移同步补 softDeletes,另加一个迁移修复已建好的表;两个迁移都带存在性判断,可重复执行。
2026-10-08 13:30:55 +08:00
401dfb089a 店铺账号密码:上游新增字段自动跟随(镜像补列 + 页面自动多出这一列)
新增 business-mirror:follow-upstream-columns:先把数据字典刷新到最新,再把上游
视图里新增、当前数据集还没同步的字段追加进镜像定义并立刻建列,下一次常规同步
就会带回数据。只做新增,不删除/不改写已有字段;字段名带 password/secret/token/
cipher/credential 等敏感关键字的一律跳过。已登记到发布 cron(每天 06:40)。

页面侧:config/upstream_mirrors.php 的 auto_follow_columns 用「页面 => 数据集」
登记,FollowedUpstreamColumns 服务把 followed_columns 变成表格列;这类列只展示、
不生成查询条件(后端还没有对应筛选口径)。

数据集自带的数据变动仍然由每分钟的 business-mirror:sync-tables 增量同步。
2026-10-08 13:19:53 +08:00
1c90b64c29 快递运费:新增导入命令与页面数据表
财务经营报表 › 快递运费 之前是空页面。现在:
- 新增 courier_freight_rows 表(来源 / 成品配件 / 交易单号 / 提供成本运费);
- 新增 courier-freight:import 命令(xlsx / csv,按表头名匹配,支持 --replace);
- 把聚水潭导出用的 xlsx 流式读取器改成通用名 XlsxSheetReader(本次文件 57.8 万行、解压后 115MB,必须流式读);
- 页面按来源 / 成品配件 / 交易单号查询,列设置与分页复用通用目录表。
2026-10-08 13:20:01 +08:00
09907d63f8 业绩明细:冲减金额按行金额取数(收入侧-支出侧,负数表示净扣减);修复补行列数缺 planting_amount 导致同步中断 2026-10-08 13:15:07 +08:00
2cf181f1bf 业绩明细:异常标注三类整理 + 优惠/冲减补行 + 匹配状态/异常标注多选联动筛选 2026-10-08 11:46:42 +08:00
627fa966ef 店铺账号密码:新增授权状态、店铺状态两列(在账号分组名称后面)
两列都来自已有的共享镜像,按店铺名关联(下划线、空格统一成短横线):
- 授权状态:聚水潭店铺档案 jst_shopInfo 的授权到期时间;无到期时间为未授权,
  已过期为已过期,其余为已授权。
- 店铺状态:店铺档案 fys_shopInfo.shopState,0 正常 / 3 关闭中 / 6 已关闭,
  与店铺基础信息页口径一致。

两列同时做成下拉筛选(已授权/已过期/未授权、正常/关闭中/已关闭),并支持按业务
顺序排序;映射结果缓存 10 分钟,避免每次列表都全表读两张档案表。

线上命中率:账号表 966 个店铺,授权状态命中 963、店铺状态命中 948。
2026-10-08 11:39:34 +08:00
8ffcd58118 种菜金额口径改为「或」:种菜明细表 ∪ 聚水潭种菜标签
之前两个条件是「与」(交集),导致聚水潭没打种菜标签的淘系订单全部为空。按业务确认改成或:订单号在「种菜明细表」或「聚水潭订单标签明细(是否种菜订单=是)」任意一边出现过,就取该订单的收入金额。同步更新页面描述与迁移注释。
2026-10-08 11:26:46 +08:00
88adb33c45 种菜金额回填改为一次覆盖全平台
--planting-only 不再要求指定 --platform:种菜订单号与平台无关,按整月刷一遍即可。
2026-10-08 11:03:05 +08:00
7b09d0de05 业绩明细:冲减金额后面新增「种菜金额」列
口径:订单号同时出现在「种菜明细表」和「聚水潭订单标签明细(是否种菜订单=是,即标签命中种菜映射)」里时,该订单的收入金额就是种菜金额,其余留空。两边的订单号都按账单侧同一套「核心单号」清洗(去日期前缀/空格/横杠)后比较;金额取该行已有的收入金额,只是展示,不参与差额计算(它本身就是收入金额的一部分)。

- 新增迁移:store_receipt_order_match_rows 增加 planting_amount 列(在 offset_amount 后面)。
- 同步命令:正式同步时写入该列;--amounts-only 一并回填;新增 --planting-only 只刷这一列(不读账单,适合给已同步好的账期补数);新增 --company 选项。
- 页面:列顺序 收入金额 / 优惠金额 / 冲减金额 / 种菜金额 / 商品金额…,顶部统计加「种菜金额合计」,导出同步带上该列。
- 补 NavigationTest 的列断言。
2026-10-08 10:59:21 +08:00
dcdea11f0f fix(发货数据): 快递公司/仓库按实际出库单取,同步命令内存上限调至 4G
- 发货明细(jst_order_express_details)可能停在作废前的旧分配(如先分工厂代发仓、后改外采由总仓出库;或换单后仍是旧快递公司),
  工作平台按实际出库单显示。快递公司与出库单不一致或为空时改用出库单 logistics_company,
  仓库按出库单 wms_co_id 映射(从快递公司一致的明细行学习仓库名,扫描结束统一回填)。
- 9 月核对:快递名称不一致 434/331,612,仓库不一致 85,其中外采快递、线下快递、换单类为此原因。
- jushuitan:sync-shipping 内存上限 1536M -> 4096M:整月窗口重跑在扫描后段内存耗尽(退出码 255)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
2026-10-08 10:52:27 +08:00
c75c3b27a7 淘系支付宝账户:发生时间筛选移到账单项目后面
只调整展示位置,筛选字段、默认值、changeDate/applyFilters 逻辑都没动。新的顺序:原始店铺 / 总账备注 / 账单项目 / 发生时间 / 关键词 / 查询·重置,仍然排在一行。
2026-10-08 10:43:53 +08:00
c515da02d0 店铺收款账户账单筛选栏改回一行(去掉强制两行的栅格)
昨天 14:30 的「其余页签对齐淘系支付宝账户样式」把 .tw-filter 改成了 4 列栅格,并把「发生时间 + 查询按钮」显式放到第二行,所以淘系微信等页签的筛选栏又变成两行。

现在改回单行 flex:店铺 / 总账备注 / 账单项目 / 关键词 / 时间 / 查询按钮默认排一行,只有容器宽度不足(<=1024px)才折行。淘系支付宝页签同步改成同一套规则,两个组件保持一致。

已用无头 Edge 渲染同款结构的静态页校验:1912 宽下筛选栏就是一行。
2026-10-08 10:35:27 +08:00
8261b17328 种菜明细表同步:利润中心优先用历史值,缺失的用店铺维度报表的 ERP 店铺资料补齐
9 月有 19 家新店铺(1245 行)在历史映射里查不到,导致利润中心为空。现在按「ops_fys_shop_info.erpShopName → responsibleDepartment」补(就是店铺维度报表里 ERP系统店铺名称 → 店铺所属利润中心 那一列),两边都查不到才留空并在命令输出里列出。

历史值仍然优先,避免把人工维护过的取值改掉:实测 371 家可在 ERP 命中的店铺里 365 家两边一致,6 家不一致(朴美家居、朴美书房、拼多多-HX103、索菲亚办公、顾升、乐之益)已单独报给用户确认。
2026-10-08 09:59:11 +08:00
ef62cac0d9 种菜明细表:新增按月从聚水潭种菜报销工单同步的命令(带自动排重)
每个月都要手工整理「中心明细表」,改成一条命令:planting-details:sync-work-orders --month=YYYY-MM。

- 口径用 1~8 月已入库的 7 万多行反推验证过:只取「工单类型=种菜报销、状态=已完成」的工单,明细表「日期」= 工单完成日 - 1 天(历史命中 100%),所以某月数据 = 完成时间落在该月次日到下月次日的工单。
- 字段映射(聚水潭只返回 fieldId,不给字段名,这批 id 是拿历史数据逐列对撞出来的):财务售价/财务运费/财务佣金/京喜差价/扣款/财务空包费/财务打款金额 分别对应工单的 任务付款/任务快递费/任务佣金/京喜差价/任务扣款/任务空包费/最终报销金额;负责人、账户、类型、标签、创建人、平台商品ID 也各有对应字段。
- 自动排重:写入前先删掉「本月 + 本次订单号」的已有行,重复执行不会翻倍;--force-clear 才整月覆盖,--dry-run 只看不写。
- 所属利润中心没有工单来源,用表里已有的 店铺→利润中心 映射回填,命中不了的店铺在命令输出里逐个提示。
- 补测试:字段映射 + 日期口径 + 去重 + 利润中心回填 + force-clear。
2026-10-08 09:35:34 +08:00
636187a9d8 业绩明细:差额口径改为(收入金额+优惠金额+冲减金额)−商品金额 2026-10-07 18:32:10 +08:00
c1995319e6 业绩明细导出:当前页按页面每页条数(10/20/50)导出 2026-10-07 17:58:04 +08:00
dba61e5c8e 业绩明细:平台/店铺可搜索多选、导出跟随筛选且空值导出 0.00、分页支持每页条数与跳页(表头吸顶/序号列固定) 2026-10-07 17:54:34 +08:00
c8a035a18f 业绩明细:新增优惠金额/冲减金额两列(取科目映射表财务备注=优惠收入/冲减收入,支持 --amounts-only 回填) 2026-10-07 17:08:46 +08:00
4bcfde58f7 按文件导入改为两遍:先挑出真正有标签的订单,再拆行写入
聚水潭导出是一单多行,实测有 97 单是「部分明细行带标签、部分为空」;单遍处理会给这些单多写一行「(无标签)」。改成第一遍收集有标签的订单号,第二遍只对整单无标签的订单写「(无标签)」,并补上对应断言。
2026-10-07 16:59:01 +08:00
8a70fd4482 修 JstOrderLabelImporter:把延迟清区间的逻辑挪到 importFromFile,恢复镜像导入的 --replace
上一个提交的补丁打在了 import() 上:镜像导入的 --replace 清空被删掉,而 importFromFile 用到未定义的 \ 直接报错。现在两边各归各位。
2026-10-07 16:57:35 +08:00
daa4c939e7 聚水潭订单标签明细:支持按聚水潭导出的订单 Excel 导入,并放开筛选下拉上限
镜像 ops_jst_orders 只覆盖 2026-04-01 之后,缺的历史区间只能靠财务在聚水潭「导出订单」导文件补。所以:- 新增 JstOrderExportReader:ZipArchive+expat 流式解析聚水潭导出 xlsx。这类文件字符串走 inlineStr、dimension 只写 A1、单文件上万行 ×135 列(解压 78MB),常规读法会读成空表或吃满内存;实测 12286 行 3.5 秒、峰值内存 10MB。
- jst-order-labels:import 新增 --file=:按「线上订单号+标签」去重后拆行写入,可配合 --from/--to 过滤窗口;带 --replace 时先成功读到数据行再清区间,避免文件有问题却先删了线上数据。
- 筛选下拉上限 500 -> 5000:导入 4~7 月后店铺已达 604 个,原来下拉里会缺 104 个店铺;提交筛选值的截断上限同步放开。
- 补两个测试:导出文件去重拆行、窗口过滤+replace。
2026-10-07 16:53:55 +08:00
chenxi
55e1533b40 Merge remote-tracking branch 'origin/main' 2026-10-07 16:50:23 +08:00
471aa714e8 fix(发货数据): 以本地全量核对通过的版本替换 #282 的同步实现
#282 的整合版在本地全量同步中发现问题,改用已与工作平台逐单核对通过的实现(保留 #283/#284 及 main 其它改动):
- 订单元数据只缓存精简字段(状态仅存取消/拆分/合并,标签只存是否线上发货),全表扫描后释放;
  #282 缓存完整标签会在全表扫描时内存超限导致同步中途退出
- 按规则排除的包裹(取消/拆分/合并/线上发货)清除本窗口旧记录
- 剩余 @ 前缀统一去掉(工作平台导出无 @)
- 自动发货仓虚拟/补差单:单号已被其它包裹实际输出时留空(对齐工作平台)
- 出库映射保留作废单但排在有效单之后;换单按出库单号顺序一对一配对
测试:同步单测相应调整(作废单排序、多候选配对)。
验证:本地清空 8.15-9.30 后全量同步 9.1-9.16,与工作平台对比两边共有包裹 195,555,带单号漏拉 1 单。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhry6eiuwf
2026-10-07 16:33:23 +08:00
chenxi
701bc42f44 Merge remote-tracking branch 'origin/main' 2026-10-07 16:18:47 +08:00
chenxi
77b808245b 业绩明细账单侧筛选收窄为「总账备注 = 订单收入」
同步命令与页面说明改为仅按「订单收入」取账单侧数据,其余匹配/输出/统计口径不变。
2026-10-07 16:18:36 +08:00
ae694004cf 总账备注匹配:账单项目自动去 wx 前缀再查一次
先原样匹配,不中则去掉开头 wx 前缀(wx基础软件服务费 → 基础软件服务费)再匹配;原样命中优先,不影响既有结果。
科目映射表优先级链路与回落原总账备注链路都按此顺序匹配。
2026-10-07 15:58:42 +08:00
0933941533 总账备注取科目映射表:平台/账单项目键做大小写与空格归一
科目映射表平台写「唯品会JX」、页签内部键「唯品会jx」,原样索引匹配不到;统一小写去空格后索引与查找。
2026-10-07 15:30:56 +08:00
1cbc8312a6 店铺收款账户账单:总账备注优先取「科目映射表」的财务备注
账单项目在「科目映射表」里能匹到(同平台+同项目)时,总账备注取该规则的「财务备注」;
多规则时按业务备注/商品名称关键词取最具体的一条;匹不到则保留原有总账备注映射。规则按平台+项目预索引,避免逐行扫全表。
2026-10-07 15:24:58 +08:00
db2ee47fd8 发布切换容器前等待生产上游同步完成 (#284) 2026-10-07 15:00:48 +08:00
chenxi
9f1f4d08a7 淘系支付宝账户筛选栏去掉数据源渠道与收支类型,关键词移到账单项目后
第 1 行:原始店铺/总账备注/账单项目/关键词;第 2 行:发生时间 + 查询/重置右对齐,
与其它账户页签布局一致。
2026-10-07 14:57:35 +08:00
chenxi
73617e4899 Merge remote-tracking branch 'origin/main' 2026-10-07 14:50:18 +08:00
chenxi
57c1ea7046 抖音账户关键词框与其它页签统一(去掉匹配方式下拉)
关键词改为单输入框;后端对 15~30 位纯数字按等值匹配(记录编码/订单号)快速返回,
其余仍为包含匹配,避免抖音账户全表扫描导致的长等待。
2026-10-07 14:50:07 +08:00
4b3baeaaf0 拼多多完整账单导入:账单项目为空时按「收支方向 + 业务描述」回填
源文件偶发账单项目为空的行;导入时在 activate 之前用同代已有口径回填(方向+描述取众数),
找不到口径的行保持原样。生产已用同一规则回填历史 10 行(技术服务费 4 / 交易收入 4 / 优惠券结算 1 / 提现 1),
拼多多页签总账备注为空从 10 行降到 0。
2026-10-07 14:36:34 +08:00
chenxi
74d3558bfa 店铺收款账户账单其余页签对齐淘系支付宝账户样式
统计卡片与筛选栏按淘系支付宝页压缩(金额改万元),筛选栏改 4 列等宽栅格
(发生时间跨 2 格、按钮右对齐),并修复窄屏工具区被撑高的问题。
2026-10-07 14:30:30 +08:00
chenxi
d7e6e50a04 Merge remote-tracking branch 'origin/main' 2026-10-07 14:22:30 +08:00
chenxi
9160ca77a0 淘系支付宝账户筛选栏改为每行 4 个的等宽栅格
四列等宽栅格(发生时间跨 2 格、按钮右对齐),容器 ≤860px 两列、≤560px 单列;
日期控件改弹性宽度避免溢出;字段与功能不变,筛选区高度由 314px 降到 155px。
2026-10-07 14:22:23 +08:00
c4a976751d 修复订单售后页面一直转圈:店铺候选改成定时预热
上一次把店铺候选的完整计算放在 app()->terminating() 里,但服务器是 mod_php,
响应要等整个请求(含终止回调)结束才算完成,浏览器就一直转圈、侧边栏停在骨架屏。
线上实测:发布后第一个打开订单售后费用明细的人,页面会卡两分钟以上。

改成:页面只返回毫秒级的店铺目录兜底,完整候选(目录 ∪ 订单镜像店铺)由新增的
order-data:warm-shop-candidates 每小时预热一次,并在发布脚本的 cron 里注册。
2026-10-07 14:21:36 +08:00
a9da8d4447 修复发货镜像字段校准与合并列表耗时 (#283) 2026-10-07 14:08:20 +08:00
916fb30f37 修复批量筛选:纯数字单号当数组键会被 PHP 转成 int
batchValues() 原来拿值本身当数组键去重,纯数字订单号(如 3317080368285022883)会被
PHP 转成 int,后续 whereIn 就成了数字和长文本比较,走不上 so_id / outer_oi_id 的
前缀索引,实测退化成全表扫描(70 秒以上)。

改成键加 v 前缀、返回原字符串,ctype_digit 显式传字符串。新增用例覆盖前导零单号
0001234 与 1234,防止再回归。
2026-10-07 14:05:53 +08:00
chenxi
c38299b078 Merge remote-tracking branch 'origin/main' 2026-10-07 13:58:01 +08:00
chenxi
4310a58104 淘系支付宝账户:压缩顶部区域、统计金额改万元、修复工具区异常高度
统计卡片与筛选栏整体收紧约 26%(金额改万元展示),修复窄屏下工具区
被 flex-basis 撑高 384px 导致明细表被推离首屏的问题;明细表样式不变。
2026-10-07 13:57:54 +08:00
e5922dd78e 订单售后费用明细 / 商品推广费用明细:订单号和商品编码支持批量查询
两个页签的「订单号」「商品编码」改成多行输入框,可以直接粘一整列:
每行一个,或用逗号、空格、分号、顿号分隔,一次最多 50 个值,多值取并集。

- 订单号批量:先去两套来源解析候选 o_id(单表、等值 IN,走各自前缀索引),
  再按订单过滤;单个订单号仍保留「从头匹配」的模糊语义。
  线上实测 20 个真实单号解析 0.01 秒。
- 商品编码批量:每个编码在 商品/款式/店铺编码(推广页为 item_id)里匹配一组,
  多组 OR;仍受统计日期/订单日期范围约束。
- 非淘系旧查询路径同步支持批量,保持两套口径一致。
2026-10-07 13:55:02 +08:00
6c054c7081 整合发货补漏与导出口径修复,保留主线同步保护 (#282) 2026-10-07 13:52:18 +08:00
4b97b30a3a 商品推广费用明细:店铺筛选改成和订单售后费用明细一致的多选控件
- 把「店铺名称:可点选的多选 + 模糊搜索」抽成公共组件
  partials/order-data-shop-picker.blade.php,订单售后费用明细改为引用它,
  商品推广费用明细用同一个控件(原来是一个纯文本输入框)。
- 推广页提交 shop_name[](多个取并集),后端镜像查询和非淘系旧查询都改成
  按多个店铺 OR 匹配,仍兼容原来的单个 shop_name。
- 推广表的店铺名带下划线(天猫_卓禾家具旗舰店),和订单镜像的「天猫-…」
  不是同一套命名,所以候选单独取自 ops_item_sales_cost_ratio 当前统计日期
  出现过的店铺,按日期缓存 24 小时(单次约 0.4 秒、约 400 个店铺)。
2026-10-07 13:39:25 +08:00
chenxi
2a30d9bac3 Merge remote-tracking branch 'origin/main' 2026-10-07 13:20:37 +08:00
chenxi
35ee115522 淘系支付宝账户筛选栏样式对齐淘系微信账户
改用与 AccountBills 相同的筛选栅格与容器查询(≤1200px 两列、≤600px 单列,
发生时间整行),替换原来 7 列一行排开的布局。
2026-10-07 13:20:30 +08:00
d76b102bca 总账备注平台口径:1688 / 新佰广 前缀按阿里巴巴匹配
新佰广1688、1688xxx 店铺名不带「阿里巴巴」前缀,之前落到页签默认平台(淘系支付宝=淘宝),
1688 系账单项目匹配不到总账备注;新增两条前缀映射(1688、新佰广 → 阿里巴巴)并补单测断言。
2026-10-07 13:20:06 +08:00
af83d75a00 订单售后费用明细:订单号查询改为先解析候选订单再过滤
订单号原来在「订单 ⋈ 明细」关联后做三列 %x% 匹配,没有日期条件时优化器会放弃
索引,淘系那套 520 万订单 × 680 万明细必须全表扫描,线上实测单条跑到 19 分钟
还没结束(还占满 Apache 工作进程)。

改成两段式:先用带前缀索引的单表查询把订单号解析成两套来源各自的候选 o_id
(线上订单号 / 子订单号从头匹配、内部订单号按数值等值),再让分支按 o_id 过滤。
两套来源分开解析,避免两侧 o_id 撞号。候选超过 500 条或订单号不足 4 个字符时
退回原来的包含匹配,保持既有行为。

顺带给 jst_OrderItem 补 outer_oi_id 前缀索引(非淘系子订单号原来要扫 70 秒),
已在生产建好索引。
2026-10-07 11:31:56 +08:00
e8b8b816f4 修复「是否种菜订单」映射读取为空:补 deleted_at 列并改用表查询
新建的 jst_order_label_planting_rules 缺 deleted_at,而模型基类带 SoftDeletes,查询报错被兜底成空映射,
页面全部显示「否」。补迁移 2026_10_07_130000(建表迁移同步补上)并把服务改为 DB 表查询。
2026-10-07 11:20:03 +08:00
96b8f503c6 账单明细:平台筛选改为按账户/数据来源分类(与店铺收款账户账单页签一致)
- 平台下拉改为淘系支付宝/淘系微信/拼多多/京东账单/京喜账单/京东网银钱包/抖音/得物

- 京东账单与京喜账单同表,按店铺前缀切分;财务备注/财务类型仍按店铺前缀匹配映射表
2026-10-07 11:12:24 +08:00
d877735507 聚水潭订单标签明细:新增「是否种菜订单」列
下单时间后面新增「是否种菜订单」列,逐行按(公司 + 标签)匹配财务维护的映射表:命中为是、未命中为否。
- 新表 jst_order_label_planting_rules + 模型 + 缓存服务,导入命令 jst-order-labels:import-planting-rules(读「判断种菜单.xlsx」)
- 导出 CSV 同步新增该列,页面/导出同口径
- 本地构建通过;改动/新增 PHP 文件 php -l 通过
2026-10-07 11:14:04 +08:00
5b95b655bb 运费对账:全量汇总缓存 1 小时,避免每 15 分钟重跑两分钟的重查询
无筛选的全量汇总约 110~200 秒,按原来的 900 秒缓存会让常看这个页面的人
反复等两分钟;带筛选的汇总走索引很快,保持 15 分钟。
2026-10-07 11:01:29 +08:00
chenxi
1a024c2b26 Merge remote-tracking branch 'origin/main' 2026-10-07 10:55:58 +08:00
chenxi
d7582cdb46 修复业绩明细导出:补平台筛选 + 改游标流式输出
导出补齐平台筛选,并把 chunk(1000) 的 OFFSET 轮询改为 cursor() 单查询流式输出。
2026-10-07 10:55:52 +08:00
af8f1403fb 修复运费对账汇总卡死与订单店铺候选查询过慢
- 运费对账全量汇总:哈希关联的 join buffer 从 16MB 提到 128MB(空间上限 384MB)。
  300 万行明细 × 400 万行最新关联表在 16MB 下会退化成磁盘多趟扫描,实测超过
  max_statement_time(300s) 也跑不完,页面「记录数/运费合计」永远停在计算中;
  调整后约 110 秒出结果并写入缓存。
- 订单售后费用明细店铺候选:改回两个来源各自 DISTINCT 后在 PHP 合并(UNION 会
  把两个上百万行结果一起物化去重排序)。同时完整候选只算一次并缓存 24 小时,
  冷缓存时先返回毫秒级的店铺目录,完整列表放到响应结束后补齐,避免页面为了
  下拉候选把工作进程占住两分钟。
2026-10-07 10:52:36 +08:00
40832546da 聚水潭订单标签明细:店铺/标签筛选改为可搜索多选
店铺名称、标签由原生单选 select 改为项目已有的「可搜索多选」组件(可打字搜索、全选/清空、勾选一条或多条),
结果按 shop_name[]/label[] GET 提交;控制器支持数组并与旧单值参数兼容,店铺 whereIn 精确匹配、多个标签 OR 包含匹配,
页面/分页/导出同口径。生产数据实测:多选店铺 951=951、多选标签 39575=39575、旧单值 92=92 均与独立 SQL 一致。
2026-10-07 10:54:07 +08:00
chenxi
2341a839f3 Merge remote-tracking branch 'origin/main' 2026-10-07 10:41:06 +08:00
chenxi
0bb4df6512 业绩明细扩展到店铺收款账户账单全部平台页签
按页签配置来源表与列映射(得物用 bill_date、京东系列/抖音用 order_no+income、
京东与京喜按店铺名拆分、京东/抖音只取活动代);单号查询同时带原始号与核心号
以匹配拼多多的日期前缀单号;镜像表增加平台维度与平台列/筛选。
2026-10-07 10:41:00 +08:00
4258bd4e0e 侧边栏权限:新增一级菜单「种菜数据汇总」不再被旧权限集合隐藏
种菜明细表 / 聚水潭订单标签明细 原本挂在「账单流水数据」下,移入新一级菜单「种菜数据汇总」后,
按页面键保存过精确权限的访问组(总账组/结算组/税务组)里没有新 section,导致菜单不显示、直达链接 403。
按既有 company-management 兼容做法:能看「账单流水数据」即可见「种菜数据汇总」。
生产数据实测(临时容器、同镜像、只读):修复前 12 个访问组账号全部看不到,修复后 21 个账号全部可见。
2026-10-07 10:27:20 +08:00
fb15402003 feat: 店铺维度报表无业绩月份列默认隐藏 (#281) 2026-10-07 10:01:13 +08:00
cf7924fdc6 订单售后费用明细:合并淘系(天猫/淘宝)订单来源
聚水潭把淘系订单放在 jst_qmcust_* 系列;页面镜像已接入后,报表改为两套来源合并:

- 订单、明细、售后分别按来源取数(ops_jst_orders* 与 ops_jst_qmcust_*),列映射一致;
  淘系明细没有 send_warehouse,用订单上的 wms_co_id 兜底
- 每套来源各自下推筛选条件并按当前页各取所需行数再合并,避免整表物化后排序
- 店铺候选同时包含两类店铺(天猫-/淘宝- 前缀)
- 镜像数据集登记:jst_qmcust_orderlist/_item、jst_qmcust_refundlist/_item 及分层策略
- 用例:淘系订单与售后进入报表、店铺候选包含淘系、双源多店铺筛选
2026-10-07 10:00:20 +08:00
83e5108ce6 订单售后费用明细:重置后不再自动带日期
- 点“重置”或提交空日期时,不再回填近 7 天默认区间,日期框保持为空并做不限日期查询
- 首次进入页面仍保留近 7 天默认区间;导出文件名在无日期时显示“不限日期”
- 补充过滤归一化与页面渲染用例
2026-10-07 10:00:20 +08:00
eda10bd740 订单售后费用明细:店铺筛选改为可点选的多选模糊查询
- 店铺名称由单个输入框改为可点选的多选下拉:候选里可按关键字模糊筛选、多选、全选当前结果/清空
- 提交 shop_name[],多个店铺取并集逐个模糊匹配;保留旧的单值 shop_name 兼容
- 候选来自订单镜像中真实出现过的店铺,缓存 1 小时;单次最多 50 个店铺,超出在面板内提示
- 补充仓库层与页面层用例
2026-10-07 10:00:20 +08:00
cd641ddfbd 每日账单脚本:远端失败重试 + 不再把失败当成功
- 新增 erp-remote.ps1:上传与远程执行统一重试(默认 3 次,重试前重新上传 CSV)
- run-erp-daily.ps1:导出失败重试一次;导出/转CSV/笔数核对/上传/导入回验失败都不写状态、不归档,退出码 1,下次自动重导
- 实测:真实 SSH 断连两次后第 3 次重试成功(8002 笔 / 283870.49 / 46435.6);连续失败的一次按新逻辑记失败并 exit 1
2026-10-07 10:02:00 +08:00
chenxi
b9c2b73655 修复业绩明细月份选择器禁用 7 月的问题
data-min-month 由写死的 2026-08 改为镜像中实际最早账期(YYYY-MM),
避免把已同步的 7 月禁用导致无法查询。
2026-10-07 09:51:26 +08:00
chenxi
278f641018 业绩明细账单侧筛选改为「总账备注」口径
账单侧改为按总账备注命中「订单/收入/交易收入/订单收入/商家结算款/跨境订单结算款/消费订单」,
复用 AccountLedgerRemarkMap + LedgerRemarkFilter,与店铺收款账户账单页同一口径,
淘系页签按店铺名前缀分淘宝/天猫/阿里巴巴取各自账单项目;统计条新增「收入为 0 的账单行」。
2026-10-07 09:39:06 +08:00
ac21fc4079 淘系支付宝账户账单:筛选项整理为一行
原 4 列网格会折成两行;改为单行 7 列(原始店铺/总账备注/账单项目/数据源渠道/收支类型/发生时间/关键词),与其它账户页签一致;窄屏 ≤1400px 回落 4 列、≤1100px 两列、≤600px 单列。
2026-10-07 09:19:00 +08:00
5ef4119a3d feat: 主体维度经营报表无数据月份列默认隐藏 (#280) 2026-10-06 23:51:16 +08:00
5d7bc3f2c4 feat: 税金预测表页面标注逻辑配置入口 2026-10-06 23:18:53 +08:00
bfc119608f feat: 新增税金预测表逻辑配置页面 2026-10-06 23:18:53 +08:00
35fe10bc69 feat: 新增税金预测表页面与取数口径说明 2026-10-06 23:17:16 +08:00
8419d4097c feat: 内部流转报表导入工作簿数据并支持查询导出
- 新增共享镜像表 business_internal_flow_records 与整份替换导入命令 internal-flow:import
- 按 Excel 表头展示抬头、标签、预计9月销量、开票、所需进项成本、收票、剩余缺口与开票比例
- 复用公司基础信息的字段查询、表头排序、筛选字段与导出 Excel 交互
- 新增导入与页面测试,覆盖合计行跳过、重导入替换、筛选排序与未导入空状态
2026-10-06 22:08:08 +08:00
5f1fc25578 修复:未带「总账备注」筛选时 filteredQuery 读取未定义键
选择总账备注才生成 bill_items;未选择时该键不存在,直接读取会触发 Undefined array key,使唯品会MP/唯品会JX 页签的常规请求报错。改为 ?? [] 读取。
2026-10-06 20:03:58 +08:00
e777e75176 店铺违规信息预警:统一表头映射,支持查询、导入导出与每日同步 2026-10-06 19:02:22 +08:00
13a9697447 店铺收款账户账单新增「总账备注」筛选;每日账单脚本支持胜算异步分片导出
- 所有账户页签在「店铺」右侧新增「总账备注」下拉:按(平台+账单项目)翻译成账单项目集合过滤,页面/列设置/导出同口径;淘系支付宝、淘系微信同页签混多平台时按店铺名前缀细分
- 每日账单脚本:胜算超出同步导出上限(url=null+taskId)时自动从「异步导出下载管理」取回本次任务全部分片并合并成一份 CSV,导入前按页面笔数核对(不一致则跳过导入)
- 实测:京东 7 月异步导出两片 219158+235846=455004 笔 / 收入 17695094.84 / 支出 8035128.21,与已导入口径一致
2026-10-06 18:46:57 +08:00
78857a51d9 feat: 店铺维度报表按主体期间拆分并支持期初数据维护
- 改为按期初数据+系统主体+爬虫新主体生成主体期间,一行一期间,修复流程历史重复行
- 变更日当天归新主体、老主体失效显示变更日前一天;关店和其他非正常状态两列显示 -
- 期初数据首次打开固化为系统库设置,可在「规则信息 → 店铺维度报表逻辑配置」在线增删改
- 爬虫店铺主体按税号归一,首次抓到的新主体写入期间变更表并自动生成新期间
- 新增期间变更表迁移与 crawl_shop_company 镜像数据集
2026-10-06 18:42:12 +08:00
f774e75fce 每日账单脚本:胜算该区间无数据时按跳过处理(不算失败) 2026-10-06 17:46:31 +08:00
73af15f2db 加固每日账单导出:设置日期前先关授权弹窗并逐次校验日历面板 2026-10-06 17:42:10 +08:00
80978061de 新增统一脚本:聚胜算每日账单(头条放心购 / 京东日账单)导出并导入(scripts/erp-daily-bills) 2026-10-06 17:33:19 +08:00
2c1ac0bb3b 总账备注映射导入支持 --sheet 选择工作表(补齐京东网银钱包/淘系微信两处空列数据) 2026-10-06 17:26:41 +08:00
d6b0b7f1d7 feat: 接入退货取件管理镜像、实际运费核算与 XLSX 导出 2026-10-06 16:05:59 +08:00
4523682d1e feat: 逆向物流报价页接入计费规则与费率表 2026-10-06 16:01:35 +08:00
c04054ca0b 修复「总账备注」在共享镜像页签不生效(mirrorIndex 漏补装饰) 2026-10-06 15:54:52 +08:00
chenxi
7d2980d5e3 Merge remote-tracking branch 'origin/main' 2026-10-06 15:41:58 +08:00
44a2664372 发布脚本:头条放心购原始账单镜像每天 09:30 定点刷新(店铺收款账户账单·抖音账户数据到齐) 2026-10-06 15:42:58 +08:00
e147c90a2c 店铺收款账户账单新增「总账备注」列(按平台+账单项目映射表取值)
- 新增 ec_account_ledger_remarks 映射表与 account-ledger-remarks:import 导入命令(平台/账单项目/总账备注)
- 新增 AccountLedgerRemarkMap:按店铺名前缀识别平台,无前缀时用页签默认平台;缺失匹配保持空白
- 全部账户页签(含淘系支付宝自有列定义)在「店铺/店铺名称」右侧新增「总账备注」列,页面/列设置/导出共用同一份列定义
- 控制器在分页与导出流读取时补齐 payload.ledger_remark,不改动上游账单与共享镜像数据
- 用例:新增 AccountLedgerRemarkMapTest;修正唯品会 JX/MP 列清单断言
2026-10-06 15:42:57 +08:00
chenxi
98cea16655 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	config/business_navigation.php
2026-10-06 15:41:47 +08:00
6af12c030e 财务经营报表:新增账单明细页(多选筛选/日历选择/四项汇总/按列导出与进度/收款明细同款分页),科目映射表字段改名财务备注与财务类型
- 账单明细:7 个平台账单分支下推查询,按 active_generation 取当前版本(修正抖音重复计数),跨月大数据量由 73s 降到 1-2s

- 筛选:平台/店铺/账单项目/财务类型多选下拉(打字模糊搜索+滚动),日期改日历选择

- 导出:全部筛选结果 CSV 流式下载并按显示列最小取数,带进度轮询;列设置按账号存本机

- 分页:每页 10/20/50,首页/上下页/末页,第 X-Y 条/共 N 条

- 科目映射表:字段改名财务备注/财务类型(导入兼容旧名)
2026-10-06 15:35:16 +08:00
chenxi
e0f89288d2 业绩明细取数口径改为「一个店铺 + 一个订单号 + 一个商品金额」
- 账单侧:账单项目扩到 11 类(交易收款、1688 增值服务系列、交易分账_货款、其它交易收款、
  先采后付账单付款、百补超级版消费者实付款、店铺过户),按「店铺 + 订单号」聚合收入与收款笔数
- 订单侧:销售全量按「店铺 + 线上订单号」先聚合求和(goods_count>0 有效行),
  一单多品/多子订单在聚合阶段合并,商品金额只挂一次
- 匹配:单号清洗(去日期前缀/空格/横杠)后按核心号 + 店铺匹配;店铺以账单「线上真实店铺」
  优先,下划线归一为横杠;订单侧单号一致但店铺不同或同号多店铺时标注异常
- 输出列:发生时间 → 店铺名称 → 线上单号 → 收入金额 → 商品金额 → 差额(收入−商品金额)
  → 匹配状态 → 异常标注;镜像表改为该行口径
- 统计:账单收款笔数、账单订单数、已匹配、未匹配、一单多笔、账单收入合计、
  订单商品金额合计、差额合计(已匹配)
- 未匹配分类:空单号 / 格式不一致 / 订单无商品金额 / 订单表无此单,并可按异常标注筛选

8 月淘系支付宝:118,500 笔收款 → 111,427 行,已匹配 101,005、未匹配 10,422
(订单无商品金额 8,352 / 订单表无此单 2,046 / 空单号 24),一单多笔 6,758。
2026-10-06 14:43:49 +08:00
chenxi
08aaed9241 业绩明细新增「店铺收款匹配订单明细」页面及同步命令
- 以店铺收款账户账单(账单项目=交易收款)为基数,按线上单号关联经营分析副本库
  的发货明细与销售全量,补出发货仓库、商品编码、商品数量、商品金额
- 匹配键:账单 online_order_no = 发货明细 so_id = 销售全量 sourceno(淘宝/天猫主订单号);
  同一订单多笔收款按订单号汇总,商品金额取 goods_count>0 的有效行
- 新增镜像表 store_receipt_order_match_rows 与命令
  store-receipt:order-match:sync <YYYY-MM>(分批查询上游,订单号每批 1000 条)
- 页面:月份/店铺名称/线上单号/匹配状态筛选、匹配统计条、分页、CSV 导出
- 修正宽精度迁移在 sqlite 测试库上执行 MySQL 改写语句导致测试全线失败的问题
2026-10-06 13:42:30 +08:00
chenxi
ffd851fc0c 四项全0时加权取两位舍入后的更改价格,修复 72.00495→72.01 偏差 2026-10-05 12:07:12 +08:00
chenxi
1ab9a45d1d 金额/单价列精度提升到 8 位小数(展示仍两位),修复原始价格精度丢失导致的 1 分钱差异 2026-10-05 11:11:58 +08:00
chenxi
05760bd0a5 原始价格保留源表精度、加权改先除再舍入(存4位),对齐手工表算法 2026-10-05 11:07:26 +08:00
chenxi
26d096a3a5 加权平均单价改为先除再舍入(中间金额不舍入,最后统一保留两位) 2026-10-05 11:03:19 +08:00
chenxi
04e021700e 行维度增加“主仓”限制:只取仓库含主仓字样的仓库简称+商品编码 2026-10-05 10:50:37 +08:00
chenxi
9526173fec 成本价兜底改按方案②:仅“聚水潭成本价记录”有生效记录时才判已定价 2026-10-04 15:35:24 +08:00
chenxi
d736ab0fd3 更改价格最终规则:空值兜底(记录时间空视为≤次月2号、生效日期空视为未匹配)、生效>本月或记录时间>次月2号取变价前价格、成本价有值视为已定价 2026-10-04 15:19:14 +08:00
chenxi
1ca789807b 无原始价格且匹配到成本价时,定价状态改为未定价(按最新规则) 2026-10-04 14:16:25 +08:00
chenxi
790df169b5 定价状态统一:原本输出的“预估价”改为“未定价”(输入仍按预估价判断分支) 2026-10-04 14:02:36 +08:00
chenxi
2b06595331 更改价格按方案乙:记录不完整视为未匹配;记录时间>次月2号取变价前价格且状态保持 2026-10-04 13:18:35 +08:00
chenxi
22ec9ab333 更改价格分支修正:变价前价格需同时满足生效时间大于本月且记录时间大于次月2号 2026-10-04 10:47:40 +08:00
chenxi
a77d369d15 修复滚动月份代发仓判定丢失导致原始数量异常:按仓库简称兜底识别代发仓 2026-09-30 18:09:31 +08:00
chenxi
c0938893c7 更改价格分支补全:变价生效时间大于本月取变价前价格;均未匹配时留空并置“待人工核查” 2026-09-30 18:00:17 +08:00
chenxi
b49332e5d9 Merge remote-tracking branch 'origin/main' 2026-09-30 17:56:10 +08:00
chenxi
8ab825ed9e 采购数量/入库金额改为入库明细按大写条码+仓库简称汇总,采购单价=入库金额/采购数量 2026-09-30 17:56:06 +08:00
8132f83713 Merge branch 'fix/qiuxiang-jst-order-labels-count-cache' 2026-09-30 17:48:54 +08:00
c46a439d73 聚水潭订单标签明细分页总数改为缓存
无筛选时 COUNT(*) 在 233 万行上要 2 秒(冷缓存更久);改为缓存总数(30 分钟,导入时预热)并手工构造分页器,翻页不再重复统计。
2026-09-30 17:48:35 +08:00
625763176d Merge branch 'feature/qiuxiang-planting-data-section' 2026-09-30 17:44:23 +08:00
b2647fa26d 新增种菜数据汇总一级菜单并把两个明细页移入
账单流水数据下移出「种菜明细表」「聚水潭订单标签明细」,新增并列一级菜单「种菜数据汇总」(图标 grass)承载这两个页面;导出路由改到 workspace/planting-data/... 并同步路由名、页面中间件与导出链接;更新导航/页面用例(账单流水数据页签列表恢复原样)。
2026-09-30 17:44:06 +08:00
5bcd6b6c52 Merge branch 'fix/qiuxiang-jst-order-labels-options-cache' 2026-09-30 17:12:41 +08:00
77ac3c58fa 聚水潭订单标签明细筛选选项改为缓存并预热
233 万行上做 distinct 扫描要几秒;新增 JstOrderLabelOptions(按公司缓存 24 小时),页面读取缓存,导入命令结束时失效并预热,避免每次打开页面都等待。
2026-09-30 17:12:27 +08:00
chenxi
cf07ad499f 入库明细新增“总计”列(=金额+预估金额),页面与导出一致 2026-09-30 17:04:44 +08:00
chenxi
5ee0813490 入库明细数据:加仓库/条码筛选与导出接口,样式对齐进销存总表筛选栏 2026-09-30 16:55:13 +08:00
chenxi
f43ab57c49 入库明细表头完全按源表 21 列顺序展示(含重复的创建时间列) 2026-09-30 16:50:34 +08:00
chenxi
ebe4d006e5 入库明细表标记 data-finance-table-native,避免共享表脚本按单行表头隐藏全部列 2026-09-30 16:48:26 +08:00
chenxi
7cc9f13eac 进销存报表新增第三个页签“入库明细数据”(只读列表+月份切换+分页,先看数据) 2026-09-30 16:46:36 +08:00
chenxi
1c679f0215 入库明细仓库简称匹配规则:仓库名匹配,含代发仓字样的按仓储方匹配 2026-09-30 16:43:19 +08:00
chenxi
fdb7f77173 Merge remote-tracking branch 'origin/main' 2026-09-30 16:39:12 +08:00
fc6b83aee0 Merge branch 'feature/qiuxiang-jst-order-labels-page' 2026-09-30 16:40:37 +08:00
e5d6c76f27 账单流水数据新增聚水潭订单标签明细页
新增「账单流水数据 › 聚水潭订单标签明细」(紧邻种菜明细表):四列(线上订单号/店铺名称/下单时间/标签),按标签拆行(一个标签一行)、订单不去重、标签不汇总,无标签订单记为「(无标签)」。新增 jst_order_labels 表(含公司/时间/标签/店铺索引)、JstOrderLabel 模型、JstOrderLabelImporter(从镜像 ops_jst_orders 按区间拆行导入,--replace 只清同区间)与 jst-order-labels:import 命令、导出路由与用例。页面筛选:下单时间区间(点整块开日历、可单独清除)、店铺、标签、线上订单号。
2026-09-30 16:40:16 +08:00
chenxi
793c160f12 入库明细仓库简称匹配:仓库名唯一匹配不到时回退按仓储方唯一匹配 2026-09-30 16:39:09 +08:00
chenxi
5d4d2ef4df Merge remote-tracking branch 'origin/main' 2026-09-30 16:36:26 +08:00
chenxi
9ca01b5d65 进销存报表入库明细数据:新增镜像表与导入命令(原样导入不去重,仓库简称按仓库名唯一匹配) 2026-09-30 16:36:24 +08:00
149d7ef74f 修复终版其他子页面的导入弹窗不弹出
上一版按脚本内容去重,只有整页加载的那个子页面弹窗绑上了事件;切换页签后换进来的
新弹窗因为脚本内容相同被跳过,所以“其他子页面点导入没反应”。

改为给弹窗脚本加 data-caiwu-replay 标记:局部替换导航后每次都重新执行,
脚本自身按目标元素 data-ready 去重,重复插入不会重复绑定。
2026-09-30 15:58:24 +08:00
chenxi
6947aafc79 Merge remote-tracking branch 'origin/main' 2026-09-30 14:48:54 +08:00
chenxi
64a82cf135 条码+仓库统一为商品编码+仓库简称(镜像并入行同步修正),并重算7/8月 2026-09-30 14:48:49 +08:00
960d418c8e 发布构建支持指定 Composer 下载源
国内网络访问 GitHub 受限时,composer install 会因 GitHub dist 拉取失败而中断
(api.github.com / codeload.github.com SSL_ERROR_SYSCALL)。

- deploy/Dockerfile 新增可选构建参数 COMPOSER_REPO_PACKAGIST,留空仍用官方源
- macOS 发布脚本默认传腾讯云镜像,可用 CAIWU_COMPOSER_REPO_PACKAGIST 覆盖
2026-09-30 14:07:55 +08:00
a8f3178ab1 新增本机保存发布密码的小工具
- scripts/store-deploy-password.sh:交互输入一次服务器密码,写入 runtime/private/deploy-password
  (权限 600、已被 Git 忽略),避免依赖系统钥匙串授权窗口
- deploy-from-mac.sh 在该文件存在时自动使用,无需额外参数
2026-09-30 11:37:34 +08:00
ff3f3424c5 mac 发布脚本:登录信息只读一次,避免反复弹钥匙串
- 启动时只读取一次密码(钥匙串/密码文件/环境变量/交互输入),写入仅本人可读的临时
  askpass 脚本供 ssh 使用,避免每条 SSH 连接都触发钥匙串授权
- 新增 --password-file,写入钥匙串时用 -T /usr/bin/security 授予读取权限
2026-09-30 11:36:30 +08:00
Codex Yueyang
37d37b0844 科目映射表:加宽平台/大类/借贷科目列,避免唯品会JX、待处理财产损益被截断 2026-09-30 10:48:57 +08:00
Codex Yueyang
bb5b03ad80 科目映射表:大类新增「税费」(支出基准) 2026-09-30 10:40:59 +08:00
Codex Yueyang
126ef735a7 科目映射表:大类筛选只列出表里真的用到的大类,去掉空的和已清空的 2026-09-30 10:34:40 +08:00
cfde842d51 Merge branch 'fix/qiuxiang-planting-details-date-filter' 2026-09-30 10:28:17 +08:00
ff23b285db 种菜明细表时间筛选改为可用日期区间控件
原来的两个原生 date 输入只像一个按钮:新增点击整块输入框即打开日历(showPicker)、选完日期立即按当前条件查询、开始/结束日期各自可一键清除,并补充近7天/本月/上月/全部日期快捷区间;补用例断言这些控件存在。
2026-09-30 10:28:02 +08:00
f3ebfb942e 修复终版页签切换后导入/编辑按钮无反应
供应商应付工作区用局部替换方式切换页签,被替换进来的页面自己追加在 body_end
的行内脚本不会执行,导入/编辑弹窗因此没有绑定事件(原「供应商应付」下同样使用
行内弹窗的板材扣款页也受影响)。

现在导航完成后补执行一次新出现的行内脚本,并按源码内容去重,避免重复绑定。
2026-09-30 10:17:12 +08:00
Codex Yueyang
0c994d2465 科目映射表:筛选改为可多选(账单项目/平台/大类/方向基准),去掉账单项目搜索按钮 2026-09-30 10:05:05 +08:00
8613fa21c7 Merge branch 'fix/qiuxiang-planting-details-export-link' 2026-09-30 10:00:02 +08:00
d27f8d0299 种菜明细表导出链接显式带上 company_id
视图里的导出链接改为 array_merge(['company_id' => company_id()], request()->query()),在没有 URL 默认参数时也能生成正确链接。
2026-09-30 09:59:53 +08:00
494b60de54 Merge branch 'fix/qiuxiang-planting-details-id-column' 2026-09-30 09:56:04 +08:00
304bb3dc8e 种菜明细表:ID 列放宽为 text 支持多行 ID
源表「ID」列存在一个单元格多行放多个 ID 的行(导入时报 Data too long for column external_id)。新增迁移把 external_id 放宽为 text,导入器按 500 字符保留原文,并补用例覆盖多行 ID。
2026-09-30 09:55:51 +08:00
b106569905 Merge branch 'feature/qiuxiang-planting-details-page' 2026-09-30 09:51:10 +08:00
28b9271b3c 账单流水数据新增种菜明细表页面与导入
新增「账单流水数据 › 种菜明细表」页面:汇总卡(笔数/财务打款金额/财务售价/扣款/京喜差价)+ 筛选(日期区间/店铺/负责人/类型/标签/关键词)+ 分页 + 按筛选全量导出,样式沿用 upstream-data 黑金令牌。新增 planting_details 表(20 列与源表一一对应,金额保留 4 位小数)、PlantingDetail 模型、PlantingDetailImporter(xlsx/xls/csv,兼容 Excel 序列号日期与千分位金额)与 planting-details:import 命令。补 PlantingDetailsPageTest 覆盖导入、序列号日期、页面渲染、筛选与导出。
2026-09-30 09:50:50 +08:00
yueyang
d5a8209664 科目映射表:账单项目改成可点选+打字过滤的候选列表,并加搜索按钮 2026-09-30 09:34:22 +08:00
chenxi
aafbfb20c7 原始数量只取仓库名称含主仓的期初数量(南昌樵舍仓仍取三方-南昌樵舍仓主仓) 2026-09-29 20:26:55 +08:00
chenxi
e54b7f8d25 南昌樵舍仓:江西仓送货的采购单据条码一并纳入行维度,采购数量合计与江西仓送货一致 2026-09-29 20:21:02 +08:00
chenxi
27c351f0c5 商品编码统一大写:表1期初基数、进销存聚合、南昌樵舍仓/江西仓送货映射的编码索引全部大写后再匹配 2026-09-29 19:54:28 +08:00
chenxi
69225ef529 Merge remote-tracking branch 'origin/main' 2026-09-29 19:28:34 +08:00
chenxi
379ba0eb99 南昌樵舍仓期初数量改取三方-南昌樵舍仓主仓对应商品的期初数量 2026-09-29 19:28:32 +08:00
yueyang
d0a5b03dd1 科目映射表:账单项目筛选改为可打字搜关键字(带下拉建议) 2026-09-29 18:58:08 +08:00
yueyang
09d473bd91 科目映射表:列表按平台归类,序号改为组内连续的列表位置(增删自动重排、筛选不变) 2026-09-29 18:43:31 +08:00
yueyang
05abcb6eaa 科目映射表:序号固定用规则编号,账单项目增加与其他条件一致的下拉筛选 2026-09-29 18:38:25 +08:00
chenxi
ea1a23c4b8 Merge remote-tracking branch 'origin/main' 2026-09-29 18:27:34 +08:00
chenxi
634f82fdc4 商品月加权平均单价维度跟进销存总表同步:并入进销存新增仓库/条码,南昌樵舍仓采购数量取简尚-江西仓送货主仓采购进仓 2026-09-29 18:27:33 +08:00
yueyang
57ea42ffb8 科目映射表:导出文件名的时间戳也按平台时区(北京时间) 2026-09-29 18:04:46 +08:00
chenxi
1cd0bc8053 Merge remote-tracking branch 'origin/main' 2026-09-29 18:00:48 +08:00
chenxi
6cc84caf33 取数口径修正:汇总数据排除南昌樵舍仓(规则12),并重算7月/8月 2026-09-29 18:00:44 +08:00
yueyang
9f73d1915c 科目映射表:区分条件留空显示并导出为「-」,导入按不限处理 2026-09-29 17:40:36 +08:00
yueyang
9b05d99cd8 科目映射表:按业务备注/商品名称区分同一账单项目的不同业务,修正重复口径 2026-09-29 17:27:25 +08:00
chenxi
ddfd4b052f 修复商品编码大小写不一致导致普通商品资料价格未匹配:聚水潭成本价/变价后价格/工作平台价格统一按大写匹配 2026-09-29 17:18:58 +08:00
chenxi
af1a98e1a1 修复商品月加权平均单价导出空白:导出同样把 Y-m 归一为当月1日 2026-09-29 17:09:49 +08:00
chenxi
a412fcb12f 商品月加权平均单价:指定月份的筛选严格按该月取数,不再回退到最新月份 2026-09-29 17:07:27 +08:00
chenxi
538b5e9a10 修复月份筛选:Y-m 归一为当月1日后再取数,并移除工具区多余的月份下拉 2026-09-29 17:05:47 +08:00
ada44a4e04 Merge branch 'feature/qiuxiang-account-mapping-pending-property-category' 2026-09-29 17:05:18 +08:00
88626ad404 科目映射表并入待处理财产损益大类
固定大类清单新增「待处理财产损益」(支出基准),前端大类→基准映射同步;补用例覆盖。另:唯品会JX 的 8 条规则平台名由「唯品会jx」统一为「唯品会JX」(数据修正,逐条 update 并记操作日志)。
2026-09-29 17:04:57 +08:00
chenxi
2df6542234 Merge remote-tracking branch 'origin/main' 2026-09-29 17:04:14 +08:00
chenxi
34762d61be 商品月加权平均单价筛选栏改用进销存总表同一套(月份选择器+商品编码/仓储方/分类+查询/清除) 2026-09-29 17:04:10 +08:00
chenxi
2d91935304 商品月加权平均单价工具区与进销存总表统一:全部筛选结果/当前显示列及顺序+导出,新增本页导出接口;月份显示改为 Y 年 m 月 2026-09-29 16:58:12 +08:00
chenxi
ea7dcff178 商品月加权平均单价筛选栏对齐进销存总表:占位文案与商品编码精确查询 2026-09-29 16:50:05 +08:00
chenxi
45305fefa2 Merge remote-tracking branch 'origin/main' 2026-09-29 16:47:41 +08:00
chenxi
57810d6e98 商品月加权平均单价页接入与进销存总表一致的筛选栏(月份/商品编码/仓储方/分类+查询/清除) 2026-09-29 16:47:30 +08:00
cf4fd5f59c Merge branch 'feature/qiuxiang-account-mapping-nine-percent-invoice' 2026-09-29 16:30:34 +08:00
f0e8803626 科目映射表票据口径并入9%
常用票据口径新增 0.09(京东短信服务费等 9% 税率),页面票据下拉可选且校验不再报提示;补用例覆盖 0.09 与 options.invoices。另:京东「综合违约金/违约赔付」的借方科目由「销售费用/营业外支出」改为「营业外支出」(数据修正,走 store.update 并记操作日志)。
2026-09-29 16:30:11 +08:00
chenxi
78127cb18f Merge remote-tracking branch 'origin/main' 2026-09-29 16:17:21 +08:00
yueyang
7f3aeb233e 科目映射表操作记录:时间固定按平台时区,避免页面请求按公司时区写入导致差 7 小时 2026-09-29 16:10:34 +08:00
c7a8baea76 macOS 发布脚本对齐 Windows 版并补文档
- deploy-from-mac.sh 增加 --skip-regctl,并在 regctl 被安全软件拦截或隧道失败时
  自动改用 docker 直传(docker save + scp + docker load/push),与 Windows 版一致
- 修正若干在 set -e 下会被误判为失败的短路条件语句
- AGENTS.md 与《开发与部署SOP》补充 macOS 发布命令、钥匙串保存登录信息,
  以及 --allow-migrations、--skip-regctl、--allow-non-main 的对应参数
2026-09-29 16:04:05 +08:00
yueyang
55e92cc95b 科目映射表加操作记录:查看新增/修改/删除/导入历史,支持筛选、导出与恢复 2026-09-29 15:56:27 +08:00
chenxi
189ae747f7 商品月加权平均单价页增加月份切换下拉(显示已计算月份) 2026-09-29 15:45:17 +08:00
chenxi
6e57ef6b86 商品月加权平均单价页接入计算结果(默认最新月份,支持?month=切换,分页与导出口径不变) 2026-09-29 15:41:04 +08:00
chenxi
bc2df131e3 商品月加权平均单价:按取数逻辑计算7月/8月 2026-09-29 15:33:20 +08:00
chenxi
2370c50bb5 Merge remote-tracking branch 'origin/main' 2026-09-29 15:22:11 +08:00
chenxi
7b43d3e22c 商品月加权平均单价:建镜像表并导入7月期初基数 2026-09-29 15:21:47 +08:00
48b436510e Merge branch 'feature/qiuxiang-account-mapping-douyin-categories' 2026-09-29 15:21:11 +08:00
46689d99d3 科目映射表并入抖音大类并把票据0按不开票处理
固定大类清单新增售后费用/平台服务费/平台佣金(支出基准)与往来(收入基准),页面大类下拉与改大类时自动带基准的前端映射同步更新;导入时票据「0 / 0.00 / 0%」归一为「不开票」。现网 38 行抖音规则已由 0 改为不开票(逐条更新、写操作日志)。
2026-09-29 15:20:44 +08:00
bba79d2d69 新增 macOS 版发布脚本
按 scripts/deploy-from-workstation.ps1 的流程与约束,新增 macOS 可用的两个脚本:

- scripts/build-frontend.sh:项目专用 Node 20(官方 SHA-256 校验,可换镜像源)、
  node_modules/JS/CSS 输入指纹缓存、产物写入 .caiwu-build/public
- scripts/deploy-from-mac.sh:分支与干净检查、本地构建镜像并校验源码与前端资源、
  迁移拦截、SSH 隧道增量推送 Registry、服务器激活与健康检查、
  校验生产页面脚本、清理临时文件/隧道/构建缓存
- 登录信息:优先 SSH 私钥,其次本机钥匙串已保存的密码,首次部署只需输入一次
2026-09-29 15:16:34 +08:00
5830b01bc6 Merge branch 'fix/yueyang-coa-template-additions' 2026-09-29 15:10:26 +08:00
fad45b053f 账套模板补 4 个待换账套的临时科目(公益性捐赠支出/手续费/代扣代缴所得税/其他应付款_暂挂) 2026-09-29 15:09:22 +08:00
chenxi
b93a662614 商品月加权平均单价页入库数据子列「采购数据」改名为「采购数量」 2026-09-29 14:23:33 +08:00
9a0565e1eb 平台内序号列表头由#统一改为序号 2026-09-29 13:49:31 +08:00
chenxi
0d0482a8fe 修复两层表头右侧跨行列的边框断格
- 基础样式 th:last-child 会去掉第二行最后一个单元格(入库金额)的右边框,
  导致与右侧跨行的加权平均单价、定价状态之间缺少竖线、上下横线错位
- 按字段顺序给真正的最后一列打 data-directory-last-column 标记,
  合并表头的其它表头单元格统一保留右边框
2026-09-29 13:39:09 +08:00
79158103de 部署脚本:regctl 被安全软件拦截时自动降级为 docker 直传 2026-09-29 13:31:30 +08:00
chenxi
0b1de5b3ce 商品月加权平均单价页在加权平均单价后增加定价状态列
- 工作表新增 P 列「定价状态」,页面按同一列顺序补为第 16 列
- 该列不分组,表头跨两行,与加权平均单价一致
2026-09-29 13:29:01 +08:00
305d511535 Merge branch 'fix/yueyang-finance-table-fixed-header-zindex' 2026-09-29 12:08:39 +08:00
bc383d90db 固定列表头压在表体固定列之上,科目映射表去掉页面级补丁 2026-09-29 12:08:29 +08:00
5d6c007c96 Merge branch 'fix/yueyang-account-mapping-index-sticky-header' 2026-09-29 11:49:35 +08:00
727de5055c 科目映射表固定表头不再被首列序号盖住 2026-09-29 11:49:28 +08:00
944466ad5b Merge branch 'fix/yueyang-account-mapping-index-label' 2026-09-29 11:47:54 +08:00
b6e52f7bff 科目映射表序号列加宽到3.5rem,表头#改为序号 2026-09-29 11:47:43 +08:00
c56109f81c Merge branch 'fix/qiuxiang-vipshop-jx-settle-time-label' 2026-09-29 11:13:06 +08:00
047a527b3f 唯品会JX账户时间筛选标签改为结算时间
唯品会JX账户的时间筛选标签由「发生时间」改为「结算时间」;标签仍集中在 DATE_FILTER_LABELS 映射(京东账单/京喜账单/京东网银钱包=费用结算时间,抖音/唯品会MP/唯品会JX=结算时间,其余默认发生时间)。
2026-09-29 11:12:48 +08:00
813e956df5 应付款终版目录接入独立按表数据
两个「终版」目录(供应商应付(终版)、快递应付(终版))改为独立数据:
保留原有导入、导出、查询、重置、编辑按钮和导入模板,数据初始为空、按表独立存放,
不再借用基础目录的页面与数据。

- 终版目录拥有自己的同名子页面与横向页签
- 新增终版按表数据的独立导入/导出/模板/编辑接口(数据集键 final:<目录>:<页面>)
- 供应商终版 13 个页面、快递终版 4 个页面已接入
- 终版导入模板统一增加“月份”列,记录标识列改为选填
2026-09-29 11:06:41 +08:00
ab3580b694 Merge branch 'fix/qiuxiang-vipshop-mp-settle-time-label' 2026-09-29 11:04:06 +08:00
21cb3dcbcf 唯品会MP账户时间筛选标签改为结算时间
唯品会MP账户的时间筛选标签由「发生时间」改为「结算时间」,与该页签的订单签收或退款时间口径一致;标签仍集中在 DATE_FILTER_LABELS 映射(抖音、唯品会MP=结算时间;京东账单/京喜账单/京东网银钱包=费用结算时间;其余默认发生时间)。
2026-09-29 11:03:53 +08:00
2ec60574ff Merge branch 'fix/qiuxiang-douyin-settle-time-label' 2026-09-29 10:53:22 +08:00
2d59e63861 抖音账户时间筛选标签改为结算时间
抖音账户的时间筛选标签由「发生时间」改为「结算时间」,与该页签的「结算时间」列名一致。标签集中成 DATE_FILTER_LABELS 映射(京东账单/京喜账单/京东网银钱包=费用结算时间,抖音=结算时间,其余沿用发生时间),不再散落 if-else。
2026-09-29 10:53:04 +08:00
3c4c47fb94 新增「财务规则 · 科目映射表」可维护页面 (#279) 2026-09-29 10:41:54 +08:00
d7dfc95192 Merge branch 'fix/qiuxiang-vipshop-mp-settle-period' 2026-09-29 10:16:06 +08:00
666efc26b6 唯品会MP账户新增订单结算时间列
唯品会MP账户账单在「订单签收或退款时间」后新增「订单结算时间」,取账单结算期间(YYYY-MM,当前系统内全部为 2026-08);列定义与导出共用 AccountBillColumns。现网 913 行的 payload 已补 settle_period=2026-08(先备份原 payload,校验无冲突后按 JSON_SET 更新,笔数与金额不变),并补齐 MP 页签的功能测试。
2026-09-29 10:15:49 +08:00
chenxi
073a7dafb1 商品月加权平均单价页实现两层多级合并表头
- 字段配置支持 group:同组字段合并为一个一级标题,未分组列标题跨两行
- 按工作表 A:O 列顺序固定 15 列(月份、条码+仓库、仓库简称、供应商、商品编码、商品名称、期初数据 5 列、入库数据 3 列、加权平均单价)
- 该页不再渲染固定序号列,与工作表列结构一致
- 合并表头页面改用目录表自身的列与表头渲染,避免共享财务表脚本按单行表头重排
- 导出与表头模板按字段配置输出 15 列
2026-09-29 09:25:35 +08:00
c815067376 Merge branch 'fix/qiuxiang-jx-settle-period' 2026-09-29 09:18:42 +08:00
7de1d30605 唯品会JX账户新增订单结算时间列
唯品会JX账户账单在销售订单号后新增「订单结算时间」,取账单结算期间(YYYY-MM,当前系统内全部为 2026-07);列定义在 AccountBillColumns,导出与页面同一份定义。现网 8518 行的 payload 已补 settle_period=2026-07(先备份原 payload,校验无冲突后按 JSON_SET 更新,金额与笔数不变)。
2026-09-29 09:18:21 +08:00
cdc8164aa5 Merge branch 'feature/qiuxiang-jingxi-bill-tab' 2026-09-28 19:26:16 +08:00
a0c99c3e8d 新增店铺收款京喜账单页签并从京东账单切分
在京东账单右侧新增京喜账单页签,复用京东日账单的取数、列定义、筛选、排序、分页与导出,只增加平台维度过滤:京东账单排除京喜店铺,京喜账单只取京喜店铺。平台口径集中在 config/jd_bill_platform.php 与 JdBillPlatform(含口径切换起始日期参数,默认全量历史),仓库通过 forPlatform() 收窄,两个口径互补、总额守恒,底层镜像数据不修改不删除。真实镜像实测:京喜 488,464 笔 + 京东 210,983 笔 = 拆分前 699,447 笔,收入/支出/差额逐项误差为 0。
2026-09-28 19:25:53 +08:00
9bee8e3f46 Merge branch 'feature/qiuxiang-vipshop-jx-account' 2026-09-28 17:52:27 +08:00
a08b28199d 新增店铺收款唯品会JX账户页签
店铺收款账户账单新增 vipshop-jx 页签:导航配置、列定义、控制器与前端路由;读取共享账单表 ec_account_bills 中的唯品会JX(JITX)原始账单(店铺/类型/二次备注/销售订单号/含税金额),按金额正负拆出收入与支出,订单日期由销售订单号前 6 位还原(原始账单无日期列)。DB 页签日期统一按 Y-m-d H:i:s 返回,JX 页签汇总口径为收支净额,并补齐单元与功能测试。
2026-09-28 17:51:41 +08:00
26556a9169 Add warehouse short names to inventory report
Restore warehouse name mapping ledger and inventory short-name column.
2026-09-28 16:33:38 +08:00
e73183f52e merge: allow deploy after migration removal 2026-09-28 13:14:11 +08:00
469992982e fix: ignore deleted migrations in deploy guard 2026-09-28 13:13:53 +08:00
4fd1a792d4 merge: revert inventory mapping release 2026-09-28 13:11:23 +08:00
454919b0c7 Revert "Merge branch 'fix/chenxi-inventory-mapping-release-20260928'"
This reverts commit c328dcbe77e114d1111c22ca22870227ad280ab7, reversing
changes made to bfbfe935eab814027d7d2328accc18a60171a712.
2026-09-28 12:00:54 +08:00
chenxi
403f66b94f Merge branch 'fix/chenxi-deploy-tunnel-process' 2026-09-28 10:53:11 +08:00
chenxi
bc65d7c7e2 保留独立增量镜像隧道进程 2026-09-28 10:53:05 +08:00
chenxi
36fee605c6 Merge branch 'fix/chenxi-regctl-executable' 2026-09-28 10:49:37 +08:00
chenxi
b1c2eabac4 修正 macOS 增量发布工具执行权限 2026-09-28 10:49:32 +08:00
chenxi
6210d496ba Merge branch 'fix/chenxi-deploy-ssh-reuse' 2026-09-28 10:47:22 +08:00
chenxi
913be7d3ce 复用发布 SSH 连接避免服务器限流 2026-09-28 10:47:12 +08:00
chenxi
7d8b290220 Merge branch 'fix/chenxi-scp-array-splat' 2026-09-28 10:43:37 +08:00
chenxi
5afe009a80 修正 macOS SCP 参数数组传递 2026-09-28 10:43:33 +08:00
chenxi
0ae62c5258 Merge branch 'fix/chenxi-macos-scp-compat' 2026-09-28 10:40:40 +08:00
chenxi
1c35d088df 兼容生产服务器旧版 SCP 上传 2026-09-28 10:40:36 +08:00
chenxi
e23fc595d6 Merge branch 'fix/chenxi-macos-deploy-runner' 2026-09-28 10:35:56 +08:00
chenxi
93cce7224e 修正 macOS 前端构建 PATH 2026-09-28 10:35:50 +08:00
chenxi
8b6731d0d8 支持从 macOS 工作站运行正式发布脚本 2026-09-28 10:13:14 +08:00
51b3529fea merge: rename vipshop account to mp account 2026-09-28 09:38:26 +08:00
0c1e73f405 fix: rename vipshop account to mp account 2026-09-28 09:35:11 +08:00
chenxi
c328dcbe77 Merge branch 'fix/chenxi-inventory-mapping-release-20260928' 2026-09-28 09:24:49 +08:00
chenxi
72777aa44e Merge remote-tracking branch 'origin/main' into fix/chenxi-inventory-mapping-release-20260928 2026-09-28 09:16:35 +08:00
chenxi
f562faca3c 完善进销存映射表与开发环境刷新配置 2026-09-28 09:16:02 +08:00
bfbfe935ea 新增淘系支付宝权威账单导入链路 2026-09-27 17:25:04 +08:00
2c02f80b01 在后台页脚显示构建版本标识 2026-09-27 14:29:03 +08:00
296e528482 小额打款工单:筛选下拉可滚动 + 完成日期改为账单页同款日历 (#276)
- 店铺/打款原因筛选项改为自绘可滚动面板(原生 datalist 高度与滚动不可控)
- 完成日期改为与账单页一致的日历控件(文本输入 + 日历按钮 + 中文月历弹层)
  值只承载日期,服务端补成整天半开区间,避免分钟级边界丢单
- 测试:php artisan test --filter=SmallPayment 16 passed (126 assertions)
2026-09-27 13:45:18 +08:00
07e055f335 修复唯品会账户页签脚本发布校验 2026-09-27 10:03:04 +08:00
chenxi
cc8687f8fb 新增进销存报表年月选择器 2026-09-27 01:21:27 +08:00
chenxi
18fdb8a22f 支持进销存多月份权威快照 2026-09-26 22:45:37 +08:00
chenxi
4782cf62c8 修复八月进销存报表重复计数 2026-09-26 19:40:23 +08:00
chenxi
75f9c1b0b9 固定生产镜像为 amd64 2026-09-26 03:48:04 +08:00
chenxi
6a9bee82e9 支持跨平台部署镜像工具 2026-09-26 03:42:25 +08:00
chenxi
9e89fe3bf2 隔离部署前端构建进程 2026-09-26 03:36:42 +08:00
chenxi
7f1570401d 修复部署构建脚本路径传递 2026-09-26 03:34:40 +08:00
chenxi
299c95a6e6 修复正式部署脚本路径解析 2026-09-26 03:32:01 +08:00
chenxi
f92ed47def 修复进销存报表大数据导出 2026-09-26 03:21:14 +08:00
chenxi
39885bccea Merge inventory export improvements 2026-09-25 22:44:59 +08:00
chenxi
3d2f27fb1a 优化进销存报表全量导出批次查询 2026-09-25 22:36:11 +08:00
chenxi
86706bf20a 完善进销存报表筛选范围与列导出 2026-09-25 21:37:57 +08:00
chenxi
f03a362410 Merge inventory report monthly logic 2026-09-25 20:28:05 +08:00
chenxi
dd5f075b65 Merge latest main into inventory report branch 2026-09-25 20:25:01 +08:00
chenxi
0143dfe693 修正进销存月报按已确认进出仓明细取数 2026-09-25 20:10:45 +08:00
ca5fa5be08 feat: collapse secondary report query fields 2026-09-24 23:58:46 +08:00
75b12df493 feat: 完善财务报表字段查询与排序
合并店铺和主体经营报表的字段查询布局,补齐数值筛选、排序和镜像状态提示。
2026-09-24 20:23:41 +08:00
c424ffffce fix: calculate withdrawal compliance from dated amounts
按余额减提现金额与预留标准计算达标状态,并接入提现金额日期化导入。
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-09-24 19:34:09 +08:00
e224cbebc3 fix: remove shipping sync order join timeout 2026-09-24 18:27:45 +08:00
896e3fefa7 重试发货同步的上游连接超时 (#272) 2026-09-24 16:24:59 +08:00
fc70cff940 整合小额打款筛选与汇总性能保护 (#271) 2026-09-24 16:14:17 +08:00
f84de98696 修复进销存汇总计数超时 (#270) 2026-09-24 15:48:30 +08:00
57346a270c 重连发货同步的短暂上游游标断线 (#269) 2026-09-24 15:36:07 +08:00
89ee272b9d 防止淘系账单筛选重复扫表 (#268)
筛选选项增加稳定缓存与单飞锁,精确行数场景移除多余 COUNT,并加入缓存预热。
2026-09-24 15:22:40 +08:00
88fcc7ab0e 统一淘系微信和支付宝账单筛选交互 (#267) 2026-09-24 15:15:09 +08:00
eb1036d0c3 整合业务资料查询与表格交互 (#266)
整合业务资料字段查询、排序、列设置与导出,并保留最新主线的数据隔离和报表修复。
2026-09-24 15:10:33 +08:00
c3b6cd9313 修复历史发货代发仓扫描超时 (#265) 2026-09-24 14:42:28 +08:00
d9b42f9cda 修复进销存漏掉已确认流水子仓维度 (#264) 2026-09-24 14:13:54 +08:00
eecb2b1863 修复发货历史窗口安全回补与数量状态 (#263) 2026-09-24 13:30:09 +08:00
bfa4f36991 支持京东钱包月初至截止日受控导入 (#262) 2026-09-24 11:44:44 +08:00
88cc6f122c 修复进销存滚动月份导出超时 (#261) 2026-09-24 10:38:40 +08:00
e7ca3026a6 feat: 导入淘系微信权威账单 (#260)
保留现有表头,新增受控幂等导入、页面查询和完整账单校验。
2026-09-24 10:29:59 +08:00
2b77118588 修复唯品会账户店铺及账单项目筛选 (#259) 2026-09-24 10:15:22 +08:00
5555a3d5e0 fix: preload shipping outbound map by window 2026-09-24 10:13:48 +08:00
bee27aa2be fix: avoid shipping sync replica timeout 2026-09-24 09:56:58 +08:00
a5a17a5c13 feat: add withdrawal table controls (#258)
> 开工时标题使用 `WIP: <任务名称>` 并先填写“任务占用与边界”。Codex 完成独立合并前自审后补齐其余内容并移除 `WIP:`。

## 任务占用与边界

- 任务名称:
- 实际开发者:
- 任务来源/团队分工:
- 目标结果:
- 预计修改路径:
- 将写入的公共契约(路由、Controller 方法、Repository、表/视图、`source_key`、`scope`、权限名、菜单键或共享组件):
- 仅复用、明确不修改的公共契约:
- 数据源、表/视图和字段:
- 上游 `source_key` / `scope`:
- 权限名、菜单键或共享组件:
- 明确不修改的范围:

## 开工碰撞检查

- [ ] Codex 已执行 `scripts/codex-task-preflight.ps1`
- [ ] 已检查最新 `main` 和所有远程任务分支
- [ ] 已搜索主线既有页面、接口、Repository 和数据查询
- [ ] 已把“将写入”和“仅复用、不修改”的公共契约分开;仅复用项不作为单独阻断条件
- [ ] 未发现实际写入重叠;或已在下方写明相关分支、具体重叠项和协调结论

相关分支、重叠项与协调结论:

## 实际改动

- 完成内容:
- Codex 实际修改的文件:
- 新增或变更的公共契约:
- 与原计划的差异:

## 验证证据

- 正常流程:
- 空数据/错误输入/无权限场景:
- 受影响旧功能:
- 自动测试及结果:
- 实际页面、接口或只读业务查询结果:

## 风险检查

- [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复
- [ ] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权
- [ ] 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [ ] 前端改动符合 `DESIGN.md`;无前端改动则写“不适用”

高风险影响、回滚和授权状态:

## 合并前复查

- [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查
- [ ] PR 不再是 `WIP:`,所有测试结果已经补齐
- [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 `origin/main...任务分支` 的完整 diff
- [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果
- [ ] 合并顺序不会覆盖其他未合并任务
- [ ] 已授权当前任务的 Codex 执行 Squash merge、验证 `main` 并清理任务分支
- [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/258
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-09-23 22:02:02 +08:00
374df29d2b 修复生产报表负载根因 (#257)
消除报表缓存穿透与后台千万级全表计数,降低同步频率并调整共享数据库资源。
2026-09-23 20:15:59 +08:00
3f80b2f226 提现管理增加导入导出 (#256)
增加店铺主体筛选及提现管理 XLSX 导入导出;导入采用预览确认并限制可写字段。
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-09-23 20:05:22 +08:00
f04f8907ea 修复生产报表查询超时 (#255)
限制长查询,减少重复聚合,并为同步版本增加短期缓存。
2026-09-23 19:08:47 +08:00
2017d6520b feat: add Jushuitan shipping data mirror 2026-09-23 18:29:15 +08:00
fe2c56a9c2 修复店铺收款账户账单日期筛选 (#254) 2026-09-23 17:44:46 +08:00
ed903a6329 快递应付:逆向物流报价页签
将逆向物流报价放入报价管理,增加页签和对应页面测试。
2026-09-23 17:44:41 +08:00
732d3680fa feat: make courier picker searchable (#253) 2026-09-23 17:29:57 +08:00
d413c762b4 fix: use courier information names for agreement signing 2026-09-23 16:16:28 +08:00
cdff51bd7a 按七月期初滚动计算后续进销存月份 (#251) 2026-09-23 13:58:49 +08:00
7e4a30365d fix: 完善本地热更新开发环境 (#250)
启用本地 PHP 与前端资源热更新,并同步初始化脚本与健康检查。
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-09-23 12:50:16 +08:00
150df2d1cb 修复进销存报表七月筛选 (#249) 2026-09-23 12:00:36 +08:00
5dab341631 Merge remote-tracking branch 'origin/main' into fix/limeng-online-daifa-daily-sync 2026-09-23 11:49:45 +08:00
170e252e6f fix: schedule online daifa mirror refresh at 5am 2026-09-23 11:46:37 +08:00
6dff4e0136 支持进销存报表七月数据 (#248) 2026-09-23 11:18:17 +08:00
07c27237d6 feat: 配置提现管理预留标准 (#247)
按店铺平台及三家指定天猫店铺配置提现预留标准。
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-09-23 11:15:24 +08:00
01de726227 快递附加费维护支持供应商、日志筛选与列宽调整 (#246) 2026-09-23 11:09:24 +08:00
chenxi
d36b46007f Merge remote-tracking branch 'origin/main' into fix/chenxi-login-redirect 2026-09-22 22:30:05 +08:00
chenxi
6ed9664028 修复登录表单重定向 2026-09-22 22:28:57 +08:00
3340475274 Merge remote-tracking branch 'origin/main' 2026-09-22 19:39:02 +08:00
76de1a033c 修复订单镜像宽字段索引 2026-09-22 19:35:53 +08:00
af10f3c972 接入聚胜算得物完整账单 (#245)
保留得物、退货退款订单和扣减其他费用明细三类渠道;增加受控幂等导入、原子替换与页面权威表查询。
2026-09-22 19:07:11 +08:00
e5ad1b2390 接入聚水潭采购单到线上代发价格 (#244) 2026-09-22 18:50:23 +08:00
9289144565 修复拼多多完整账单导入和日期筛选 (#242) 2026-09-22 17:36:40 +08:00
7894526d07 新增附加费报价批量导入 (#243)
增加模板下载、文件预览校验、重复处理和批量写入,复用公司隔离、报价 Repository 与操作日志。
2026-09-22 17:31:56 +08:00
d9ed4c467d 合并线上代发价格原始表单列删除 2026-09-22 17:21:57 +08:00
54291f0d78 移除线上代发价格原始表单列 2026-09-22 17:21:18 +08:00
5b264751b9 支持京东账单月中截止日期并保护后续账单 (#241) 2026-09-22 16:48:33 +08:00
06a30ee853 修复生产业务镜像迁移连接 (#240) 2026-09-22 16:07:51 +08:00
8c5ae23b53 导入唯品会亿家达 MP 原始账单并扩展表头 (#239) 2026-09-22 15:21:35 +08:00
fd0041fdb5 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	config/database.php
2026-09-22 15:01:14 +08:00
1c7647a3c2 接入拼多多完整账单并保留营销账户 (#238) 2026-09-22 14:55:44 +08:00
836848f431 完善供应商应付五项操作 2026-09-22 14:49:09 +08:00
d3b0fbc87b Merge remote-tracking branch 'origin/feature/limeng-shipping-data-filters'
# Conflicts:
#	app/Http/Controllers/Business/Pages.php
2026-09-22 14:47:20 +08:00
04b1fca4ae 新增店铺收款唯品会账户入口 (#237) 2026-09-22 14:09:35 +08:00
34cece995f Merge remote-tracking branch 'origin/main' into fix/limeng-agreement-personal-counterparty 2026-09-22 13:40:32 +08:00
2f35edbf97 修正京东账单顶部导航名称 (#236) 2026-09-22 13:30:59 +08:00
3b1ae5aeb4 Merge remote-tracking branch 'origin/main' into fix/limeng-agreement-personal-counterparty 2026-09-22 13:27:26 +08:00
f8dcad69e8 支持个人对方签署主体 2026-09-22 12:15:26 +08:00
578e29a50f 重命名京东流水为京东账单 (#235) 2026-09-22 11:39:09 +08:00
0a0b899991 修复抖音月中导入范围,保留截止日后流水 (#234) 2026-09-22 10:42:38 +08:00
chenxi
d3f728d1b6 修复进销存九月期初结转 2026-09-21 18:20:15 +08:00
3b1d689b26 修复生产构建 Composer 镜像解析 (#233) 2026-09-21 17:49:00 +08:00
87bb178607 修复收款账户协议签署主体识别 (#231) 2026-09-21 17:01:32 +08:00
3b0764fa35 增加签署任务查询筛选 (#232) 2026-09-21 16:51:47 +08:00
4d068382ac 新增京喜违规预警页签 2026-09-21 16:18:41 +08:00
77ef0df98e 恢复店铺违规预警子页面层级 2026-09-21 15:53:11 +08:00
f8c9c6ef2d 合并最新主线并保留风险平台横向页签 2026-09-21 14:16:41 +08:00
1e074bc4e5 为风险管控页增加平台横向子页面 2026-09-21 13:49:19 +08:00
fe0a7e08f0 新增店铺违规信息预警子页面 (#230)
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-09-21 12:56:36 +08:00
f340560a5c 新增财务经营风险管控页面 (#229)
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-09-21 11:13:38 +08:00
cf29972218 修复快递汇总筛选导出与日期格式 (#228) 2026-09-21 10:58:21 +08:00
f40c102a7b 新增快递附加费操作日志 (#227)
## 实际改动
- 为快递附加费报价规则和附加费类型记录新增、更改、启用、停用日志。
- 日志按公司隔离,记录操作人、时间、对象及安全字段的变更前后值,页面展示最近 50 条。
- 报价/类型写入与日志记录使用同一事务;审计表未迁移时保留只读查看并阻止无日志写入。

## 修改范围
- 新增审计日志 Repository、Model 和迁移。
- 修改报价/类型 Repository、页面控制器、现有 VIP 黑金页面和对应 Feature 测试。
- 不修改路由、权限、菜单、上游数据源或生产配置。

## 协作检查
- 已获取最新 `main` 并检查远程任务分支。
- `origin/feature/limeng-shipping-data-filters` 同样修改 `Pages.php`,但其逻辑为独立的发货数据页面,与本任务方法、路由、Repository、表和视图无实际重叠。

## 验证
- `CourierSurchargePageTest`:9 个测试通过。
- `CourierPayableSummaryPagesTest`:14 个测试通过。
- 合计 23 个测试、336 个断言通过。
- 已验证现有报价可见、未迁移警告及写入禁用状态。

## 风险与授权
- 新增 1 张审计日志表,需要随生产发布执行迁移。
- 用户已明确要求主线部署,授权本次迁移与发布。
- 未提交 `.env`、密码、Token、私钥、真实财务数据、生产日志或构建产物。
- 回滚代码时保留审计表及历史记录,不执行破坏性删表。

## 合并前自审
- 已逐文件审核完整 diff,确认无越界修改、重复实现、敏感内容和未处理冲突。
- 使用 Squash merge 合并到最新 `main`,再按标准脚本部署。

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/227
2026-09-21 10:42:13 +08:00
4c3ef7b41d 快递汇总三个子页面新增查询功能 (#226)
新增日期、负责人、快递公司和跨行关键词查询;筛选在分页前执行并保留翻页条件。
2026-09-21 10:14:51 +08:00
a46d4dc678 修复签署文件日期未生成 (#225)
统一使用 signature_date 变量,并在推送 E 签宝前写入协议与报价确认单。
2026-09-21 10:02:37 +08:00
f418063426 修复前端缓存复用导致账单筛选回退 (#224) 2026-09-21 09:29:02 +08:00
cb5b7fb680 修复进销存八月期初数量 (#223) 2026-09-21 01:08:46 +08:00
54c419ff0e 修正进销存报表商品编码匹配 (#222) 2026-09-21 00:08:49 +08:00
ac6b34189b 修正进销存报表仓库名称映射 (#221) 2026-09-20 23:28:10 +08:00
caf2030bdf 修复进销存报表子仓维度 (#220) 2026-09-20 22:19:11 +08:00
d8622dfd3f 增加进销存报表筛选导出 (#219) 2026-09-20 18:37:45 +08:00
4e0dcc7f2b 修正进销存报表商品编码范围 (#218) 2026-09-20 17:48:27 +08:00
135a54619d 优化全仓进销存分页查询 (#217) 2026-09-20 16:12:07 +08:00
12164f22a7 新增退件费导入页面与50万行上限 (#216)
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-09-20 15:59:53 +08:00
1e3f014743 接入全仓库进销存报表 (#215) 2026-09-20 15:44:01 +08:00
79588e88f2 fix: 提升快递汇总导入上限至50万 (#214)
运费差与其他附加费汇总的单次导入上限统一提升至 500,000 条,并补充超过旧上限的回归验证。
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-09-20 14:09:46 +08:00
0be09eab4b 导入进销存报表表头及演示数据 (#213)
按附件补齐进销存报表表头与演示数据,并完成页面和回归验证。
2026-09-20 12:58:13 +08:00
da4e7d79bc 新增进销存报表菜单入口 (#212)
在商品价格管理下新增进销存报表入口和只读空状态页面;NavigationTest 5 passed,53 assertions。
2026-09-20 12:27:03 +08:00
56949d6d29 增加其他附加费导入模板 (#211)
## 实际改动
- 为“其他附加费汇总”增加与“运费差”一致的导入弹窗和模板下载入口。
- 新增空白 Excel 模板,字段为日期、负责人、快递公司、快递单号、商品ID、条码、店铺、交易单号、金额、备注、成品条码。
- 日期及编号列按文本保存,金额列保留数值格式;模板不包含用户提供文件中的真实业务数据。
- 扩展模板下载路由与控制器映射,并补充权限、格式、长编号和实际导入回归测试。

## 修改文件
- app/Http/Controllers/Business/CourierPayableSummaries.php
- resources/templates/courier-payables/other-surcharge-import-template.xlsx
- resources/views/business/pages/courier-payables/summary-table.blade.php
- routes/admin.php
- tests/Feature/Business/CourierPayableSummaryPagesTest.php

## 验证结果
- CourierPayableSummaryPagesTest:8 passed,88 assertions。
- Blade 编译:php artisan view:cache 通过。
- git diff --check 通过。
- 模板已核对为空白,且未提交真实财务数据。

## 风险检查
- 涉及导入和页面权限,已增加对应回归测试。
- 不涉及数据库迁移、生产配置或数据修复。
- 前端复用现有 VIP 黑金组件样式,符合 DESIGN.md。

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/211
2026-09-20 11:39:50 +08:00
5947d385ec 增加运费差模板日期列 (#210) 2026-09-20 11:04:01 +08:00
9846f21297 新增运费差导入模板下载 (#209) 2026-09-20 10:05:55 +08:00
1751d8e062 接入淘系支付宝账单副本库全量数据 (#208)
使用经营分析副本库作为全量分页、筛选、汇总与导出主数据源;演示表仅按记录编码补充源表缺失字段。验证:47 项 PHP 测试、435 assertions;6 项账户账单脚本测试;真实页面全量及日期筛选验收通过。
2026-09-19 01:52:45 +08:00
e806dcb3d6 修复 E 签宝签署方账号标签 (#207) 2026-09-18 18:32:36 +08:00
760d6ec79a 修复账户账单日期选择器无法打开日历 (#206) 2026-09-18 17:54:51 +08:00
51c447c5f6 修复账户账单筛选栏在窄内容区的布局 (#205) 2026-09-18 17:04:20 +08:00
c0898b4914 修复账户账单筛选与导出范围一致性 (#204) 2026-09-18 16:56:09 +08:00
3db5605375 接入胜算京东钱包至京东网银钱包账户 (#203) 2026-09-18 15:07:43 +08:00
1c08c61b90 优化京东日账单深页查询 (#202) 2026-09-18 14:35:07 +08:00
232c0e6b23 接入胜算京东日账单至京东流水 (#201) 2026-09-18 14:27:39 +08:00
57a417de30 新增快递应付运费差和附加费汇总页面 (#200)
新增两个汇总子页面,并提供按公司隔离的导入、展示和导出能力。
2026-09-17 16:18:51 +08:00
7a55165c78 修复仓储核算明细查看并支持导出 (#199) 2026-09-17 15:47:12 +08:00
3fbddfa14a 修复协议状态提示与历史文件下载 2026-09-17 11:59:08 +08:00
da4888ac9c 完善商品价格导入导出与编辑 (#197) 2026-09-16 18:20:25 +08:00
8d147f02a8 修复终版应付款项权限联动 (#196) 2026-09-16 13:15:39 +08:00
b7ea8e6df8 恢复应付款项终版独立入口 (#195) 2026-09-16 11:27:58 +08:00
a3cd9f4d03 修复 Livewire 请求导致本机网址被防火墙封禁 (#194) 2026-09-16 11:16:36 +08:00
34b68bb431 feat: 增加发货数据筛选日期选择与导出 2026-09-16 10:57:27 +08:00
b038909cb0 feat: 发货数据页接入(caiwu 直连聚水潭/奇门)
- 聚水潭奇门客户端与发货同步器骨架:QimenClient(签名+POST)、ShippingDataSynchronizer(erp.sctrade.query 列表 + erp.sradesku.query 明细 + upsert + modified 水位)
- 发货明细扁平展示表 jst_oms_shipping(对齐工作平台发货数据 47 列/顺序,用于快递运费核算);原始表 jst_sctrade/_items/_skus/_receiver + jst_sync_state
- 快递应付新增「发货数据」页:Pages.contentForShippingData + 视图 + business_navigation 导航项(slug=shipping-data)
- ShippingOrderRepository 读展示表;artisan jushuitan:sync-shipping + 每10分钟增量调度(JST_SYNC_ENABLED 默认关)
- 落库连接 jushuitan_local(本地容器,env 可覆盖);演示 Seeder(6 条家具发货样例);.env.jushuitan.example

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014nvGZkg27YMEdhRy6EiUwf
2026-09-16 10:57:12 +08:00
769d9a6ad5 修复快递资料分页统计首屏不可见 (#191) 2026-09-16 10:23:03 +08:00
3f387cd194 接入聚胜算京东钱包店铺余额 (#192)
Co-authored-by: 92451 <924518135@qq.com>
Co-committed-by: 92451 <924518135@qq.com>
2026-09-15 20:16:25 +08:00
94adb5b547 得物账单纳入退款及其他费用渠道 (#190) 2026-09-15 14:39:38 +08:00
23f55ea774 移除三方仓进销存费用汇总入口 (#189) 2026-09-15 13:36:03 +08:00
7146b19bd3 修复生产发布健康探针 (#188)
使用容器已声明的公开静态资源执行发布健康检查,避免根路径被 Laravel 防火墙拒绝后误回滚。
2026-09-15 11:53:17 +08:00
9dbdef5e2e 每日商品仓储费明细 (#187)
增加逐日逐商品仓储费明细、日期切换、Excel 导出和公司数据隔离。
2026-09-15 11:44:20 +08:00
8f150080c5 修正聚水潭成本价历史凭据边界与未同步状态 (#186)
保留完整成本价历史方案,区分经典接口凭据与新开放平台凭据;首次同步未完成时显示待同步,不误报 0 条。

验证:33 passed,260 assertions。
2026-09-15 11:39:09 +08:00
edb32c33cf 接入得物收款账单镜像 (#185)
固定同步 Dewu 主收款渠道,接入得物账户分页、筛选、汇总和导出,并补充镜像与回归测试。

未部署,未执行生产首次同步。
2026-09-15 11:09:03 +08:00
730522d1a5 fix: use official jushuitan worklog contract 2026-09-15 09:49:31 +08:00
18c63d6abf fix: keep warehouse settlement schema compatible 2026-09-15 09:32:44 +08:00
41d80e4056 feat: add jushuitan cost price history 2026-09-15 09:21:09 +08:00
0b00f01566 feat: 三方仓仓储费一键核算+导出核算明细+每期存档
在「第三方仓储报价」页新增仓储费核算:选月份一键按当期生效报价核算
(入库管理费/出库费用/每日仓储费),结果存档并可导出汇总/每日/逐商品三表。

- 新增计算服务 ThirdPartyWarehouseStorageFeeCalculator(口径与报价页公式一致,
  重量取普通商品资料 ops_jst_sku.weight)
- 新增存档表 business_third_party_warehouse_storage_settlements + 模型
- 新增 maatwebsite/excel 三表导出 ThirdPartyWarehouseStorageSettlementExport
- 报价仓库新增 effectiveFor(按仓库+结算月取当期生效报价)
- Livewire 组件与报价页新增核算/导出/历史 UI
- 新增计算服务测试(附 2026-08 真实验收基准 1538.20/6099.60/8376.48/16014.28)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVQ6NsLsbtxm5Ng7Eew3s9
2026-09-15 09:21:05 +08:00
7b4deb10e8 修复淘系支付宝账单混入其他平台数据 (#184) 2026-09-14 15:49:22 +08:00
aa7f063a70 重构资金预测表工作台 (#183) 2026-09-14 14:28:20 +08:00
fb691d9127 修复拼多多货款账单加载超时 (#182)
省略固定渠道重复条件,并补充拼多多账单项目索引。
2026-09-14 13:55:28 +08:00
a15cfc1578 fix: prevent warehouse settlement page timeout 2026-09-14 13:30:57 +08:00
59f5f5b4f0 feat: 普通商品展示完整价格变更记录 (#181)
普通商品资料展示同一 SKU 的完整定价/变价历史,并支持导出单商品价格变更明细。
2026-09-14 12:33:49 +08:00
edf51109fe 修复部署后抖音账单页面停留在未配置占位页 (#180) 2026-09-14 12:08:23 +08:00
7f71e49c5f fix: keep empty bill offset amounts unknown 2026-09-14 11:59:29 +08:00
31eaf55ed5 账单冲减/售后明细:接入 500112 冲减收入取数(暂用退货红冲金额)
- OrderDataRepository 与 OrderDataMirrorRepository 的 afterSales() 现对 account_500112 取数:\n  暂以「退货红冲金额(reversal_order_amount,即负值商品金额的绝对值)」作为收入冲减口径。\n- 该字段此前在 expenseFields() 中仅被列示、从未取值,故本地恒为 null;\n  其余 account_* 科目字段仍为空,待科目归集(金蝶/外部科目源)整体接入。\n- 取数口径为临时占位,待财务确认最终规则后替换(集中在一处,易于调整)。\n- 账单冲减子页面本页 500112 合计已可正常汇总。
2026-09-14 11:45:35 +08:00
chenxi
0ffbbb7c6a 隐藏营销账户账单未启用平台 2026-09-14 11:40:25 +08:00
chenxi
6a787610b5 feat: 接入京东营销账户余额 2026-09-14 11:40:06 +08:00
Codex
df14f627f3 feat: 接入提现管理及每日店铺余额 2026-09-14 11:38:56 +08:00
f586ac5b9e 修正三方仓仓储费导出精度 2026-09-14 11:38:42 +08:00
695a88a7ff 新增三方仓八月仓储费核算导出 2026-09-14 11:38:41 +08:00
3beaa85cc6 修复抖音全部月份账单查询等待过长 (#179) 2026-09-14 11:29:38 +08:00
30ac6a39c0 修复抖音收款账户与胜算月度明细差异 (#178) 2026-09-14 11:02:29 +08:00
1e5c2a7e0b 移除应付款项重复终版入口 (#177) 2026-09-13 14:48:07 +08:00
179b2cdbcc 接入并校准三方仓进销存 (#176)
新增三方仓进销存共享镜像、按日查询汇总导出、每日零点增量同步和权威历史锚点校准。
2026-09-12 20:29:43 +08:00
57d779eb2f 新增三方仓仓储报价录入 (#175) 2026-09-12 19:39:21 +08:00
98c01ea400 快递应付数据页显示总条数和总页数 (#174) 2026-09-12 18:00:06 +08:00
6a4959d5ad 修正横向导航归属原快递应付入口 (#173) 2026-09-12 17:26:42 +08:00
e594bdc456 快递应付子目录移入终版横向工作区 (#172) 2026-09-12 16:58:05 +08:00
25e1b7461a 新增应付款项终版目录 (#170) 2026-09-12 16:05:20 +08:00
e56cbddfc8 普通商品资料接入聚水潭物理与管理字段 (#169) 2026-09-12 15:51:02 +08:00
5b21426595 供应商应付子目录移入页面横向导航 (#167) 2026-09-12 13:51:29 +08:00
0ae8afb0e8 京准通增加记账时间 (#168)
京准通明细及 CSV 增加数据库记账时间字段。
2026-09-12 13:42:19 +08:00
2d3e9cd865 接入拼多多店铺收款货款账单 (#164) 2026-09-12 11:16:33 +08:00
d57598962a Merge remote-tracking branch 'origin/main' 2026-09-12 10:38:39 +08:00
678ee3472b 修复抖音账户发生时间筛选与导出验收 (#166) 2026-09-12 10:35:39 +08:00
8f871f772e feat: 显示京准通流水收支类型 2026-09-12 10:32:19 +08:00
349b99addd 整合基础数据与商品价格管理导航 (#165) 2026-09-12 09:53:24 +08:00
60063e9d34 修复小额打款导出筛选条件 (#163) 2026-09-11 16:16:21 +08:00
a0858a0ff2 将营销账户账单 Alibaba 改为阿里巴巴账户 (#161) 2026-09-11 14:49:35 +08:00
a01f136ce1 按月滚动计算阿里巴巴账户余额 (#160) 2026-09-11 13:50:12 +08:00
5e5ed83449 导入阿里巴巴账户余额快照 (#159) 2026-09-11 12:25:36 +08:00
dab8d3916e 整理应付款项导航分组 (#158) 2026-09-11 11:47:04 +08:00
e4e80c054a 对齐聚水潭小额打款完成时间和状态口径 (#157) 2026-09-11 11:23:21 +08:00
7269e67c20 fix: 修复抖音账户账单筛选控件 (#156) 2026-09-11 10:33:59 +08:00
e385a5260a test: 补齐普通商品工作平台价格覆盖 2026-09-10 17:55:45 +08:00
d6129a9544 普通商品资料:接入工作平台价格结算价+定价状态+变价明细,删除1688代发单价列,修正pm_priceAdjustment索引前缀
变价明细取自 jiansun.dbo.pm_priceAdjustment_log 按商品编码最新一条 priceType=1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01DVQ6NsLsbtxm5Ng7Eew3s9
2026-09-10 15:39:24 +08:00
83c4151a55 fix: 加速抖音账单项目筛选 (#152)
复用现有覆盖索引,通过两阶段分页降低账单项目筛选开销。
2026-09-10 13:33:47 +08:00
1f83aca929 feat: 统一财务表格列设置与镜像导出 (#151) 2026-09-10 13:27:28 +08:00
6631e598c0 fix: 优化快递全量汇总的哈希关联 (#150) 2026-09-09 19:36:25 +08:00
330d550210 merge: 合并线上代发价格完整功能到主线 2026-09-09 19:17:04 +08:00
266d7155ef fix: 快递对账明细与耗时汇总解耦 (#149) 2026-09-09 18:40:15 +08:00
a9c85b2a9f 线上代发价格:接入钉钉流程(全部已完成)+ 标签 + 商品编码 + 明细展开与SQL分页
- 按流程状态=已完成同步全部记录(去掉收款人过滤)
- 明细表「收款费用明细」JSON 解码并按行展开为多行
- 新增「标签」列:收款人=宁波顾家望业家居有限公司→刘萧代发,其余→1688代发
- 「料号」列改为「商品编码」(值留空)
- 仓库改为数据库层分页 + SQL 聚合,避免大数据量拖垮页面

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:38:35 +08:00
3717a62abf fix: 加速账户账单店铺模糊筛选 (#148) 2026-09-09 17:53:36 +08:00
8e00d05134 fix: 修复抖音账户账单筛选 (#147) 2026-09-09 17:43:59 +08:00
a03372c770 fix: 账单冲减复用订单售后费用口径 (#146)
Co-authored-by: 苏木 <594326199@qq.com>
Co-committed-by: 苏木 <594326199@qq.com>
2026-09-09 17:42:42 +08:00
chenxi
4bdfa8106e 调整财务系统一级菜单顺序 2026-09-09 17:15:55 +08:00
a2c6388e73 fix: 补齐快递运费汇总覆盖索引 (#145) 2026-09-09 16:54:08 +08:00
881796f13e fix: 分批导出账单并加速渠道查询 (#144) 2026-09-09 15:38:44 +08:00
91bec8f386 fix: 快递对账分页索引与重复汇总优化 (#143) 2026-09-09 15:26:41 +08:00
db26b449b6 test: update mirror dataset count after main merge 2026-09-09 15:18:10 +08:00
bd5478029c Merge remote-tracking branch 'origin/main' into codex/merge-normal-products-main 2026-09-09 14:51:47 +08:00
91c26114ab merge: 合并普通商品工作平台价格到主分支 2026-09-09 14:48:47 +08:00
6934dad938 避免支付宝账单在增量同步期间反复全表计数 (#142) 2026-09-09 14:38:43 +08:00
qiuxiang
79f408156d 接入头条放心购抖音账户账单 2026-09-09 14:08:58 +08:00
b66f223ebb 修复账单冲减取数和工单加载,整合快递页面上线 (#141) 2026-09-09 14:04:43 +08:00
5dcab54e06 feat: 增加线上代发价格筛选和导出 2026-09-09 13:56:38 +08:00
01a2e6bfa8 统一财务账单表格排版与交互 (#139) 2026-09-09 13:21:38 +08:00
284ba38337 取消共享镜像接入与同步的重复人工审批 (#138) 2026-09-09 12:05:38 +08:00
784e82b0a2 支持直接使用密码部署并自动复用本机登录信息 (#137) 2026-09-09 11:54:21 +08:00
914a3fe352 取消普通生产发布的重复授权要求 (#136) 2026-09-09 11:40:00 +08:00
a50b7596e8 修复小额打款工单关联未命中组合索引 (#135) 2026-09-09 10:53:53 +08:00
46107e196f 发布小额打款索引修复与线上代发价格 (#134) 2026-09-09 10:46:17 +08:00
99abdea2b2 feat: 接入工作平台采购价格到普通商品资料 2026-09-09 07:09:29 +08:00
a2c420c74f 精简开发规则并补齐表格交互要求 (#133) 2026-09-08 23:03:15 +08:00
7fa8284255 支持明确指定的钉钉主管理员首次登录 (#132) 2026-09-08 22:21:44 +08:00
230962f6a3 修复支付宝账单翻页重复全表计数 (#131) 2026-09-08 21:46:34 +08:00
f2508eeaeb 淘系支付宝账单切换经营分析副本镜像 (#129) 2026-09-08 19:50:55 +08:00
e1d62f0d79 修复当前页密码网关分片读取 (#130) 2026-09-08 18:25:37 +08:00
d14c8acb82 店铺密码在当前页直接显示 (#125) 2026-09-08 17:33:47 +08:00
a2138704fb 修复店铺维度报表店铺 ID 并新增店铺名称列 (#128) 2026-09-08 16:40:36 +08:00
b65702147c 补全用户分组入口并统一侧边栏字号 (#127) 2026-09-08 16:14:28 +08:00
d876822a6e 修复生产健康检查被应用防火墙误拦截 (#123) 2026-09-08 14:46:38 +08:00
c7ac541334 用户多分组及分组页面权限 (#122) 2026-09-08 14:30:48 +08:00
6fa73352d0 build: use bundled dockerfile frontend 2026-09-08 14:08:43 +08:00
a03b9017a6 perf: avoid unnecessary bill offset joins 2026-09-08 14:05:51 +08:00
5812612bd3 perf: add bill offset order join index 2026-09-08 14:01:58 +08:00
5a34782dcd Merge remote-tracking branch 'origin/main' into feature/sumu-bill-offset
# Conflicts:
#	tests/Feature/Business/PagesTest.php
2026-09-08 13:19:38 +08:00
b5d87eae35 feat: add bill offset mirror page and export 2026-09-08 13:09:23 +08:00
9ad1b1c6bb feat: 接入快递运费对账业务镜像 (#118) 2026-09-08 12:26:04 +08:00
e94d556125 用户管理布局与管理员身份权限设置 (#119) 2026-09-08 12:02:22 +08:00
8dc9f3bc6f feat: 店铺账号密码页显示真实绑定手机号
- 「绑定手机号」字段取消 secret 遮蔽,页面与导出均显示镜像中的真实手机号
- 更新 StoreAccountMirrorRepositoryTest 与 PrototypePageTest 覆盖新行为
- 密码配置状态列已由 #116 完成,本提交不重复改动
2026-09-08 11:41:59 +08:00
0b988a4b99 未配置权限的钉钉用户默认显示完整业务页面 (#117) 2026-09-08 11:29:32 +08:00
b2b5c33d47 管理员按需查看与复制店铺密码 (#116) 2026-09-08 07:02:25 +08:00
cc355b32ce 修复旧钉钉回调丢失域名会话 (#115) 2026-09-08 06:30:59 +08:00
0743f298a3 财务花名册准入与钉钉授权自动开户 (#114) 2026-09-08 06:14:45 +08:00
62533293ae fix: 隔离 ClickHouse 回填代次 (#113) 2026-09-07 22:58:12 +08:00
a2b3db3afa fix: show vip marketing promotion flow 2026-09-07 18:03:07 +08:00
d66f0cf119 fix: align vip tab with tax declaration data 2026-09-07 17:02:03 +08:00
c3719c2240 docs: add workstation Git and SSH handoff details 2026-09-07 15:51:18 +08:00
77d6671de3 fix: PowerShell 7 正确创建缺失的中继任务 (#112) 2026-09-07 15:11:00 +08:00
c96b314a9d fix: 本地账号稳定注册上游中继任务 (#111) 2026-09-07 15:08:58 +08:00
6796d68012 fix: 中台推广数据接入生产受限隧道 (#110) 2026-09-07 14:59:27 +08:00
050ad0ab97 feat: 整合淘系支付宝账单共享业务镜像 (#109) 2026-09-07 14:41:37 +08:00
a989734b33 fix: query only the active promotion platform (#108) 2026-09-07 14:22:18 +08:00
a7d02993c8 test: isolate mirror connections during feature tests (#107) 2026-09-07 14:10:16 +08:00
a851bbe5a7 fix: spool parquet archives before upload (#106) 2026-09-07 12:56:31 +08:00
7c6bf6757d fix: flatten business data plan table stats (#105) 2026-09-07 12:50:09 +08:00
a1c0e245d6 Merge remote-tracking branch 'origin/main' 2026-09-07 12:28:52 +08:00
d46ed01a81 feat: merge promotion bills into marketing account bills 2026-09-07 12:27:46 +08:00
be670ad988 feat: stage tiered business data storage (#104) 2026-09-07 11:55:37 +08:00
a56328750e docs: add new workstation development handoff guide 2026-09-07 11:16:45 +08:00
f0150f1c49 feat: register shared original bill mirror 2026-09-07 09:41:59 +08:00
d48cb7d639 feat: 店铺维度报表条件查询与主体归属字段接入
实际修改:
- 店铺维度报表新增真实数据库条件查询:店铺字段走共享镜像 SQL
  WHERE,主体/能否变更/生效/失效走生命周期行级过滤(左闭右开)
- 店铺负责人/部门负责人/利润中心改为店铺账号 PostgreSQL 镜像值,
  按店铺名称关联,未匹配时回退经营分析副本库原值
- 能否变更按主表 platform 判定(唯品会MP/淘工厂=否,其余=是),
  筛选改为 是/否 下拉;默认生效时间 2026-07-01,失效未记录显示存续中
- 主体维度经营报表接入生命周期收入窗口与注册地址解析市字段
- 同步更新视图测试并重写 StoreReportRepositoryTest 匹配 SQLite 实现

验证:容器内 tests/Feature/Business + tests/Unit/EcommerceFinance +
tests/Unit/Business 共 324 passed / 4 skipped;真实数据端到端验证
(无筛选 1935 行、负责人/利润中心/失效区间筛选抽样全对)。
无新增数据库迁移,不涉及生产数据写入。
2026-09-06 19:27:51 +08:00
0584aa3742 feat: enforce closed DingTalk account access 2026-09-04 23:38:56 +08:00
6c574294f7 同步主体维度经营报表页面 (#103)
任务名称:同步主体维度经营报表页面
实际开发者:yueyang
目标结果:让主体维度经营报表接入团队共享业务镜像,显示分页、主体销量汇总及明确的数据来源提示。
实际修改:
- 注册 company-management/subject-operating-report 到只读页面映射并复用 StoreReportRepository。
- 页面标识改为共享镜像;有数据时显示共享镜像提示。
- 新增页面读取共享报表服务的回归测试。
修改文件:app/Business/TableRegistry.php;config/business_navigation.php;resources/views/business/pages/partials/directory-table.blade.php;tests/Feature/Business/PrototypePageTest.php
验证:容器内临时加载当前测试文件后执行相关套件,9 tests、53 assertions 全部通过;git diff --check 通过;实际页面首页共 2073 条,第 2 页及第 42/42 页可读。
风险:不涉及数据库迁移、生产配置或业务数据写入;未提交凭据、.env 或真实数据。
2026-09-04 17:40:44 +08:00
ca131731f6 fix: keep agreement signing page readable for legacy payloads 2026-09-04 16:14:30 +08:00
4ef1208b2f 聚水潭小额打款工单页面 (#102)
## 任务占用与边界

- 任务名称:聚水潭聚工单-协同工单-打款工单-小额打款页面
- 实际开发者:yueyang
- 任务来源/团队分工:退款数据子页面接入
- 目标结果:在“退款”下新增小额打款子页面,读取共享业务镜像,支持分页、筛选、汇总、导出和同步状态提示。
- 预计修改路径:Pages Controller、business_navigation、upstream_mirrors、镜像 Repository、Blade 页面、admin 路由、测试、镜像清单文档。
- 将写入的公共契约:页面 slug、管理路由、镜像数据集注册(jiansun 两张表)、Mirror Repository。
- 仅复用、明确不修改的公共契约:现有共享镜像连接、同步框架、权限校验、导航结构和通用分页/导出模式。
- 数据源、表/视图和字段:jiansun.dbo.jst_workOrderInfo、jiansun.dbo.jst_small_payment_order_map;固定小额打款工单类型 82D94F5C54544EE3833CD8A3AB8B9F73。
- 上游 source_key / scope:source_key=jiansun;沿用团队共享连接 scope。
- 明确不修改的范围:不执行共享数据库迁移、不执行首次全量同步、不修改 .env/APP_KEY、不配置 operations_replica。

## 开工碰撞检查

- [x] 已执行团队预检并核对 origin/main 与远程任务分支
- [x] 已搜索主线既有页面、接口、Repository 和数据查询
- [x] 已区分将写入与仅复用的公共契约
- [x] 未发现实际写入重叠

## 实际改动

- 新增退款“小额打款”页面及导航入口。
- 新增 SmallPaymentWorkOrderMirrorRepository:共享镜像分页、稳定排序、筛选、金额汇总、导出行和状态判断。
- 注册 jst_workOrderInfo 与 jst_small_payment_order_map 镜像数据集,并补充真实 businessFieldList 解析字段。
- 增加页面、Repository、导航和同步器相关测试。

## 验证证据

- 相关测试:19 项通过,143 个断言通过。
- Blade 缓存、PHP 语法检查、git diff --check 通过。
- 实际本地页面已打开,导航显示“小额打款”;首次同步未执行时页面明确显示“业务镜像尚未建立”。
- route:cache 受项目已有重复 livewire.update 路由影响失败,与本次改动无关;普通路由列表和视图缓存正常。

## 风险检查

- [x] 未提交 .env、密码、Token、私钥、真实财务数据、生产日志或构建产物
- [x] 不涉及共享数据库迁移或生产数据写入
- [x] 前端复用现有 VIP 黑金设计令牌和共享页面结构

## 合并前复查

- [ ] PR 不再是 WIP,需在完成最终自审和测试后移除 WIP 前缀。
- [ ] Codex 已用 origin/main...任务分支复核完整 diff。

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/102
2026-09-04 13:54:17 +08:00
b5a53f061b fix: use canonical url after DingTalk callback 2026-09-03 18:44:30 +08:00
1486f22b55 fix: rewrite DingTalk callback redirects to https 2026-09-03 18:42:28 +08:00
754e421378 fix: preserve https after DingTalk callback 2026-09-03 18:37:07 +08:00
f544edd82c fix: reconcile progress inventory before sync 2026-09-03 18:22:45 +08:00
aa451f03db fix: send progress technical summaries 2026-09-03 18:12:37 +08:00
f0c7a54eb9 fix: send progress feature descriptions 2026-09-03 18:11:49 +08:00
e6552378cc fix: sync exact progress commits safely 2026-09-03 18:10:19 +08:00
2329fb7a5f feat: add full directory table export 2026-09-03 18:01:04 +08:00
qiuxiang
d7c564d900 feat: add jingxi refund data headers 2026-09-03 17:53:53 +08:00
798a178927 chore: retire private certificate release path 2026-09-03 17:53:09 +08:00
qiuxiang
6a4868b196 fix: match vipshop reconciliation headers to taobao 2026-09-03 17:08:56 +08:00
qiuxiang
4396f139ad feat: add vipshop tab to zhongcai reconciliation 2026-09-03 17:08:56 +08:00
b48786c7c4 test: stabilize DingTalk callback route check 2026-09-03 17:07:11 +08:00
0f057bc1d6 fix: route plain HTTP DingTalk callback 2026-09-03 17:07:11 +08:00
e59b0531bf 移除售后费用页聚水潭工单区块 (#101) 2026-09-03 17:06:33 +08:00
658470f099 test: cover store account directory entry 2026-09-03 16:58:06 +08:00
475286718f feat: add report field visibility selector 2026-09-03 16:55:24 +08:00
369c41bf77 feat: add directory search export and template controls 2026-09-03 16:55:24 +08:00
362fddbf6c fix: restore store account directory tag 2026-09-03 16:55:23 +08:00
0cc7492d45 feat: 财务经营报表接入聚水潭退货退款 (#100) 2026-09-03 16:18:13 +08:00
6995ad31c5 feat: add DingTalk account login 2026-09-03 15:42:46 +08:00
cb02ca8a36 feat: show Jushuitan work orders on after-sales pages 2026-09-03 14:28:26 +08:00
7e64d24458 feat: complete courier e-sign workflow and PDF rendering 2026-09-03 13:54:00 +08:00
992e5a6da7 fix: separate progress author and board identity 2026-09-02 18:47:14 +08:00
88526b9619 附加费类型字典自维护(含进仓费) (#98)
## 任务占用与边界
- 任务名称:附加费类型字典自维护
- 实际开发者:zhoushengchen
- 目标结果:在快递附加费报价维护页支持附加费类型的新增、启用/停用;新增报价仅可选择启用类型,历史报价保留原类型。
- 预计修改路径:附加费类型 Repository、Model、Controller、报价 Repository/Controller、页面、管理路由、类型表迁移和专项测试。
- 将写入的公共契约:business.courier-surcharge.types.store、business.courier-surcharge.types.status、business_courier_surcharge_types。
- 仅复用、不修改的公共契约:business.pages.show、business.courier-surcharge.store。
- 明确不修改的范围:上游数据库、业务镜像、七牛、生产配置和无关页面。

## 开工碰撞检查
- [x] 已检查最新 main 和所有远程任务分支
- [x] 已运行 scripts/codex-task-preflight.ps1
- [x] 未发现与其他远程任务的实际写入重叠

## 实际改动
- 新增附加费类型字典及公司范围隔离。
- 新增类型管理弹窗、创建接口、启用/停用接口。
- 报价表单和后端校验动态读取启用类型;筛选仍可查看全部类型。
- 默认初始化原有 11 种类型,并增加“进仓费”。
- 增加幂等增量迁移,补齐已执行建表迁移环境中的“进仓费”。

## 验证证据
- tests/Feature/Business/CourierSurchargePageTest.php:6 个用例、60 个断言通过。
- 相关 PHP 文件语法检查通过。
- business.courier-surcharge 路由共 5 条,路由检查通过。
- git diff --check 通过。
- 本地共享系统库现状:类型表已存在,当前公司有 11 个旧类型;增量迁移待执行。

## 风险检查
- 未提交 .env、密码、Token、私钥、真实财务数据、生产日志或构建产物。
- 前端遵循 DESIGN.md 的 VIP 黑金令牌。
- 数据库影响:仅新增类型表(若环境尚未建表)或幂等补充“进仓费”,不删除、不覆盖已有类型。
- 回滚:类型字典不做物理删除;迁移 down 保持空操作以保护业务引用。
- 已获得本次“合并 PR 并执行类型表迁移”的明确授权;迁移目标为本机当前连接的团队系统数据库,不涉及生产部署。

## 合并前复查
- [x] 已重新 fetch 远程引用并再次运行碰撞检查
- [x] 已逐文件审核 origin/main...任务分支完整 diff
- [ ] PR 创建后移除 WIP 并执行 Squash merge
- [ ] 合并后验证 main、执行迁移并验证页面

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/98
2026-09-02 18:22:17 +08:00
2a756edac7 快递附加费报价正式维护模块 (#97)
## 任务目标
将“快递附加费报价维护”从前端演示改为正式可持久化维护模块,新增、编辑、启停后刷新仍保留。

## 开工范围
- 将写入:`business_courier_surcharge_quotes` 数据表及迁移;`business.courier-surcharge.*` 保存、更新、状态接口;报价维护页的数据读取和维护交互;对应 Feature 测试。
- 预计路径:
  - `app/Business/CourierSurchargeQuoteRepository.php`
  - `app/Http/Controllers/Business/CourierSurchargeQuotes.php`
  - `app/Http/Controllers/Business/Pages.php`
  - `app/Models/Business/CourierSurchargeQuote.php`
  - `database/migrations/2026_09_02_000000_create_courier_surcharge_quotes_table.php`
  - `resources/views/business/pages/courier-payables/courier-surcharge.blade.php`
  - `routes/admin.php`
  - `tests/Feature/Business/CourierSurchargePageTest.php`
- 页面与路由:`courier-payables/courier-surcharge`;新增、编辑、启停使用 POST/PATCH 正式接口。
- 数据源与字段:当前公司报价规则表;快递名称、快递归属、对账人、可多选附加费类型、结算方式、计价标准、生效条件、生效日期、状态、备注。
- 权限与公共契约:复用现有 `business.page:courier-payables,courier-surcharge` 和 `business.pages.show`,不修改全局权限或共享组件。
- 明确不修改:上游数据库、业务镜像、生产配置、七牛、其他业务页面和部署脚本。

## 验证
- `docker exec -u www-data jiangling_app php artisan test tests/Feature/Business/CourierSurchargePageTest.php`
- 3 passed,41 assertions。
- PHP 语法检查及路由检查通过。
- 本地共享模式页面已实际刷新验收;由于迁移尚未在共享系统数据库执行,页面明确显示“数据表尚未就绪”并禁用维护操作。

## 风险与后续
- 新迁移尚未执行。共享系统数据库迁移属于管理员/发布授权范围,普通开发者不得直接执行;管理员完成迁移后页面即可启用正式维护。
- 不自动把旧的六条演示规则写入正式表,待产品决定是否由管理员录入或增加一次性初始化迁移。

Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/97
2026-09-02 16:53:42 +08:00
23fda46567 fix: install eSign polling cron during release 2026-09-02 16:16:54 +08:00
59a984d32a feat: add callback-free eSign status polling 2026-09-02 16:07:16 +08:00
aaab198edb 自动同步种菜应收对账开发进度 (#95) 2026-09-01 17:52:56 +08:00
1b9e6ed2b3 feat: add installation details export (#94)
创建日期筛选文案;新增保留筛选条件的 CSV 导出;补充页面与导出回归测试。
2026-09-01 16:10:07 +08:00
3980f3f6b9 test: 补充店铺收款账户整合回归 2026-09-01 15:40:33 +08:00
853cee4ccf Merge remote-tracking branch 'origin/feature/WuYongJie-store-receiving-accounts' into feature/WuYongJie-integrate-remote-20260901
# Conflicts:
#	modules/EcommerceFinance/Repositories/BusinessInformationMirrorRepository.php
#	resources/views/business/pages/show.blade.php
2026-09-01 15:33:04 +08:00
43345e5d57 Merge remote-tracking branch 'origin/feature/qiuxiang-zhongcai-platform-tabs' into feature/WuYongJie-integrate-remote-20260901 2026-09-01 15:31:46 +08:00
b0507880ed Merge remote-tracking branch 'origin/feature/limeng-jushuitan-work-orders' into feature/WuYongJie-integrate-remote-20260901 2026-09-01 15:31:45 +08:00
adf98f2105 feat: 接入项目推进计划看板 2026-09-01 15:08:53 +08:00
qiuxiang
5b776bcbb5 feat: align taobao reconciliation headers 2026-09-01 14:59:36 +08:00
36d0dc688b feat: 接入聚水潭仓内未发工单镜像 2026-09-01 14:34:37 +08:00
qiuxiang
bfb1487adb feat: align pdd reconciliation headers 2026-09-01 14:27:55 +08:00
qiuxiang
6ce4fbe6a7 fix: remove duplicate douyin order header 2026-09-01 14:14:43 +08:00
qiuxiang
056f78a4a0 feat: align tmall reconciliation headers 2026-09-01 13:57:16 +08:00
qiuxiang
61a91d6300 feat: align jd reconciliation headers 2026-09-01 13:41:15 +08:00
qiuxiang
6614121331 Merge remote-tracking branch 'origin/main' into feature/qiuxiang-zhongcai-platform-tabs 2026-09-01 13:30:05 +08:00
qiuxiang
a58c606939 feat: align douyin reconciliation headers 2026-09-01 13:26:49 +08:00
b1bfbe2400 perf: avoid promotion sku map scans 2026-09-01 13:23:31 +08:00
344b8331f4 perf: avoid filesort on order mirror pages 2026-09-01 13:18:19 +08:00
qiuxiang
ea682327ab feat: add more zhongcai platform tabs 2026-09-01 13:11:09 +08:00
qiuxiang
5ecb3598c4 Merge remote-tracking branch 'origin/main' into feature/qiuxiang-zhongcai-platform-tabs 2026-09-01 13:08:45 +08:00
173d218d68 perf: hydrate order sku details after pagination 2026-09-01 13:01:43 +08:00
d95dc675a2 fix: materialize daily store report totals 2026-09-01 12:59:03 +08:00
8b0f15c346 fix: avoid invalid indexes on mirrored long text columns 2026-09-01 12:46:55 +08:00
qiuxiang
1f5ed4efe9 fix: disable empty platform selection 2026-09-01 11:58:26 +08:00
qiuxiang
ad893594ae Merge remote-tracking branch 'origin/main' into feature/qiuxiang-zhongcai-platform-tabs 2026-09-01 11:56:16 +08:00
qiuxiang
51450007d4 feat: add zhongcai reconciliation platform tabs 2026-09-01 11:56:16 +08:00
d62ad27d0f fix: centralize shared business data and remove local sqlite path 2026-09-01 11:34:37 +08:00
e45b876108 feat: 重建店铺收款账户管理 2026-09-01 09:38:03 +08:00
997 changed files with 181727 additions and 4462 deletions

View File

@ -22,6 +22,7 @@ storage/debugbar
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
storage/framework/testing
storage/logs/*
tests
test-data
@ -29,3 +30,6 @@ docs
scripts
npm-debug.log
yarn-error.log
public/net-layer-demo.html
public/perf-diff-plan.html
public/plan-a-changes-202608.csv

30
.env.jushuitan.example Normal file
View File

@ -0,0 +1,30 @@
# ============================================================
# 聚水潭(聚石塔 / 奇门)开放平台接入 —— caiwu 直连发货数据
# 复用工作平台现有授权。把下列键补进 runtime/.env(勿提交 Git)。
# 值留空时同步器会安全拒绝请求,不会误拉。
# ============================================================
# 奇门网关地址(对应工作平台配置 ErpJSTUrl)
JST_QIMEN_URL=
# 授权凭证(复用现有一套)
JST_APP_KEY=
JST_APP_SECRET=
JST_CUSTOMER_ID=
# 奇门固定参数(一般无需改)
JST_API_VERSION=2
JST_FORMAT=json
JST_SIMPLIFY=true
# 同步参数(可选,均有默认值)
JST_SYNC_PAGE_LIMIT=100
JST_SYNC_OVERLAP_HOURS=48
JST_SYNC_WINDOW_HOURS=24
JST_SYNC_MAX_PAGES=0
JST_REQUEST_TIMEOUT=30
JST_RETRY_TIMES=5
JST_RETRY_SLEEP_MS=1000
# 定时增量总开关:凭证填好、迁移跑完、联调校准后再设为 true
JST_SYNC_ENABLED=false

4
.gitignore vendored
View File

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

View File

@ -115,16 +115,16 @@
},
"typographyMeta": {
"title": {
"displayName": "页面与一级导航标题",
"purpose": "右侧页面标题和一级导航,固定 14px,不随视口流体缩放。"
"displayName": "页面标题",
"purpose": "右侧页面标题,固定 14px,不随视口流体缩放。"
},
"body": {
"displayName": "业务正文",
"purpose": "表单、说明、按钮和常规业务文字。"
},
"label": {
"displayName": "字段与二级导航标签",
"purpose": "筛选字段、状态标签和二级菜单,固定 12px。"
"displayName": "字段与导航标签",
"purpose": "筛选字段、状态标签和一、二级菜单,固定 12px。"
},
"data": {
"displayName": "表格与金额数据",
@ -197,7 +197,7 @@
"refersTo": "sidebar-item-active",
"description": "浅黑功能栏上的单一圆形 chevron 活动状态。",
"html": "<div class=\"ds-sidebar\"><a class=\"ds-sidebar-item is-active\" href=\"#\"><svg class=\"ds-sidebar-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M4 4h16v16H4zM8 9h8M8 13h8M8 17h5\"/></svg><span>店铺收款账户账单</span><span class=\"ds-active-indicator\" aria-hidden=\"true\">›</span></a></div>",
"css": ".ds-sidebar { width: 240px; padding: 8px; background: #1D1D1F; } .ds-sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 4px; color: #F0E4BF; font: 500 14px/1.25 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; transition: background 150ms cubic-bezier(.22,1,.36,1); } .ds-sidebar-item:hover { background: rgba(240,228,191,.10); } .ds-sidebar-item.is-active { background: #2A2927; color: #F2E7C5; } .ds-sidebar-icon { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; } .ds-active-indicator { display: inline-flex; width: 16px; height: 16px; margin-left: auto; align-items: center; justify-content: center; border: 1px solid rgba(246,233,194,.72); border-radius: 999px; background: rgba(246,233,194,.14); color: #F6E9C2; font-size: 14px; } .ds-sidebar-item:focus-visible { outline: 2px solid #F2E7C5; outline-offset: 2px; }"
"css": ".ds-sidebar { width: 224px; padding: 8px; background: #1D1D1F; } .ds-sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 4px; color: #F0E4BF; font: 500 12px/16px 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; transition: background 150ms cubic-bezier(.22,1,.36,1); } .ds-sidebar-item:hover { background: rgba(240,228,191,.10); } .ds-sidebar-item.is-active { background: #2A2927; color: #F2E7C5; } .ds-sidebar-icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; } .ds-active-indicator { display: inline-flex; width: 16px; height: 16px; margin-left: auto; align-items: center; justify-content: center; border: 1px solid rgba(246,233,194,.72); border-radius: 999px; background: rgba(246,233,194,.14); color: #F6E9C2; font-size: 14px; } .ds-sidebar-item:focus-visible { outline: 2px solid #F2E7C5; outline-offset: 2px; }"
},
{
"name": "Workspace Tabs",
@ -205,7 +205,7 @@
"refersTo": "tab-active",
"description": "白色工作区内使用深金活动文字和单一金色下划线的局部标签切换。",
"html": "<nav class=\"ds-tabs\" aria-label=\"账户类型\"><a class=\"ds-tab is-active\" aria-current=\"page\" href=\"#\">淘系支付宝账户</a><a class=\"ds-tab\" href=\"#\">淘系微信账户</a></nav>",
"css": ".ds-tabs { display: flex; align-items: center; gap: 36px; min-height: 80px; padding: 0 32px; overflow-x: auto; border: 1px solid #D8D2C7; border-radius: 16px; background: #fff; } .ds-tab { position: relative; display: inline-flex; min-height: 80px; align-items: center; color: #595959; font: 600 14px/1.25 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; white-space: nowrap; transition: color 150ms cubic-bezier(.22,1,.36,1); } .ds-tab:hover, .ds-tab.is-active { color: #856632; } .ds-tab.is-active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; border-radius: 999px 999px 0 0; background: #B9954F; content: ''; } .ds-tab:focus-visible { outline: 2px solid #856632; outline-offset: 3px; }"
"css": ".ds-tabs { display: flex; align-items: center; gap: 36px; min-height: 34px; padding: 0 32px; overflow-x: auto; border: 1px solid #D8D2C7; border-radius: 16px; background: #fff; } .ds-tab { position: relative; display: inline-flex; min-height: 34px; align-items: center; color: #595959; font: 600 14px/1.25 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; white-space: nowrap; transition: color 150ms cubic-bezier(.22,1,.36,1); } .ds-tab:hover, .ds-tab.is-active { color: #856632; } .ds-tab.is-active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; border-radius: 999px 999px 0 0; background: #B9954F; content: ''; } .ds-tab:focus-visible { outline: 2px solid #856632; outline-offset: 3px; }"
},
{
"name": "Business Panel",
@ -225,7 +225,7 @@
{
"name": "Finance Data Table",
"kind": "custom",
"description": "紧凑、可扫描、数字右对齐的财务数据表。",
"description": "紧凑、可扫描、数字右对齐的财务数据表。支持列显示/隐藏、拖动排序、恢复默认及按用户和具体表格保存;冻结多层表头,横向滚动条始终可达。导出明确筛选范围和可见列顺序,保留精度并排除密码;允许导入的业务提供模板、预览、校验及失败反馈,只读镜像不写回。",
"html": "<table class=\"ds-table\"><thead><tr><th>订单号</th><th class=\"ds-num\">收入</th><th class=\"ds-num\">余额</th></tr></thead><tbody><tr><td>20260722001</td><td class=\"ds-num ds-income\">¥1,280.00</td><td class=\"ds-num\">¥8,460.00</td></tr></tbody></table>",
"css": ".ds-table { width: 100%; border-collapse: collapse; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font: 400 12.5px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-table th { padding: 9px 10px; border-bottom: 1px solid #D8D2C7; background: #FBF8F1; color: #595959; font-size: 12px; font-weight: 600; text-align: left; } .ds-table td { padding: 9px 10px; border-bottom: 1px solid #EEE9DF; } .ds-table tbody tr:hover { background: #F7F0DE; } .ds-table .ds-num { text-align: right; font-variant-numeric: tabular-nums; } .ds-table .ds-income { color: #B80000; font-weight: 600; }"
}
@ -237,8 +237,8 @@
"深黑工具轨道、浅黑功能栏、浅色内容区,三层边界清楚。",
"浅金用于深色导航,深金用于浅色内容区的文字与主要操作。",
"全站使用 JetBrains Mono,标题和数据采用固定字号,不使用流体放大。",
"页面标题位于内容区右侧,保持 14px,与侧边栏一级标题同级。",
"一级导航 14px,二级导航 12px;同一层级的图标、字号和交互状态必须一致。",
"页面标题位于内容区右侧,保持 14px;导航使用统一的紧凑字号。",
"一、二级导航统一 12px;同一层级的图标、字号和交互状态必须一致。",
"表格、筛选器和状态反馈优先,装饰性卡片、阴影和动画受到严格限制。",
"标签切换是局部状态变化,不触发整页刷新;加载前后字号和布局不得跳变。"
],
@ -254,26 +254,10 @@
{ "name": "The No-Nested-Cards Rule", "body": "卡片只用于真实分组;卡片内部不能再套同样外观的卡片。优先使用标题、间距、分隔线和表格分组表达层级。", "section": "elevation" }
],
"dos": [
"**Do** 在所有前端任务开始前读取根目录 `DESIGN.md`,并优先复用全局 `--vip-*` 令牌和既有组件。",
"**Do** 保持深黑工具轨道、浅黑功能栏、浅色内容区的三层结构。",
"**Do** 在深色导航使用浅金,在浅色内容区使用对比度合格的深金。",
"**Do** 将页面标题放在右侧,使用 14px 字号和匹配业务语义的统一图标。",
"**Do** 让一级导航保持 14px、二级导航保持 12px,并为所有交互实现 hover、focus、active、disabled、loading 和 error 状态。",
"**Do** 让标签切换、筛选和分页保持局部更新,并使用 History API 维护可分享 URL。",
"**Do** 对正文、小号标签、按钮和占位文字检查至少 4.5:1 的对比度。",
"**Do** 在设计规则确需改变时,先更新 `DESIGN.md` 和 `.impeccable/design.json`,再修改页面代码。"
"复用已有令牌和组件,按 DESIGN.md 保持同类页面一致;常规排版和交互细节自主决定,不增加额外设计审批。",
"临时截图和 design-qa.md 仅作验收证据;规则变化时同步现有设计文档,不另建规范文件。",
"保持首屏布局稳定、焦点可见,正文和小号文字对比度至少 4.5:1;不添加与业务无关的装饰或宣传内容。"
],
"donts": [
"**Don't** 把遗留的紫色、靛蓝、亮蓝硬编码(例如 `#4F46E5`、`#4338CA`、`#2563EB`)复制到新页面;信息状态蓝色除外。",
"**Don't** 在白色或浅灰背景上把亮金 `colors.content-accent` 用作小号正文或白字按钮底色;改用 `colors.content-accent-dark`。",
"**Don't** 在内容区使用大面积黑色背景和金色正文;黑底金字只属于导航、浮层或明确的高层级控制区域。",
"**Don't** 同时显示竖条、圆点和圆形 chevron 等多个选中标识;每个菜单项只能有一个活动指示器。",
"**Don't** 在业务内容内部重复页面 H1,或让首次加载时出现远大于 14px 的标题闪烁。",
"**Don't** 用普通 `<a>` 默认导航实现站内标签切换,导致整个文档重新加载。",
"**Don't** 给有边框的卡片叠加宽而软的大阴影,也不要使用超过 16px 的卡片圆角。",
"**Don't** 使用渐变文字、玻璃拟态、装饰网格、斜纹背景、彩色侧边条或与任务无关的动画。",
"**Don't** 恢复“Akaunting 提供支持”之类的供应商页脚文字,或在业务页面加入非业务宣传信息。",
"**Don't** 把临时截图路径、个人电脑路径或 `design-qa.md` 当成团队视觉规范。"
]
"donts": []
}
}

1
.mailmap Normal file
View File

@ -0,0 +1 @@
chenxi <yuechenna@dingtalk.com> chenxi <chenxi@caiwuxitong.local>

View File

@ -6,8 +6,9 @@
- 默认使用中文,先给结论;以当前代码、配置、数据和日志为准,旧文档和历史结论仅作参考。
- 用户说“先看、排查、审计”时先只读说明事实和影响;用户说“修改、修复、实现”时,Codex 可直接完成范围内的本地改动、测试、提交和 PR,不必为正常开发步骤逐项请示。
- 一次只处理一个明确任务,优先复用现有模式和解决根因;不顺手重构、升级依赖或修改无关模块。
- 只有以下情况需要停止并说明:产品方向存在会改变结果的歧义;与其他任务发生实际写入冲突;需要生产发布、生产数据写入、迁移、删除或其他不可逆操作;缺少完成任务必需的外部权限。
- 围绕用户当前目标持续完成实现和必要验证,常规实现细节自主决定;优先复用现有模式,不顺手重构、升级依赖或修改无关模块。
- 仅在缺失信息会实质改变业务目标、实际写入冲突无法自行安全解决、缺少必需权限,或高风险操作尚未获授权时暂停依赖该条件的步骤;其余工作继续。
- 明确授权在同一任务、目标和影响范围内持续有效,不重复确认;范围、风险或费用实质变化时再确认。不得把 Skill 流程建议当成额外审批要求,也不自行增加团队契约、负责人审批或强制流程。
- 不为理论上可能、但正常使用无法触发的问题增加复杂框架、兜底层或重复检查。
## 2. 开工与多人协作
@ -15,8 +16,8 @@
- 开工先核对项目目录、`git status --short --branch`、`origin`、Git 身份和最新 `main`;保护现有未提交内容,不覆盖、不顺带提交。
- 开发从最新 `main` 建立独立任务分支:功能用 `feature/用户名-任务名`,修复用 `fix/用户名-问题名`,紧急修复用 `hotfix/用户名-问题名`。
- 只改独立页面、独立测试或文档时,不强制运行重型预检或提前建立 WIP PR;确认没有明显同名任务后即可开发。
- 当任务会修改共享路由、Controller、Repository、数据表/视图、`source_key`、`scope`、权限名、菜单键或公共组件,或远程已有相同区域任务时,才运行 `scripts/codex-task-preflight.ps1`,列明“将修改”和“仅复用”内容,并尽早推送 WIP PR。
- 只有双方会写同一契约或同一文件时才算冲突。仅调用现有路由、组件、Repository 或读取同一数据源不构成阻塞。
- 涉及多人正在修改的共享文件时,可用 `scripts/codex-task-preflight.ps1` 辅助检查;不把预检或提前创建 WIP PR 设为普通开发的必经步骤。
- 冲突以当前实际改动为准。结合 PR 合并状态和文件差异排除旧分支、squash 合并造成的误报;仅复用组件或读取同一数据源不阻塞开发。
- 多代理只用于边界清楚且不修改同一批文件的独立工作;主 Codex 负责最终复核。
## 3. Git、PR 与合并
@ -26,7 +27,7 @@
- 提交前检查 `git status`、实际 diff 和暂存区,只提交当前任务文件;不得带入 `.env`、凭据、日志、构建产物或他人的改动。
- PR 说明做了什么、修改文件、验证结果,以及是否涉及金额、权限、数据库或配置。小型低风险任务不要求冗长模板说明。
- 任务 Codex 在需求满足、相关测试通过且完整 diff 自审无误后,可直接执行 Squash merge、验证远程 `main` 并清理已合并分支,不需要另一位开发者或负责人再次操作。
- 生产部署、生产迁移、数据修复、删除或覆盖配置仍需单独明确授权,不能因为代码已合并而自动执行。
- 普通代码发布在相关验证通过、完整 diff 自审并合并到最新 `main` 后,由任务 Codex 直接部署和验收,无需另行取得部署授权。上游接入和共享镜像同步按第 6 节自主完成;除此之外的生产迁移、数据修复、删除或覆盖配置仍需单独明确授权。
## 4. 验证要求
@ -34,6 +35,7 @@
- 前端改动至少检查目标页面、关键交互和错误状态;后端改动至少运行相关测试或走一次目标接口;纯文档改动检查内容、链接和 diff 即可。
- 涉及金额、权限、导入、审批或数据库结构时,增加对应边界和回归验证。
- 已确认且状态未变化的内容不重复检查。未验证或验证失败时如实说明,不能写“应该没问题”。
- 数据页面按本次影响验证代表性数据量下的首页、翻页和筛选耗时;明显长时间等待不能以“返回正确”判定通过。低风险改动不增加重复或仅复述实现的测试。
- 明确区分:本地已验证、已提交/推送、已合并、已上线和仍需生产验收。
## 5. 前端与视觉
@ -48,7 +50,7 @@
- 密码、Token、SSH 私钥、数据库连接串、真实财务数据和生产日志不得进入 Git、PR 或公开文档。
- 需要长期保存的敏感信息写入被 Git 忽略的 `runtime/private/敏感信息.md`;写入前用 `git check-ignore` 确认。负责人明确要求时,可在当前一对一对话提供指定范围的敏感信息,但不得扩大传播。
- 生产数据库写入、迁移、删除、批量导入和凭据轮换必须先说明影响,准备备份或回滚,并取得明确授权。普通本地只读排查和有限业务查询不需要额外审批。
- 本任务所需的上游只读排查、数据字典刷新、数据集登记和启用、共享镜像建表及索引迁移、首次全量、增量、历史回补和镜像校准,由开发者的 Codex 自主完成,无需负责人逐次授权或代执行。Codex 自行核对来源与范围、备份受影响的配置/镜像及同步状态,使用生产同步节点和现有互斥锁分批执行,并核对源数据、完成状态与真实页面;不把这些工作转交给新人。可从上游重建的镜像修复属于此范围;上游写入、原始业务/财务数据修改、无关数据删除、凭据轮换和覆盖生产连接配置仍需单独授权。
- `caiwu_system_config` 保存用户、权限、设置、连接资料、数据字典和同步状态;`caiwu_business_mirror` 保存项目已正式接入的上游业务数据。二者不是 SQL Server 网络代理。
- 上游数据库统一按只读黑盒处理。业务查询使用固定、参数化的 Repository/查询执行器,禁止把用户输入拼成 SQL 或执行写操作。
- 正式页面优先读取共享业务镜像;尚未迁移的旧页面可继续使用现有快照兼容层。接入新数据时沿用现有镜像数据集、稳定游标、断点同步和同步状态模式,不另造一套链路。
@ -57,18 +59,17 @@
## 7. 新人本地开发
- 新人只需要启动 Docker Desktop、在网页登录,以及输入一次负责人提供的系统数据库密码;拉取代码、初始化、缓存、容器和连接排错由 Codex 完成。
- 标准初始化由 Codex 在最新 `main` 上运行不带参数的 `deploy/local-init.sh`。不要让新人判断 `--build`、手工迁移共享数据库、复制他人 `.env` 或配置上游连接。
- 本地连接系统数据库后,用户、菜单、设置和上游登记继续使用团队系统数据库,不把主连接切回本机临时 MySQL。
- 与数据库无关的普通页面或业务功能可以直接开发,不要求开发者先理解或验证全部上游、镜像和生产部署链路。
- 只有接入新上游数据时,才需要验证对应来源的只读查询、镜像同步状态、总数及代表性分页/筛选;无需每个普通任务重复验证两套数据库。
- 新人只需启动 Docker Desktop、网页登录并输入负责人提供的系统数据库密码;Codex 按 SOP 运行不带参数的 `deploy/local-init.sh`,完成初始化和排错,不让新人选择构建或迁移参数。
- 连接团队系统数据库后保留该连接,不切回临时库、不复制他人 `.env`;普通功能开发无需检查全部上游链路,仅接入新数据时验证对应来源。
## 8. 构建与生产部署
- 只有用户明确要求“部署、上线”时才执行生产发布;正式发布只使用已合并的最新 `main`。
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
- 当前生产入口为 `https://218.93.190.75:8442`;未完成正式域名或公网 `80/443` 验收前,不改成无端口地址,也不在日常发布使用 `-ConfigureHttps`。
- 发布脚本检测到新增数据库迁移时必须停止,取得负责人明确授权并完成备份后才能使用迁移开关。
- 发布脚本会拒绝覆盖“不在本次版本里”的线上提交(防止互相冲掉已上线的改动)。热修复必须基于线上当前版本构建,发布后立即合入 `main`;遇到拦截先 `git fetch` 并合入最新 `main` 或线上热修复分支,不得随手加 `-AllowRollback`(`--allow-rollback`),撤回线上发布需负责人明确决定。
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,macOS 开发机使用 `bash scripts/deploy-from-mac.sh`;两者流程一致(本机构建前端与镜像、迁移拦截、增量推送服务器本机 Registry、激活与健康检查、双版本保留)。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。
- 当前生产入口为 `https://caiwu.jiansuntuzhi.cn:8442`;公网仍只使用 `8442`,日常发布不得改成无端口地址。
- 发布脚本检测到新增数据库迁移时,由 Codex 核对全部待执行迁移。仅涉及本任务共享镜像建表、字段、索引和同步登记的迁移,完成备份后自主使用 `-AllowMigrations`(macOS 为 `--allow-migrations`);其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
- 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。
- 程序静态资源随应用镜像同源发布;七牛只存用户上传的业务图片、票据和附件。
- 只有本地初始化、服务器维护、生产发布或数据库迁移任务需要读取 `docs/开发与部署SOP.md` 的对应章节;普通功能开发无需加载整份 SOP。
@ -77,3 +78,5 @@
- 本文件是团队当前有效的简明规则。`DESIGN.md` 和 `docs/开发与部署SOP.md` 只在对应任务中按需读取。
- 详细文档与本文件冲突时,以本文件和负责人最新明确要求为准;历史截图、旧聊天记录和临时排错方案不能覆盖当前规则。
- 可说明的工作提交后运行增量的项目推进同步。
- 仅同步 Git 元数据;匹配不清或同步失败时停止同步,不影响提交、推送或发布。

View File

@ -86,13 +86,13 @@ components:
sidebar-item:
backgroundColor: "{colors.sidebar-black}"
textColor: "{colors.sidebar-gold}"
typography: "{typography.title}"
typography: "{typography.label}"
rounded: "{rounded.sm}"
padding: "8px 10px"
sidebar-item-active:
backgroundColor: "{colors.sidebar-active}"
textColor: "{colors.rail-gold}"
typography: "{typography.title}"
typography: "{typography.label}"
rounded: "{rounded.sm}"
padding: "8px 10px"
tab-default:
@ -100,13 +100,13 @@ components:
textColor: "{colors.content-muted}"
typography: "{typography.title}"
padding: "0 4px"
height: "80px"
minHeight: "34px"
tab-active:
backgroundColor: "{colors.surface}"
textColor: "{colors.content-accent-dark}"
typography: "{typography.title}"
padding: "0 4px"
height: "80px"
minHeight: "34px"
panel:
backgroundColor: "{colors.surface}"
textColor: "{colors.content-ink}"
@ -131,8 +131,8 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
- 深黑工具轨道、浅黑功能栏、浅色内容区,三层边界清楚。
- 浅金用于深色导航,深金用于浅色内容区的文字与主要操作。
- 全站使用 JetBrains Mono,标题和数据采用固定字号,不使用流体放大。
- 页面标题位于内容区右侧,保持 14px,与侧边栏一级标题同级。
- 一级导航 14px,二级导航 12px;同一层级的图标、字号和交互状态必须一致。
- 页面标题位于内容区右侧,保持 14px;导航使用统一的紧凑字号。
- 一、二级导航统一 12px;同一层级的图标、字号和交互状态必须一致。
- 表格、筛选器和状态反馈优先,装饰性卡片、阴影和动画受到严格限制。
- 标签切换是局部状态变化,不触发整页刷新;加载前后字号和布局不得跳变。
@ -191,8 +191,8 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
### Hierarchy
- **页面标题** (600, 14px, 1.25): 固定在内容头部右侧,与配套图标同行;禁止在业务内容内部重复放置大号 H1。
- **侧边栏一级标题** (500–600, 14px, 1.25): 模块和一级功能名称。
- **侧边栏二级标题** (400–500, 12px, 16px): 比一级标题小一档,保持单行和稳定缩进。
- **侧边栏一级标题** (500–600, 12px, 16px): 模块和一级功能名称。
- **侧边栏二级标题** (400–500, 12px, 16px): 通过缩进区分层级,保持单行和稳定行高。
- **正文** (400, 13px, 1.5): 表单说明、面板正文和常规业务文字。
- **标签** (500, 12px, 16px): 筛选条件、字段名、状态标签和辅助说明。
- **表格与金额** (400–600, 12.5px, 1.5): 启用等宽数字 `tabular-nums`,金额和数量右对齐。
@ -233,8 +233,8 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
### Navigation
- 一级导航 14px,二级导航 12px;文字和对应 Material 图标使用同一浅金色。
- 图标统一采用 Material Icons Outlined/Round,常规尺寸 20–24px;同一业务语义不得在不同页面更换图标体系。
- 一、二级导航统一 12px;文字和对应 Material 图标使用同一浅金色。
- 图标统一采用 Material Icons Outlined/Round,导航图标统一 16px;同一业务语义不得在不同页面更换图标体系。
- 当前二级菜单只显示一个 16px 圆形浅金 chevron 指示器;禁止同时保留竖条、圆点或第二个箭头。
- 悬停使用轻微浅金透明底,选中使用 `colors.sidebar-active`;状态变化为 150–200ms。
@ -246,7 +246,7 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
### Tabs
- 桌面标签栏高度 80px,白色表面,标签间距以可点击性和单行展示为准。
- 标签栏采用白色表面,按所在区域保持紧凑高度和足够点击空间;同类页面一致,不把主标签栏的 80px 高度套用到局部标签。
- 默认文字使用辅助墨色;活动文字使用 `colors.content-accent-dark`,底部使用 3px `colors.content-accent` 下划线。
- 标签图标与文字同色;切换只更新内容、URL History 和活动状态,不触发 document 级刷新。
- 标签变化使用 150ms 状态过渡;`prefers-reduced-motion` 下立即切换。
@ -277,6 +277,12 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
- 行悬停使用极浅金/中性背景;选中状态同时提供图标、文字或边界,不只改变底色。
- 收入、支出、错误和成功色遵循业务语义;不得为了“好看”交换红绿含义。
- 各业务表格支持列显示/隐藏、拖动排序和恢复默认;按用户及页面、标签页、表格分别保存,表头、数据和合计同步排列,必要操作列可固定。
- 单层或多层表头随纵向滚动保持可见;横向滚动条在浏览数据时始终可达,不得落到屏幕外或必须滚至整表底部才能操作。
- 合理设置表格滚动容器,适配视口、缩放和侧栏变化,避免多层滚动互相干扰。优先复用现有组件,具体实现自主选择。
- 导出明确范围,支持当前筛选结果及可见列顺序,不把当前页冒充全部;保留金额精度、日期和长编号格式,遵守字段权限,密码不导出,大数据量沿用分批或后台机制。
- 允许导入的业务提供模板、预览、字段校验、重复处理和逐行失败反馈,不静默覆盖数据;只读上游镜像不增加写回入口。业务规则不明时只澄清必要问题。
### Scrollbars
- 内容滚动条为 8px,轨道使用浅中性色,滑块使用 VIP 金;悬停使用更深金色。
@ -288,28 +294,8 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
- 空状态说明“为什么没有数据”和“下一步能做什么”,不能只写“暂无数据”。
- 错误状态保留重试入口和明确原因;局部失败不能清空整个页面或触发整页刷新。
## 6. Do's and Don'ts
## 6. 使用原则
### Do:
- **Do** 在所有前端任务开始前读取根目录 `DESIGN.md`,并优先复用全局 `--vip-*` 令牌和既有组件。
- **Do** 保持深黑工具轨道、浅黑功能栏、浅色内容区的三层结构。
- **Do** 在深色导航使用浅金,在浅色内容区使用对比度合格的深金。
- **Do** 将页面标题放在右侧,使用 14px 字号和匹配业务语义的统一图标。
- **Do** 让一级导航保持 14px、二级导航保持 12px,并为所有交互实现 hover、focus、active、disabled、loading 和 error 状态。
- **Do** 让标签切换、筛选和分页保持局部更新,并使用 History API 维护可分享 URL。
- **Do** 对正文、小号标签、按钮和占位文字检查至少 4.5:1 的对比度。
- **Do** 在设计规则确需改变时,先更新 `DESIGN.md` 和 `.impeccable/design.json`,再修改页面代码。
### Don't:
- **Don't** 把遗留的紫色、靛蓝、亮蓝硬编码(例如 `#4F46E5`、`#4338CA`、`#2563EB`)复制到新页面;信息状态蓝色除外。
- **Don't** 在白色或浅灰背景上把亮金 `colors.content-accent` 用作小号正文或白字按钮底色;改用 `colors.content-accent-dark`。
- **Don't** 在内容区使用大面积黑色背景和金色正文;黑底金字只属于导航、浮层或明确的高层级控制区域。
- **Don't** 同时显示竖条、圆点和圆形 chevron 等多个选中标识;每个菜单项只能有一个活动指示器。
- **Don't** 在业务内容内部重复页面 H1,或让首次加载时出现远大于 14px 的标题闪烁。
- **Don't** 用普通 `<a>` 默认导航实现站内标签切换,导致整个文档重新加载。
- **Don't** 给有边框的卡片叠加宽而软的大阴影,也不要使用超过 16px 的卡片圆角。
- **Don't** 使用渐变文字、玻璃拟态、装饰网格、斜纹背景、彩色侧边条或与任务无关的动画。
- **Don't** 恢复“Akaunting 提供支持”之类的供应商页脚文字,或在业务页面加入非业务宣传信息。
- **Don't** 把临时截图路径、个人电脑路径或 `design-qa.md` 当成团队视觉规范。
- 复用已有令牌和组件,按上述标准保持同类页面一致;常规排版和交互细节自主决定,不增加额外设计审批。
- 临时截图和 `design-qa.md` 仅作验收证据,不覆盖本文件;规则变化时同步 `.impeccable/design.json`,不另建规范文件。
- 保持首屏布局稳定、焦点可见,正文和小号文字对比度至少 4.5:1;不添加与业务无关的装饰或宣传内容。

View File

@ -102,10 +102,10 @@ SYSTEM_DATABASE_MODE=shared
1. 从共享数据字典确认来源、表或视图和字段;目录缺失或过期时由 Codex 执行 `upstream:schema:sync`;
2. 在本地通过 `UpstreamReadOnlyQueryFactory` 执行一次限定字段、限定行数的真实只读探索查询,不需要先有 Repository、`scope` 或生产快照;
3. 确认业务目的、字段含义、筛选条件、排序方式、最大返回量、公司/权限范围及建议缓存时间;
4. 在任务分支中通过 `UpstreamReadOnlyQuery` 实现固定、参数化的只读查询,页面只调用 Repository;
5. 为生产查询定义稳定 `scope`,通过 `UpstreamQueryCache` 读取,并在对应 `UpstreamSnapshotRefresher` 中登记同一范围;
4. 在任务分支中实现固定、参数化的批次 Repository,声明稳定游标并写入 `caiwu_business_mirror`;
5. 正式页面只调用镜像 Repository;只有尚未迁移的历史页面才暂时保留 `UpstreamQueryCache`;
6. 完成自动测试和本地真实只读验收后合并到 `main`;
7. 生产部署后生成首份共享快照,再验证第二个应用实例复用该快照。
7. 生产中央节点完成首次镜像后,再验证第二个应用实例直接复用同一镜像。开发机不启动刷新 worker。
系统数据库中只保存受控查询的标识、参数、访问范围和结果,不保存可执行的任意 SQL。需要调整查询时必须修改代码并重新走合并、部署和验收流程。
@ -122,9 +122,6 @@ docker compose -f docker-compose.local.yml ps
# 查看应用日志
docker compose -f docker-compose.local.yml logs -f app
# 查看共享快照后台进程
docker compose -f docker-compose.local.yml logs -f snapshot_worker
# 重启应用
docker compose -f docker-compose.local.yml restart app
@ -148,7 +145,10 @@ docker compose -f docker-compose.local.yml start
## 修改后的生效方式
- PHP、配置、模块、页面模板和路由目录已经挂载到容器,通常清理缓存或重启应用即可;
- PHP、配置、模块、页面模板和路由目录已经挂载到容器;开发环境关闭 PHP OPcache 和 Blade 编译缓存,刷新浏览器即可加载最新源码,无需重启 PHP 服务;
- 开发环境启动时只执行 `optimize:clear`,不会生成 Laravel 的配置、路由或视图缓存;生产环境仍保留原有缓存构建机制;
- 本地 Compose 会同时启动 `frontend` watcher。修改 `resources/assets` 下的 Vue、JavaScript 或 SCSS 后,Laravel Mix 会通过轮询自动重新编译,Apache 使用共享静态资源卷提供最新文件;浏览器刷新即可看到结果。
- 如果 watcher 构建失败,查看 `docker compose -f docker-compose.local.yml logs -f frontend`;修复源码后它会继续监听并重新编译。
- Composer 依赖、前端依赖、Dockerfile 或构建资源发生变化时,重新执行 `bash deploy/local-init.sh`,脚本会自动重建;
- 修改 `runtime/.env` 后,执行:
@ -157,8 +157,12 @@ docker compose -f docker-compose.local.yml restart app
docker compose -f docker-compose.local.yml exec -u www-data app php artisan optimize:clear
```
停止本地环境时,`app`、`frontend` 和 `mysql` 会一起停止;再次运行初始化脚本会恢复 PHP 服务和前端 watcher。
## 相关文档
- 新电脑接续指南:[docs/新电脑开发接续指南.md](docs/新电脑开发接续指南.md)
- 文档索引:[docs/README.md](docs/README.md)
- 团队规则:`AGENTS.md`
- 开发和正式发布:`docs/开发与部署SOP.md`
- 上游业务接入:`docs/ecommerce-finance-development.md`

View File

@ -17,7 +17,7 @@
Codex 的接入验收不能只回答“成功”或“失败”,必须逐个来源报告:来源标识、数据库和 Schema、验证对象、读取字段、返回行数、耗时、数据状态及失败阶段。连接资料由系统自动处理,不属于新人操作项。
完整步骤和排错说明见 [LOCAL_DEPLOY.md](LOCAL_DEPLOY.md)。
完整步骤和排错说明见 [LOCAL_DEPLOY.md](LOCAL_DEPLOY.md)。需要在另一台电脑继续开发时,按 [新电脑开发接续指南](docs/新电脑开发接续指南.md) 操作;所有 Markdown 文档的入口见 [文档索引](docs/README.md)。
## 数据库边界
@ -30,6 +30,8 @@ Codex 的接入验收不能只回答“成功”或“失败”,必须逐个
## 开发和发布
- 新电脑接续指南:[docs/新电脑开发接续指南.md](docs/新电脑开发接续指南.md)
- 文档索引:[docs/README.md](docs/README.md)
- 团队协作与安全规则:[AGENTS.md](AGENTS.md)
- 本地测试环境:[LOCAL_DEPLOY.md](LOCAL_DEPLOY.md)
- 开发和正式部署:[docs/开发与部署SOP.md](docs/开发与部署SOP.md)

View File

@ -17,6 +17,13 @@ class BusinessTableImporter
string $page,
?string $tab = null,
): array {
if ($section === 'fund-management' && $page === 'fund-forecast') {
return app(FundForecastWorkbookImporter::class)->import(
$file,
(int) (company_id() ?: 1),
);
}
$spreadsheet = IOFactory::load($file->getRealPath());
[$rows, $headerRow, $columnMap] = $this->bestMatchingSheet($spreadsheet, $descriptor);

View File

@ -0,0 +1,163 @@
<?php
namespace App\Business;
use App\Models\Business\CourierAgreementSignature;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Throwable;
/** Read the signed snapshot directly: no duplicate contracts, attachment copies or archive backfill. */
class CourierContractPageData
{
public const REMINDER_DAYS = 30;
public const STATUSES = [
'due' => '即将到期',
'expired' => '约定期限已届满',
'active' => '有效期内',
'upcoming' => '未到开始日期',
'unknown' => '期限待核对',
];
public function page(bool $includeRows = true): array
{
$search = mb_substr(trim((string) request()->query('contract_search', '')), 0, 255);
$status = (string) request()->query('contract_status', '');
$status = array_key_exists($status, self::STATUSES) ? $status : '';
$from = $this->date(request()->query('contract_end_from'));
$to = $this->date(request()->query('contract_end_to'));
if ($from && $to && $from > $to) {
[$from, $to] = [$to, $from];
}
$filters = ['contract_search' => $search, 'contract_status' => $status,
'contract_end_from' => $from ?? '', 'contract_end_to' => $to ?? ''];
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$page = max((int) request()->query('page', 1), 1);
$options = ['path' => request()->url(), 'query' => request()->except('page')];
$data = [
'courierContractFilters' => $filters,
'courierContractRows' => new LengthAwarePaginator([], 0, $limit, $page, $options),
'courierContractCounts' => array_fill_keys(array_keys(self::STATUSES), 0),
'courierContractUnreadable' => 0,
'courierContractError' => null,
];
try {
if (! Schema::hasTable('business_courier_agreement_signatures')) {
$data['courierContractError'] = '协议签署数据表尚未建立,合同管理暂时无法读取。';
return $data;
}
$matched = [];
$today = Carbon::today(config('app.timezone'));
// Dates and partner types are encrypted. Stream bounded chunks instead of SQL JSON queries
// or persisting a second, potentially stale copy of the signed business snapshot.
$signatures = CourierAgreementSignature::query()->where('company_id', (int) company_id())
->where('document_type', 'cooperation-contract')->where('status', CourierAgreementSignature::STATUS_ARCHIVED)
->select(['id', 'company_id', 'business_no', 'courier_name', 'payload', 'document_type', 'status',
'signed_pdf_path', 'certificate_path', 'archived_at', 'created_by', 'created_at'])
->lazyById(250);
foreach ($signatures as $signature) {
$payload = $signature->archivedCourierContractPayload();
if ($payload === null) {
try {
$signature->payload;
} catch (Throwable) {
$data['courierContractUnreadable']++;
}
continue;
}
$row = $this->snapshot($signature, $payload, $today);
$data['courierContractCounts'][$row['status']]++;
if (! $includeRows || ($status !== '' && $status !== $row['status'])) {
continue;
}
if (($from && (! $row['end_date'] || $row['end_date'] < $from))
|| ($to && (! $row['end_date'] || $row['end_date'] > $to))) {
continue;
}
if ($search !== '' && ! str_contains(mb_strtolower(implode(' ', [
$row['courier_name'], $row['number'], $row['business_no'], $row['party_a'], $row['party_b'],
])), mb_strtolower($search))) {
continue;
}
$matched[] = $row;
}
if ($includeRows) {
$priority = ['due' => 0, 'expired' => 1, 'unknown' => 2, 'active' => 3, 'upcoming' => 4];
usort($matched, static fn (array $a, array $b): int =>
[$priority[$a['status']], $a['end_date'] ?? '9999-12-31', -$a['id']]
<=> [$priority[$b['status']], $b['end_date'] ?? '9999-12-31', -$b['id']]);
$total = count($matched);
$items = array_slice($matched, ($page - 1) * $limit, $limit);
$owners = user_model_class()::query()->whereIn('id', array_filter(array_column($items, 'created_by')))
->pluck('name', 'id');
foreach ($items as &$row) {
$row['creator'] = $owners[$row['created_by']] ?? '未记录';
$row['has_signed_file'] = $this->listedFileAvailable($row['signed_path']);
$row['has_certificate'] = $this->listedFileAvailable($row['certificate_path']);
unset($row['signed_path'], $row['certificate_path']);
}
unset($row);
$data['courierContractRows'] = new LengthAwarePaginator($items, $total, $limit, $page, $options);
}
} catch (Throwable $exception) {
report($exception);
$data['courierContractCounts'] = array_fill_keys(array_keys(self::STATUSES), 0);
$data['courierContractError'] = '归档合同暂时无法读取,请稍后重试。';
}
return $data;
}
private function snapshot(CourierAgreementSignature $signature, array $payload, Carbon $today): array
{
$start = $this->date($payload['cooperation_start_date'] ?? null);
$end = $this->date($payload['cooperation_end_date'] ?? null);
$days = $end ? (int) $today->diffInDays(Carbon::parse($end, config('app.timezone')), false) : null;
$status = match (true) {
! $start || ! $end || $start > $end => 'unknown',
$end < $today->toDateString() => 'expired',
$start > $today->toDateString() => 'upcoming',
$days <= self::REMINDER_DAYS => 'due',
default => 'active',
};
$person = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
return [
'id' => (int) $signature->id,
'courier_name' => (string) $signature->courier_name,
'business_no' => (string) $signature->business_no,
'number' => trim((string) ($payload['cooperation_contract_number'] ?? '')),
'party_a' => trim((string) ($payload['cooperation_party_a_name'] ?? '')),
'party_b' => trim((string) ($payload['cooperation_party_b_name'] ?? '')),
'legal_representative' => $person ? '不适用(个人签署)' : trim((string) ($payload['cooperation_party_b_legal_representative'] ?? '')),
'start_date' => $start, 'end_date' => $end, 'days' => $status === 'unknown' ? null : $days,
'status' => $status, 'created_by' => $signature->created_by,
'created_at' => $signature->created_at, 'archived_at' => $signature->archived_at,
'signed_path' => $signature->signed_pdf_path, 'certificate_path' => $signature->certificate_path,
];
}
private function date(mixed $value): ?string
{
if (! is_string($value) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return null;
}
[$year, $month, $day] = array_map('intval', explode('-', $value));
return checkdate($month, $day, $year) ? $value : null;
}
private function listedFileAvailable(?string $path): bool
{
if (blank($path)) {
return false;
}
// Do not issue up to 50 sequential object-storage HEAD requests for one list page.
// Downloads always verify the actual file; local files can be checked cheaply here.
return config('filesystems.disks.uploads.driver') !== 'local' || Storage::disk('uploads')->exists($path);
}
}

View File

@ -0,0 +1,222 @@
<?php
namespace App\Business;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use RuntimeException;
/**
* 快递运费导入(.xlsx / .csv)。
*
* 源表头(按表头名匹配,列顺序无关):
* - 必需:来源 / 成品配件(也接受「成品运费」)/ 交易单号 / 提供成本运费;
* - 可选:快递单号 / 条形码 / 店铺名称(「财务系统运费整理.xlsx」起提供的列,老文件没有也能导)。
* 快递方给的文件常见几十万行,xlsx 走 XlsxSheetReader 流式读取,不整表进内存。
*/
class CourierFreightImporter
{
/** 源表头 → 数据库列 */
private const COLUMNS = [
'来源' => 'source',
'成品配件' => 'part',
'成品运费' => 'part',
'交易单号' => 'transaction_no',
'快递单号' => 'express_no',
'条形码' => 'barcode',
'店铺名称' => 'shop_name',
'提供成本运费' => 'freight_amount',
];
private const HEADERS = ['来源', '成品配件', '交易单号', '提供成本运费'];
/** 可选列:文件里有就导入,没有就留空(老文件兼容)。 */
private const OPTIONAL_HEADERS = ['快递单号', '条形码', '店铺名称'];
/** 表头别名:源文件的列名写法不统一(成品配件 / 成品运费;店铺名称 / 店铺)。 */
private const HEADER_ALIASES = [
'成品配件' => ['成品配件', '成品运费'],
'店铺名称' => ['店铺名称', '店铺'],
];
private const CHUNK = 2000;
public function __construct(
private readonly DatabaseManager $databases,
private readonly ?XlsxSheetReader $reader = null,
) {}
/**
* @return array{file:string,rows:int,inserted:int,skipped:int,amount:string,sources:array<string,int>,seconds:float}
*/
public function import(string $path, int $companyId = 1, bool $replace = false, string $source = 'courier-freight-import'): array
{
if (! is_file($path)) {
throw new InvalidArgumentException('找不到要导入的文件:' . $path);
}
$target = $this->databases->connection();
$startedAt = microtime(true);
$now = now();
if ($replace) {
$target->table('courier_freight_rows')->where('company_id', $companyId)->delete();
}
$inserted = 0;
$skipped = 0;
$amount = 0.0;
$sources = [];
$buffer = [];
$flush = function () use (&$buffer, &$inserted, $target): void {
foreach (array_chunk($buffer, self::CHUNK) as $chunk) {
$target->table('courier_freight_rows')->insert($chunk);
$inserted += count($chunk);
}
$buffer = [];
};
foreach ($this->lines($path) as $line) {
$sourceValue = $this->text($line['来源'] ?? null, 64);
$transactionNo = $this->text($line['交易单号'] ?? null, 191);
if ($sourceValue === null && $transactionNo === null) {
$skipped++;
continue;
}
$value = $this->number($line['提供成本运费'] ?? null);
$sources[$sourceValue ?? ''] = ($sources[$sourceValue ?? ''] ?? 0) + 1;
$amount += $value;
$buffer[] = [
'company_id' => $companyId,
'source' => $sourceValue ?? '',
'part' => $this->text($line['成品配件'] ?? null, 191),
'transaction_no' => $transactionNo ?? '',
'express_no' => $this->text($line['快递单号'] ?? null, 64),
'barcode' => $this->text($line['条形码'] ?? null, 191),
'shop_name' => $this->text($line['店铺名称'] ?? null, 191),
'freight_amount' => number_format($value, 4, '.', ''),
'created_at' => $now,
'updated_at' => $now,
];
if (count($buffer) >= self::CHUNK * 4) {
$flush();
}
}
$flush();
arsort($sources);
return [
'file' => basename($path),
'rows' => $inserted + $skipped,
'inserted' => $inserted,
'skipped' => $skipped,
'amount' => number_format($amount, 2, '.', ''),
'sources' => $sources,
'seconds' => round(microtime(true) - $startedAt, 1),
];
}
/** @return \Generator<int, array<string,string>> */
private function lines(string $path): \Generator
{
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'csv') {
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException('无法读取文件:' . $path);
}
try {
$header = null;
while (($cells = fgetcsv($handle)) !== false) {
if ($header === null) {
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) ($cells[0] ?? ''));
$header = array_map(static fn ($value): string => trim((string) $value), $cells);
$this->assertHeader($header);
continue;
}
$line = [];
foreach ($header as $index => $name) {
$line[$this->canonicalName($name)] = (string) ($cells[$index] ?? '');
}
yield $line;
}
} finally {
fclose($handle);
}
return;
}
if (mb_strtolower(pathinfo($path, PATHINFO_EXTENSION)) !== 'xlsx') {
throw new InvalidArgumentException('只支持 xlsx、csv。');
}
$columns = array_map(
static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name,
self::HEADERS,
);
$optionalColumns = array_map(
static fn (string $name): array|string => self::HEADER_ALIASES[$name] ?? $name,
self::OPTIONAL_HEADERS,
);
yield from ($this->reader ?? app(XlsxSheetReader::class))->rows($path, $columns, $optionalColumns);
}
/** 把表头别名折回规范列名。 */
private function canonicalName(string $header): string
{
foreach (self::HEADER_ALIASES as $canonical => $names) {
if (in_array($header, $names, true)) {
return $canonical;
}
}
return $header;
}
/** @param list<string> $header */
private function assertHeader(array $header): void
{
foreach (self::HEADERS as $required) {
$names = self::HEADER_ALIASES[$required] ?? [$required];
if (array_intersect($names, $header) === []) {
throw new InvalidArgumentException('文件缺少列:' . $required);
}
}
}
private function text(mixed $value, int $limit): ?string
{
if ($value === null) {
return null;
}
$text = trim((string) (is_scalar($value) ? $value : ''));
return $text === '' ? null : mb_substr($text, 0, $limit);
}
private function number(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
if (is_numeric($value)) {
return (float) $value;
}
$text = str_replace([',', ' ', '¥', '¥'], '', (string) $value);
return is_numeric($text) ? (float) $text : 0.0;
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Business;
use App\Models\Business\CourierOnboarding;
use App\Support\CourierOnboardingService;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* 「快递入驻」列表页数据:状态统计、筛选和分页。
*/
class CourierOnboardingPageData
{
/** 列表页状态分组,键为页面筛选值。 */
public const STATUS_GROUPS = [
'pending' => ['label' => '待处理', 'statuses' => [CourierOnboarding::STATUS_SUBMITTED, CourierOnboarding::STATUS_FIRST_APPROVED, CourierOnboarding::STATUS_APPROVED]],
'filling' => ['label' => '待快递填写', 'statuses' => [CourierOnboarding::STATUS_INVITED, CourierOnboarding::STATUS_RETURNED]],
'activated' => ['label' => '已开通', 'statuses' => [CourierOnboarding::STATUS_ACTIVATED]],
'closed' => ['label' => '已关闭', 'statuses' => [CourierOnboarding::STATUS_REJECTED, CourierOnboarding::STATUS_CANCELLED]],
'all' => ['label' => '全部', 'statuses' => []],
];
public function __construct(private readonly CourierOnboardingService $service)
{
}
public function page(): array
{
$group = (string) request()->query('group', 'pending');
$group = array_key_exists($group, self::STATUS_GROUPS) ? $group : 'pending';
$search = trim((string) request()->query('search', ''));
$limit = min(max((int) setting('default.list_limit', 25), 1), 100);
$data = [
'view' => 'business.pages.courier-payables.courier-onboarding',
'onboardingReady' => $this->service->ready(),
'onboardingGroup' => $group,
'onboardingSearch' => $search,
'onboardingGroups' => self::STATUS_GROUPS,
'onboardingCounts' => [],
'onboardingRows' => new LengthAwarePaginator([], 0, $limit),
];
if (! $data['onboardingReady']) {
return $data;
}
$counts = $this->service->statusCounts();
$data['onboardingCounts'] = collect(self::STATUS_GROUPS)
->map(static fn (array $config): int => $config['statuses'] === []
? array_sum($counts)
: array_sum(array_intersect_key($counts, array_flip($config['statuses']))))
->all();
$query = CourierOnboarding::query()->withCount('files');
$statuses = self::STATUS_GROUPS[$group]['statuses'];
if ($statuses !== []) {
$query->whereIn('status', $statuses);
}
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(static function ($builder) use ($like): void {
foreach (['number', 'invite_label', 'company_full_name', 'network_name', 'courier_name', 'contact_name', 'contact_phone'] as $column) {
$builder->orWhere($column, 'like', $like);
}
});
}
$data['onboardingRows'] = $query->latest('updated_at')->latest('id')
->paginate($limit)
->withQueryString();
return $data;
}
/**
* 快递资料页「入驻快递」页签:已开通的入驻快递,作为新快递的主数据。
*/
public function activated(int $page, int $limit): LengthAwarePaginator
{
if (! $this->service->ready()) {
return new LengthAwarePaginator([], 0, $limit, $page);
}
$search = trim((string) request()->query('search', ''));
$query = CourierOnboarding::query()->where('status', CourierOnboarding::STATUS_ACTIVATED);
if ($search !== '') {
$like = '%' . addcslashes($search, '%_\\') . '%';
$query->where(static function ($builder) use ($like): void {
foreach (['courier_name', 'company_full_name', 'network_name', 'contact_name'] as $column) {
$builder->orWhere($column, 'like', $like);
}
});
}
return $query->latest('activated_at')->paginate($limit, ['*'], 'page', $page)->withQueryString();
}
}

View File

@ -0,0 +1,314 @@
<?php
namespace App\Business;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierOnboardingFile;
use App\Support\CourierRequoteService;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository;
use Throwable;
/**
* 快递总览:工作平台(钉钉流程 → wp_express)和财务系统快递入驻按快递合成一行。
*
* 字段各有归属:快递代码、发货账号、ERP账户ID 归工作平台;主体、对公/对私账户、证照、报价、签署归财务系统。
* 两边先按入驻里关联的「工作平台快递代码」对,没关联的按系统快递名称对。
*/
final class CourierOverviewData
{
public const SOURCES = [
'erp_pending' => '待 ERP 开通',
'both' => '入驻 + 工作平台',
'onboarding' => '只有入驻',
'platform' => '只在工作平台(缺入驻资料)',
'mismatch' => '账户对不上',
];
private ?Collection $onboardingCache = null;
public function __construct(
private readonly CourierInformationMirrorRepository $mirror,
private readonly CourierRequoteService $requotes,
) {
}
/**
* @return array{rows:LengthAwarePaginator,counts:array<string,int>,platform_ready:bool}
*/
public function page(array $filters, int $page, int $perPage): array
{
$all = $this->rows();
$platformReady = $all['platform_ready'];
$rows = collect($all['rows']);
$counts = [
'all' => $rows->count(),
'both' => $rows->where('source', 'both')->count(),
'platform' => $rows->where('source', 'platform')->count(),
'onboarding' => $rows->where('source', 'onboarding')->count(),
'mismatch' => $rows->where('account_status', 'mismatch')->count(),
'erp_pending' => $rows->where('erp_status', 'pending')->count(),
];
$source = (string) ($filters['source'] ?? '');
if ($source === 'mismatch') {
$rows = $rows->where('account_status', 'mismatch');
} elseif ($source === 'erp_pending') {
$rows = $rows->where('erp_status', 'pending');
} elseif (array_key_exists($source, self::SOURCES)) {
$rows = $rows->where('source', $source);
}
$keyword = trim((string) ($filters['search'] ?? ''));
if ($keyword !== '') {
$rows = $rows->filter(static fn (array $row): bool => str_contains(
mb_strtolower($row['name'] . ' ' . ($row['expcode'] ?? '') . ' ' . ($row['onboarding']?->company_full_name ?? '')),
mb_strtolower($keyword),
));
}
$rows = $rows->values();
return [
'rows' => new LengthAwarePaginator(
$rows->forPage($page, $perPage)->values(),
$rows->count(),
$perPage,
$page,
['path' => request()->url(), 'query' => request()->except('page')],
),
'counts' => $counts,
'platform_ready' => $platformReady,
];
}
/**
* @return array{rows:list<array<string,mixed>>,platform_ready:bool}
*/
public function rows(): array
{
try {
$platform = $this->mirror->overviewSource();
} catch (Throwable $exception) {
report($exception);
$platform = null;
}
$onboardings = Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->with('files')
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->latest('id')
->get()
: collect();
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
$used = [];
$rows = [];
foreach ($platform['express'] ?? [] as $express) {
$code = (string) $express->expcode;
$onboarding = $byCode->get($code) ?? $byName->get(CourierOnboarding::normalizeName($express->expname));
if ($onboarding !== null && isset($used[$onboarding->id])) {
$onboarding = null;
}
if ($onboarding !== null) {
$used[$onboarding->id] = true;
}
$rows[] = $this->row($onboarding, $express, $platform['accounts'][$code] ?? []);
}
foreach ($onboardings as $onboarding) {
if (! isset($used[$onboarding->id])) {
$rows[] = $this->row($onboarding, null, []);
}
}
usort($rows, static fn (array $a, array $b): int => [$a['sort'], $a['name']] <=> [$b['sort'], $b['name']]);
return ['rows' => $rows, 'platform_ready' => $platform !== null];
}
/**
* @param list<object> $accounts
* @return array<string,mixed>
*/
private function row(?CourierOnboarding $onboarding, ?object $express, array $accounts): array
{
$source = $onboarding && $express ? 'both' : ($express ? 'platform' : 'onboarding');
$platformNumbers = collect($accounts)
->map(static fn ($account): string => preg_replace('/\D+/', '', (string) ($account->account ?? '')))
->filter()
->values();
$onboardingNumbers = collect([$onboarding?->bank_account_number, $onboarding?->private_account_number])
->map(static fn ($number): string => preg_replace('/\D+/', '', (string) $number))
->filter()
->values();
$accountStatus = match (true) {
$source !== 'both' => null,
$platformNumbers->isEmpty() => 'platform_missing',
$onboardingNumbers->isEmpty() => 'onboarding_missing',
$onboardingNumbers->diff($platformNumbers)->isEmpty() => 'match',
$onboardingNumbers->intersect($platformNumbers)->isNotEmpty() => 'partial',
default => 'mismatch',
};
$required = $onboarding ? CourierOnboardingFile::requiredKindsFor($onboarding->entity_type) : [];
$kinds = $onboarding ? $onboarding->files->pluck('kind')->unique()->all() : [];
$missingFiles = array_values(array_diff($required, $kinds));
$effective = $onboarding && filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
: null;
// ERP 开通:入驻开通后等钉钉「新建快递」流程回填快递代码;工作平台老快递视为已开通。
$erpStatus = match (true) {
$onboarding === null => 'opened',
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
$onboarding->erp_opened_at !== null || $express !== null => 'opened',
default => 'pending',
};
$erpAccountId = $onboarding?->erp_account_id
?: collect($accounts)->pluck('erpAccountId')->filter()->unique()->implode(' / ');
return [
'erp_status' => $erpStatus,
'erp_account_id' => $erpAccountId ?: null,
'erp_source' => $onboarding?->erpSourceLabel() ?: ($express ? '工作平台' : null),
'name' => (string) ($express->expname ?? $onboarding?->courier_name ?? $onboarding?->company_full_name ?? ''),
'expcode' => $express?->expcode ?? $onboarding?->workplatform_expcode,
'source' => $source,
'sort' => match (true) {
$erpStatus === 'pending' => 0,
$accountStatus === 'mismatch' => 1,
$source === 'onboarding' => 2,
$source === 'both' => 3,
default => 4,
},
'platform' => $express,
'platform_accounts' => $accounts,
'onboarding' => $onboarding,
'account_status' => $accountStatus,
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding?->entity_type), $missingFiles),
'effective_version' => $effective?->version_code,
'linked_by' => $onboarding && $express ? ((string) $onboarding->workplatform_expcode === (string) $express->expcode ? 'code' : 'name') : null,
];
}
/**
* 「快递资料」表格右侧接上的入驻信息:本页工作平台快递按快递代码 / 系统快递名称对上的入驻,
* 以及还没进工作平台(待 ERP 开通、或停用工作平台后新入驻)的快递,放在第一页最前面。
*
* @param list<object> $expressRows 本页工作平台快递
* @return array{matches:array<string,array<string,mixed>>,extra:list<array<string,mixed>>}
*/
public function expressAppend(array $expressRows, int $page, string $nameKeyword = '', string $codeFilter = ''): array
{
$onboardings = $this->onboardings();
if ($onboardings->isEmpty()) {
return ['matches' => [], 'extra' => []];
}
$byCode = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->workplatform_expcode))
->keyBy(static fn (CourierOnboarding $o): string => (string) $o->workplatform_expcode);
$byName = $onboardings->filter(static fn (CourierOnboarding $o): bool => filled($o->courier_name))
->keyBy(static fn (CourierOnboarding $o): string => CourierOnboarding::normalizeName($o->courier_name));
$matches = [];
foreach ($expressRows as $express) {
$onboarding = $byCode->get((string) ($express->expcode ?? '')) ?? $byName->get(CourierOnboarding::normalizeName($express->expname ?? ''));
if ($onboarding !== null) {
$matches[(string) ($express->Id ?? $express->expcode)] = $this->onboardingInfo($onboarding, true);
}
}
$extra = [];
if ($page === 1) {
try {
$platform = $this->mirror->overviewSource();
} catch (Throwable) {
$platform = null;
}
$codes = collect($platform['express'] ?? [])->pluck('expcode')->map(static fn ($code): string => (string) $code)->flip();
$names = collect($platform['express'] ?? [])->pluck('expname')->map(static fn ($name): string => CourierOnboarding::normalizeName($name))->flip();
foreach ($onboardings as $onboarding) {
$inPlatform = (filled($onboarding->workplatform_expcode) && $codes->has((string) $onboarding->workplatform_expcode))
|| (filled($onboarding->courier_name) && $names->has(CourierOnboarding::normalizeName($onboarding->courier_name)));
if ($inPlatform) {
continue;
}
if ($codeFilter !== '' && strcasecmp((string) $onboarding->workplatform_expcode, $codeFilter) !== 0) {
continue;
}
if ($nameKeyword !== '' && ! str_contains(mb_strtolower((string) $onboarding->courier_name . ' ' . $onboarding->company_full_name), mb_strtolower($nameKeyword))) {
continue;
}
$extra[] = $this->onboardingInfo($onboarding, false);
}
}
return ['matches' => $matches, 'extra' => $extra];
}
/** 快递资料里选中的一行对应的入驻(先按快递公司代码,再按系统快递名称)。 */
public function infoFor(?string $code, ?string $name): ?array
{
$onboardings = $this->onboardings();
$onboarding = (filled($code) ? $onboardings->first(static fn (CourierOnboarding $o): bool => strcasecmp((string) $o->workplatform_expcode, (string) $code) === 0) : null)
?? (filled($name) ? $onboardings->first(static fn (CourierOnboarding $o): bool => CourierOnboarding::normalizeName($o->courier_name) === CourierOnboarding::normalizeName($name)) : null);
return $onboarding !== null ? $this->onboardingInfo($onboarding, true) : null;
}
/** @return Collection<int,CourierOnboarding> */
private function onboardings(): Collection
{
return $this->onboardingCache ??= Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->with('files')
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->latest('id')
->get()
: collect();
}
/** @return array<string,mixed> */
private function onboardingInfo(CourierOnboarding $onboarding, bool $inPlatform): array
{
$required = CourierOnboardingFile::requiredKindsFor($onboarding->entity_type);
$missing = array_values(array_diff($required, $onboarding->files->pluck('kind')->unique()->all()));
$effective = filled($onboarding->courier_name) && Schema::hasTable('business_courier_quote_versions')
? $this->requotes->effectiveVersion((string) $onboarding->courier_name)
: null;
// 合同起止:协议签署里「合作合同」已签署的那份(最新一份)上填的合作期限。
$contract = app(\App\Support\CourierOnboardingService::class)->agreementSignatures($onboarding)
->first(static fn (\App\Models\Business\CourierAgreementSignature $signature): bool => $signature->document_type === 'cooperation-contract'
&& in_array($signature->status, [\App\Models\Business\CourierAgreementSignature::STATUS_COMPLETED, \App\Models\Business\CourierAgreementSignature::STATUS_ARCHIVED], true));
$contractPayload = $contract !== null && is_array($contract->payload) ? $contract->payload : [];
return [
'contract_start' => $contractPayload['cooperation_start_date'] ?? null,
'contract_end' => $contractPayload['cooperation_end_date'] ?? null,
'onboarding' => $onboarding,
'erp_status' => match (true) {
$onboarding->status !== CourierOnboarding::STATUS_ACTIVATED => 'na',
$inPlatform || $onboarding->erp_opened_at !== null => 'opened',
default => 'pending',
},
'checks' => $onboarding->accountChecks(),
'missing_files' => array_map(static fn (string $kind): string => CourierOnboardingFile::labelFor($kind, $onboarding->entity_type), $missing),
'effective_version' => $effective?->version_code,
];
}
public static function mask(?string $number): string
{
$digits = preg_replace('/\s+/', '', (string) $number);
return $digits === '' ? '' : '尾号 ' . mb_substr($digits, -4);
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace App\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
class CourierPayableSummaryFilter
{
/** @return array{date:string,person:string,courier:string,keyword:string} */
public function fromRequest(Request $request): array
{
$clean = static function (string $key, int $maxLength) use ($request): string {
$value = $request->query($key, '');
if (! is_scalar($value)) {
return '';
}
return mb_substr(trim((string) $value), 0, $maxLength);
};
return [
'date' => $clean('date', 50),
'person' => $clean('person', 100),
'courier' => $clean('courier', 100),
'keyword' => $clean('keyword', 191),
];
}
/** @param array{date:string,person:string,courier:string,keyword:string} $filters */
public function hasFilters(array $filters): bool
{
return collect($filters)->contains(static fn (string $value): bool => $value !== '');
}
/**
* @param Collection<int, mixed> $rows
* @param list<string> $headers
* @param array{date:string,person:string,courier:string,keyword:string} $filters
* @return Collection<int, mixed>
*/
public function apply(Collection $rows, array $headers, array $filters): Collection
{
if (! $this->hasFilters($filters)) {
return $rows;
}
$dateColumn = $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']);
$personColumn = $this->columnIndex($headers, ['负责人', '责任人']);
$courierColumn = $this->columnIndex($headers, ['快递公司', '快递名称', '快递名', '快递']);
return $rows->filter(function (mixed $row) use ($filters, $dateColumn, $personColumn, $courierColumn): bool {
if (! is_array($row)) {
return false;
}
if (! $this->columnMatches($row, $dateColumn, $filters['date'])
|| ! $this->columnMatches($row, $personColumn, $filters['person'])
|| ! $this->columnMatches($row, $courierColumn, $filters['courier'])) {
return false;
}
if ($filters['keyword'] === '') {
return true;
}
foreach ($row as $value) {
if ($this->contains($value, $filters['keyword'])) {
return true;
}
}
return false;
});
}
/** @param list<string> $headers */
public function dateColumnIndex(array $headers): ?int
{
return $this->columnIndex($headers, ['日期', '结算月份', '结算月', '月份']);
}
/** @param list<string> $headers */
private function columnIndex(array $headers, array $aliases): ?int
{
$normalizedAliases = array_map(
static fn (string $alias): string => mb_strtolower(preg_replace('/[\\s ]+/u', '', $alias) ?? $alias),
$aliases,
);
foreach ($headers as $index => $header) {
$normalizedHeader = mb_strtolower(preg_replace('/[\\s ]+/u', '', trim((string) $header)) ?? trim((string) $header));
if (in_array($normalizedHeader, $normalizedAliases, true)) {
return (int) $index;
}
}
return null;
}
private function columnMatches(array $row, ?int $column, string $needle): bool
{
if ($needle === '') {
return true;
}
return $column !== null && $this->contains($row[$column] ?? '', $needle);
}
private function contains(mixed $value, string $needle): bool
{
if (! is_scalar($value) && ! $value instanceof \Stringable) {
return false;
}
return mb_stripos((string) $value, $needle, 0, 'UTF-8') !== false;
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace App\Business;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Throwable;
/**
* 快递应付的两个汇总页 → 「交易单号 ⇒ 金额类取值」映射。
*
* 这些数据都是财务在「应付款项 › 快递应付 › 运费差 / 其他附加费汇总 / 退件费」页面上传的汇总快照
* (JSON 文件,不是数据库表),快递运费页面按交易单号把对应金额带出来展示。
* 快照重新导入后文件时间会变,缓存键跟着变,自动失效。
*/
class CourierPayableSummaryMap
{
/** 运费差:差异(结算运费-提供成本运费)。 */
public const PAGE_FREIGHT_DIFFERENCE = 'freight-difference';
/** 其他附加费汇总:金额。 */
public const PAGE_OTHER_SURCHARGE = 'other-surcharge-summary';
/** 退件费:结算金额(单号列叫「交易订单」)。 */
public const PAGE_RETURN_FEE = 'return-fee';
/**
* 汇总页 → [单号列的关键字候选, 取值列的关键字候选]。
*
* 各页表头写法不统一(「交易单号」/「交易订单」、「金额」/「结算金额」),
* 所以按候选顺序找:先全等匹配,再退化成包含匹配。
*/
private const PAGES = [
self::PAGE_FREIGHT_DIFFERENCE => [['交易单号', '交易订单'], ['差异', '金额']],
self::PAGE_OTHER_SURCHARGE => [['交易单号', '交易订单'], ['金额']],
self::PAGE_RETURN_FEE => [['交易订单', '交易单号'], ['结算金额', '金额']],
];
private const CACHE_MINUTES = 10;
public function __construct(private readonly CourierPayableSummaryStore $store) {}
/**
* @param string $page self::PAGE_* 之一
* @return array<string,string> 交易单号 => 金额(保留源文件里的原值字符串)
*/
public function map(int $companyId, string $page): array
{
if (! isset(self::PAGES[$page])) {
return [];
}
$disk = Storage::disk('uploads');
$path = $this->store->path($companyId, $page);
if (! $disk->exists($path)) {
return [];
}
// 文件时间戳进缓存键:财务重新导入运费差后不用等缓存过期。
$key = sprintf('courier-payable-summary-map:v1:%s:%d:%d', $page, $companyId, (int) $disk->lastModified($path));
return Cache::remember($key, now()->addMinutes(self::CACHE_MINUTES), function () use ($companyId, $page): array {
try {
return $this->build($companyId, $page);
} catch (Throwable $exception) {
report($exception);
return [];
}
});
}
/** @return array<string,string> */
private function build(int $companyId, string $page): array
{
$snapshot = $this->store->snapshot($companyId, $page);
if ($snapshot === null) {
return [];
}
[$orderHeaders, $valueHeaders] = self::PAGES[$page];
$headers = array_map(static fn ($value): string => trim((string) $value), (array) $snapshot['headers']);
$orderColumn = $this->columnIndex($headers, $orderHeaders);
$valueColumn = $this->columnIndex($headers, $valueHeaders);
if ($orderColumn === null || $valueColumn === null) {
return [];
}
$map = [];
foreach ((array) $snapshot['rows'] as $row) {
$orderNos = trim((string) ($row[$orderColumn] ?? ''));
if ($orderNos === '') {
continue;
}
$value = trim((string) ($row[$valueColumn] ?? ''));
if ($value === '') {
continue;
}
// 源表一个格子可能放多个交易单号(逗号分隔),逐个挂到同一个金额上。
foreach (explode(',', $orderNos) as $orderNo) {
$orderNo = trim($orderNo);
if ($orderNo !== '' && ! isset($map[$orderNo])) {
$map[$orderNo] = $value;
}
}
}
return $map;
}
/**
* 按表头名定位:候选顺序优先,每个候选先找完全相等的表头,再退化成包含匹配
* (例如「差异(结算运费-提供成本运费)」只能靠包含匹配)。
*
* @param list<string> $needles
*/
private function columnIndex(array $headers, array $needles): ?int
{
foreach ($needles as $needle) {
foreach ($headers as $index => $header) {
if ($header === $needle) {
return (int) $index;
}
}
}
foreach ($needles as $needle) {
foreach ($headers as $index => $header) {
if ($header !== '' && mb_strpos($header, $needle) !== false) {
return (int) $index;
}
}
}
return null;
}
}

View File

@ -0,0 +1,264 @@
<?php
namespace App\Business;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Cell\StringValueBinder;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
use Throwable;
class CourierPayableSummaryStore
{
public const PAGES = [
'freight-difference' => '运费差',
'other-surcharge-summary' => '其他附加费汇总',
'return-fee' => '退件费',
];
private const MAX_COLUMNS = 200;
private const MAX_ROWS_BY_PAGE = [
'freight-difference' => 500_000,
'other-surcharge-summary' => 500_000,
'return-fee' => 500_000,
];
private const MAX_CELL_LENGTH = 10000;
public function title(string $page): string
{
abort_unless(isset(self::PAGES[$page]), 404);
return self::PAGES[$page];
}
public function maxRows(string $page): int
{
$this->title($page);
return self::MAX_ROWS_BY_PAGE[$page];
}
/**
* @return array{version:int,headers:list<string>,rows:list<list<string>>,source_name:string,imported_at:string,imported_by:int|null}|null
*/
public function snapshot(int $companyId, string $page): ?array
{
$this->title($page);
$path = $this->path($companyId, $page);
$disk = Storage::disk('uploads');
if (! $disk->exists($path)) {
return null;
}
try {
$snapshot = json_decode($disk->get($path), true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable $exception) {
report($exception);
throw new RuntimeException('courier_payable_summary_unreadable', previous: $exception);
}
if (! is_array($snapshot)
|| ! is_array($snapshot['headers'] ?? null)
|| ! is_array($snapshot['rows'] ?? null)) {
throw new RuntimeException('courier_payable_summary_invalid');
}
return $snapshot;
}
/** @return array{rows:int,columns:int} */
public function import(UploadedFile $file, int $companyId, string $page, ?int $userId): array
{
$this->title($page);
$valueBinder = Cell::getValueBinder();
Cell::setValueBinder(new StringValueBinder());
try {
$reader = IOFactory::createReaderForFile($file->getRealPath());
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($file->getRealPath());
[$headers, $rows] = $this->readSpreadsheet($spreadsheet, $page);
} catch (ValidationException $exception) {
throw $exception;
} catch (Throwable $exception) {
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
} finally {
Cell::setValueBinder($valueBinder);
if (isset($spreadsheet)) {
$spreadsheet->disconnectWorksheets();
}
}
$payload = json_encode([
'version' => 1,
'headers' => $headers,
'rows' => $rows,
'source_name' => mb_substr($file->getClientOriginalName(), 0, 255),
'imported_at' => now()->toIso8601String(),
'imported_by' => $userId,
], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
$disk = Storage::disk('uploads');
$path = $this->path($companyId, $page);
$previousPath = $this->previousPath($companyId, $page);
if ($disk->exists($path)) {
$previous = $disk->get($path);
if (! $disk->put($previousPath, $previous)) {
throw new RuntimeException('无法备份当前汇总数据。');
}
}
if (! $disk->put($path, $payload)) {
throw new RuntimeException('无法保存汇总数据。');
}
return ['rows' => count($rows), 'columns' => count($headers)];
}
public function path(int $companyId, string $page): string
{
$this->title($page);
return "courier-payable-summaries/{$companyId}/{$page}.json";
}
private function previousPath(int $companyId, string $page): string
{
return "courier-payable-summaries/{$companyId}/{$page}.previous.json";
}
/** @return array{0:list<string>,1:list<list<string>>} */
private function readSpreadsheet($spreadsheet, string $page): array
{
$maxRows = $this->maxRows($page);
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
$rawRows = $worksheet->toArray(null, false, true, false);
$headerIndex = $this->headerIndex($rawRows);
if ($headerIndex === null) {
continue;
}
$lastColumn = $this->lastUsedColumn($rawRows, $headerIndex);
if ($lastColumn >= self::MAX_COLUMNS) {
throw ValidationException::withMessages([
'import' => '单个汇总表最多支持 ' . self::MAX_COLUMNS . ' 列。',
]);
}
$headers = [];
for ($column = 0; $column <= $lastColumn; $column++) {
$label = $this->cell($rawRows[$headerIndex][$column] ?? null);
$headers[] = $label !== '' ? $label : '未命名列' . ($column + 1);
}
$rows = [];
foreach (array_slice($rawRows, $headerIndex + 1) as $rawRow) {
$row = [];
$hasValue = false;
for ($column = 0; $column <= $lastColumn; $column++) {
$value = $this->cell($rawRow[$column] ?? null);
$row[] = $value;
$hasValue = $hasValue || $value !== '';
}
if (! $hasValue) {
continue;
}
$rows[] = $row;
if (count($rows) > $maxRows) {
throw ValidationException::withMessages([
'import' => '单次最多导入 ' . number_format($maxRows) . ' 条汇总数据,请拆分文件后重试。',
]);
}
}
if ($rows === []) {
throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']);
}
return [$headers, $rows];
}
throw ValidationException::withMessages(['import' => '没有找到有效的表头和数据。']);
}
private function headerIndex(array $rows): ?int
{
$firstNonEmpty = null;
foreach (array_slice($rows, 0, 20, true) as $index => $row) {
$count = count(array_filter(
$row,
fn (mixed $value): bool => $this->cell($value) !== '',
));
if ($count === 0) {
continue;
}
$firstNonEmpty ??= $index;
if ($count >= 2) {
return $index;
}
}
return $firstNonEmpty;
}
private function lastUsedColumn(array $rows, int $headerIndex): int
{
$lastColumn = -1;
foreach (array_slice($rows, $headerIndex) as $row) {
foreach ($row as $column => $value) {
if ($this->cell($value) !== '') {
$lastColumn = max($lastColumn, (int) $column);
}
}
}
return max($lastColumn, 0);
}
private function cell(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? '是' : '否';
}
if (! is_scalar($value) && ! $value instanceof \Stringable) {
return '';
}
$value = trim((string) $value);
if (mb_strlen($value) > self::MAX_CELL_LENGTH) {
throw ValidationException::withMessages(['import' => '单元格内容过长,请精简后重试。']);
}
return $value;
}
}

View File

@ -0,0 +1,382 @@
<?php
namespace App\Business;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierOnboarding;
use App\Models\Business\CourierPaymentAccount;
use App\Models\Business\CourierProfile;
use App\Models\Business\CourierProfileEvent;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* 快递资料在财务系统这一侧的数据:编辑覆盖值、打款信息(入驻对公/对私 + 新增/变更记录)、操作日志。
* 工作平台镜像只读,这里的数据都存在财务系统自己的表里。
*/
final class CourierProfileData
{
/** @return array<string,array<string,mixed>> 快递代码 => 覆盖值 */
public function overrides(array $codes): array
{
$codes = array_values(array_unique(array_filter(array_map('strval', $codes), 'filled')));
if ($codes === [] || ! Schema::hasTable('business_courier_profiles')) {
return [];
}
return CourierProfile::query()->whereIn('expcode', $codes)->get()
->mapWithKeys(static fn (CourierProfile $profile): array => [(string) $profile->expcode => (array) $profile->overrides])
->all();
}
/** 把覆盖值盖到快递资料行上,`_overridden` 记下改过的字段。 */
public function apply(object $row, array $overrides): object
{
$row = clone $row;
$changed = [];
foreach ($overrides as $field => $value) {
if (array_key_exists($field, CourierProfile::EDITABLE)) {
$row->{$field} = $value;
$changed[] = $field;
}
}
$row->_overridden = $changed;
return $row;
}
/** 财务系统入驻(还没有工作平台记录)的快递,按快递资料的列铺成一行。 */
public function onboardingRow(array $info): object
{
/** @var CourierOnboarding $onboarding */
$onboarding = $info['onboarding'];
return (object) [
'Id' => null,
'expcode' => $onboarding->workplatform_expcode,
'expname' => $onboarding->courier_name,
'paymethod' => $onboarding->settlement_cycle,
'status' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? '1' : '0',
'kuaiyunsgn' => $onboarding->service_type === '快运' ? '1' : '0',
'tradebtime' => $info['contract_start'] ?? null,
'tradeetime' => $info['contract_end'] ?? null,
'linker' => $onboarding->contact_name,
'linkmobile' => $onboarding->contact_phone,
'dzqzjbr' => $onboarding->esign_account,
'undefined3' => $onboarding->deduction_method,
'modified' => $onboarding->updated_at?->toDateTimeString(),
'_onboarding_only' => true,
'_info' => $info,
];
}
/** 按快递公司代码取当前显示的一行主信息(工作平台镜像优先,没有则用财务系统入驻),已盖上编辑覆盖值。 */
public function currentRow(string $code): ?object
{
$mirror = app(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class);
$overview = app(CourierOverviewData::class);
$row = null;
try {
if ($mirror->available() && $mirror->ready()) {
$found = $mirror->paginate(array_merge(
array_fill_keys(['expname', 'undefined1', 'undefined2', 'undefined3', 'tradebtime_from', 'tradebtime_to', 'status'], ''),
['expcode' => $code],
), 1, 1)['data'][0] ?? null;
if ($found !== null) {
$row = clone $found;
$row->_info = $overview->infoFor($code, $found->expname ?? null);
}
}
} catch (Throwable $exception) {
report($exception);
}
if ($row === null && ($info = $overview->infoFor($code, null)) !== null) {
$row = $this->onboardingRow($info);
}
return $row !== null ? $this->apply($row, $this->overrides([$code])[$code] ?? []) : null;
}
/**
* 协议签署页预填:打款信息新增 → 收款账户证明;变更 → 收款账户变更。
*
* @return array<string,mixed>|null
*/
public static function agreementPrefill(mixed $id): ?array
{
if (! is_numeric($id) || ! Schema::hasTable('business_courier_payment_accounts')) {
return null;
}
$record = CourierPaymentAccount::query()->find((int) $id);
if ($record === null) {
return null;
}
$onboarding = app(CourierOverviewData::class)->infoFor($record->expcode, $record->courier_name)['onboarding'] ?? null;
$fields = $record->action === CourierPaymentAccount::ACTION_CHANGE
? [
'account_change_date' => $record->change_date?->toDateString(),
'old_account_name' => $record->old_account_name,
'old_account_number' => $record->old_account_number,
'old_account_stop_date' => $record->old_stop_date?->toDateString(),
'new_account_effective_date' => $record->effective_date?->toDateString(),
'new_account_name' => $record->account_name,
'new_bank_name' => $record->bank_name,
'new_bank_account' => $record->account_number,
]
: [
'account_proof_effective_date' => $record->effective_date?->toDateString(),
'account_proof_account_name' => $record->account_name,
'account_proof_bank_name' => $record->bank_name,
'account_proof_bank_account' => $record->account_number,
];
if ($onboarding !== null && filled($onboarding->company_full_name)) {
$fields['counterparty_organization_name'] = $onboarding->company_full_name;
}
return [
'id' => $record->id,
'action' => $record->actionLabel(),
'courier_name' => $record->courier_name,
'expcode' => $record->expcode,
'document_type' => $record->documentType(),
'signer_type' => $onboarding !== null && $onboarding->entity_type === CourierOnboarding::ENTITY_PERSON ? 'person' : 'organization',
'esign_account' => $onboarding?->esign_account,
'fields' => array_filter($fields, static fn ($value): bool => filled($value)),
];
}
/** 快递资料右侧接的入驻字段(我方填写;扣款方式对齐到左边「扣款方式」列,不重复显示)。 */
public const APPENDED_PROFILE_FIELDS = [
'courier_category', 'cooperation_mode', 'courier_attribute', 'waybill_type', 'shipping_mode',
'need_logistics_check', 'workplatform_account', 'courier_email', 'network_code', 'network_region',
];
/** 工作平台的行:扣款方式没填时用入驻里我方填的。 */
public function fillFromOnboarding(object $row): object
{
$onboarding = $row->_info['onboarding'] ?? null;
if ($onboarding !== null && blank($row->undefined3 ?? null) && filled($onboarding->deduction_method)) {
$row = clone $row;
$row->undefined3 = $onboarding->deduction_method;
}
return $row;
}
/** 入驻里填的网点,按工作平台网点表的列铺成一行(排在网点明细最前面)。 */
public function onboardingNetwork(CourierOnboarding $onboarding): ?object
{
if (blank($onboarding->network_code) && blank($onboarding->network_name) && blank($onboarding->network_region)) {
return null;
}
return (object) [
'id' => '入驻 ' . $onboarding->number,
'expcode' => $onboarding->workplatform_expcode,
'netcode' => $onboarding->network_code,
'netname' => $onboarding->network_name,
'address' => $onboarding->network_region,
'linker' => $onboarding->contact_name,
'linkermobile' => $onboarding->contact_phone,
'status' => $onboarding->status === CourierOnboarding::STATUS_ACTIVATED ? '1' : '0',
'created' => $onboarding->created_at?->toDateTimeString(),
];
}
/** 保存编辑:只记和当前显示值不同的字段,并写一条操作日志。 */
public function update(string $expcode, ?string $courierName, object $current, array $values, int $userId): int
{
$profile = CourierProfile::query()->firstOrNew(['company_id' => company_id(), 'expcode' => $expcode]);
$overrides = (array) ($profile->overrides ?? []);
$changes = [];
foreach (CourierProfile::EDITABLE as $field => $label) {
if (! array_key_exists($field, $values)) {
continue;
}
$new = $this->normalize($field, $values[$field]);
$old = $this->normalize($field, $current->{$field} ?? null);
if ($new === $old) {
continue;
}
$overrides[$field] = $new;
$changes[$field] = ['label' => $label, 'old' => $old, 'new' => $new];
}
if ($changes === []) {
return 0;
}
$profile->fill([
'courier_name' => $courierName ?: $profile->courier_name,
'overrides' => $overrides,
'updated_by' => $userId,
]);
$profile->created_by ??= $userId;
$profile->save();
$this->log($expcode, 'edit', '修改 ' . implode('、', array_column($changes, 'label')), $changes, $userId);
return count($changes);
}
public function log(string $expcode, string $action, string $summary, ?array $changes, int $userId): void
{
CourierProfileEvent::query()->create([
'company_id' => company_id(),
'expcode' => $expcode,
'action' => $action,
'summary' => mb_substr($summary, 0, 500),
'changes' => $changes,
'created_by' => $userId,
]);
}
/** @return Collection<int,CourierProfileEvent> */
public function events(?string $expcode = null, int $limit = 100): Collection
{
if (! Schema::hasTable('business_courier_profile_events')) {
return collect();
}
return CourierProfileEvent::query()
->with('creator')
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
->latest('id')
->limit($limit)
->get();
}
/**
* 财务系统这一侧的打款信息(完整显示):入驻时填的对公/对私账户 + 打款信息新增/变更记录。
*
* @return list<array<string,mixed>>
*/
public function financeAccounts(?string $expcode = null, ?CourierOnboarding $onboarding = null): array
{
$rows = [];
if (Schema::hasTable('business_courier_payment_accounts')) {
$records = CourierPaymentAccount::query()
->when(filled($expcode), static fn ($query) => $query->where('expcode', $expcode))
->latest('id')
->get();
foreach ($records as $record) {
$status = $this->paymentStatus($record);
$rows[] = [
'source' => $record->actionLabel(),
'expcode' => $record->expcode,
'courier_name' => $record->courier_name,
'type' => $record->account_type,
'name' => $record->account_name,
'bank' => $record->bank_name,
'number' => $record->account_number,
'effective_date' => $record->effective_date?->toDateString(),
'old' => $record->action === CourierPaymentAccount::ACTION_CHANGE
? trim($record->old_account_name . ' ' . $record->old_account_number) . ($record->old_stop_date ? '(' . $record->old_stop_date->toDateString() . ' 停用)' : '')
: null,
'status' => $status['label'],
'tone' => $status['tone'],
'erp_account_id' => null,
'created_at' => $record->created_at?->toDateTimeString(),
'record' => $record,
];
}
}
$onboardings = $onboarding !== null
? collect([$onboarding])
: (Schema::hasTable('business_courier_onboardings')
? CourierOnboarding::query()
->whereNotIn('status', [CourierOnboarding::STATUS_CANCELLED, CourierOnboarding::STATUS_REJECTED])
->when(filled($expcode), static fn ($query) => $query->where('workplatform_expcode', $expcode))
->latest('id')
->get()
: collect());
foreach ($onboardings as $item) {
$checks = $item->accountChecks();
foreach ([
['对公', $item->bank_account_name, $item->bank_name, $item->bank_account_number, $checks['corporate'] ?? null],
['对私', $item->private_account_name, $item->private_bank_name, $item->private_account_number, $checks['private'] ?? null],
] as [$type, $name, $bank, $number, $check]) {
if (! filled($name) && ! filled($number)) {
continue;
}
$rows[] = [
'source' => '快递入驻 ' . $item->number,
'expcode' => $item->workplatform_expcode,
'courier_name' => $item->courier_name,
'type' => $type,
'name' => $name,
'bank' => $bank,
'number' => $number,
'effective_date' => $item->activated_at?->toDateString(),
'old' => null,
'status' => $check === false ? ($type === '对公' ? '户名与营业执照主体不一致' : '收款人与法定代表人不一致') : $item->statusLabel(),
'tone' => $check === false ? 'is-bad' : ($item->status === CourierOnboarding::STATUS_ACTIVATED ? 'is-ok' : 'is-warn'),
'erp_account_id' => $item->erp_account_id,
'created_at' => $item->created_at?->toDateTimeString(),
'onboarding' => $item,
];
}
}
return $rows;
}
/**
* 打款信息新增/变更的状态:按协议签署里同一快递、同类文件、同一新账号的签署任务推导。
*
* @return array{label:string,tone:string,signature:?CourierAgreementSignature}
*/
public function paymentStatus(CourierPaymentAccount $record): array
{
$signature = null;
if (Schema::hasTable('business_courier_agreement_signatures') && filled($record->courier_name)) {
$field = $record->action === CourierPaymentAccount::ACTION_CHANGE ? 'new_bank_account' : 'account_proof_bank_account';
$number = $this->digits($record->account_number);
$signature = CourierAgreementSignature::query()
->where('courier_name', $record->courier_name)
->where('document_type', $record->documentType())
->where('created_at', '>=', $record->created_at)
->latest('id')
->get()
->first(function (CourierAgreementSignature $signature) use ($field, $number): bool {
try {
return $this->digits((string) data_get($signature->payload, $field)) === $number;
} catch (Throwable) {
return false;
}
});
}
return match (true) {
$signature === null => ['label' => '待推送协议', 'tone' => 'is-warn', 'signature' => null],
in_array($signature->status, [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true) => ['label' => '已生效', 'tone' => 'is-ok', 'signature' => $signature],
in_array($signature->status, [CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_FAILED], true) => ['label' => '签署未完成,需重新推送', 'tone' => 'is-bad', 'signature' => $signature],
default => ['label' => '签署中', 'tone' => 'is-warn', 'signature' => $signature],
};
}
private function digits(?string $value): string
{
return (string) preg_replace('/[\s\-]+/u', '', (string) $value);
}
private function normalize(string $field, mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string) $value);
if ($value === '' || $value === '-') {
return null;
}
if (in_array($field, CourierProfile::BOOLEAN_FIELDS, true)) {
return in_array(strtolower($value), ['1', 'true', 'yes', 'y', '是'], true) ? '1' : '0';
}
if (in_array($field, CourierProfile::DATE_FIELDS, true)) {
return substr($value, 0, 10);
}
return $value;
}
}

View File

@ -0,0 +1,151 @@
<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeAuditLog;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
/**
* 快递附加费维护审计日志的固定入口。
*
* 日志只保存当前公司、操作人和报价/类型的安全字段快照,不保存密码、
* Token、连接串或原始请求内容。
*/
class CourierSurchargeAuditLogRepository
{
/** @var array<string,string> */
private const FIELD_LABELS = [
'courier' => '快递名称',
'scope' => '快递归属',
'reconciler' => '对账人',
'supplier' => '供应商',
'types' => '附加费类型',
'mode' => '结算方式',
'standard' => '计价标准',
'condition' => '生效条件',
'effective_at' => '生效日期',
'remark' => '备注',
'status' => '状态',
'name' => '类型名称',
];
/** @var array<string,string> */
private const ACTION_LABELS = [
CourierSurchargeAuditLog::ACTION_CREATED => '新增',
CourierSurchargeAuditLog::ACTION_UPDATED => '更改',
CourierSurchargeAuditLog::ACTION_ACTIVATED => '启用',
CourierSurchargeAuditLog::ACTION_DEACTIVATED => '停用',
];
/** @var array<string,string> */
private const TARGET_LABELS = [
CourierSurchargeAuditLog::TARGET_QUOTE => '报价规则',
CourierSurchargeAuditLog::TARGET_TYPE => '附加费类型',
];
public function ready(): bool
{
try {
return Schema::hasTable((new CourierSurchargeAuditLog())->getTable());
} catch (QueryException) {
return false;
}
}
/** @param array<string,mixed> $changes */
public function record(
string $action,
string $targetType,
?int $targetId,
string $targetLabel,
array $changes,
int $actorId,
): CourierSurchargeAuditLog {
abort_unless($this->ready(), 503, '附加费操作日志数据表尚未完成迁移。');
abort_unless(array_key_exists($action, self::ACTION_LABELS), 422, '附加费日志动作不合法。');
abort_unless(array_key_exists($targetType, self::TARGET_LABELS), 422, '附加费日志对象不合法。');
return CourierSurchargeAuditLog::query()->create([
'company_id' => company_id(),
'actor_id' => $actorId ?: null,
'action' => $action,
'target_type' => $targetType,
'target_id' => $targetId,
'target_label' => mb_substr(trim($targetLabel), 0, 191),
'changes' => $changes,
]);
}
/**
* @return Collection<int,array<string,mixed>>
*/
public function recent(int $limit = 50): Collection
{
if (! $this->ready()) {
return collect();
}
try {
return CourierSurchargeAuditLog::query()
->with('actor')
->orderByDesc('id')
->limit(max(1, min($limit, 200)))
->get()
->map(fn (CourierSurchargeAuditLog $log): array => [
'id' => $log->id,
'occurred_at' => optional($log->created_at)->format('Y-m-d H:i:s') ?: '',
'actor_name' => $log->actor?->name ?: '系统',
'action' => $log->action,
'action_label' => self::ACTION_LABELS[$log->action] ?? $log->action,
'target_type' => $log->target_type,
'target_id' => $log->target_id,
'target_type_label' => self::TARGET_LABELS[$log->target_type] ?? $log->target_type,
'target_label' => $log->target_label,
'changes_label' => $this->changesLabel($log),
])
->values();
} catch (QueryException) {
return collect();
}
}
private function changesLabel(CourierSurchargeAuditLog $log): string
{
$changes = (array) $log->changes;
if ($log->action === CourierSurchargeAuditLog::ACTION_CREATED) {
return '新增记录';
}
if (in_array($log->action, [CourierSurchargeAuditLog::ACTION_ACTIVATED, CourierSurchargeAuditLog::ACTION_DEACTIVATED], true)) {
$before = data_get($changes, 'before.status');
$after = data_get($changes, 'after.status');
return sprintf('状态:%s → %s', $before ?: '—', $after ?: '—');
}
$before = (array) ($changes['before'] ?? []);
$after = (array) ($changes['after'] ?? []);
$parts = [];
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $field) {
$old = $this->valueLabel($before[$field] ?? null);
$new = $this->valueLabel($after[$field] ?? null);
if ($old !== $new) {
$parts[] = sprintf('%s:%s → %s', self::FIELD_LABELS[$field] ?? $field, $old, $new);
}
}
return $parts === [] ? '未改变字段' : implode(';', $parts);
}
private function valueLabel(mixed $value): string
{
if (is_array($value)) {
return implode('、', array_map(static fn (mixed $item): string => (string) $item, $value));
}
$value = trim((string) $value);
return $value === '' ? '—' : $value;
}
}

View File

@ -0,0 +1,375 @@
<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeAuditLog;
use App\Models\Business\CourierSurchargeQuote;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 快递附加费报价的固定读写入口。
*
* 页面和控制器不直接操作数据库,所有公司范围、字段白名单和状态转换
* 都在这里集中处理,便于后续结算页面复用同一套报价规则。
*/
class CourierSurchargeQuoteRepository
{
public const TYPES = [
'退回费', '拦截费', '改地址', '异形件', '超长费',
'上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加',
];
public const MODES = [
'固定单价', '按重量', '按体积', '按尺寸/最长边',
'聚工单客服核实', '取发出运费', '发出运费倍数', '按账单',
];
public const SCOPES = ['自有快递', '代发仓'];
public function __construct(
private readonly CourierSurchargeTypeRepository $types,
private readonly CourierSurchargeAuditLogRepository $audits,
)
{
}
public function ready(): bool
{
try {
$table = (new CourierSurchargeQuote())->getTable();
return Schema::hasTable($table)
&& Schema::hasColumn($table, 'supplier')
&& $this->types->ready();
} catch (QueryException) {
return false;
}
}
/**
* @param array<string,string> $filters
* @return array{ready:bool, rows:Collection, summary:array<string,int>, options:array<string,Collection|array>}
*/
public function pageData(array $filters): array
{
if (! $this->ready()) {
return [
'ready' => false,
'rows' => collect(),
'summary' => ['total' => 0, 'active' => 0, 'inactive' => 0, 'couriers' => 0],
'options' => $this->emptyOptions(),
];
}
try {
$all = CourierSurchargeQuote::query()
->orderBy('id')
->get();
} catch (QueryException) {
return [
'ready' => false,
'rows' => collect(),
'summary' => ['total' => 0, 'active' => 0, 'inactive' => 0, 'couriers' => 0],
'options' => $this->emptyOptions(),
];
}
$filtered = $all->filter(function (CourierSurchargeQuote $quote) use ($filters): bool {
foreach (['courier', 'scope', 'reconciler', 'supplier', 'mode'] as $key) {
if (($filters[$key] ?? '') !== '' && (string) $quote->{$key} !== $filters[$key]) {
return false;
}
}
if (($filters['type'] ?? '') !== '' && ! in_array($filters['type'], (array) $quote->types, true)) {
return false;
}
if (($filters['status'] ?? '') !== '' && $quote->statusLabel() !== $filters['status']) {
return false;
}
$keyword = trim((string) ($filters['keyword'] ?? ''));
if ($keyword !== '') {
$searchable = implode('|', [
$quote->courier,
$quote->scope,
$quote->reconciler,
(string) $quote->supplier,
$quote->typeLabel(),
$quote->mode,
$quote->standard,
$quote->condition,
(string) $quote->remark,
]);
if (! str_contains($searchable, $keyword)) {
return false;
}
}
return true;
})->values();
$typeRecords = $this->types->all();
$activeTypeNames = $typeRecords
->where('status', CourierSurchargeType::STATUS_ACTIVE)
->pluck('name')
->values();
return [
'ready' => true,
'rows' => $filtered->map(fn (CourierSurchargeQuote $quote): array => $this->present($quote)),
'summary' => [
'total' => $filtered->count(),
'active' => $filtered->where('status', CourierSurchargeQuote::STATUS_ACTIVE)->count(),
'inactive' => $filtered->where('status', CourierSurchargeQuote::STATUS_INACTIVE)->count(),
'couriers' => $filtered->pluck('courier')->unique()->count(),
],
'options' => [
'couriers' => $all->pluck('courier')->filter()->unique()->sort()->values(),
'types' => $activeTypeNames,
'filter_types' => $typeRecords->pluck('name')->values(),
'type_records' => $typeRecords->map(fn ($type): array => [
'id' => $type->id,
'name' => $type->name,
'status' => $type->status,
'status_label' => $type->statusLabel(),
])->values(),
'modes' => collect(self::MODES),
'scopes' => self::SCOPES,
'reconcilers' => $all->pluck('reconciler')->filter()->unique()->sort()->values(),
'suppliers' => $all->pluck('supplier')->filter()->unique()->sort()->values(),
'statuses' => ['已启用', '已停用'],
],
];
}
/** @return list<string> */
public function validTypeNames(): array
{
return $this->types->validNames();
}
/** @return list<string> */
public function activeTypeNames(): array
{
return $this->types->activeNames();
}
/** @param array<string,mixed> $data */
public function findImportDuplicate(array $data, bool $lockForUpdate = false): ?CourierSurchargeQuote
{
$query = CourierSurchargeQuote::query()
->where('company_id', company_id())
->where('courier', trim((string) $data['courier']))
->where('scope', trim((string) $data['scope']))
->where('reconciler', trim((string) $data['reconciler']))
->where('mode', trim((string) $data['mode']))
->where('condition', trim((string) $data['condition']))
->whereDate('effective_at', (string) $data['effective_at']);
$supplier = trim((string) ($data['supplier'] ?? ''));
$supplier === '' ? $query->whereNull('supplier') : $query->where('supplier', $supplier);
if ($lockForUpdate) {
$query->lockForUpdate();
}
$types = $this->normalizedTypes((array) $data['types']);
return $query->get()->first(
fn (CourierSurchargeQuote $quote): bool => $this->normalizedTypes((array) $quote->types) === $types,
);
}
/** @param array<string,mixed> $data */
public function importIdentity(array $data): string
{
return hash('sha256', json_encode([
trim((string) ($data['courier'] ?? '')),
trim((string) ($data['scope'] ?? '')),
trim((string) ($data['reconciler'] ?? '')),
trim((string) ($data['supplier'] ?? '')),
$this->normalizedTypes((array) ($data['types'] ?? [])),
trim((string) ($data['mode'] ?? '')),
trim((string) ($data['effective_at'] ?? '')),
trim((string) ($data['condition'] ?? '')),
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
}
/** @param array<string,mixed> $data */
public function create(array $data, int $userId): CourierSurchargeQuote
{
return DB::transaction(function () use ($data, $userId): CourierSurchargeQuote {
$quote = CourierSurchargeQuote::query()->create($this->attributes($data, $userId) + [
'status' => CourierSurchargeQuote::STATUS_ACTIVE,
'created_by' => $userId,
]);
$this->audits->record(
CourierSurchargeAuditLog::ACTION_CREATED,
CourierSurchargeAuditLog::TARGET_QUOTE,
(int) $quote->id,
$this->targetLabel($quote),
['after' => $this->snapshot($quote)],
$userId,
);
return $quote;
});
}
/** @param array<string,mixed> $data */
public function update(CourierSurchargeQuote $quote, array $data, int $userId): CourierSurchargeQuote
{
return DB::transaction(function () use ($quote, $data, $userId): CourierSurchargeQuote {
$before = $this->snapshot($quote);
$quote->update($this->attributes($data, $userId));
$fresh = $quote->fresh();
$this->audits->record(
CourierSurchargeAuditLog::ACTION_UPDATED,
CourierSurchargeAuditLog::TARGET_QUOTE,
(int) $fresh->id,
$this->targetLabel($fresh),
['before' => $before, 'after' => $this->snapshot($fresh)],
$userId,
);
return $fresh;
});
}
public function setStatus(CourierSurchargeQuote $quote, string $status, int $userId): CourierSurchargeQuote
{
abort_unless(in_array($status, [CourierSurchargeQuote::STATUS_ACTIVE, CourierSurchargeQuote::STATUS_INACTIVE], true), 422, '报价规则状态不合法。');
return DB::transaction(function () use ($quote, $status, $userId): CourierSurchargeQuote {
$oldStatus = $quote->status;
$quote->forceFill(['status' => $status, 'updated_by' => $userId])->save();
$fresh = $quote->fresh();
if ($oldStatus !== $status) {
$this->audits->record(
$status === CourierSurchargeQuote::STATUS_ACTIVE
? CourierSurchargeAuditLog::ACTION_ACTIVATED
: CourierSurchargeAuditLog::ACTION_DEACTIVATED,
CourierSurchargeAuditLog::TARGET_QUOTE,
(int) $fresh->id,
$this->targetLabel($fresh),
[
'before' => ['status' => $this->statusLabel($oldStatus)],
'after' => ['status' => $this->statusLabel($status)],
],
$userId,
);
}
return $fresh;
});
}
private function targetLabel(CourierSurchargeQuote $quote): string
{
$type = $quote->typeLabel();
return trim(sprintf('%s%s', $quote->courier, $type === '' ? '' : ' · ' . $type));
}
/** @return array<string,mixed> */
private function snapshot(CourierSurchargeQuote $quote): array
{
return [
'courier' => $quote->courier,
'scope' => $quote->scope,
'reconciler' => $quote->reconciler,
'supplier' => $quote->supplier,
'types' => array_values((array) $quote->types),
'mode' => $quote->mode,
'standard' => $quote->standard,
'condition' => $quote->condition,
'effective_at' => optional($quote->effective_at)->format('Y-m-d'),
'status' => $this->statusLabel($quote->status),
'remark' => $quote->remark,
];
}
private function statusLabel(?string $status): string
{
return $status === CourierSurchargeQuote::STATUS_ACTIVE ? '已启用' : '已停用';
}
/** @param array<string,mixed> $data */
private function attributes(array $data, int $userId): array
{
return [
'company_id' => company_id(),
'courier' => trim((string) $data['courier']),
'scope' => trim((string) $data['scope']),
'reconciler' => trim((string) $data['reconciler']),
'supplier' => trim((string) ($data['supplier'] ?? '')) ?: null,
'types' => array_values(array_unique(array_map('strval', $data['types']))),
'mode' => trim((string) $data['mode']),
'standard' => trim((string) $data['standard']),
'condition' => trim((string) $data['condition']),
'effective_at' => $data['effective_at'],
'remark' => trim((string) ($data['remark'] ?? '')) ?: null,
'updated_by' => $userId,
];
}
/** @param array<int,mixed> $types @return list<string> */
private function normalizedTypes(array $types): array
{
$types = array_values(array_unique(array_filter(array_map(
static fn (mixed $type): string => trim((string) $type),
$types,
))));
sort($types, SORT_STRING);
return $types;
}
/** @return array<string,mixed> */
private function present(CourierSurchargeQuote $quote): array
{
return [
'id' => 'SUR-' . str_pad((string) $quote->id, 3, '0', STR_PAD_LEFT),
'db_id' => $quote->id,
'courier' => $quote->courier,
'scope' => $quote->scope,
'reconciler' => $quote->reconciler,
'supplier' => $quote->supplier,
'types' => array_values((array) $quote->types),
'type' => $quote->typeLabel(),
'mode' => $quote->mode,
'standard' => $quote->standard,
'condition' => $quote->condition,
'effective_at' => optional($quote->effective_at)->format('Y-m-d'),
'status_code' => $quote->status,
'status' => $quote->statusLabel(),
'remark' => $quote->remark,
];
}
/** @return array<string,Collection|array> */
private function emptyOptions(): array
{
return [
'couriers' => collect(),
'types' => collect(self::TYPES),
'filter_types' => collect(self::TYPES),
'type_records' => collect(),
'modes' => collect(self::MODES),
'scopes' => self::SCOPES,
'reconcilers' => collect(),
'suppliers' => collect(),
'statuses' => ['已启用', '已停用'],
];
}
}

View File

@ -0,0 +1,386 @@
<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeQuote;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class CourierSurchargeQuoteWorkbookImporter
{
public const MAX_ROWS = 500;
public const DUPLICATE_SKIP = 'skip';
public const DUPLICATE_UPDATE = 'update';
public const HEADERS = [
'快递名称',
'快递归属',
'对账人',
'供应商',
'附加费类型',
'结算方式',
'计价标准',
'生效条件',
'生效日期',
'状态',
'备注',
];
public function __construct(private readonly CourierSurchargeQuoteRepository $quotes)
{
}
public function template(): StreamedResponse
{
$types = $this->quotes->activeTypeNames();
return response()->streamDownload(function () use ($types): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('附加费报价');
$sheet->fromArray(self::HEADERS, null, 'A1');
$lastColumn = Coordinate::stringFromColumnIndex(count(self::HEADERS));
$headerRange = 'A1:' . $lastColumn . '1';
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D8D2C7']]],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($headerRange);
$widths = [18, 14, 14, 20, 24, 18, 30, 32, 14, 12, 30];
foreach ($widths as $index => $width) {
$column = Coordinate::stringFromColumnIndex($index + 1);
$sheet->getColumnDimension($column)->setWidth($width);
$sheet->getStyle($column . '2:' . $column . (self::MAX_ROWS + 1))
->getNumberFormat()
->setFormatCode('@');
}
$instructions = $spreadsheet->createSheet();
$instructions->setTitle('填写说明');
$instructions->fromArray([
['项目', '填写要求'],
['必填字段', '快递名称、快递归属、对账人、附加费类型、结算方式、计价标准、生效条件、生效日期、状态。'],
['供应商', '选填;没有供应商时保持空白。'],
['附加费类型', '多种类型使用“、”分隔;只能填写当前已启用类型。当前可选:' . implode('、', $types)],
['快递归属', '自有快递 或 代发仓。'],
['结算方式', implode('、', CourierSurchargeQuoteRepository::MODES)],
['生效日期', '使用 YYYY-MM-DD,例如 2026-09-22。'],
['状态', '已启用 或 已停用;也可填写 active 或 inactive。'],
['重复规则', '预览后必须明确选择跳过已有规则或更新已有规则,系统不会静默覆盖。'],
['单次上限', '最多 ' . self::MAX_ROWS . ' 条非空数据。'],
], null, 'A1');
$instructions->getStyle('A1:B1')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
]);
$instructions->getColumnDimension('A')->setWidth(18);
$instructions->getColumnDimension('B')->setWidth(90);
$instructions->getStyle('A1:B10')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true);
foreach (range(1, 10) as $row) {
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 34);
}
$spreadsheet->setActiveSheetIndex(0);
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, '附加费报价导入模板_' . now()->format('Ymd') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
/**
* @return array{
* rows:list<array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int}>,
* summary:array{total:int,valid:int,new:int,duplicate:int,invalid:int}
* }
*/
public function preview(UploadedFile $file): array
{
$extension = mb_strtolower($file->getClientOriginalExtension());
if (! in_array($extension, ['xlsx', 'xls', 'csv'], true)) {
throw ValidationException::withMessages(['import' => '仅支持 xlsx、xls 或 csv 文件。']);
}
try {
$reader = IOFactory::createReader(match ($extension) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
});
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($file->getRealPath());
$rows = $spreadsheet->getActiveSheet()->toArray(null, false, false, false);
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
} finally {
if (isset($spreadsheet)) {
$spreadsheet->disconnectWorksheets();
}
}
$headers = array_map(fn (mixed $value): string => $this->text($value), $rows[0] ?? []);
while ($headers !== [] && end($headers) === '') {
array_pop($headers);
}
if ($headers !== self::HEADERS) {
throw ValidationException::withMessages([
'import' => '表头不正确。请下载最新模板,并保持 11 个字段名称及顺序不变。',
]);
}
$dataRows = [];
foreach (array_slice($rows, 1, null, true) as $index => $row) {
$row = array_slice(array_pad($row, count(self::HEADERS), null), 0, count(self::HEADERS));
if (collect($row)->every(fn (mixed $value): bool => $this->text($value) === '')) {
continue;
}
$dataRows[] = ['line' => $index + 1, 'values' => $row];
if (count($dataRows) > self::MAX_ROWS) {
throw ValidationException::withMessages([
'import' => '单次最多导入 ' . self::MAX_ROWS . ' 条非空数据,请拆分文件后重试。',
]);
}
}
if ($dataRows === []) {
throw ValidationException::withMessages(['import' => '表格中没有可预览的数据行。']);
}
$previewRows = array_map(fn (array $row): array => $this->normalizeRow($row['values'], $row['line']), $dataRows);
$signatures = [];
foreach ($previewRows as $index => $row) {
if ($row['errors'] === []) {
$signatures[$this->quotes->importIdentity($row['data'])][] = $index;
}
}
foreach ($signatures as $indexes) {
if (count($indexes) < 2) {
continue;
}
$lines = implode('、', array_map(fn (int $index): int => $previewRows[$index]['line'], $indexes));
foreach ($indexes as $index) {
$previewRows[$index]['errors'][] = '文件内规则重复(行号:' . $lines . ')。';
}
}
foreach ($previewRows as &$row) {
if ($row['errors'] !== []) {
$row['result'] = 'invalid';
$row['result_label'] = '校验失败';
continue;
}
$existing = $this->quotes->findImportDuplicate($row['data']);
$row['existing_id'] = $existing?->id;
$row['result'] = $existing ? 'duplicate' : 'new';
$row['result_label'] = $existing ? '已有规则' : '可新增';
}
unset($row);
$collection = collect($previewRows);
return [
'rows' => $previewRows,
'summary' => [
'total' => $collection->count(),
'valid' => $collection->where('result', '!=', 'invalid')->count(),
'new' => $collection->where('result', 'new')->count(),
'duplicate' => $collection->where('result', 'duplicate')->count(),
'invalid' => $collection->where('result', 'invalid')->count(),
],
];
}
/**
* @param list<array{line:int,data:array<string,mixed>,errors:list<string>}> $rows
* @return array{created:int,updated:int,skipped:int,total:int}
*/
public function commit(array $rows, string $duplicatePolicy, int $userId): array
{
if (! in_array($duplicatePolicy, [self::DUPLICATE_SKIP, self::DUPLICATE_UPDATE], true)) {
throw ValidationException::withMessages(['duplicate_policy' => '请选择已有规则的处理方式。']);
}
if ($rows === [] || count($rows) > self::MAX_ROWS || collect($rows)->contains(fn (array $row): bool => ($row['errors'] ?? []) !== [])) {
throw ValidationException::withMessages(['import' => '预览数据无效或包含错误,请重新上传文件。']);
}
$activeTypes = $this->quotes->activeTypeNames();
foreach ($rows as $row) {
$inactive = array_values(array_diff((array) ($row['data']['types'] ?? []), $activeTypes));
if ($inactive !== []) {
throw ValidationException::withMessages([
'import' => '附加费类型“' . implode('、', $inactive) . '”已停用或不存在,请重新预览。',
]);
}
}
$counts = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'total' => count($rows)];
DB::transaction(function () use ($rows, $duplicatePolicy, $userId, &$counts): void {
foreach ($rows as $row) {
$data = $row['data'];
$status = $data['status'];
unset($data['status']);
$existing = $this->quotes->findImportDuplicate($data, true);
if ($existing && $duplicatePolicy === self::DUPLICATE_SKIP) {
$counts['skipped']++;
continue;
}
if ($existing) {
$quote = $this->quotes->update($existing, $data, $userId);
$counts['updated']++;
} else {
$quote = $this->quotes->create($data, $userId);
$counts['created']++;
}
if ($quote->status !== $status) {
$this->quotes->setStatus($quote, $status, $userId);
}
}
});
return $counts;
}
/** @return array{line:int,data:array<string,mixed>,display:array<string,string>,errors:list<string>,result:string,result_label:string,existing_id:?int} */
private function normalizeRow(array $values, int $line): array
{
$raw = array_combine(self::HEADERS, $values);
$display = [];
foreach ($raw as $header => $value) {
$display[$header] = $this->text($value);
}
$errors = [];
foreach (['快递名称', '快递归属', '对账人', '附加费类型', '结算方式', '计价标准', '生效条件', '生效日期', '状态'] as $header) {
if ($display[$header] === '') {
$errors[] = $header . '不能为空。';
}
}
$this->checkLength($display, '快递名称', 191, $errors);
$this->checkLength($display, '对账人', 100, $errors);
$this->checkLength($display, '供应商', 191, $errors);
$this->checkLength($display, '计价标准', 2000, $errors);
$this->checkLength($display, '生效条件', 2000, $errors);
$this->checkLength($display, '备注', 2000, $errors);
$types = array_values(array_unique(array_filter(array_map(
'trim',
preg_split('/[、,,;;]+/u', $display['附加费类型']) ?: [],
))));
if (count($types) > 50) {
$errors[] = '附加费类型最多 50 项。';
}
$validTypes = $this->quotes->validTypeNames();
$activeTypes = $this->quotes->activeTypeNames();
$unknownTypes = array_values(array_diff($types, $validTypes));
$inactiveTypes = array_values(array_diff(array_intersect($types, $validTypes), $activeTypes));
if ($unknownTypes !== []) {
$errors[] = '附加费类型不存在:' . implode('、', $unknownTypes) . '。';
}
if ($inactiveTypes !== []) {
$errors[] = '附加费类型已停用:' . implode('、', $inactiveTypes) . '。';
}
if ($display['快递归属'] !== '' && ! in_array($display['快递归属'], CourierSurchargeQuoteRepository::SCOPES, true)) {
$errors[] = '快递归属只能填写“自有快递”或“代发仓”。';
}
if ($display['结算方式'] !== '' && ! in_array($display['结算方式'], CourierSurchargeQuoteRepository::MODES, true)) {
$errors[] = '结算方式不在允许范围内。';
}
$effectiveAt = $this->date($raw['生效日期']);
if ($display['生效日期'] !== '' && $effectiveAt === null) {
$errors[] = '生效日期必须是有效的 YYYY-MM-DD 日期。';
}
$status = match (mb_strtolower($display['状态'])) {
'已启用', CourierSurchargeQuote::STATUS_ACTIVE => CourierSurchargeQuote::STATUS_ACTIVE,
'已停用', CourierSurchargeQuote::STATUS_INACTIVE => CourierSurchargeQuote::STATUS_INACTIVE,
default => null,
};
if ($display['状态'] !== '' && $status === null) {
$errors[] = '状态只能填写“已启用/active”或“已停用/inactive”。';
}
if ($effectiveAt !== null) {
$display['生效日期'] = $effectiveAt;
}
return [
'line' => $line,
'data' => [
'courier' => $display['快递名称'],
'scope' => $display['快递归属'],
'reconciler' => $display['对账人'],
'supplier' => $display['供应商'] !== '' ? $display['供应商'] : null,
'types' => $types,
'mode' => $display['结算方式'],
'standard' => $display['计价标准'],
'condition' => $display['生效条件'],
'effective_at' => $effectiveAt,
'status' => $status,
'remark' => $display['备注'],
],
'display' => $display,
'errors' => array_values(array_unique($errors)),
'result' => 'invalid',
'result_label' => '校验失败',
'existing_id' => null,
];
}
/** @param array<string,string> $values @param list<string> $errors */
private function checkLength(array $values, string $field, int $max, array &$errors): void
{
if (mb_strlen($values[$field]) > $max) {
$errors[] = $field . '不能超过 ' . $max . ' 个字符。';
}
}
private function date(mixed $value): ?string
{
if (is_numeric($value) && (float) $value > 0) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d');
} catch (Throwable) {
return null;
}
}
$value = $this->text($value);
if (! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches)) {
return null;
}
return checkdate((int) $matches[2], (int) $matches[3], (int) $matches[1]) ? $value : null;
}
private function text(mixed $value): string
{
return trim(str_replace("\xEF\xBB\xBF", '', (string) ($value ?? '')));
}
}

View File

@ -0,0 +1,198 @@
<?php
namespace App\Business;
use App\Models\Business\CourierSurchargeAuditLog;
use App\Models\Business\CourierSurchargeQuote;
use App\Models\Business\CourierSurchargeType;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
/**
* 附加费类型字典的固定读写入口。
*
* 类型不做物理删除,停用后保留历史报价中的原值,避免编辑历史规则时
* 意外丢失类型。报价规则和字典均由当前公司全局作用域隔离。
*/
class CourierSurchargeTypeRepository
{
public function __construct(private readonly CourierSurchargeAuditLogRepository $audits)
{
}
/** @var list<string> */
public const DEFAULT_NAMES = [
'退回费', '拦截费', '改地址', '异形件', '超长费',
'上楼费', '送货费', '超区费', '安装费', '保费', '发出运费附加', '进仓费',
];
public function ready(): bool
{
try {
return Schema::hasTable((new CourierSurchargeType())->getTable());
} catch (QueryException) {
return false;
}
}
/** @return Collection<int,CourierSurchargeType> */
public function all(): Collection
{
if (! $this->ready()) {
return collect();
}
try {
$types = CourierSurchargeType::query()
->orderBy('sort_order')
->orderBy('id')
->get();
if ($types->isEmpty() && company_id()) {
$this->ensureDefaults((int) company_id());
$types = CourierSurchargeType::query()
->orderBy('sort_order')
->orderBy('id')
->get();
}
return $types;
} catch (QueryException) {
return collect();
}
}
/** @return list<string> */
public function validNames(): array
{
return $this->all()->pluck('name')->values()->all();
}
/** @return list<string> */
public function activeNames(): array
{
return $this->all()
->where('status', CourierSurchargeType::STATUS_ACTIVE)
->pluck('name')
->values()
->all();
}
public function create(string $name, int $userId): CourierSurchargeType
{
$name = trim($name);
if ($name === '') {
throw ValidationException::withMessages(['name' => '附加费类型不能为空。']);
}
try {
if (CourierSurchargeType::query()->where('name', $name)->exists()) {
throw ValidationException::withMessages(['name' => '当前公司已有同名附加费类型。']);
}
$sortOrder = ((int) CourierSurchargeType::query()->max('sort_order')) + 1;
return DB::transaction(function () use ($name, $sortOrder, $userId): CourierSurchargeType {
$type = CourierSurchargeType::query()->create([
'company_id' => company_id(),
'name' => $name,
'status' => CourierSurchargeType::STATUS_ACTIVE,
'sort_order' => $sortOrder,
'created_by' => $userId,
'updated_by' => $userId,
]);
$this->audits->record(
CourierSurchargeAuditLog::ACTION_CREATED,
CourierSurchargeAuditLog::TARGET_TYPE,
(int) $type->id,
$type->name,
['after' => ['name' => $type->name, 'status' => '已启用']],
$userId,
);
return $type;
});
} catch (QueryException $exception) {
if ((int) $exception->getCode() === 23000) {
throw ValidationException::withMessages(['name' => '当前公司已有同名附加费类型。']);
}
throw $exception;
}
}
public function setStatus(CourierSurchargeType $type, string $status, int $userId): CourierSurchargeType
{
abort_unless(in_array($status, [CourierSurchargeType::STATUS_ACTIVE, CourierSurchargeType::STATUS_INACTIVE], true), 422, '附加费类型状态不合法。');
return DB::transaction(function () use ($type, $status, $userId): CourierSurchargeType {
$oldStatus = $type->status;
$type->forceFill([
'status' => $status,
'updated_by' => $userId,
])->save();
$fresh = $type->fresh();
if ($oldStatus !== $status) {
$this->audits->record(
$status === CourierSurchargeType::STATUS_ACTIVE
? CourierSurchargeAuditLog::ACTION_ACTIVATED
: CourierSurchargeAuditLog::ACTION_DEACTIVATED,
CourierSurchargeAuditLog::TARGET_TYPE,
(int) $fresh->id,
$fresh->name,
[
'before' => ['status' => $this->statusLabel($oldStatus)],
'after' => ['status' => $this->statusLabel($status)],
],
$userId,
);
}
return $fresh;
});
}
private function statusLabel(?string $status): string
{
return $status === CourierSurchargeType::STATUS_ACTIVE ? '已启用' : '已停用';
}
private function ensureDefaults(int $companyId): void
{
foreach (self::DEFAULT_NAMES as $sortOrder => $name) {
CourierSurchargeType::query()->firstOrCreate(
['company_id' => $companyId, 'name' => $name],
[
'status' => CourierSurchargeType::STATUS_ACTIVE,
'sort_order' => $sortOrder,
'created_by' => null,
'updated_by' => null,
],
);
}
}
/** @return array<string,int> */
public function usageCounts(Collection $types): array
{
if ($types->isEmpty() || ! Schema::hasTable((new CourierSurchargeQuote())->getTable())) {
return [];
}
$counts = array_fill_keys($types->pluck('name')->all(), 0);
CourierSurchargeQuote::query()->get(['types'])->each(function (CourierSurchargeQuote $quote) use (&$counts): void {
foreach (array_unique((array) $quote->types) as $name) {
if (array_key_exists($name, $counts)) {
$counts[$name]++;
}
}
});
return $counts;
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace App\Business;
use App\Models\Business\FundForecast;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Schema;
class FundForecastPage
{
public const GROUPS = [
['key' => 'balance', 'label' => '余额', 'fields' => FundForecast::BALANCE_FIELDS],
['key' => 'income', 'label' => '收入', 'fields' => FundForecast::INCOME_FIELDS],
['key' => 'expense', 'label' => '支出', 'fields' => FundForecast::EXPENSE_FIELDS],
['key' => 'summary', 'label' => '汇总', 'fields' => FundForecast::SUMMARY_FIELDS],
];
public function data(Request $request): array
{
$filters = $this->filters($request);
if (! Schema::hasColumns('business_fund_forecast', ['company_name', 'balance_bank', 'gap_surplus'])) {
return $this->pendingData($request, $filters);
}
$base = FundForecast::query();
$this->applyFilters($base, $filters);
$scopeCounts = [
'all' => (clone $base)->count(),
'gap' => $this->applyScope(clone $base, 'gap')->count(),
'unreported' => $this->applyScope(clone $base, 'unreported')->count(),
'pending_review' => $this->applyScope(clone $base, 'pending_review')->count(),
];
$query = $this->applyScope(clone $base, $filters['view']);
$summaryQuery = clone $query;
$rows = $query
->orderByDesc('budget_month')
->orderBy('company_name')
->orderByDesc('id')
->paginate(50)
->withQueryString();
$labels = FundForecast::columnLabels();
$groups = array_map(function (array $group) use ($labels): array {
$group['columns'] = array_map(
fn (string $field): array => ['field' => $field, 'label' => $labels[$field]],
$group['fields'],
);
return $group;
}, self::GROUPS);
return [
'ready' => true,
'filters' => $filters,
'months' => FundForecast::query()->whereNotNull('budget_month')->where('budget_month', '<>', '')
->distinct()->orderByDesc('budget_month')->pluck('budget_month')->all(),
'regions' => FundForecast::query()->whereNotNull('region')->where('region', '<>', '')
->distinct()->orderBy('region')->pluck('region')->all(),
'rows' => $rows,
'groups' => $groups,
'scopeCounts' => $scopeCounts,
'summary' => [
'company_count' => (clone $summaryQuery)->whereNotNull('company_name')->where('company_name', '<>', '')
->distinct()->count('company_name'),
'balance_total' => $this->sumColumns(clone $summaryQuery, FundForecast::BALANCE_FIELDS),
'income_total' => (float) (clone $summaryQuery)->sum('forecast_month_income'),
'gap_surplus' => (float) (clone $summaryQuery)->sum('gap_surplus'),
],
];
}
private function pendingData(Request $request, array $filters): array
{
$labels = FundForecast::columnLabels();
$groups = array_map(function (array $group) use ($labels): array {
$group['columns'] = array_map(
fn (string $field): array => ['field' => $field, 'label' => $labels[$field]],
$group['fields'],
);
return $group;
}, self::GROUPS);
return [
'ready' => false,
'filters' => $filters,
'months' => [],
'regions' => [],
'rows' => new LengthAwarePaginator([], 0, 50, max((int) $request->query('page', 1), 1), [
'path' => $request->url(),
'query' => $request->query(),
]),
'groups' => $groups,
'scopeCounts' => ['all' => 0, 'gap' => 0, 'unreported' => 0, 'pending_review' => 0],
'summary' => ['company_count' => 0, 'balance_total' => 0.0, 'income_total' => 0.0, 'gap_surplus' => 0.0],
];
}
private function filters(Request $request): array
{
$month = trim((string) $request->query('budget_month', ''));
$month = preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month) === 1 ? $month : '';
$status = trim((string) $request->query('report_status', ''));
$status = in_array($status, ['待填报', '待审核', '已审核'], true) ? $status : '';
$view = trim((string) $request->query('view', 'all'));
$view = in_array($view, ['all', 'gap', 'unreported', 'pending_review'], true) ? $view : 'all';
return [
'budget_month' => $month,
'region' => mb_substr(trim((string) $request->query('region', '')), 0, 64),
'report_status' => $status,
'company_name' => mb_substr(trim((string) $request->query('company_name', '')), 0, 191),
'view' => $view,
];
}
private function applyFilters(Builder $query, array $filters): void
{
if ($filters['budget_month'] !== '') {
$query->where('budget_month', $filters['budget_month']);
}
if ($filters['region'] !== '') {
$query->where('region', $filters['region']);
}
if ($filters['report_status'] !== '') {
$query->where('report_status', $filters['report_status']);
}
if ($filters['company_name'] !== '') {
$query->where('company_name', 'like', '%' . addcslashes($filters['company_name'], '%_\\') . '%');
}
}
private function applyScope(Builder $query, string $scope): Builder
{
return match ($scope) {
'gap' => $query->where(function (Builder $nested): void {
$nested->where('has_gap', true)->orWhere('gap_surplus', '<', 0);
}),
'unreported' => $query->where(function (Builder $nested): void {
$nested->whereNull('report_status')->orWhere('report_status', '待填报');
}),
'pending_review' => $query->where('report_status', '待审核'),
default => $query,
};
}
private function sumColumns(Builder $query, array $columns): float
{
return array_reduce(
$columns,
fn (float $sum, string $column): float => $sum + (float) (clone $query)->sum($column),
0.0,
);
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Business;
use App\Models\Business\FundForecast;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use Symfony\Component\HttpFoundation\StreamedResponse;
class FundForecastTemplate
{
private const MAX_ROWS = 5000;
public function download(): StreamedResponse
{
$spreadsheet = $this->makeSpreadsheet();
return response()->streamDownload(function () use ($spreadsheet): void {
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->setPreCalculateFormulas(false);
$writer->save('php://output');
$spreadsheet->disconnectWorksheets();
}, '资金预测表-导入模板.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'no-store, no-cache, must-revalidate',
]);
}
private function makeSpreadsheet(): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$spreadsheet->getProperties()->setTitle('资金预测表导入模板')->setSubject('资金预测表批量导入');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('资金预测表');
$sheet->setShowGridlines(false);
$sheet->freezePane('C3');
$sheet->getRowDimension(1)->setRowHeight(26);
$sheet->getRowDimension(2)->setRowHeight(24);
$sheet->setCellValueExplicit('A1', '预测月份', DataType::TYPE_STRING);
$sheet->setCellValueExplicit('B1', '公司名称', DataType::TYPE_STRING);
$sheet->mergeCells('A1:A2');
$sheet->mergeCells('B1:B2');
$labels = FundForecast::columnLabels();
$columnIndex = 3;
foreach (FundForecastPage::GROUPS as $group) {
$startColumn = Coordinate::stringFromColumnIndex($columnIndex);
$endColumn = Coordinate::stringFromColumnIndex($columnIndex + count($group['fields']) - 1);
$sheet->setCellValueExplicit("{$startColumn}1", $group['label'], DataType::TYPE_STRING);
$sheet->mergeCells("{$startColumn}1:{$endColumn}1");
foreach ($group['fields'] as $field) {
$column = Coordinate::stringFromColumnIndex($columnIndex++);
$sheet->setCellValueExplicit("{$column}2", $labels[$field], DataType::TYPE_STRING);
$sheet->getColumnDimension($column)->setWidth(16);
}
}
$lastColumn = Coordinate::stringFromColumnIndex($columnIndex - 1);
$sheet->getColumnDimension('A')->setWidth(14);
$sheet->getColumnDimension('B')->setWidth(28);
$sheet->getStyle("A1:{$lastColumn}2")->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)->setVertical(Alignment::VERTICAL_CENTER);
$sheet->getStyle("A1:{$lastColumn}2")->getFont()->setBold(true);
$sheet->getStyle("A1:{$lastColumn}1")->getFont()->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle("A1:{$lastColumn}1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF856632');
$sheet->getStyle("A2:{$lastColumn}2")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FFF7F0DE');
$sheet->getStyle("A1:{$lastColumn}2")->getBorders()->getAllBorders()
->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7');
$sheet->getStyle('A3:A' . (self::MAX_ROWS + 2))->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT);
$sheet->getStyle('C3:AD' . (self::MAX_ROWS + 2))->getNumberFormat()->setFormatCode('#,##0.00');
for ($row = 3; $row <= self::MAX_ROWS + 2; $row++) {
$sheet->setCellValue("AA{$row}", "=SUM(C{$row}:H{$row})");
$sheet->setCellValue("AB{$row}", "=SUM(I{$row}:N{$row})");
$sheet->setCellValue("AC{$row}", "=SUM(O{$row}:Z{$row})");
$sheet->setCellValue("AD{$row}", "=AA{$row}+AB{$row}-AC{$row}");
$sheet->setCellValue("AE{$row}", "=IF(AD{$row}<0,\"是\",\"否\")");
}
return $spreadsheet;
}
}

View File

@ -0,0 +1,162 @@
<?php
namespace App\Business;
use App\Models\Business\FundForecast;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
class FundForecastWorkbookImporter
{
private const HEADER_FIELDS = [
'预策月份' => 'budget_month',
'预测月份' => 'budget_month',
'公司名称' => 'company_name',
'余额/银行' => 'balance_bank',
'余额/京东' => 'balance_jd',
'余额/抖音' => 'balance_douyin',
'余额/得物' => 'balance_dewu',
'余额/淘系支付宝' => 'balance_taobao_alipay',
'余额/微信' => 'balance_wechat',
'收入/京东' => 'income_jd',
'收入/抖音' => 'income_douyin',
'收入/得物' => 'income_dewu',
'收入/淘系支付宝' => 'income_taobao_alipay',
'收入/货款' => 'income_goods_payment',
'收入/其他收入' => 'income_other',
'支出/供应商货款' => 'expense_supplier_goods',
'支出/快递款' => 'expense_courier',
'支出/纸厂压贴' => 'expense_paper_lamination',
'支出/封边条' => 'expense_edge_band',
'支出/日常报销' => 'expense_daily_reimbursement',
'支出/淘宝红包' => 'expense_taobao_red_packet',
'支出/板材' => 'expense_board',
'支出/店铺充值' => 'expense_store_top_up',
'支出/种菜' => 'expense_planting',
'支出/工资' => 'expense_salary',
'支出/税金' => 'expense_tax',
'支出/店铺花费暂估10%' => 'expense_store_spend',
];
public function import(UploadedFile $file, int $companyId): array
{
$spreadsheet = IOFactory::load($file->getRealPath());
$worksheet = $spreadsheet->getSheet(0);
$rows = $worksheet->toArray(null, true, false, false);
$columnMap = $this->columnMap($rows[0] ?? [], $rows[1] ?? []);
foreach (['budget_month', 'company_name'] as $required) {
if (! in_array($required, $columnMap, true)) {
throw ValidationException::withMessages([
'import' => '资金预测表缺少“预测月份”或“公司名称”表头。',
]);
}
}
$created = 0;
$updated = 0;
DB::transaction(function () use ($rows, $columnMap, $companyId, &$created, &$updated): void {
foreach (array_slice($rows, 2) as $offset => $row) {
if (collect($row)->every(fn ($value): bool => $value === null || trim((string) $value) === '')) {
continue;
}
$rowNumber = $offset + 3;
$data = [];
foreach ($columnMap as $columnIndex => $field) {
$data[$field] = $this->value($row[$columnIndex] ?? null, $field, $rowNumber);
}
if (($data['budget_month'] ?? '') === '' || ($data['company_name'] ?? '') === '') {
throw ValidationException::withMessages([
'import' => "第 {$rowNumber} 行必须填写预测月份和公司名称。",
]);
}
$identity = [
'company_id' => $companyId,
'budget_month' => $data['budget_month'],
'company_name' => $data['company_name'],
];
$record = FundForecast::query()->where($identity)->first();
$data['report_status'] = '待审核';
if ($record) {
$record->update($data);
$updated++;
} else {
FundForecast::create(array_merge($identity, $data));
$created++;
}
}
});
if ($created + $updated === 0) {
throw ValidationException::withMessages([
'import' => '表格中没有可导入的数据行。',
]);
}
return compact('created', 'updated');
}
private function columnMap(array $groups, array $headers): array
{
$map = [];
$group = '';
$count = max(count($groups), count($headers));
for ($index = 0; $index < $count; $index++) {
$candidate = trim((string) ($groups[$index] ?? ''));
if ($candidate !== '') {
$group = $candidate;
}
$header = trim((string) ($headers[$index] ?? ''));
$combined = $header === '' ? $group : ($group === '' ? $header : $group . '/' . $header);
$field = self::HEADER_FIELDS[$this->normalizeHeader($combined)] ?? null;
if ($field !== null) {
$map[$index] = $field;
}
}
return $map;
}
private function normalizeHeader(string $header): string
{
return preg_replace('/[\s_\-()()]+/u', '', trim($header));
}
private function value(mixed $value, string $field, int $rowNumber): mixed
{
if ($value === null || trim((string) $value) === '') {
return null;
}
if ($field === 'budget_month') {
if (is_numeric($value)) {
return ExcelDate::excelToDateTimeObject($value)->format('Y-m');
}
$text = trim((string) $value);
if (preg_match('/^(\d{4})[年\/.\-](\d{1,2})(?:月)?$/u', $text, $matches) === 1) {
$month = (int) $matches[2];
if ($month >= 1 && $month <= 12) {
return sprintf('%04d-%02d', (int) $matches[1], $month);
}
}
throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行预测月份格式不正确。"]);
}
if ($field === 'company_name') {
return mb_substr(trim((string) $value), 0, 191);
}
$number = str_replace([',', ',', '¥', '¥'], '', trim((string) $value));
if (! is_numeric($number)) {
throw ValidationException::withMessages(['import' => "第 {$rowNumber} 行包含无效金额。"]);
}
return (float) $number;
}
}

View File

@ -0,0 +1,213 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
class InventoryWarehouseNameMappingWorkbookImporter
{
private const HEADERS = ['仓储方', '仓库', '仓库简称'];
/** @return array{rows:list<array>,sheet:string,headers:list<string>,summary:array} */
public function preview(UploadedFile $file): array
{
$spreadsheet = IOFactory::load($file->getRealPath());
$worksheet = $this->findWorksheet($spreadsheet);
if ($worksheet === null) {
throw ValidationException::withMessages([
'import' => '未找到包含“仓储方、仓库、仓库简称”三列的工作表。Sheet2 仅是辅助仓库清单,不能直接导入。',
]);
}
$rows = $worksheet->toArray(null, true, false, false);
$parsed = [];
$seen = [];
$created = 0;
$same = 0;
$conflicts = 0;
$invalid = 0;
foreach (array_slice($rows, 1) as $offset => $row) {
$rowNumber = $offset + 2;
$provider = trim((string) ($row[0] ?? ''));
$warehouse = trim((string) ($row[1] ?? ''));
$shortName = trim((string) ($row[2] ?? ''));
if ($provider === '' && $warehouse === '' && $shortName === '') {
continue;
}
$key = $this->key($provider, $warehouse);
$item = [
'row_number' => $rowNumber,
'warehouse_provider' => $provider,
'warehouse' => $warehouse,
'warehouse_short_name' => $shortName,
'status' => 'new',
'message' => '待新增',
];
if ($provider === '' || $warehouse === '' || $shortName === '') {
$item['status'] = 'invalid';
$item['message'] = '仓储方、仓库、仓库简称均为必填字段';
$invalid++;
} elseif (isset($seen[$key])) {
$item['status'] = $seen[$key]['warehouse_short_name'] === $shortName ? 'same' : 'conflict';
$item['message'] = $item['status'] === 'same'
? '与文件内前一行重复,将跳过'
: '与文件内前一行的仓库简称冲突';
$item['status'] === 'same' ? $same++ : $conflicts++;
} else {
$existing = InventoryWarehouseNameMapping::query()
->where('warehouse_provider', $provider)
->where('warehouse', $warehouse)
->first();
if ($existing) {
$item['status'] = $existing->warehouse_short_name === $shortName ? 'same' : 'conflict';
$item['message'] = $item['status'] === 'same'
? '数据库已有相同映射,将跳过'
: '数据库已有不同简称,禁止静默覆盖';
$item['status'] === 'same' ? $same++ : $conflicts++;
} else {
$created++;
}
}
$parsed[] = $item;
if ($provider !== '' && $warehouse !== '' && $shortName !== '' && ! isset($seen[$key])) {
$seen[$key] = $item;
}
}
return [
'rows' => $parsed,
'sheet' => $worksheet->getTitle(),
'headers' => self::HEADERS,
'summary' => compact('created', 'same', 'conflicts', 'invalid'),
];
}
/** @param array{rows:list<array>} $preview */
public function commit(array $preview): array
{
$rows = $preview['rows'] ?? [];
$invalid = collect($rows)->first(fn (array $row): bool => in_array($row['status'] ?? '', ['invalid', 'conflict'], true));
if ($invalid !== null) {
throw ValidationException::withMessages([
'import' => '导入预览存在必填字段错误或简称冲突,请先处理标记为失败的行。',
]);
}
$created = 0;
$skipped = 0;
DB::transaction(function () use ($rows, &$created, &$skipped): void {
foreach ($rows as $row) {
if (($row['status'] ?? '') === 'same') {
$skipped++;
continue;
}
$query = InventoryWarehouseNameMapping::query()
->where('warehouse_provider', $row['warehouse_provider'])
->where('warehouse', $row['warehouse']);
if ($query->exists()) {
throw ValidationException::withMessages([
'import' => "第 {$row['row_number']} 行在确认时已被其他操作占用,请重新预览。",
]);
}
InventoryWarehouseNameMapping::create([
'company_id' => company_id() ?: 1,
'warehouse_provider' => $row['warehouse_provider'],
'warehouse' => $row['warehouse'],
'warehouse_short_name' => $row['warehouse_short_name'],
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
$created++;
}
});
return compact('created', 'skipped');
}
public static function suggestion(string $provider, string $warehouse): string
{
$provider = trim($provider);
$warehouse = trim($warehouse);
$haystack = $provider . ' ' . $warehouse;
if (str_starts_with($provider, '奇门-')) {
return '不看';
}
foreach ([
'配件单仓' => '配件单仓-不看',
'POP仓' => '京东入仓-不看',
'自动发货仓' => '虚拟仓-不看',
'京东云仓' => '京东云仓-不看',
'京东京造' => '京东京造-不看',
'苏州福利品仓' => '苏州福利品仓-不看',
'信誉订单仓' => '信誉订单仓-不看',
'代发仓' => '代发仓',
'销退仓' => '销退仓-不看',
'客退' => '客退仓-不看',
'次品仓' => '退货报废-不看',
'数量校验仓' => '数量校验仓-不看',
] as $needle => $result) {
if (str_contains($haystack, $needle)) {
return $result;
}
}
foreach ([
'运营C崔桥工厂仓' => '崔桥工厂仓',
'运营C横林二仓' => '横林二仓',
'运营C睢宁' => '睢宁仓',
'运营C新苏北' => '新苏北仓',
'江西仓送货' => '南昌樵舍仓',
] as $needle => $result) {
if (str_contains($haystack, $needle)) {
return $result;
}
}
$short = preg_replace('/(主仓|进货仓)$/u', '', $warehouse) ?: $warehouse;
$short = preg_replace('/^(简尚-|京仓-|三方-|奇门-)/u', '', $short) ?: $short;
$providerBase = preg_replace('/^(简尚-|京仓-|三方-|奇门-)/u', '', $provider) ?: $provider;
if ($short === $providerBase || str_starts_with($short, $providerBase)) {
$short = $providerBase;
}
return $short !== '' ? $short : $warehouse;
}
private function findWorksheet($spreadsheet): ?object
{
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
$row = $worksheet->toArray(null, true, false, false)[0] ?? [];
$headers = array_map(fn ($value): string => $this->normalize((string) $value), $row);
$expected = array_map(fn ($value): string => $this->normalize($value), self::HEADERS);
if (count(array_intersect($expected, $headers)) === count($expected)) {
return $worksheet;
}
}
return null;
}
private function normalize(string $value): string
{
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim($value)));
}
private function key(string $provider, string $warehouse): string
{
return $provider . "\0" . $warehouse;
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
class InventoryWarehouseShortNames
{
/** @return array<string,string> */
public function forCompany(int $companyId): array
{
return InventoryWarehouseNameMapping::allCompanies()
->companyId($companyId)
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name'])
->mapWithKeys(static fn (InventoryWarehouseNameMapping $mapping): array => [
self::key($mapping->warehouse_provider, $mapping->warehouse) => $mapping->warehouse_short_name,
])->all();
}
/** @param array<string,string> $names */
public function fill(object $row, array $names): object
{
$row->warehouse_short_name = $names[self::key(
(string) ($row->warehouse_provider ?? ''),
(string) ($row->warehouse ?? ''),
)] ?? '';
return $row;
}
private static function key(string $provider, string $warehouse): string
{
return $provider . "\0" . $warehouse;
}
}

View File

@ -0,0 +1,367 @@
<?php
namespace App\Business;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use RuntimeException;
/**
* 聚水潭订单标签明细导入(拆标签写入 jst_order_labels)。
*
* 一个标签一行:一单有 N 个标签就写 N 行;订单不去重、标签不汇总。
* 无标签的订单保留一行,标签记为「(无标签)」,方便与订单总数核对。
*
* 两个数据源:
* - 共享镜像 ops_jst_orders(常规,覆盖镜像有数据的区间);
* - 聚水潭「导出订单」Excel(镜像没有的历史区间,例如 2026-03,只能靠财务导出补)。
*/
class JstOrderLabelImporter
{
public const NO_LABEL = '(无标签)';
private const READ_CHUNK = 2000;
private const INSERT_CHUNK = 2000;
/** 聚水潭导出文件里用到的表头。 */
private const EXPORT_COLUMNS = ['线上订单号', '店铺名称', '下单时间', '标签'];
public function __construct(
private readonly DatabaseManager $databases,
private readonly ?XlsxSheetReader $exportReader = null,
private readonly ?JstOrderLabelItemCodes $itemCodes = null,
) {}
/**
* @return array{from:string,to:string,orders:int,rows:int,no_label_orders:int,labels:int,seconds:float}
*/
public function import(string $from, string $to, int $companyId = 1, bool $replace = false, string $source = 'jst-order-label-import'): array
{
$from = $this->normalizeDate($from, '起始日期');
$to = $this->normalizeDate($to, '结束日期');
if ($from > $to) {
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
}
$start = $from . ' 00:00:00';
$end = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
$mirror = $this->databases->connection(config('upstream_mirrors.connection', 'business_mirror'));
if (! $mirror->getSchemaBuilder()->hasTable('ops_jst_orders')) {
throw new RuntimeException('聚水潭订单镜像尚未建立(ops_jst_orders)。');
}
$target = $this->databases->connection();
$startedAt = microtime(true);
$itemCodes = $this->itemCodes ?? new JstOrderLabelItemCodes();
$hasItemTable = $mirror->getSchemaBuilder()->hasTable('ops_jst_order_item');
if ($replace) {
$target->table('jst_order_labels')
->where('company_id', $companyId)
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->delete();
app(PlantingOrderLabelLookup::class)->forget($companyId);
}
$now = now();
$orders = 0;
$rows = 0;
$noLabelOrders = 0;
$buffer = [];
$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 = [];
};
$mirror->table('ops_jst_orders')
->where('order_date', '>=', $start)
->where('order_date', '<', $end)
->orderBy('Id')
->chunkById(self::READ_CHUNK, function ($records) use (&$buffer, &$orders, &$noLabelOrders, $flush, $companyId, $source, $now, $itemCodes, $hasItemTable, $mirror): void {
// 货号:同批订单的商品编码(镜像商品明细,按 o_id 取),导入时一并写入
$orderCodes = $hasItemTable
? $itemCodes->forOrderIds($mirror, 'ops_jst_order_item', $records->pluck('o_id')->all())
: [];
foreach ($records as $record) {
$orders++;
$orderedAt = $record->order_date === null ? null : substr((string) $record->order_date, 0, 19);
if ($orderedAt === null || $orderedAt === '') {
continue;
}
$labels = $this->splitLabels((string) ($record->labels ?? ''));
if ($labels === []) {
$labels = [self::NO_LABEL];
$noLabelOrders++;
}
$itemCode = $orderCodes[(int) $record->o_id] ?? null;
foreach ($labels as $label) {
$buffer[] = [
'company_id' => $companyId,
'order_no' => mb_substr((string) $record->so_id, 0, 100),
'shop_name' => mb_substr((string) ($record->shop_name ?? ''), 0, 191),
'ordered_at' => $orderedAt,
'label' => $label,
'item_code' => $itemCode,
'created_from' => $source,
'created_at' => $now,
'updated_at' => $now,
];
}
if (count($buffer) >= self::INSERT_CHUNK * 2) {
$flush();
}
}
}, 'Id');
$flush();
$labels = $target->table('jst_order_labels')
->where('company_id', $companyId)
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->distinct()
->count('label');
// 导入会改变筛选选项,重新预热缓存,避免页面下次打开时做几秒的 distinct 扫描。
app(JstOrderLabelOptions::class)->forget($companyId);
app(JstOrderLabelOptions::class)->warm($companyId);
return [
'from' => $from,
'to' => $to,
'orders' => $orders,
'rows' => $rows,
'no_label_orders' => $noLabelOrders,
'labels' => (int) $labels,
'seconds' => round(microtime(true) - $startedAt, 1),
];
}
/**
* 从聚水潭「导出订单」Excel 导入。
*
* 导出文件是一单多行(商品明细),且同一单里可能只有部分明细行带标签:
* 第一遍先挑出真正有标签的订单号,第二遍再按「线上订单号 + 标签」去重拆行写入,
* 只对整单没标签的订单写「(无标签)」。同时兼容只勾 4 列导出的极简表头。
*
* 传 $from/$to 时会顺带按窗口过滤;带 $replace 时必须给窗口,
* 以便在写入前先清掉该区间,避免重复导入翻倍。
*
* @return array{file:string,company_id:int,from:?string,to:?string,orders:int,rows:int,no_label_orders:int,skipped:int,labels:int,seconds:float}
*/
public function importFromFile(
string $path,
?string $from = null,
?string $to = null,
int $companyId = 1,
bool $replace = false,
string $source = 'jst-order-label-file-import',
): array {
$from = $from === null || trim($from) === '' ? null : $this->normalizeDate(trim($from), '起始日期');
$to = $to === null || trim($to) === '' ? null : $this->normalizeDate(trim($to), '结束日期');
if ($from !== null && $to !== null && $from > $to) {
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
}
if ($replace && ($from === null || $to === null)) {
throw new InvalidArgumentException('按文件导入并使用 --replace 时,必须同时给出 --from 与 --to,才能确定要清空的区间。');
}
$start = $from === null ? null : $from . ' 00:00:00';
$end = $to === null ? null : date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
$target = $this->databases->connection();
$startedAt = microtime(true);
// 清空区间推迟到第一次成功读到数据行之后:万一文件打不开或表头不对,
// 不能先把线上已有行删掉又导入失败。
$clearWindow = function () use ($target, $companyId, $start, $end): void {
$target->table('jst_order_labels')
->where('company_id', $companyId)
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->delete();
app(PlantingOrderLabelLookup::class)->forget($companyId);
};
$cleared = ! $replace;
$now = now();
$orders = 0;
$rows = 0;
$noLabelOrders = 0;
$skipped = 0;
$seen = [];
$orderSeen = [];
$buffer = [];
$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 = [];
};
$reader = $this->exportReader ?? app(XlsxSheetReader::class);
// 第一遍:挑出「有标签」的订单号。聚水潭导出是一单多行,同一单常常只有部分
// 明细行带标签;不按订单判断就会给本来有标签的订单多写一行「(无标签)」。
$labeledOrders = [];
$anyRow = false;
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
$anyRow = true;
if ($this->splitLabels((string) ($line['标签'] ?? '')) === []) {
continue;
}
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
if ($orderNo !== '') {
$labeledOrders[$orderNo] = true;
}
}
if (! $anyRow) {
throw new RuntimeException('订单导出文件里没有数据行:' . basename($path));
}
if (! $cleared) {
$clearWindow();
$cleared = true;
}
// 第二遍:按「线上订单号 + 标签」去重后写入。
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
$orderedAt = $this->normalizeOrderedAt((string) ($line['下单时间'] ?? ''));
if ($orderNo === '' || $orderedAt === null) {
$skipped++;
continue;
}
if ($start !== null && ($orderedAt < $start || $orderedAt >= $end)) {
$skipped++;
continue;
}
$labels = $this->splitLabels((string) ($line['标签'] ?? ''));
$withoutLabel = $labels === [];
if ($withoutLabel) {
if (isset($labeledOrders[$orderNo])) {
continue;
}
$labels = [self::NO_LABEL];
}
if (! isset($orderSeen[$orderNo])) {
$orderSeen[$orderNo] = true;
$orders++;
}
$shopName = mb_substr(trim((string) ($line['店铺名称'] ?? '')), 0, 191);
foreach ($labels as $label) {
$fingerprint = hash('xxh3', $orderNo . "\0" . $label);
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
if ($withoutLabel) {
$noLabelOrders++;
}
$buffer[] = [
'company_id' => $companyId,
'order_no' => mb_substr($orderNo, 0, 100),
'shop_name' => $shopName,
'ordered_at' => $orderedAt,
'label' => $label,
'created_from' => $source,
'created_at' => $now,
'updated_at' => $now,
];
}
if (count($buffer) >= self::INSERT_CHUNK * 2) {
$flush();
}
}
$flush();
$labelQuery = $target->table('jst_order_labels')->where('company_id', $companyId);
if ($start !== null) {
$labelQuery->where('ordered_at', '>=', $start)->where('ordered_at', '<', $end);
}
$labels = $labelQuery->distinct()->count('label');
app(JstOrderLabelOptions::class)->forget($companyId);
app(JstOrderLabelOptions::class)->warm($companyId);
return [
'file' => basename($path),
'company_id' => $companyId,
'from' => $from,
'to' => $to,
'orders' => $orders,
'rows' => $rows,
'no_label_orders' => $noLabelOrders,
'skipped' => $skipped,
'labels' => (int) $labels,
'seconds' => round(microtime(true) - $startedAt, 1),
];
}
/** 下单时间归一成 Y-m-d H:i:s;解析不了就返回 null(该行跳过)。 */
private function normalizeOrderedAt(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
/** @return list<string> */
private function splitLabels(string $labels): array
{
$out = [];
foreach (explode(',', $labels) as $label) {
$label = trim($label);
if ($label !== '') {
$out[] = mb_substr($label, 0, 191);
}
}
return array_values(array_unique($out));
}
private function normalizeDate(string $value, string $label): string
{
$value = trim($value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。');
}
[$year, $month, $day] = array_map('intval', explode('-', $value));
if (! checkdate($month, $day, $year)) {
throw new InvalidArgumentException($label . '不是有效日期。');
}
return $value;
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Business;
use Illuminate\Database\ConnectionInterface;
/**
* 订单「货号」查询:从聚水潭商品明细镜像按内部订单号取商品编码(sku_id)。
*
* 「被替换(Replaced)/取消(Cancelled、SentCancelled)」的明细行不算订单当前货号,
* 所以换过商品的订单(如信誉订单虚拟条码 X00006)只保留换入后的编码。
* 一单多个编码用英文逗号连接。
*/
class JstOrderLabelItemCodes
{
/** 商品明细状态:这些行不计入订单当前货号。 */
public const EXCLUDED_STATUSES = ['Replaced', 'Cancelled', 'SentCancelled'];
/**
* @param list<int|string> $orderIds 内部订单号(o_id)
* @return array<int,string> [内部订单号 => 货号]
*/
public function forOrderIds(ConnectionInterface $connection, string $itemsTable, array $orderIds): array
{
$orderIds = array_values(array_unique(array_filter(
array_map(static fn ($value): int => (int) $value, $orderIds),
static fn (int $value): bool => $value > 0,
)));
if ($orderIds === []) {
return [];
}
$rows = $connection->table($itemsTable)
->whereIn('o_id', $orderIds)
->where(function ($query): void {
$query->whereNull('item_status')
->orWhereNotIn('item_status', self::EXCLUDED_STATUSES);
})
->get(['o_id', 'sku_id']);
$skus = [];
foreach ($rows as $row) {
$sku = trim((string) $row->sku_id);
if ($sku === '') {
continue;
}
$skus[(int) $row->o_id][$sku] = true;
}
$codes = [];
foreach ($skus as $orderId => $list) {
$list = array_keys($list);
sort($list);
$codes[$orderId] = mb_substr(implode(',', $list), 0, 191);
}
return $codes;
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Business;
use App\Models\Business\JstOrderLabel;
use Illuminate\Support\Facades\Cache;
/**
* 聚水潭订单标签明细的筛选下拉选项。
*
* 表里有 200 万行以上,distinct 扫描一次要几秒;这里按公司缓存 24 小时,
* 导入命令结束时会主动预热,避免每次打开页面都慢。
*
* 上限只是防呆(选项过多会拖慢页面),要高于实际店铺数:2026-10 导入 4~7 月后
* 店铺已达 604 个,原来卡在 500 会让一部分店铺选不出来。
*/
class JstOrderLabelOptions
{
private const TTL_HOURS = 24;
private const TOTAL_TTL_MINUTES = 30;
private const LIMIT = 5000;
/** @return list<string> */
public function labels(int $companyId): array
{
return $this->remember($companyId, 'label');
}
/** @return list<string> */
public function shops(int $companyId): array
{
return $this->remember($companyId, 'shop_name');
}
public function warm(int $companyId): void
{
$this->labels($companyId);
$this->shops($companyId);
$this->total($companyId);
}
public function forget(int $companyId): void
{
Cache::forget($this->key($companyId, 'label'));
Cache::forget($this->key($companyId, 'shop_name'));
Cache::forget($this->totalKey($companyId));
}
/** 无筛选时的总行数:233 万行上 COUNT(*) 要 2 秒,缓存后翻页不再重复统计。 */
public function total(int $companyId): int
{
$key = $this->totalKey($companyId);
$cached = Cache::get($key);
if (is_numeric($cached)) {
return (int) $cached;
}
$count = (int) JstOrderLabel::query()->where('company_id', $companyId)->count();
Cache::put($key, $count, now()->addMinutes(self::TOTAL_TTL_MINUTES));
return $count;
}
/** @return list<string> */
private function remember(int $companyId, string $column): array
{
$key = $this->key($companyId, $column);
$cached = Cache::get($key);
if (is_array($cached)) {
return $cached;
}
$values = JstOrderLabel::query()
->where('company_id', $companyId)
->whereNotNull($column)
->where($column, '<>', '')
->distinct()
->orderBy($column)
->limit(self::LIMIT)
->pluck($column)
->all();
Cache::put($key, $values, now()->addHours(self::TTL_HOURS));
return $values;
}
private function key(int $companyId, string $column): string
{
return "jst-order-labels:options:v1:{$companyId}:{$column}";
}
private function totalKey(int $companyId): string
{
return "jst-order-labels:total:v1:{$companyId}";
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Business;
use App\Models\Business\JstOrderLabelPlantingRule;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* 聚水潭订单标签明细的「是否种菜订单」判断:
* - 按(公司 + 标签)匹配财务维护的映射表;
* - 订单货号含信誉订单虚拟条码(X00006)的直接判为「是」。
*
* 映射表由财务维护(判断种菜单.xlsx)并按公司缓存,页面与导出共用同一份结果;
* 映射表缺失或未导入时不报错,统一按「否」展示。
*/
class JstOrderLabelPlantingRules
{
private const TTL_HOURS = 24;
/** 该货号(信誉订单虚拟条码)的订单直接判为种菜订单。 */
public const PLANTING_ITEM_CODE = 'X00006';
/** @return array<string,string> [标签 => 是/否] */
public function map(int $companyId): array
{
return Cache::remember($this->key($companyId), now()->addHours(self::TTL_HOURS), function () use ($companyId): array {
try {
if (! Schema::hasTable('jst_order_label_planting_rules')) {
return [];
}
// 直接用表查询:避开基类的软删除/公司作用域与模型缓存,映射表由命令写入后立即失效重读
$map = [];
foreach (DB::table('jst_order_label_planting_rules')
->where('company_id', $companyId)
->get(['label', 'is_planting']) as $row) {
$label = trim((string) $row->label);
if ($label === '') {
continue;
}
$map[$label] = trim((string) $row->is_planting) === '否' ? '否' : '是';
}
return $map;
} catch (Throwable) {
return [];
}
});
}
/**
* 单条(标签 + 货号)的判断结果:标签命中映射表取其值;
* 货号含 X00006 的一律为「是」,其余未命中为「否」。
*/
public function flag(int $companyId, ?string $label, ?string $itemCode = null): string
{
$key = trim((string) $label);
$flag = $key === '' ? '否' : ($this->map($companyId)[$key] ?? '否');
if ($flag === '否' && $this->hasPlantingItemCode($itemCode)) {
return '是';
}
return $flag;
}
/** 货号(可能多个,逗号连接)里是否含信誉订单虚拟条码。 */
private function hasPlantingItemCode(?string $itemCode): bool
{
foreach (explode(',', (string) $itemCode) as $code) {
if (mb_strtoupper(trim($code)) === self::PLANTING_ITEM_CODE) {
return true;
}
}
return false;
}
public function forget(int $companyId): void
{
Cache::forget($this->key($companyId));
app(PlantingOrderLabelLookup::class)->forget($companyId);
}
private function key(int $companyId): string
{
return "jst-order-labels:planting-rules:v1:{$companyId}";
}
}

View File

@ -0,0 +1,299 @@
<?php
namespace App\Business;
use App\Models\Business\PlantingDetail;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use Throwable;
/**
* 种菜明细表导入(xlsx / xls / csv)。
*
* 源表头与 planting_details 列一一对应;按表头名匹配,列顺序变化不影响。
* 日期兼容 Excel 序列号与常见字符串;金额按 4 位小数保存。
*/
class PlantingDetailImporter
{
/** 源表头 → 数据库列 */
private const COLUMNS = [
'日期' => 'bill_date',
'订单编号' => 'order_no',
'财务售价' => 'finance_selling_price',
'财务运费' => 'finance_freight',
'财务佣金' => 'finance_commission',
'京喜差价' => 'jingxi_price_diff',
'扣款' => 'deduction',
'财务空包费' => 'finance_empty_package_fee',
'财务打款金额' => 'finance_payment_amount',
'笔数' => 'pieces',
'备注' => 'remark',
'正确店铺' => 'store',
'负责人' => 'owner',
'账户' => 'account',
'类型' => 'type',
'创建人' => 'creator',
'标签' => 'tag',
'所属利润中心' => 'profit_center',
'负责平台' => 'responsible_platform',
'ID' => 'external_id',
];
private const MONEY_COLUMNS = [
'finance_selling_price', 'finance_freight', 'finance_commission',
'jingxi_price_diff', 'deduction', 'finance_empty_package_fee', 'finance_payment_amount',
];
private const CHUNK = 1000;
/**
* @return array{rows:int,inserted:int,skipped:int,selling_price:string,payment_amount:string,date_min:?string,date_max:?string}
*/
public function import(string $path, int $companyId = 1, bool $replace = false, ?int $userId = null, string $source = 'planting-detail-import'): array
{
if (! is_file($path)) {
throw new InvalidArgumentException('找不到要导入的文件:' . $path);
}
$grid = $this->read($path);
if ($grid === []) {
throw new InvalidArgumentException('文件里没有可读的数据行。');
}
$headerIndex = $this->locateHeader($grid);
if ($headerIndex === null) {
throw new InvalidArgumentException('没找到表头:至少要有「日期」「订单编号」「财务打款金额」。');
}
$map = $this->mapHeader($grid[$headerIndex]);
if (! isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) {
throw new InvalidArgumentException('表头缺少「日期」或「订单编号」或「财务打款金额」。');
}
$now = now();
$rows = [];
$skipped = 0;
$sellingPrice = 0.0;
$paymentAmount = 0.0;
$dateMin = null;
$dateMax = null;
$inserted = 0;
$connection = DB::connection();
if ($replace) {
PlantingDetail::query()->where('company_id', $companyId)->forceDelete();
}
$flush = function () use (&$rows, &$inserted, $connection): void {
if ($rows !== []) {
foreach (array_chunk($rows, self::CHUNK) as $chunk) {
$connection->table('planting_details')->insert($chunk);
$inserted += count($chunk);
}
$rows = [];
}
};
foreach (array_slice($grid, $headerIndex + 1) as $gridRow) {
if (! $this->hasContent($gridRow)) {
continue;
}
$date = $this->toDate($this->cell($gridRow, $map, 'bill_date'));
$orderNo = $this->toText($this->cell($gridRow, $map, 'order_no'));
if ($date === null || $orderNo === null) {
$skipped++;
continue;
}
$record = [
'company_id' => $companyId,
'bill_date' => $date,
'order_no' => mb_substr($orderNo, 0, 100),
'pieces' => (int) ($this->toNumber($this->cell($gridRow, $map, 'pieces')) ?? 0),
'remark' => $this->toText($this->cell($gridRow, $map, 'remark')),
'store' => $this->toText($this->cell($gridRow, $map, 'store')),
'owner' => $this->toText($this->cell($gridRow, $map, 'owner')),
'account' => $this->toText($this->cell($gridRow, $map, 'account')),
'type' => $this->toText($this->cell($gridRow, $map, 'type')),
'creator' => $this->toText($this->cell($gridRow, $map, 'creator')),
'tag' => $this->toText($this->cell($gridRow, $map, 'tag')),
'profit_center' => $this->toText($this->cell($gridRow, $map, 'profit_center')),
'responsible_platform' => $this->toText($this->cell($gridRow, $map, 'responsible_platform')),
// 源表存在一个单元格放多个 ID(换行分隔)的情况,这里保留到 500 字符。
'external_id' => $this->toText($this->cell($gridRow, $map, 'external_id'), 500),
'created_from' => $source,
'created_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
foreach (self::MONEY_COLUMNS as $column) {
$value = $this->toNumber($this->cell($gridRow, $map, $column)) ?? 0.0;
$record[$column] = number_format($value, 4, '.', '');
}
$sellingPrice += (float) $record['finance_selling_price'];
$paymentAmount += (float) $record['finance_payment_amount'];
$dateMin = $dateMin === null || $date < $dateMin ? $date : $dateMin;
$dateMax = $dateMax === null || $date > $dateMax ? $date : $dateMax;
$rows[] = $record;
if (count($rows) >= self::CHUNK * 4) {
$flush();
}
}
$flush();
return [
'rows' => $inserted + $skipped,
'inserted' => $inserted,
'skipped' => $skipped,
'selling_price' => number_format($sellingPrice, 4, '.', ''),
'payment_amount' => number_format($paymentAmount, 4, '.', ''),
'date_min' => $dateMin,
'date_max' => $dateMax,
];
}
/** @return array<int,array<int,mixed>> */
private function read(string $path): array
{
$extension = mb_strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($extension === 'csv') {
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException('无法读取文件。');
}
$grid = [];
$first = true;
while (($cells = fgetcsv($handle)) !== false) {
if ($first && isset($cells[0])) {
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $cells[0]);
$first = false;
}
$grid[] = $cells;
}
fclose($handle);
return $grid;
}
if (! in_array($extension, ['xlsx', 'xls'], true)) {
throw new InvalidArgumentException('只支持 xlsx、xls、csv。');
}
try {
$reader = IOFactory::createReader($extension === 'xlsx' ? 'Xlsx' : 'Xls');
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($path);
} catch (Throwable $exception) {
throw new InvalidArgumentException('读不出这个表格:' . $exception->getMessage());
}
try {
return $spreadsheet->getSheet(0)->toArray(null, true, false, false);
} finally {
$spreadsheet->disconnectWorksheets();
}
}
/** @param array<int,array<int,mixed>> $grid */
private function locateHeader(array $grid): ?int
{
foreach ($grid as $index => $row) {
$map = $this->mapHeader($row);
if (isset($map['bill_date'], $map['order_no'], $map['finance_payment_amount'])) {
return $index;
}
}
return null;
}
/** @param array<int,mixed> $row @return array<string,int> */
private function mapHeader(array $row): array
{
$map = [];
foreach ($row as $index => $value) {
$label = trim((string) (is_scalar($value) ? $value : ''));
if ($label === '') {
continue;
}
$column = self::COLUMNS[$label] ?? null;
if ($column !== null && ! isset($map[$column])) {
$map[$column] = (int) $index;
}
}
return $map;
}
/** @param array<int,mixed> $row @param array<string,int> $map */
private function cell(array $row, array $map, string $column): mixed
{
return isset($map[$column]) ? ($row[$map[$column]] ?? null) : null;
}
private function hasContent(array $row): bool
{
foreach ($row as $value) {
if ($value !== null && trim((string) (is_scalar($value) ? $value : '')) !== '') {
return true;
}
}
return false;
}
private function toDate(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 60000) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d');
} catch (Throwable) {
return null;
}
}
$text = trim((string) $value);
$timestamp = strtotime($text);
return $timestamp === false ? null : date('Y-m-d', $timestamp);
}
private function toText(mixed $value, int $limit = 191): ?string
{
if ($value === null) {
return null;
}
$text = trim((string) (is_scalar($value) ? $value : ''));
return $text === '' ? null : mb_substr($text, 0, $limit);
}
private function toNumber(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value)) {
return (float) $value;
}
$text = str_replace([',', ' ', '¥', '¥'], '', (string) $value);
return is_numeric($text) ? (float) $text : null;
}
}

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,396 @@
<?php
namespace App\Business;
use App\Models\Business\PlantingDetail;
use Illuminate\Database\DatabaseManager;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
/**
* 聚水潭「种菜报销」协同工单 → 种菜明细表(账单流水数据 › 种菜数据汇总 › 种菜明细表)月度同步。
*
* 口径(用 1~8 月已入库的 7 万多行反推、命中率 100%):
* - 只取「工单类型 = 种菜报销、工单状态 = 已完成」的工单;
* - 明细表「日期」= 工单完成日 − 1 天。所以某个月的数据 = 完成时间落在该月次日到下月次日的工单;
* - 一张工单一行,金额列与工单表单字段一一对应(见 FIELD_* 常量)。
*
* 排重:写入前先删掉「本月 + 本次订单号」的已有行,所以同一个月重复执行不会翻倍;
* 想整月覆盖(连没有对应工单的人工行一起清掉)用 --force-clear。
*
* 「所属利润中心」没有工单来源,按这个顺序取:
* 1. 表里已有的 店铺→利润中心 历史值(人工维护过的不覆盖);
* 2. 店铺维度报表的 ERP 系统店铺资料(ops_fys_shop_info.erpShopName → responsibleDepartment);
* 3. 两边都没有就留空,并在命令输出里列出来。
*/
class PlantingWorkOrderSynchronizer
{
/** 店铺维度报表用的店铺资料数据集(ERP系统店铺名称 → 店铺所属利润中心)。 */
public const SHOP_INFO_DATASET = 'operations_replica.rpt.fys_shopInfo';
/** 聚水潭协同工单镜像(jiansun.dbo.jst_workOrderInfo → js_jst_work_order_info)。 */
public const WORK_ORDER_DATASET = 'jiansun.dbo.jst_workOrderInfo';
/** 「种菜报销」这个工单类型的固定 id。 */
public const WORK_TYPE_ID = '6F03128403F2442CABCF283B59FAD88D';
/** 已完成的工单状态。 */
private const STATUS_CONFIRMED = 'CONFIRMED';
public const SOURCE = 'work-order-sync';
/** 工单表单字段(聚水潭只给 fieldId,不给字段名,这里的对应关系是拿历史数据逐列对撞出来的)。 */
private const FIELD_SELLING_PRICE = 'NUMBERField1413856620250924131615'; // 财务售价 = 任务付款金额
private const FIELD_FREIGHT = 'NUMBERField1413856620251113150537'; // 财务运费 = 任务快递费
private const FIELD_COMMISSION = 'NUMBERField1413856620251230154457'; // 财务佣金 = 任务佣金
private const FIELD_JINGXI_DIFF = 'NUMBERField1413856620250924131623'; // 京喜差价
private const FIELD_DEDUCTION = 'NUMBERField1413856620251113150612'; // 扣款 = 任务扣款费用
private const FIELD_EMPTY_PACKAGE = 'NUMBERField1413856620251113145620'; // 财务空包费 = 任务空包费
private const FIELD_PAYMENT = 'NUMBERField1413856620251117101250'; // 财务打款金额 = 最终报销金额
private const FIELD_OWNER = 'TEXTField1413856620251117180931'; // 负责人(收款人姓名)
private const FIELD_ACCOUNT = 'TEXTField1413856620251117113451'; // 账户(打款账号)
private const FIELD_TYPE = 'TEXTField1413856620251113150546'; // 类型(任务备注)
private const FIELD_TAG = 'businessRemarkField'; // 标签(业务备注)
private const FIELD_ORDER_NO = 'orderField'; // 订单编号(线上订单号)
private const FIELD_STORE = 'shopField'; // 正确店铺(店铺名称)
/** 金额列,统一按 4 位小数落库。 */
private const MONEY_COLUMNS = [
'finance_selling_price',
'finance_freight',
'finance_commission',
'jingxi_price_diff',
'deduction',
'finance_empty_package_fee',
'finance_payment_amount',
];
private const INSERT_CHUNK = 1000;
private const DELETE_CHUNK = 500;
public function __construct(
private readonly DatabaseManager $databases,
private readonly BusinessMirrorDatasetRegistry $datasets,
) {}
/**
* @return array{
* from:string,to:string,orders:int,inserted:int,deleted:int,
* selling_price:string,payment_amount:string,date_min:?string,date_max:?string,
* profit_center_from_erp:int,unmatched_stores:array<string,int>,dry_run:bool
* }
*/
public function sync(
string $from,
string $to,
int $companyId = 1,
bool $dryRun = false,
bool $forceClear = false,
): array {
$from = $this->normalizeDate($from, '起始日期');
$to = $this->normalizeDate($to, '结束日期');
if ($from > $to) {
throw new InvalidArgumentException('起始日期不能晚于结束日期。');
}
// 「日期」= 工单完成日 − 1,所以完成时间窗口整体后移一天。
$completeFrom = date('Y-m-d', strtotime($from . ' +1 day')) . ' 00:00:00';
$completeTo = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00';
$target = $this->databases->connection();
$mirror = $this->databases->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$table = $this->datasets->get(self::WORK_ORDER_DATASET)->targetTable();
if (! $mirror->getSchemaBuilder()->hasTable($table)) {
throw new RuntimeException('聚水潭协同工单镜像尚未建立(' . $table . ')。');
}
// 历史值优先(人工维护过的不覆盖),没有的再用 ERP 店铺资料补齐。
$storeToProfitCenter = $this->storeToProfitCenter($companyId);
$erpProfitCenters = $this->erpStoreToProfitCenter($mirror);
$now = now();
$rows = [];
$orders = 0;
$fromErp = 0;
$unmatchedStores = [];
$sellingPrice = 0.0;
$paymentAmount = 0.0;
$dateMin = null;
$dateMax = null;
$records = $mirror->table($table)
->where('woTypeId', self::WORK_TYPE_ID)
->where('status', self::STATUS_CONFIRMED)
->where('completeTime', '>=', $completeFrom)
->where('completeTime', '<', $completeTo)
->orderBy('completeTime')
->orderBy('woNo')
->get(['woNo', 'createby', 'completeTime', 'businessFieldList']);
foreach ($records as $record) {
$fields = $this->fields((string) $record->businessFieldList);
$orderNo = $this->text($fields[self::FIELD_ORDER_NO] ?? null);
if ($orderNo === null) {
continue;
}
$billDate = date('Y-m-d', strtotime((string) $record->completeTime . ' -1 day'));
if ($billDate < $from || $billDate > $to) {
continue;
}
$store = $this->text($fields[self::FIELD_STORE . '#label'] ?? null);
$profitCenter = null;
if ($store !== null) {
$profitCenter = $storeToProfitCenter[$store] ?? null;
if ($profitCenter === null && isset($erpProfitCenters[$store])) {
$profitCenter = $erpProfitCenters[$store];
$fromErp++;
}
}
if ($store !== null && $profitCenter === null) {
$unmatchedStores[$store] = ($unmatchedStores[$store] ?? 0) + 1;
}
$row = [
'company_id' => $companyId,
'bill_date' => $billDate,
'order_no' => mb_substr($orderNo, 0, 100),
'pieces' => 1,
'remark' => null,
'store' => $store,
'owner' => $this->text($fields[self::FIELD_OWNER] ?? null),
'account' => $this->text($fields[self::FIELD_ACCOUNT] ?? null),
'type' => $this->text($fields[self::FIELD_TYPE] ?? null),
'creator' => $this->text($record->createby),
'tag' => $this->text($fields[self::FIELD_TAG] ?? null),
'profit_center' => $profitCenter,
'responsible_platform' => null,
'external_id' => $this->text($this->platformItemId($fields), 100),
'created_from' => self::SOURCE,
'created_by' => null,
'created_at' => $now,
'updated_at' => $now,
];
foreach (self::MONEY_COLUMNS as $column) {
$row[$column] = number_format(0.0, 4, '.', '');
}
foreach ([
'finance_selling_price' => self::FIELD_SELLING_PRICE,
'finance_freight' => self::FIELD_FREIGHT,
'finance_commission' => self::FIELD_COMMISSION,
'jingxi_price_diff' => self::FIELD_JINGXI_DIFF,
'deduction' => self::FIELD_DEDUCTION,
'finance_empty_package_fee' => self::FIELD_EMPTY_PACKAGE,
'finance_payment_amount' => self::FIELD_PAYMENT,
] as $column => $field) {
$row[$column] = number_format($this->number($fields[$field] ?? null), 4, '.', '');
}
$orders++;
$sellingPrice += (float) $row['finance_selling_price'];
$paymentAmount += (float) $row['finance_payment_amount'];
$dateMin = $dateMin === null || $billDate < $dateMin ? $billDate : $dateMin;
$dateMax = $dateMax === null || $billDate > $dateMax ? $billDate : $dateMax;
$rows[] = $row;
}
$orderNumbers = array_values(array_unique(array_column($rows, 'order_no')));
$deleted = 0;
if (! $dryRun) {
$deleted = $this->clear($target, $companyId, $from, $to, $orderNumbers, $forceClear);
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
$target->table('planting_details')->insert($chunk);
}
}
arsort($unmatchedStores);
return [
'from' => $from,
'to' => $to,
'orders' => $orders,
'inserted' => $dryRun ? 0 : count($rows),
'deleted' => $deleted,
'selling_price' => number_format($sellingPrice, 4, '.', ''),
'payment_amount' => number_format($paymentAmount, 4, '.', ''),
'date_min' => $dateMin,
'date_max' => $dateMax,
'profit_center_from_erp' => $fromErp,
'unmatched_stores' => $unmatchedStores,
'dry_run' => $dryRun,
];
}
/**
* 排重:删掉本月内、这次要写入的那批订单号对应的已有行。
*
* @param list<string> $orderNumbers
*/
private function clear(
\Illuminate\Database\ConnectionInterface $target,
int $companyId,
string $from,
string $to,
array $orderNumbers,
bool $forceClear,
): int {
$base = static fn () => $target->table('planting_details')
->where('company_id', $companyId)
->where('bill_date', '>=', $from)
->where('bill_date', '<=', $to);
if ($forceClear) {
return (int) $base()->delete();
}
$deleted = 0;
foreach (array_chunk($orderNumbers, self::DELETE_CHUNK) as $chunk) {
$deleted += (int) $base()->whereIn('order_no', $chunk)->delete();
}
return $deleted;
}
/** @return array<string,string> 店铺 → 利润中心(同店铺多个取值时取出现次数最多的) */
private function storeToProfitCenter(int $companyId): array
{
$counts = [];
$rows = PlantingDetail::query()
->where('company_id', $companyId)
->whereNotNull('store')
->whereNotNull('profit_center')
->selectRaw('store, profit_center, COUNT(*) AS total')
->groupBy('store', 'profit_center')
->get();
foreach ($rows as $row) {
$store = (string) $row->store;
$counts[$store][(string) $row->profit_center] = (int) $row->total;
}
$map = [];
foreach ($counts as $store => $values) {
arsort($values);
$map[$store] = (string) array_key_first($values);
}
return $map;
}
/**
* 店铺维度报表的 ERP 系统店铺 → 店铺所属利润中心(ops_fys_shop_info.responsibleDepartment)。
*
* @return array<string,string>
*/
private function erpStoreToProfitCenter(\Illuminate\Database\ConnectionInterface $mirror): array
{
$table = $this->datasets->get(self::SHOP_INFO_DATASET)->targetTable();
if (! $mirror->getSchemaBuilder()->hasTable($table)) {
return [];
}
$map = [];
foreach ($mirror->table($table)
->whereNotNull('erpShopName')
->where('responsibleDepartment', '<>', '')
->get(['erpShopName', 'responsibleDepartment']) as $row) {
$name = trim((string) $row->erpShopName);
$department = trim((string) $row->responsibleDepartment);
if ($name !== '' && $department !== '' && ! isset($map[$name])) {
$map[$name] = mb_substr($department, 0, 191);
}
}
return $map;
}
/** @return array<string,mixed> fieldId → value(带 valueLabel 的另存为 fieldId#label) */
private function fields(string $businessFieldList): array
{
$fields = [];
$items = json_decode($businessFieldList, true);
if (! is_array($items)) {
return $fields;
}
foreach ($items as $item) {
if (! is_array($item) || ! isset($item['fieldId'])) {
continue;
}
if ($item['fieldId'] === 'itemInfo') {
// 商品明细是嵌套结构,原样留着给 platformItemId 取平台商品 ID。
$fields['itemInfo'] = $item;
continue;
}
$value = $item['value'] ?? null;
if (is_array($value)) {
$value = implode(',', array_map('strval', $value));
}
$fields[(string) $item['fieldId']] = $value;
if (($item['valueLabel'] ?? null) !== null) {
$fields[(string) $item['fieldId'] . '#label'] = $item['valueLabel'];
}
}
return $fields;
}
/** @param array<string,mixed> $fields */
private function platformItemId(array $fields): ?string
{
$itemInfo = $fields['itemInfo'] ?? null;
if (! is_array($itemInfo)) {
return null;
}
foreach ((array) ($itemInfo['childFieldList'] ?? []) as $childRow) {
foreach ((array) $childRow as $child) {
if (is_array($child) && ($child['fieldId'] ?? null) === 'platformItemId') {
return $this->text($child['value'] ?? null, 100);
}
}
}
return null;
}
private function text(mixed $value, int $limit = 191): ?string
{
if ($value === null) {
return null;
}
$text = trim((string) (is_scalar($value) ? $value : ''));
return $text === '' ? null : mb_substr($text, 0, $limit);
}
private function number(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
return is_numeric($value) ? (float) $value : 0.0;
}
private function normalizeDate(string $value, string $label): string
{
$value = trim($value);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
throw new InvalidArgumentException($label . '必须是 YYYY-MM-DD。');
}
[$year, $month, $day] = array_map('intval', explode('-', $value));
if (! checkdate($month, $day, $year)) {
throw new InvalidArgumentException($label . '不是有效日期。');
}
return $value;
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Business;
/**
* 从工商注册地址(rpt.fys_companyInfo.registeredAddress)解析地级市。
*
* 地址标准格式为「省/自治区/直辖市 + 地级行政区 + 区县 + 街道」,按顺序匹配:
* 1. 直辖市(北京/上海/天津/重庆)本身就是市级,直接返回「XX市」;
* 2. 剥掉省/自治区前缀后取第一个地级行政区(市/自治州/地区/盟),无省级前缀时直接匹配;
* 3. 本地地址常省略省/市前缀(如「常州经济开发区…」「武进区…」),
* 按本地区划前缀映射兜底;
* 4. 地址缺行政区划或写法不规则时返回 null,由页面按统一缺失值展示。
*/
final class RegisteredAddressCity
{
private const MUNICIPALITIES = ['北京', '上海', '天津', '重庆'];
/** 地级行政区名最长 8 字(克孜勒苏柯尔克孜自治州),过长的匹配多为「自贸试验区」类园区名,宁缺勿错。 */
private const PREFECTURE_PATTERN = '/^(.{2,8}?(?:市|自治州|地区|盟))/u';
private const PROVINCE_PATTERN = '/^(.{2,12}?(?:省|自治区))/u';
/** 公司库本地(常州)地址省略省市前缀时的区划前缀映射;按需扩展。 */
private const DISTRICT_PREFIX_CITY_MAP = [
'常州' => '常州市',
'天宁' => '常州市',
'钟楼' => '常州市',
'新北' => '常州市',
'武进' => '常州市',
'金坛' => '常州市',
'戚墅堰' => '常州市',
'溧阳' => '常州市',
];
public static function parse(mixed $address): ?string
{
$address = trim((string) ($address ?? ''));
if ($address === '') {
return null;
}
// 部分地址带「中国」前缀,剥掉后再匹配行政区划。
$address = (string) preg_replace('/^(?:中国|中华人民共和国)/u', '', $address);
foreach (self::MUNICIPALITIES as $municipality) {
if (mb_strpos($address, $municipality) === 0) {
return $municipality . '市';
}
}
// 剥掉省/自治区前缀后取第一个地级行政区;地址无省级前缀时直接匹配。
$rest = (string) preg_replace(self::PROVINCE_PATTERN, '', $address);
if (preg_match(self::PREFECTURE_PATTERN, $rest, $matches) === 1) {
return $matches[1];
}
// 本地地址省略省/市前缀时按区划前缀识别。
foreach (self::DISTRICT_PREFIX_CITY_MAP as $prefix => $city) {
if (mb_strpos($rest, $prefix) === 0) {
return $city;
}
}
return null;
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
class StoreReceivingAccountSynchronizer
{
public function __construct(
private readonly BusinessInformationMirrorRepository $mirror,
) {
}
/**
* Seed account rows from the read-only store-information mirror while
* preserving any credentials already entered by the user.
*
* @return array{created:int,updated:int,skipped:int,by_platform:array<string,int>}
*/
public function sync(?int $companyId = null): array
{
$companyId ??= (int) (company_id() ?: 1);
$created = 0;
$updated = 0;
$skipped = 0;
$byPlatform = [];
// Load the local directory once; avoid one SQL lookup per upstream
// shop every time a tab is opened.
$existing = StoreReceivingAccount::withoutGlobalScopes()
->withTrashed()
->where('company_id', $companyId)
->get();
$byWallet = [];
$byPlatformAndName = [];
$byName = [];
foreach ($existing as $account) {
$platform = (string) $account->store_platform;
$wallet = trim((string) $account->wallet_number);
$name = trim((string) $account->store_name);
if ($wallet !== '') {
$byWallet[$platform . "\0" . $wallet] ??= $account;
}
if ($name !== '') {
$byPlatformAndName[$platform . "\0" . $name] ??= $account;
$byName[$name] ??= $account;
}
}
foreach ($this->mirror->receivingAccountStores() as $row) {
$erpStoreName = trim((string) ($row['erp_store_name'] ?? ''));
$platform = $this->classify($erpStoreName, (string) ($row['source_platform'] ?? ''));
if ($erpStoreName === '' || $platform === null) {
$skipped++;
continue;
}
$storeId = trim((string) ($row['store_id'] ?? $row['id'] ?? ''));
$account = $storeId !== ''
? ($byWallet[$platform . "\0" . $storeId] ?? null)
: null;
// Older rows created before wallet_number was populated may have
// been classified by the upstream platform. Reuse them by ERP
// name so a corrected prefix classification does not duplicate a
// store row.
$account ??= $byPlatformAndName[$platform . "\0" . $erpStoreName]
?? $byName[$erpStoreName]
?? null;
$attributes = [
'company_id' => $companyId,
'store_platform' => $platform,
'store_name' => $erpStoreName,
'store_entity_name' => trim((string) ($row['company_name'] ?? '')),
'shop_name' => trim((string) ($row['shop_name'] ?? '')) ?: $erpStoreName,
'store_status' => $this->status($row['store_status'] ?? null),
'wallet_number' => $storeId,
'store_id' => $storeId,
'source_platform' => trim((string) ($row['source_platform'] ?? '')),
];
if ($account === null) {
$account = new StoreReceivingAccount();
$account->fill($attributes);
$account->save();
$created++;
if ($storeId !== '') {
$byWallet[$platform . "\0" . $storeId] = $account;
}
$byPlatformAndName[$platform . "\0" . $erpStoreName] = $account;
$byName[$erpStoreName] ??= $account;
} else {
if ($account->trashed()) {
$account->restore();
}
$account->fill($attributes);
if ($account->isDirty()) {
$account->save();
$updated++;
}
if ($storeId !== '') {
$byWallet[$platform . "\0" . $storeId] = $account;
}
$byPlatformAndName[$platform . "\0" . $erpStoreName] = $account;
$byName[$erpStoreName] ??= $account;
}
$byPlatform[$platform] = ($byPlatform[$platform] ?? 0) + 1;
}
return compact('created', 'updated', 'skipped', 'byPlatform');
}
public function classify(string $erpStoreName, string $sourcePlatform = ''): ?string
{
$prefix = preg_split('/[-—_-|丨\/\\()()【】\[\]·::]+/u', trim($erpStoreName), 2)[0] ?? '';
$prefix = trim((string) $prefix);
$sourcePlatform = trim($sourcePlatform);
$platforms = ['淘宝分销', '阿里巴巴', '天猫', '唯品会', '淘宝', '京喜', '京东', '抖音', '拼多多'];
foreach ($platforms as $platform) {
if (str_starts_with($prefix, $platform)) {
return $platform;
}
}
foreach ($platforms as $platform) {
if (str_starts_with($sourcePlatform, $platform)) {
return $platform;
}
}
return null;
}
private function status(mixed $value): ?string
{
return match ((string) $value) {
'0', '正常' => '正常',
'3', '关闭中', '关店中' => '关店中',
'6', '已关闭', '已关店' => '已关店',
default => trim((string) $value) ?: null,
};
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Business;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StoreReceivingAccountTemplate
{
public function download(string $tab): StreamedResponse
{
$headers = StoreReceivingAccountWorkbookImporter::headers()[$tab] ?? null;
abort_if($headers === null, 404);
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle(mb_substr(StoreReceivingAccountWorkbookImporter::sheetTitle($tab), 0, 31));
$lastColumn = Coordinate::stringFromColumnIndex(count($headers));
$sheet->freezePane('A2');
$sheet->setAutoFilter("A1:{$lastColumn}1");
foreach ($headers as $index => $header) {
$column = Coordinate::stringFromColumnIndex($index + 1);
$sheet->setCellValue("{$column}1", $header);
$sheet->getColumnDimension($column)->setWidth(max(16, min(28, mb_strlen($header) + 8)));
}
$sheet->getRowDimension(1)->setRowHeight(26);
$sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle("A1:{$lastColumn}1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF856632');
return response()->streamDownload(function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, StoreReceivingAccountWorkbookImporter::sheetTitle($tab) . '-店铺收款账户模板.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'no-store, no-cache, must-revalidate',
]);
}
}

View File

@ -0,0 +1,214 @@
<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
class StoreReceivingAccountWorkbookImporter
{
/** @var array<string,list<string>> */
private const HEADERS = [
'taobao' => [
'类型', 'ERP店铺名称', '店铺名称', '店铺状态', '店铺主体', '原店铺主体', '状态',
'登录账号', '登录密码', '支付密码', '提现路径', '提现账户名称', '支付宝账号',
],
'jd' => [
'类型', 'ERP店铺名称', '店铺名称', '店铺id', '店铺状态', '店铺主体', '原店铺主体',
'登录账号', '登录密码', '支付验证方式', '金融账号', '金融密码', '支付验证方式',
'状态', '提现路径', '提现账户名称', '银行卡账号(后四位)',
],
'douyin' => [
'ERP店铺名称', '店铺名称', '店铺状态', '店铺主体', '原店铺主体', '状态',
'登录账号', '登录密码', '支付密码', '提现路径', '提现账户名称', '银行卡账号(后四位)',
],
'pinduoduo' => [
'ERP店铺名称', '店铺名称', '店铺状态', '店铺主体', '原店铺主体', '状态',
'登录账号', '登录密码', '支付密码', '提现路径', '提现账户名称', '银行卡账号(后四位)',
],
];
public function import(UploadedFile $file, string $tab, ?int $companyId = null): array
{
if (! isset(self::HEADERS[$tab])) {
throw ValidationException::withMessages(['import' => '未知的店铺收款账户页签。']);
}
$spreadsheet = IOFactory::load($file->getRealPath());
$worksheet = $this->worksheet($spreadsheet, $tab);
if ($worksheet === null) {
throw ValidationException::withMessages([
'import' => '未找到“' . $this->sheetTitle($tab) . '”工作表,请使用当前页签的模板。',
]);
}
$expected = self::HEADERS[$tab];
$actual = array_map(static fn ($value): string => trim((string) $value), $worksheet->toArray(null, true, false, false)[0] ?? []);
if ($actual !== $expected) {
throw ValidationException::withMessages([
'import' => '“' . $this->sheetTitle($tab) . '”表头不匹配,请先下载当前页签模板。',
]);
}
$rows = $worksheet->toArray(null, true, false, false);
$records = [];
$skipped = 0;
foreach (array_slice($rows, 1) as $index => $row) {
$row = array_pad($row, count($expected), null);
$erpName = trim((string) ($row[array_search('ERP店铺名称', $expected, true)] ?? ''));
if ($erpName === '') {
$skipped++;
continue;
}
$records[] = $this->mapRow($tab, $expected, $row);
}
$created = 0;
$updated = 0;
$companyId ??= (int) (company_id() ?: 1);
DB::transaction(function () use ($records, $companyId, &$created, &$updated): void {
foreach ($records as $data) {
$platform = (string) $data['store_platform'];
$query = StoreReceivingAccount::withoutGlobalScopes()
->withTrashed()
->where('company_id', $companyId)
->where('store_platform', $platform);
if (($data['wallet_number'] ?? '') !== '') {
$query->where('wallet_number', $data['wallet_number']);
} else {
$query->where('store_name', $data['store_name']);
}
$record = $query->first();
if ($record === null) {
$record = new StoreReceivingAccount();
$record->fill(['company_id' => $companyId] + $data);
$record->save();
$created++;
continue;
}
if ($record->trashed()) {
$record->restore();
}
$record->fill($data);
$record->save();
$updated++;
}
});
return [
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'rows' => count($records),
];
}
/** @return array<string,list<string>> */
public static function headers(): array
{
return self::HEADERS;
}
public static function sheetTitle(string $tab): string
{
return match ($tab) {
'taobao' => '淘宝',
'jd' => '京东',
'douyin' => '抖音小店',
'pinduoduo' => '拼多多',
default => $tab,
};
}
private function worksheet($spreadsheet, string $tab): ?object
{
$title = $this->sheetTitle($tab);
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
if (trim((string) $worksheet->getTitle()) === $title) {
return $worksheet;
}
}
return null;
}
/** @param list<string> $headers */
private function mapRow(string $tab, array $headers, array $row): array
{
$value = static function (string $header) use ($headers, $row): string {
$index = array_search($header, $headers, true);
return $index === false ? '' : trim((string) ($row[$index] ?? ''));
};
$platform = $tab === 'taobao' || $tab === 'jd' ? $value('类型') : ($tab === 'douyin' ? '抖音' : '拼多多');
$allowedPlatforms = TableRegistry::tabValues('fund-management', 'store-receiving-accounts', $tab);
if (! in_array($platform, $allowedPlatforms, true)) {
throw ValidationException::withMessages([
'import' => '第 ' . (count($row) > 0 ? '数据' : '') . '行的平台类型不属于当前页签,请检查“类型”列。',
]);
}
$storeId = $value('店铺id');
$data = [
'store_platform' => $platform,
'store_name' => $value('ERP店铺名称'),
'wallet_number' => $storeId,
'store_entity_name' => $value('店铺主体'),
'login_account' => $value('登录账号'),
'login_password' => $value('登录密码'),
'receiving_method' => $value('提现路径') ?: '银行卡',
'receiving_account_name' => $value('提现账户名称'),
'alipay_or_bank_account' => $value('支付宝账号') ?: $value('银行卡账号(后四位)'),
'opening_bank' => null,
'erp_store_name' => $value('ERP店铺名称'),
'shop_name' => $value('店铺名称'),
'store_status' => $value('店铺状态'),
'original_store_entity' => $value('原店铺主体'),
'account_status' => $value('状态'),
'payment_password' => $value('支付密码'),
'payment_verification_method' => $value('支付验证方式'),
'financial_account' => $value('金融账号'),
'financial_password' => $value('金融密码'),
'payment_verification_method_secondary' => $this->valueAt($headers, $row, '支付验证方式', 1),
'bank_card_last_four' => $value('银行卡账号(后四位)'),
'store_id' => $storeId,
'source_platform' => $platform,
];
return array_filter($data, static fn ($item): bool => $item !== null);
}
/** @param list<string> $headers */
private function valueAt(array $headers, array $row, string $header, int $occurrence): string
{
$found = 0;
foreach ($headers as $index => $value) {
if ($value !== $header) {
continue;
}
if ($found++ === $occurrence) {
return trim((string) ($row[$index] ?? ''));
}
}
return '';
}
}

View File

@ -0,0 +1,273 @@
<?php
namespace App\Business;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreViolationRuleRepository;
use Throwable;
/**
* 店铺违规信息预警:预警级别、责任人、处理截止等派生字段的计算。
*
* - 级别/是否提醒:按「平台 + 违规类型」匹配违规字典;原文先去(…)括号处置说明,
* 再做 相等/前缀/关键词 匹配;匹配不到 = 不需要提醒(只入库不推送)。
* - 责任人:店铺账号镜像的运营负责人(与店铺维度报表同口径),
* 未命中时回退店铺档案(ops_fys_shop_info.shopHead)。
* - 处理截止:申诉截止日期非空→当天 23:59:59;否则按字典时效(小时)或
* 级别默认天数(P0+1 / P1+2 / P2+3 天)的当天 23:59:59。
*/
class StoreViolationAlertCalculator
{
/** @var list<array<string,mixed>>|null */
private ?array $rules = null;
/** @var array<string,array{operation_owner:string,operation_director:string,profit_center:string}>|null */
private ?array $ownerMap = null;
/** @var array<string,?string> */
private array $ownerFallback = [];
public function __construct(
private readonly StoreViolationRuleRepository $ruleRepository,
private readonly StoreAccountMirrorRepository $storeAccounts,
) {
}
public function flush(): void
{
$this->rules = null;
$this->ownerMap = null;
$this->ownerFallback = [];
}
/** @return array<string,mixed> 预警相关字段(用于同步/导入写入) */
public function calculate(
string $platform,
?string $shopName,
?string $violationType,
?string $violationDate,
?string $appealDeadline,
): array {
$rule = self::matchRule($this->rules(), $platform, $violationType);
$needsAlert = $rule !== null && (bool) ($rule['needs_alert'] ?? true);
$level = null;
if ($needsAlert) {
$level = trim((string) ($rule['alert_level'] ?? ''));
if ($level === '' || ! array_key_exists($level, (array) config('store_violations.alert_levels', []))) {
$level = 'P2';
}
}
$hours = isset($rule['handling_hours']) && $rule['handling_hours'] !== null
? (int) $rule['handling_hours']
: null;
return [
'needs_alert' => $needsAlert,
'alert_level' => $level,
'alert_rule_id' => $needsAlert ? ($rule['id'] ?? null) : null,
'alert_action' => $needsAlert ? self::nullableText($rule['suggested_action'] ?? null, 500) : null,
'alert_channel' => $needsAlert ? self::nullableText($rule['remind_channel'] ?? null, 64) : null,
'handling_hours' => $needsAlert ? $hours : null,
'owner_name' => $this->ownerFor($shopName),
'handling_deadline' => self::deadlineFor($violationDate, $appealDeadline, $hours, $level),
];
}
/** @return list<array<string,mixed>> */
private function rules(): array
{
if ($this->rules === null) {
try {
$this->rules = $this->ruleRepository->activeRules();
} catch (Throwable) {
$this->rules = [];
}
}
return $this->rules;
}
/**
* 清理违规类型原文:反复去掉成对括号(含处置说明)并去除空白。
*/
public static function cleanViolationType(?string $text): string
{
$text = trim((string) $text);
if ($text === '') {
return '';
}
$previous = null;
while ($previous !== $text) {
$previous = $text;
$text = preg_replace('/[((【\[][^((【\[))】\]]*[))】\]]/u', '', $text) ?? $text;
}
return preg_replace('/[\s\x{3000}]+/u', '', $text) ?? $text;
}
private static function loose(string $text): string
{
return preg_replace('/[\/、,,。.;;::·\-—_]+/u', '', $text) ?? $text;
}
/**
* 按顺序匹配字典:相等 → 前缀 → 分词包含 → 宽松包含 → 关键词包含。
*
* @param list<array<string,mixed>> $rules 已按优先级排序
* @return array<string,mixed>|null
*/
public static function matchRule(array $rules, string $platform, ?string $violationType): ?array
{
$record = self::cleanViolationType($violationType);
if ($record === '') {
return null;
}
$looseRecord = self::loose($record);
foreach ($rules as $rule) {
if (trim((string) ($rule['platform'] ?? '')) !== $platform) {
continue;
}
$ruleType = self::cleanViolationType($rule['violation_type'] ?? null);
if ($ruleType !== '') {
if ($record === $ruleType || str_starts_with($record, $ruleType)) {
return $rule;
}
foreach (preg_split('/[\/、]/u', $ruleType) ?: [] as $part) {
$part = trim((string) $part);
if ($part !== '' && mb_strlen($part) >= 2 && mb_stripos($record, $part) !== false) {
return $rule;
}
}
$looseRule = self::loose($ruleType);
if ($looseRule !== '' && str_contains($looseRecord, $looseRule)) {
return $rule;
}
}
foreach (preg_split('/[|、,\/]/u', (string) ($rule['match_keywords'] ?? '')) ?: [] as $keyword) {
$keyword = self::loose(self::cleanViolationType($keyword));
if ($keyword !== '' && str_contains($looseRecord, $keyword)) {
return $rule;
}
}
}
return null;
}
/**
* 处理截止:申诉截止优先;否则字典时效(小时)或级别默认天数(当天 23:59:59)。
*/
public static function deadlineFor(
?string $violationDate,
?string $appealDeadline,
?int $handlingHours,
?string $alertLevel,
): ?string {
$appeal = trim((string) $appealDeadline);
if ($appeal !== '') {
try {
return Carbon::parse($appeal)->endOfDay()->toDateTimeString();
} catch (Throwable) {
// 解析失败时退回按级别计算
}
}
$date = trim((string) $violationDate);
if ($date === '') {
return null;
}
try {
$base = Carbon::parse($date)->startOfDay();
} catch (Throwable) {
return null;
}
if ($handlingHours !== null && $handlingHours > 0) {
return $base->addHours($handlingHours)->toDateTimeString();
}
$level = trim((string) $alertLevel);
if ($level === '') {
return null;
}
$days = (int) (config('store_violations.alert_default_days.' . $level) ?? 0);
return $days > 0 ? $base->addDays($days)->endOfDay()->toDateTimeString() : null;
}
/** 是否超时:处理状态不在终态且当前时间已过处理截止。 */
public static function isOverdue(mixed $handlingStatus, mixed $handlingDeadline, ?Carbon $now = null): bool
{
$status = trim((string) $handlingStatus);
if (in_array($status, ['处理完成', '已忽略', '已处理', '无需处理'], true)) {
return false;
}
$deadline = trim((string) $handlingDeadline);
if ($deadline === '') {
return false;
}
try {
return ($now ?? Carbon::now())->greaterThan(Carbon::parse($deadline));
} catch (Throwable) {
return false;
}
}
private function ownerFor(?string $shopName): ?string
{
$name = trim((string) $shopName);
if ($name === '') {
return null;
}
try {
if ($this->ownerMap === null) {
$this->ownerMap = $this->storeAccounts->ownershipByStoreName();
}
$owner = trim((string) ($this->ownerMap[$name]['operation_owner'] ?? ''));
if ($owner !== '') {
return mb_substr($owner, 0, 64);
}
} catch (Throwable) {
$this->ownerMap ??= [];
}
// 兜底:店铺档案(经营分析副本库)的店铺运营负责人。
if (array_key_exists($name, $this->ownerFallback)) {
return $this->ownerFallback[$name];
}
$head = null;
try {
$row = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('ops_fys_shop_info')
->where('shopName', $name)
->orderByDesc('id')
->first(['shopHead']);
$head = self::nullableText($row->shopHead ?? null, 64);
} catch (Throwable) {
$head = null;
}
return $this->ownerFallback[$name] = $head;
}
private static function nullableText(mixed $value, int $limit): ?string
{
$text = trim((string) $value);
return $text === '' ? null : mb_substr($text, 0, $limit);
}
}

View File

@ -0,0 +1,151 @@
<?php
namespace App\Business;
use DateTimeImmutable;
use DateTimeInterface;
use InvalidArgumentException;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
/**
* 店铺违规信息预警:上游抓取数据与导入文件到统一表头的映射。
*/
class StoreViolationMapper
{
public function platformForSourceValue(mixed $value): ?string
{
$normalized = mb_strtolower(trim((string) $value));
if ($normalized === '') {
return null;
}
foreach ((array) config('store_violations.platforms', []) as $slug => $platform) {
if ($normalized === $slug) {
return $slug;
}
foreach ($platform['source_values'] ?? [] as $sourceValue) {
if ($normalized === mb_strtolower(trim((string) $sourceValue))) {
return $slug;
}
}
}
return null;
}
public function categoryForContent(?string $content): string
{
$content = trim((string) $content);
if ($content === '') {
return (string) config('store_violations.default_category', '其他');
}
foreach ((array) config('store_violations.category_rules', []) as $category => $keywords) {
foreach ((array) $keywords as $keyword) {
if ($keyword !== '' && mb_stripos($content, (string) $keyword) !== false) {
return (string) $category;
}
}
}
return (string) config('store_violations.default_category', '其他');
}
/**
* 上游抓取行(rpt.crawl_shop_violation)→ 统一字段。
*
* @param array<string,mixed> $row
* @return array<string,mixed>|null 平台无法识别时返回 null
*/
public function mapCrawlRow(array $row): ?array
{
$platform = $this->platformForSourceValue($row['platform'] ?? null);
if ($platform === null) {
return null;
}
$content = trim((string) ($row['violation_content'] ?? ''));
return [
'platform' => $platform,
'shop_name' => mb_substr(trim((string) ($row['shop_name'] ?? '')), 0, 191),
'violation_type' => $content === '' ? null : mb_substr($content, 0, 500),
'violation_category' => $this->categoryForContent($content),
// 上游视图补齐这两列后由同步命令自动带出(见 SyncStoreViolations 的列探测)。
'violation_reason' => $this->normalizeLabel($row['violation_reason'] ?? null, 500),
'disposition' => $this->normalizeLabel($row['disposition'] ?? null, 500),
'violation_no' => mb_substr(trim((string) ($row['violation_no'] ?? '')), 0, 100),
'violation_date' => $this->normalizeDate($row['violation_date'] ?? null),
'status' => $this->normalizeLabel($row['status'] ?? null, 64),
'appeal_deadline' => $this->normalizeDate($row['deadline'] ?? null),
'window_name' => $this->normalizeLabel($row['window_name'] ?? null, 191),
'crawl_date' => $this->normalizeDate($row['crawl_date'] ?? null),
'source' => 'crawl',
];
}
public function normalizeDate(mixed $value): ?string
{
if ($value === null || $value === '') {
return null;
}
if ($value instanceof DateTimeInterface) {
return $value->format('Y-m-d');
}
if (is_numeric($value)) {
$number = (float) $value;
if ($number >= 20000 && $number <= 80000) {
return ExcelDate::excelToDateTimeObject($number)->format('Y-m-d');
}
}
$text = trim((string) $value);
if ($text === '' || in_array($text, ['-', '—', '无'], true)) {
return null;
}
$text = str_replace(['年', '月'], '-', str_replace('日', '', $text));
$text = str_replace(['/', '.'], '-', $text);
$text = trim(preg_replace('/\s+/', ' ', $text) ?? $text);
try {
return (new DateTimeImmutable($text))->format('Y-m-d');
} catch (\Throwable) {
throw new InvalidArgumentException('无法识别的日期:' . $value);
}
}
public function normalizePoints(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value)) {
return (float) $value;
}
$text = trim((string) $value);
if ($text === '' || in_array($text, ['-', '—', '无', '不扣分'], true)) {
return null;
}
$text = preg_replace('/(分|点|个?扣|扣除)/u', '', $text) ?? $text;
$text = trim($text);
if ($text === '' || ! is_numeric($text)) {
throw new InvalidArgumentException('无法识别的扣分:' . $value);
}
return (float) $text;
}
private function normalizeLabel(mixed $value, int $limit): ?string
{
$text = trim((string) $value);
return $text === '' ? null : mb_substr($text, 0, $limit);
}
}

View File

@ -0,0 +1,237 @@
<?php
namespace App\Business;
use App\Models\Common\Company;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\URL;
use Modules\EcommerceFinance\Repositories\StoreViolationPushRepository;
use RuntimeException;
use Throwable;
/**
* 店铺违规信息预警:内部群机器人推送(先留接口,配置 Webhook 后启用)。
*
* - P0/P1 即时推送(每天 09:00 起由定时任务检查),消息含去认领签名链接;
* - P2 不即时推,每日 10:00 汇总成一条日报;
* - 以违规记录为唯一对象:已推送的不再重复推送(失败可显式重试)。
*/
class StoreViolationNotifier
{
public function __construct(private readonly StoreViolationPushRepository $pushes)
{
}
public function enabled(): bool
{
$config = (array) config('store_violations.push', []);
return (bool) ($config['enabled'] ?? false)
&& trim((string) ($config['webhook'] ?? '')) !== '';
}
/**
* @return array{attempted:int,succeeded:int,failed:int}
*/
public function pushInstant(int $limit = 200, bool $retryFailed = false): array
{
$levels = array_values(array_map('strval', (array) config('store_violations.push.instant_levels', ['P0', 'P1'])));
$candidates = $this->pushes->instantCandidates($levels, $retryFailed, $limit);
$summary = ['attempted' => $candidates->count(), 'succeeded' => 0, 'failed' => 0];
$ownerContacts = (array) config('store_violations.push.owner_contacts', []);
foreach ($candidates as $record) {
$owner = trim((string) ($record->owner_name ?? ''));
$atMobiles = [];
if ($owner !== '' && ! empty($ownerContacts[$owner])) {
$atMobiles[] = (string) $ownerContacts[$owner];
}
[$title, $text] = $this->instantMessage($record);
$pushNo = $this->pushNo();
$now = Carbon::now();
try {
$this->send($title, $text, $atMobiles);
$this->pushes->markPushed([(int) $record->id], $now);
$summary['succeeded']++;
$this->pushes->log([
'push_no' => $pushNo,
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'instant',
'platform' => (string) ($record->platform ?? ''),
'violation_no' => (string) ($record->violation_no ?? ''),
'message_digest' => mb_substr($title, 0, 500),
'mentioned' => $owner,
'result' => 'success',
'error' => null,
]);
} catch (Throwable $exception) {
$summary['failed']++;
$this->pushes->markPushFailed((int) $record->id, $now);
$this->pushes->log([
'push_no' => $pushNo,
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'instant',
'platform' => (string) ($record->platform ?? ''),
'violation_no' => (string) ($record->violation_no ?? ''),
'message_digest' => mb_substr($title, 0, 500),
'mentioned' => $owner,
'result' => 'failed',
'error' => mb_substr($exception->getMessage(), 0, 500),
]);
}
}
return $summary;
}
/**
* @return array{pending:array<string,int>,overdue:int,p2:int,sent:bool}
*/
public function pushDigest(): array
{
$data = $this->pushes->digestData();
$pending = $data['pending'];
$overdue = $data['overdue'];
$p2Ids = $data['p2_pending']->pluck('id')->map(static fn ($id): int => (int) $id)->all();
$lines = [
'### 店铺违规预警 · 每日汇总(' . Carbon::now()->format('m-d') . ')',
'- 待处理:P0 ' . (int) ($pending['P0'] ?? 0)
. ' 条 / P1 ' . (int) ($pending['P1'] ?? 0)
. ' 条 / P2 ' . (int) ($pending['P2'] ?? 0) . ' 条',
'- 已超时未认领:' . $overdue->count() . ' 条',
];
foreach ($overdue as $index => $record) {
$lines[] = sprintf(
' %d. [%s] %s(责任人:%s) · 截止 %s',
$index + 1,
(string) ($record->alert_level ?? ''),
(string) ($record->shop_name ?? ''),
trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配',
(string) ($record->handling_deadline ?? ''),
);
}
$now = Carbon::now();
try {
$this->send('店铺违规预警 · 每日汇总', implode("\n", $lines), []);
} catch (Throwable $exception) {
$this->pushes->log([
'push_no' => $this->pushNo(),
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'daily',
'platform' => null,
'violation_no' => null,
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
'mentioned' => null,
'result' => 'failed',
'error' => mb_substr($exception->getMessage(), 0, 500),
]);
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => false];
}
// 汇总成功后把已点名的 P2 记录标记为已推送;未点名的明细汇总不重复提醒。
$this->pushes->markPushed($p2Ids, $now);
$this->pushes->log([
'push_no' => $this->pushNo(),
'pushed_at' => $now,
'channel' => (string) config('store_violations.push.channel', 'dingtalk_robot'),
'message_type' => 'daily',
'platform' => null,
'violation_no' => null,
'message_digest' => mb_substr(implode(' ', array_slice($lines, 0, 3)), 0, 500),
'mentioned' => null,
'result' => 'success',
'error' => null,
]);
return ['pending' => $pending, 'overdue' => $overdue->count(), 'p2' => count($p2Ids), 'sent' => true];
}
/** @return array{0:string,1:string} */
private function instantMessage(object $record): array
{
$level = (string) ($record->alert_level ?? '');
$levelLabel = (string) (config('store_violations.alert_levels.' . $level . '.label') ?? $level);
$platformLabel = (string) (config('store_violations.platforms.' . (string) ($record->platform ?? '') . '.label') ?? ($record->platform ?? ''));
$title = '店铺违规预警(' . $levelLabel . ')';
$lines = [
'### 【' . $levelLabel . '】店铺违规预警',
'- 平台/店铺:' . $platformLabel . ' / ' . (string) ($record->shop_name ?? ''),
'- 违规类型:' . mb_substr((string) ($record->violation_type ?? ''), 0, 200),
'- 处罚措施:' . (trim((string) ($record->disposition ?? '')) !== '' ? (string) $record->disposition : '—'),
'- 处理建议:' . (trim((string) ($record->alert_action ?? '')) !== '' ? (string) $record->alert_action : '按平台规则处理'),
'- 责任人:' . (trim((string) ($record->owner_name ?? '')) !== '' ? (string) $record->owner_name : '未分配'),
'- 处理截止:' . (string) ($record->handling_deadline ?? '—'),
];
$claimUrl = $this->claimUrl((int) $record->id);
if ($claimUrl !== null) {
$lines[] = '- [去认领](' . $claimUrl . ')(认领后处理状态自动变为「处理中」)';
}
return [$title, implode("\n", $lines)];
}
private function claimUrl(int $recordId): ?string
{
try {
$companyId = (int) (Company::query()->orderBy('id')->value('id') ?? 1);
return URL::signedRoute('business.store-violation-alert.claim', [
'record' => $recordId,
'company_id' => $companyId,
]);
} catch (Throwable) {
return null;
}
}
/** @param list<string> $atMobiles */
private function send(string $title, string $text, array $atMobiles): void
{
$config = (array) config('store_violations.push', []);
$webhook = trim((string) ($config['webhook'] ?? ''));
if ($webhook === '') {
throw new RuntimeException('推送 Webhook 未配置。');
}
$url = $webhook;
$secret = trim((string) ($config['secret'] ?? ''));
if ($secret !== '') {
$timestamp = (string) (int) (microtime(true) * 1000);
$sign = urlencode(base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true)));
$url .= (str_contains($url, '?') ? '&' : '?') . 'timestamp=' . $timestamp . '&sign=' . $sign;
}
$response = Http::timeout(10)->post($url, [
'msgtype' => 'markdown',
'markdown' => ['title' => $title, 'text' => $text],
'at' => [
'atMobiles' => array_values($atMobiles),
'isAtAll' => (bool) ($config['at_all'] ?? false),
],
]);
if (! $response->successful()) {
throw new RuntimeException('推送接口返回 HTTP ' . $response->status());
}
$body = (array) $response->json();
if ((int) ($body['errcode'] ?? 0) !== 0) {
throw new RuntimeException('推送接口错误:' . (string) ($body['errcode'] ?? '?') . ' ' . (string) ($body['errmsg'] ?? ''));
}
}
private function pushNo(): string
{
return 'VP' . Carbon::now()->format('ymdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 6));
}
}

View File

@ -0,0 +1,519 @@
<?php
namespace App\Business;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
/**
* 店铺违规信息预警:导入文件按统一表头映射到 store_violation_records。
*
* preview() 只解析和校验,不写库;commit() 按 (platform, violation_no) 幂等写入。
*/
class StoreViolationWorkbookImporter
{
public function __construct(
private readonly StoreViolationMapper $mapper,
private readonly StoreViolationRecordRepository $repository,
private readonly StoreViolationAlertCalculator $alerts,
) {
}
/**
* @return array{platform:string,sheet:string,columns:list<array{key:string,label:string}>,unknown_headers:list<string>,rows:list<array>,summary:array,existing_checked:bool}
*/
public function preview(UploadedFile $file, string $platform): array
{
$platforms = (array) config('store_violations.platforms', []);
if (! array_key_exists($platform, $platforms)) {
throw ValidationException::withMessages(['import' => '未知平台,请刷新页面后重试。']);
}
if (! $this->repository->available()) {
throw ValidationException::withMessages(['import' => '导入功能尚未就绪:统一表尚未建立,请联系管理员完成迁移。']);
}
try {
$spreadsheet = IOFactory::load($file->getRealPath());
} catch (Throwable $exception) {
report($exception);
throw ValidationException::withMessages(['import' => '无法读取该文件,请上传 xlsx / xls / csv 格式的表格。']);
}
$worksheet = $spreadsheet->getActiveSheet();
$matrix = $worksheet->toArray(null, true, true, false);
[$headerIndex, $columnMap, $unknownHeaders] = $this->detectHeaders($matrix);
if ($headerIndex === null) {
throw ValidationException::withMessages([
'import' => '未找到可识别的表头行,请使用统一表头或下载导入模板。',
]);
}
$missing = [];
foreach (['shop_name', 'violation_no'] as $required) {
if (! in_array($required, $columnMap, true)) {
$missing[] = $this->labelFor($required);
}
}
if ($missing !== []) {
throw ValidationException::withMessages([
'import' => '缺少必填列:' . implode('、', $missing) . '。请使用统一表头或下载导入模板。',
]);
}
$maxRows = (int) config('store_violations.import.max_rows', 20000);
$dataRows = array_slice($matrix, $headerIndex + 1);
if (count($dataRows) > $maxRows) {
throw ValidationException::withMessages([
'import' => '单次最多导入 ' . number_format($maxRows) . ' 行,请拆分文件后重试。',
]);
}
$rows = [];
$seen = [];
$invalid = 0;
foreach ($dataRows as $offset => $row) {
$rowNumber = $headerIndex + 2 + $offset;
$raw = $this->rowToFields($row, $columnMap);
if ($this->isEmptyRow($raw)) {
continue;
}
$item = $this->normalizeRow($raw, $platform, $rowNumber, $columnMap);
if ($item['status'] === 'valid') {
$key = $platform . '|' . $item['data']['violation_no'];
if (isset($seen[$key])) {
$item = $seen[$key]['data'] === $item['data']
? $this->markRow($item, 'same', '与文件内前一行重复,将跳过')
: $this->markRow($item, 'invalid', '与文件内前一行的数据冲突,请先合并');
} else {
$seen[$key] = $item;
}
}
if ($item['status'] === 'invalid') {
$invalid++;
}
$rows[] = $item;
}
$summary = ['new' => 0, 'update' => 0, 'same' => 0, 'invalid' => $invalid];
$this->compareWithExisting($rows, $platform, $summary);
return [
'platform' => $platform,
'sheet' => $worksheet->getTitle(),
'columns' => collect((array) config('store_violations.columns', []))
->map(fn (array $column): array => ['key' => $column['key'], 'label' => $column['label']])
->all(),
'unknown_headers' => $unknownHeaders,
'provided_columns' => array_values($columnMap),
'rows' => $rows,
'summary' => $summary,
];
}
/** @param array{rows:list<array>} $preview */
public function commit(array $preview): array
{
$platform = (string) ($preview['platform'] ?? '');
$rows = $preview['rows'] ?? [];
$provided = array_values(array_unique((array) ($preview['provided_columns'] ?? [])));
$now = now();
$unifiedColumns = array_map(
static fn (array $column): string => (string) $column['key'],
(array) config('store_violations.columns', []),
);
$updateColumns = array_values(array_intersect($unifiedColumns, $provided));
if (! in_array('violation_category', $updateColumns, true) && in_array('violation_type', $provided, true)) {
$updateColumns[] = 'violation_category';
}
$updateColumns[] = 'source';
$updateColumns[] = 'updated_at';
// 预警派生字段(级别/责任人/处理截止等):导入时按当前字典自动计算写入。
$updateColumns = array_values(array_unique(array_merge(
$updateColumns,
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
)));
$payload = [];
$created = 0;
$updated = 0;
foreach ($rows as $row) {
$status = $row['status'] ?? '';
if (! in_array($status, ['new', 'update'], true)) {
continue;
}
$data = $row['data'] ?? [];
if (($data['platform'] ?? null) !== $platform || ($data['violation_no'] ?? '') === '') {
continue;
}
$matchType = trim((string) ($data['violation_type'] ?? '')) !== ''
? (string) $data['violation_type']
: ($data['violation_reason'] ?? null);
$data = array_merge($data, $this->alerts->calculate(
$platform,
(string) ($data['shop_name'] ?? ''),
$matchType,
$data['violation_date'] ?? null,
$data['appeal_deadline'] ?? null,
));
foreach (array_values(array_diff($updateColumns, ['source', 'updated_at'])) as $column) {
if (! array_key_exists($column, $data)) {
$data[$column] = null;
}
}
$payload[] = $data + [
'source' => 'import',
'created_at' => $now,
'updated_at' => $now,
];
$status === 'new' ? $created++ : $updated++;
}
$affected = $this->repository->upsertRows($payload, $updateColumns);
return ['created' => $created, 'updated' => $updated, 'affected' => $affected];
}
/**
* @param list<list<mixed>> $matrix
* @return array{0:?int,1:list<string>,2:list<string>}
*/
private function detectHeaders(array $matrix): array
{
$bestIndex = null;
$bestMap = [];
$bestUnknown = [];
$bestScore = 0;
foreach ($matrix as $index => $row) {
$map = [];
$unknown = [];
foreach ($row as $cell) {
$label = trim((string) $cell);
if ($label === '') {
continue;
}
$key = $this->matchHeader($label);
if ($key === null) {
$unknown[] = $label;
continue;
}
if (! in_array($key, $map, true)) {
$map[] = $key;
}
}
if (count($map) > $bestScore) {
$bestIndex = $index;
$bestMap = $map;
$bestUnknown = $unknown;
$bestScore = count($map);
}
// 命中 6 列以上视为表头,避免把数据行误判为表头
if ($bestScore >= 6) {
break;
}
}
if ($bestIndex === null || $bestScore < 2) {
return [null, [], []];
}
return [$bestIndex, $bestMap, array_values(array_unique($bestUnknown))];
}
private function matchHeader(string $label): ?string
{
$normalized = mb_strtolower(preg_replace('/[\s"* ]/u', '', $label) ?? $label);
foreach ((array) config('store_violations.header_aliases', []) as $key => $aliases) {
foreach ((array) $aliases as $alias) {
$alias = mb_strtolower(preg_replace('/[\s"* ]/u', '', (string) $alias) ?? (string) $alias);
if ($alias !== '' && $normalized === $alias) {
return (string) $key;
}
}
}
return null;
}
/**
* @param list<mixed> $row
* @param list<string> $columnMap
* @return array<string,mixed>
*/
private function rowToFields(array $row, array $columnMap): array
{
$fields = [];
foreach ($row as $index => $cell) {
$key = $columnMap[$index] ?? null;
if ($key !== null) {
$fields[$key] = $cell;
}
}
return $fields;
}
/** @param array<string,mixed> $fields */
private function isEmptyRow(array $fields): bool
{
foreach ($fields as $value) {
if (trim((string) $value) !== '') {
return false;
}
}
return true;
}
/**
* @param array<string,mixed> $raw
* @return array{row_number:int,status:string,message:string,data:array<string,mixed>}
*/
private function normalizeRow(array $raw, string $platform, int $rowNumber, array $provided): array
{
$errors = [];
$data = ['platform' => $platform];
$has = static fn (string $key): bool => in_array($key, $provided, true);
if ($has('shop_name')) {
$shopName = trim((string) ($raw['shop_name'] ?? ''));
if ($shopName === '') {
$errors[] = '店铺名不能为空';
} elseif (mb_strlen($shopName) > 191) {
$errors[] = '店铺名超过 191 字';
}
$data['shop_name'] = mb_substr($shopName, 0, 191);
}
if ($has('violation_no')) {
$violationNo = trim((string) ($raw['violation_no'] ?? ''));
if ($violationNo === '') {
$errors[] = '违规单号不能为空';
} elseif (mb_strlen($violationNo) > 100) {
$errors[] = '违规单号超过 100 字';
}
$data['violation_no'] = mb_substr($violationNo, 0, 100);
}
$type = $has('violation_type') ? trim((string) ($raw['violation_type'] ?? '')) : null;
if ($has('violation_type')) {
$data['violation_type'] = $type === '' ? null : mb_substr($type, 0, 500);
}
$reason = $has('violation_reason') ? trim((string) ($raw['violation_reason'] ?? '')) : null;
if ($has('violation_reason')) {
$data['violation_reason'] = $reason === '' ? null : mb_substr($reason, 0, 500);
}
if ($has('disposition')) {
$disposition = trim((string) ($raw['disposition'] ?? ''));
$data['disposition'] = $disposition === '' ? null : mb_substr($disposition, 0, 500);
}
if ($has('violation_category')) {
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
$category = trim((string) ($raw['violation_category'] ?? ''));
$data['violation_category'] = $category === ''
? $this->mapper->categoryForContent($categorySource)
: mb_substr($category, 0, 64);
} elseif ($has('violation_type') || $has('violation_reason')) {
$categorySource = ($type !== null && $type !== '') ? $type : (string) $reason;
$data['violation_category'] = $this->mapper->categoryForContent($categorySource);
}
if ($has('violation_date')) {
try {
$data['violation_date'] = $this->mapper->normalizeDate($raw['violation_date'] ?? null);
} catch (Throwable $exception) {
$errors[] = '违规日期:' . $exception->getMessage();
$data['violation_date'] = null;
}
}
if ($has('points_deducted')) {
try {
$data['points_deducted'] = $this->mapper->normalizePoints($raw['points_deducted'] ?? null);
} catch (Throwable $exception) {
$errors[] = '扣分:' . $exception->getMessage();
$data['points_deducted'] = null;
}
}
if ($has('status')) {
$status = trim((string) ($raw['status'] ?? ''));
$data['status'] = $status === '' ? null : mb_substr($status, 0, 64);
}
if ($has('penalty_date')) {
try {
$data['penalty_date'] = $this->mapper->normalizeDate($raw['penalty_date'] ?? null);
} catch (Throwable $exception) {
$errors[] = '处罚日期:' . $exception->getMessage();
$data['penalty_date'] = null;
}
}
if ($has('appeal_deadline')) {
try {
$data['appeal_deadline'] = $this->mapper->normalizeDate($raw['appeal_deadline'] ?? null);
} catch (Throwable $exception) {
$errors[] = '申诉截止日期:' . $exception->getMessage();
$data['appeal_deadline'] = null;
}
}
if ($has('handling_status')) {
$handling = trim((string) ($raw['handling_status'] ?? ''));
$legacyMap = (array) config('store_violations.handling_status_legacy', []);
$handling = (string) ($legacyMap[$handling] ?? $handling);
$options = (array) config('store_violations.handling_statuses', []);
if ($handling === '') {
$data['handling_status'] = (string) config('store_violations.default_handling_status', '未处理');
} elseif (! in_array($handling, $options, true)) {
$errors[] = '处理状态必须是:' . implode('、', $options);
$data['handling_status'] = $handling;
} else {
$data['handling_status'] = $handling;
}
}
if ($has('ignore_reason')) {
$ignoreReason = trim((string) ($raw['ignore_reason'] ?? ''));
$data['ignore_reason'] = $ignoreReason === '' ? null : mb_substr($ignoreReason, 0, 191);
}
if (($data['handling_status'] ?? null) === '已忽略' && trim((string) ($data['ignore_reason'] ?? '')) === '') {
$errors[] = '处理状态为“已忽略”时必须填写忽略原因';
}
return [
'row_number' => $rowNumber,
'status' => $errors === [] ? 'valid' : 'invalid',
'message' => $errors === [] ? '待导入' : implode(';', $errors),
'data' => $data,
];
}
private function markRow(array $row, string $status, string $message): array
{
$row['status'] = $status;
$row['message'] = $message;
return $row;
}
/**
* 与库内已有记录比对:无 → new,字段完全一致 → same,否则 → update。
*
* @param list<array> $rows
*/
private function compareWithExisting(array &$rows, string $platform, array &$summary): void
{
$keys = collect($rows)
->filter(fn (array $row): bool => $row['status'] === 'valid' && ($row['data']['violation_no'] ?? '') !== '')
->pluck('data.violation_no')
->unique()
->values();
if ($keys->isEmpty()) {
return;
}
$existing = collect();
foreach ($keys->chunk(500) as $chunk) {
$existing = $existing->merge(
DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table(StoreViolationRecordRepository::TABLE)
->where('platform', $platform)
->whereIn('violation_no', $chunk->all())
->get()
->keyBy('violation_no'),
);
}
foreach ($rows as $index => $row) {
if ($row['status'] !== 'valid') {
continue;
}
$record = $existing->get($row['data']['violation_no']);
if ($record === null) {
$summary['new']++;
$rows[$index] = $this->markRow($row, 'new', '新增记录');
continue;
}
$rows[$index] = $this->sameAsExisting($row['data'], $record)
? $this->markRow($row, 'same', '与库内记录一致,将跳过')
: $this->markRow($row, 'update', '库内已有该单号,将按文件内容更新');
$rows[$index]['status'] === 'same' ? $summary['same']++ : $summary['update']++;
}
}
private function sameAsExisting(array $data, object $record): bool
{
$normalizePoints = static function (mixed $value): string {
if ($value === null || $value === '') {
return '';
}
return number_format((float) $value, 2, '.', '');
};
foreach ($data as $key => $value) {
if ($key === 'platform') {
continue;
}
$current = $record->{$key} ?? null;
if ($key === 'points_deducted') {
if ($normalizePoints($value) !== $normalizePoints($current)) {
return false;
}
continue;
}
if (trim((string) $value) !== trim((string) $current)) {
return false;
}
}
return true;
}
private function labelFor(string $key): string
{
foreach ((array) config('store_violations.columns', []) as $column) {
if (($column['key'] ?? null) === $key) {
return (string) $column['label'];
}
}
return $key;
}
}

View File

@ -17,6 +17,7 @@ use App\Models\Business\StoreChange;
use App\Models\Business\StoreChangeLog;
use App\Models\Business\StoreClosing;
use App\Models\Business\StoreInformation;
use App\Models\Business\ShopInfoFlow;
use App\Models\Business\StoreInvoice;
use App\Models\Business\StoreOpening;
use App\Models\Business\StoreReceivingAccount;
@ -144,6 +145,7 @@ class TableRegistry
{
return [
'company-management/basic-information' => CompanyInformation::class,
'company-management/subject-operating-report' => StoreInformation::class,
'company-management/registration' => CompanyRegistration::class,
'company-management/changes' => CompanyChange::class,
'company-management/cancellation' => CompanyCancellation::class,
@ -154,6 +156,7 @@ class TableRegistry
'trademark-management/renewal' => Trademark::class,
'trademark-management/information' => Trademark::class,
'store-management/basic-information' => StoreInformation::class,
'store-management/store-accounts' => StoreInformation::class,
'store-management/store-report' => StoreInformation::class,
'store-management/opening-process' => StoreOpening::class,
'store-management/changes' => StoreChange::class,
@ -169,6 +172,7 @@ class TableRegistry
'fund-management/shared-company-accounts' => BankAccount::class, // 重用 BankAccount,仅类型过滤
'billing-transactions/store-receiving-balance' => StoreReceivingAccount::class,
'financial-operations/installation-details' => InstallationDetail::class,
'financial-risk-control/process-statistics' => ShopInfoFlow::class,
];
}
@ -195,6 +199,17 @@ class TableRegistry
*/
public static function describe(string $section, string $page, ?string $tab = null): ?TableDescriptor
{
if ($section === 'fund-management' && $page === 'store-receiving-accounts') {
return new TableDescriptor(
model: StoreReceivingAccount::class,
table: (new StoreReceivingAccount())->getTable(),
fields: self::storeReceivingAccountFields($tab),
tab: $tab,
tabFilterColumn: $tab ? 'store_platform' : null,
readOnly: false,
);
}
$modelClass = self::modelClassFor($section, $page, $tab);
if (! $modelClass) {
return null;
@ -241,10 +256,13 @@ class TableRegistry
{
return in_array("{$section}/{$page}", [
'company-management/basic-information',
'company-management/subject-operating-report',
'store-management/basic-information',
'store-management/store-accounts',
'store-management/store-report',
'trademark-management/information',
'financial-operations/installation-details',
'financial-risk-control/process-statistics',
], true);
}
@ -336,17 +354,90 @@ class TableRegistry
if ($key === 'fund-management/store-receiving-accounts') {
return match ($tabSlug) {
'taobao-alipay' => '淘宝',
'taobao-wechat' => '淘宝',
'pinduoduo' => '拼多多',
'jd-wallet' => '京东',
'taobao' => '淘宝',
'jd' => '京东',
'douyin' => '抖音',
'dewu' => '得物',
'vipshop' => '唯品会',
'pinduoduo' => '拼多多',
default => null,
};
}
return null;
}
/** @return list<string> */
public static function tabValues(string $section, string $page, string $tabSlug): array
{
if ($section !== 'fund-management' || $page !== 'store-receiving-accounts') {
$value = self::tabValue($section, $page, $tabSlug);
return $value === null ? [] : [$value];
}
return match ($tabSlug) {
'taobao' => ['淘宝', '阿里巴巴', '天猫', '淘宝分销', '唯品会'],
'jd' => ['京东', '京喜'],
'douyin' => ['抖音'],
'pinduoduo' => ['拼多多'],
default => [],
};
}
/** @return list<array{name:string,label:string,type:string,options?:list<string>,currency:bool}> */
private static function storeReceivingAccountFields(?string $tab): array
{
$text = static fn (string $name, string $label): array => [
'name' => $name, 'label' => $label, 'type' => 'text', 'options' => null, 'currency' => false,
];
$select = static fn (string $name, string $label, array $options): array => [
'name' => $name, 'label' => $label, 'type' => 'select', 'options' => $options, 'currency' => false,
];
$fields = [];
if ($tab === 'taobao' || $tab === 'jd') {
$fields[] = $select('store_platform', '类型', self::tabValues('fund-management', 'store-receiving-accounts', $tab));
}
$fields[] = $text('erp_store_name', 'ERP店铺名称');
$fields[] = $text('shop_name', '店铺名称');
if ($tab === 'jd') {
$fields[] = $text('store_id', '店铺id');
}
$fields[] = $select('store_status', '店铺状态', ['正常', '关店中', '已关店']);
$fields[] = $text('store_entity_name', '店铺主体');
$fields[] = $text('original_store_entity', '原店铺主体');
if ($tab === 'taobao') {
$fields[] = $select('account_status', '状态', ['已维护', '待维护']);
$fields[] = $text('login_account', '登录账号');
$fields[] = $text('login_password', '登录密码');
$fields[] = $text('payment_password', '支付密码');
$fields[] = $select('withdrawal_path', '提现路径', ['公户', '网商银行', '支付宝']);
$fields[] = $text('withdrawal_account_name', '提现账户名称');
$fields[] = $text('alipay_or_bank_account', '支付宝账号');
} elseif ($tab === 'jd') {
$fields[] = $text('login_account', '登录账号');
$fields[] = $text('login_password', '登录密码');
$fields[] = $text('payment_verification_method', '支付验证方式');
$fields[] = $text('financial_account', '金融账号');
$fields[] = $text('financial_password', '金融密码');
$fields[] = $text('payment_verification_method_secondary', '支付验证方式');
$fields[] = $select('account_status', '状态', ['已维护', '待维护']);
$fields[] = $select('withdrawal_path', '提现路径', ['公户', '网商银行', '支付宝']);
$fields[] = $text('withdrawal_account_name', '提现账户名称');
$fields[] = $text('bank_card_last_four', '银行卡账号(后四位)');
} else {
$fields[] = $select('account_status', '状态', ['已维护', '待维护']);
$fields[] = $text('login_account', '登录账号');
$fields[] = $text('login_password', '登录密码');
$fields[] = $text('payment_password', '支付密码');
$fields[] = $select('withdrawal_path', '提现路径', ['公户', '网商银行', '支付宝']);
$fields[] = $text('withdrawal_account_name', '提现账户名称');
$fields[] = $text('bank_card_last_four', '银行卡账号(后四位)');
}
return $fields;
}
}

View File

@ -35,7 +35,7 @@ class TaobaoAlipayBillSpreadsheetImporter
'counterparty_account', 'account_order_no', 'business_serial_no', 'goods_name',
];
public function import(string $path, int $companyId): array
public function import(string $path, int $companyId, bool $replace = false): array
{
if (! is_file($path) || ! is_readable($path)) {
throw new InvalidArgumentException('导入文件不存在或不可读取。');
@ -55,21 +55,27 @@ class TaobaoAlipayBillSpreadsheetImporter
throw new InvalidArgumentException('表头与淘系支付宝账户模板不一致。');
}
$rows = [];
$skipped = 0;
for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) {
$values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0];
if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) {
$skipped++;
continue;
}
$rows[] = $this->normalizeRow(array_combine(self::FIELDS, $values), $rowNumber);
}
$inserted = 0;
$updated = 0;
$skipped = 0;
$replaced = 0;
DB::transaction(function () use ($sheet, $companyId, &$inserted, &$updated, &$skipped) {
for ($rowNumber = 2; $rowNumber <= $sheet->getHighestDataRow(); $rowNumber++) {
$values = $sheet->rangeToArray("A{$rowNumber}:Y{$rowNumber}", null, false, false)[0];
DB::transaction(function () use ($rows, $companyId, $replace, &$inserted, &$updated, &$replaced) {
if ($replace) {
$replaced = DB::table('ec_alipay_bills')->where('company_id', $companyId)->delete();
}
if (collect($values)->every(fn ($value) => $value === null || trim((string) $value) === '')) {
$skipped++;
continue;
}
$data = array_combine(self::FIELDS, $values);
$data = $this->normalizeRow($data, $rowNumber);
foreach ($rows as $data) {
$record = AlipayBill::query()
->where('company_id', $companyId)
->where('record_code', $data['record_code'])
@ -93,7 +99,7 @@ class TaobaoAlipayBillSpreadsheetImporter
$spreadsheet->disconnectWorksheets();
return compact('inserted', 'updated', 'skipped');
return compact('inserted', 'updated', 'skipped', 'replaced');
}
private function normalizeRow(array $data, int $rowNumber): array

View File

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Business;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* 三方仓核算的商品重量来源。
*
* 重量取【普通商品资料】主档「重量(kg)」,即业务镜像表 ops_jst_sku 的
* weight 列,按商品编码(sku_id)关联。字段与表定义与
* Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository 一致
* (dataset operations_replica.rpt.jst_sku → ops_jst_sku,见 config/upstream_mirrors.php)。
* 同一编码可能有多条历史行,取最新 autoid 的重量。
*/
class ThirdPartyWarehouseProductWeightRepository
{
/** 普通商品资料主档在业务镜像中的落地表。 */
private const PRODUCT_TABLE = 'ops_jst_sku';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* 按商品编码批量取重量(kg)。
*
* @param list<string> $skuCodes
* @return array<string,float> 商品编码 => 重量(kg)
*/
public function weightsBySkuCodes(array $skuCodes): array
{
$codes = [];
foreach ($skuCodes as $code) {
$code = trim((string) $code);
if ($code !== '') {
$codes[$code] = $code;
}
}
$codes = array_values($codes);
if ($codes === [] || ! $this->available()) {
return [];
}
try {
$latest = $this->connection->table(self::PRODUCT_TABLE)
->where('sku_type', 'normal')
->whereIn('sku_id', $codes)
->groupBy('sku_id')
->select('sku_id')
->selectRaw('MAX(autoid) AS autoid');
return $this->connection->table(self::PRODUCT_TABLE . ' as s')
->joinSub($latest, 'latest', function ($join): void {
$join->on('latest.sku_id', '=', 's.sku_id')
->on('latest.autoid', '=', 's.autoid');
})
->pluck('s.weight', 's.sku_id')
->map(static fn (mixed $weight): float => (float) $weight)
->all();
} catch (Throwable) {
return [];
}
}
private function available(): bool
{
try {
$schema = Schema::connection($this->connection->getName());
return $schema->hasTable(self::PRODUCT_TABLE)
&& $schema->hasColumn(self::PRODUCT_TABLE, 'weight')
&& $schema->hasColumn(self::PRODUCT_TABLE, 'sku_id');
} catch (Throwable) {
return false;
}
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Business;
use App\Models\Business\ThirdPartyWarehouseQuote;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
/**
* 三方仓仓储报价的唯一读写入口。
*
* 结算只需按仓库和结算期查找生效日不晚于该期间的最新启用记录,
* 因此每次价格变化都新增版本,不覆盖旧报价。
*/
class ThirdPartyWarehouseQuoteRepository
{
public function ready(): bool
{
try {
return Schema::hasTable((new ThirdPartyWarehouseQuote())->getTable());
} catch (QueryException) {
return false;
}
}
/** @return array{ready:bool,rows:Collection,summary:array<string,int>} */
public function pageData(): array
{
if (! $this->ready()) {
return $this->emptyPageData();
}
try {
$quotes = ThirdPartyWarehouseQuote::query()
->with('owner')
->orderByDesc('effective_at')
->orderByDesc('id')
->get();
} catch (QueryException) {
return $this->emptyPageData();
}
return [
'ready' => true,
'rows' => $quotes->map(fn (ThirdPartyWarehouseQuote $quote): array => $this->present($quote)),
'summary' => [
'total' => $quotes->count(),
'active' => $quotes->where('status', ThirdPartyWarehouseQuote::STATUS_ACTIVE)->count(),
'warehouses' => $quotes->pluck('warehouse_name')->filter()->unique()->count(),
],
];
}
/**
* 取某仓库在结算月生效的最新启用报价:生效日 ≤ 结算月月末,按生效日、id 倒序取一条。
*/
public function effectiveFor(string $warehouseName, string $period): ?ThirdPartyWarehouseQuote
{
if (! $this->ready()) {
return null;
}
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) {
return null;
}
$monthEnd = Carbon::createFromFormat('Y-m-d', $period . '-01')->endOfMonth()->toDateString();
try {
return ThirdPartyWarehouseQuote::query()
->where('warehouse_name', trim($warehouseName))
->where('status', ThirdPartyWarehouseQuote::STATUS_ACTIVE)
->whereDate('effective_at', '<=', $monthEnd)
->orderByDesc('effective_at')
->orderByDesc('id')
->first();
} catch (QueryException) {
return null;
}
}
/** @param array<string,mixed> $data */
public function create(array $data, int $userId): ThirdPartyWarehouseQuote
{
return ThirdPartyWarehouseQuote::query()->create([
'company_id' => company_id(),
'warehouse_name' => trim((string) $data['warehouse_name']),
'inbound_rate_per_ton' => $data['inbound_rate_per_ton'],
'outbound_rate_per_order' => $data['outbound_rate_per_order'],
'pieces_per_pallet' => $data['pieces_per_pallet'],
'pallet_area_sqm' => $data['pallet_area_sqm'],
'storage_rate_per_sqm_month' => $data['storage_rate_per_sqm_month'],
'effective_at' => $data['effective_at'],
'status' => ThirdPartyWarehouseQuote::STATUS_ACTIVE,
'remark' => trim((string) ($data['remark'] ?? '')) ?: null,
'created_by' => $userId,
'updated_by' => $userId,
]);
}
public function setStatus(ThirdPartyWarehouseQuote $quote, string $status, int $userId): ThirdPartyWarehouseQuote
{
abort_unless(in_array($status, [
ThirdPartyWarehouseQuote::STATUS_ACTIVE,
ThirdPartyWarehouseQuote::STATUS_INACTIVE,
], true), 422, '报价状态不合法。');
$quote->forceFill(['status' => $status, 'updated_by' => $userId])->save();
return $quote->fresh();
}
/** @return array<string,mixed> */
private function present(ThirdPartyWarehouseQuote $quote): array
{
return [
'id' => 'WHQ-' . str_pad((string) $quote->id, 4, '0', STR_PAD_LEFT),
'db_id' => $quote->id,
'warehouse_name' => $quote->warehouse_name,
'inbound_rate_per_ton' => (float) $quote->inbound_rate_per_ton,
'outbound_rate_per_order' => (float) $quote->outbound_rate_per_order,
'pieces_per_pallet' => (float) $quote->pieces_per_pallet,
'pallet_area_sqm' => (float) $quote->pallet_area_sqm,
'storage_rate_per_sqm_month' => (float) $quote->storage_rate_per_sqm_month,
'effective_at' => optional($quote->effective_at)->format('Y-m-d'),
'status_code' => $quote->status,
'status' => $quote->statusLabel(),
'remark' => $quote->remark,
'creator' => optional($quote->owner)->name ?: '-',
];
}
/** @return array{ready:false,rows:Collection,summary:array<string,int>} */
private function emptyPageData(): array
{
return [
'ready' => false,
'rows' => collect(),
'summary' => ['total' => 0, 'active' => 0, 'warehouses' => 0],
];
}
}

View File

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace App\Business;
use Carbon\Carbon;
use Modules\EcommerceFinance\Repositories\ThirdPartyWarehouseStorageMirrorRepository;
/**
* 三方仓月度仓储费核算引擎。
*
* 口径(与「第三方仓储报价」页公式一致;各移动列按数量/绝对值计入):
* - 入库管理费 = Σ( 普通商品资料重量kg ÷ 1000 ×
* (加工进仓+加工出仓+采购进仓+采购退货+其它退货+销售退货+盘点+调拨入) ) × 入库单价
* - 出库费用 = Σ( 销售出仓 + 其它出库 + 调拨出 ) × 出库单价
* - 每日仓储费 = Σ_日( 当日库存结余 ÷ 每托件数 × 每托面积 × 面积单价 ÷ 当月天数 )
* - 合计 = 入库 + 出库 + 仓储 + 记账 + 盘亏扣款 + 赔偿
* 系统数据为唯一核算来源,不读取仓库提供/核对/差异列。
*/
class ThirdPartyWarehouseStorageFeeCalculator
{
/** 入库计费口径涉及的移动列(取绝对数量求和)。 */
private const INBOUND_COLUMNS = [
'processing_in', 'processing_out', 'purchase_in', 'purchase_return',
'other_return', 'sales_return', 'stocktake', 'transfer_in',
];
/** 出库计费口径涉及的移动列(取绝对数量求和)。 */
private const OUTBOUND_COLUMNS = ['sales_out', 'other_out', 'transfer_out'];
public function __construct(
private readonly ThirdPartyWarehouseStorageMirrorRepository $mirror,
private readonly ThirdPartyWarehouseQuoteRepository $quotes,
private readonly ThirdPartyWarehouseProductWeightRepository $weights,
) {
}
/**
* @return array{summary:array<string,mixed>,daily:list<array<string,mixed>>,per_sku:list<array<string,mixed>>,daily_sku:list<array<string,mixed>>}
*/
public function calculate(int $warehouseId, string $warehouseName, string $period): array
{
abort_unless(
preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) === 1,
422,
'结算月份格式应为 YYYY-MM。',
);
$quote = $this->quotes->effectiveFor($warehouseName, $period);
abort_if($quote === null, 422, '请先在「第三方仓储报价」录入该仓库在 ' . $period . ' 生效的报价。');
$monthStart = Carbon::createFromFormat('Y-m-d', $period . '-01')->startOfMonth();
$days = (int) $monthStart->daysInMonth;
$inboundRate = (float) $quote->inbound_rate_per_ton;
$outboundRate = (float) $quote->outbound_rate_per_order;
$piecesPerPallet = (float) $quote->pieces_per_pallet;
$palletArea = (float) $quote->pallet_area_sqm;
$sqmRate = (float) $quote->storage_rate_per_sqm_month;
/** @var array<string,array{closing:float,inbound_qty:float,outbound_qty:float}> $daily */
$daily = [];
/** @var array<string,array<string,mixed>> $skus */
$skus = [];
$dailySku = [];
$storagePerPiecePerDay = $days > 0 && $piecesPerPallet > 0
? $palletArea * $sqmRate / $piecesPerPallet / $days
: 0.0;
foreach ($this->mirror->exportRows(['dimension' => 'month', 'period' => $period], 5000) as $row) {
$date = trim((string) ($row['date'] ?? ''));
$code = trim((string) ($row['sku_code'] ?? ''));
if ($date === '' || $code === '') {
continue;
}
$opening = (float) ($row['opening_qty'] ?? 0);
$closing = (float) ($row['closing_qty'] ?? 0);
$inboundQty = 0.0;
foreach (self::INBOUND_COLUMNS as $column) {
$inboundQty += abs((float) ($row[$column] ?? 0));
}
$outboundQty = 0.0;
foreach (self::OUTBOUND_COLUMNS as $column) {
$outboundQty += abs((float) ($row[$column] ?? 0));
}
if (! isset($daily[$date])) {
$daily[$date] = ['closing' => 0.0, 'inbound_qty' => 0.0, 'outbound_qty' => 0.0];
}
$daily[$date]['closing'] += $closing;
$daily[$date]['inbound_qty'] += $inboundQty;
$daily[$date]['outbound_qty'] += $outboundQty;
$dailySku[] = [
'date' => $date,
'owner' => (string) ($row['owner'] ?? ''),
'sku_code' => $code,
'product_name' => (string) ($row['product_name'] ?? ''),
'supplier' => (string) ($row['supplier'] ?? ''),
'category' => (string) ($row['category'] ?? ''),
'unit' => (string) ($row['unit'] ?? ''),
'opening' => round($opening, 2),
'net_movement' => round($closing - $opening, 2),
'closing' => round($closing, 2),
'storage_fee' => round($closing * $storagePerPiecePerDay, 6),
];
if (! isset($skus[$code])) {
$skus[$code] = [
'owner' => (string) ($row['owner'] ?? ''),
'sku_code' => $code,
'product_name' => (string) ($row['product_name'] ?? ''),
'supplier' => (string) ($row['supplier'] ?? ''),
'category' => (string) ($row['category'] ?? ''),
'unit' => (string) ($row['unit'] ?? ''),
'first_date' => $date,
'last_date' => $date,
'opening' => $opening,
'closing' => $closing,
'inbound_qty' => 0.0,
'outbound_qty' => 0.0,
];
}
$sku = &$skus[$code];
if ($date <= $sku['first_date']) {
$sku['first_date'] = $date;
$sku['opening'] = $opening;
}
if ($date >= $sku['last_date']) {
$sku['last_date'] = $date;
$sku['closing'] = $closing;
}
$sku['inbound_qty'] += $inboundQty;
$sku['outbound_qty'] += $outboundQty;
unset($sku);
}
$weightMap = $this->weights->weightsBySkuCodes(array_keys($skus));
$inboundTons = 0.0;
$outboundQtyTotal = 0.0;
$perSku = [];
foreach ($skus as $code => $sku) {
$weight = (float) ($weightMap[$code] ?? 0.0);
$perTon = $weight / 1000;
$totalTon = $perTon * $sku['inbound_qty'];
$inboundTons += $totalTon;
$outboundQtyTotal += $sku['outbound_qty'];
$perSku[] = [
'owner' => $sku['owner'],
'sku_code' => $code,
'product_name' => $sku['product_name'],
'supplier' => $sku['supplier'],
'category' => $sku['category'],
'unit' => $sku['unit'],
'weight' => round($weight, 4),
'per_ton' => round($perTon, 6),
'inbound_qty' => round($sku['inbound_qty'], 2),
'total_ton' => round($totalTon, 4),
'outbound_qty' => round($sku['outbound_qty'], 2),
'opening' => round($sku['opening'], 2),
'closing' => round($sku['closing'], 2),
];
}
$inboundFee = round($inboundTons * $inboundRate, 2);
$outboundFee = round($outboundQtyTotal * $outboundRate, 2);
ksort($daily);
$dailyRows = [];
$storageFee = 0.0;
foreach ($daily as $date => $agg) {
$dayStorage = $days > 0 && $piecesPerPallet > 0
? $agg['closing'] / $piecesPerPallet * $palletArea * $sqmRate / $days
: 0.0;
$storageFee += $dayStorage;
$dailyRows[] = [
'date' => $date,
'inbound_qty' => round($agg['inbound_qty'], 2),
'outbound_qty' => round($agg['outbound_qty'], 2),
'closing' => round($agg['closing'], 2),
'storage_fee' => round($dayStorage, 4),
'outbound_fee' => round($agg['outbound_qty'] * $outboundRate, 2),
'total' => round($dayStorage + $agg['outbound_qty'] * $outboundRate, 2),
];
}
$storageFee = round($storageFee, 2);
$totalFee = round($inboundFee + $outboundFee + $storageFee, 2);
return [
'summary' => [
'period' => $period,
'warehouse_id' => $warehouseId,
'warehouse_name' => $warehouseName,
'quote_id' => (int) $quote->id,
'days_in_month' => $days,
'inbound_rate_per_ton' => $inboundRate,
'outbound_rate_per_order' => $outboundRate,
'pieces_per_pallet' => $piecesPerPallet,
'pallet_area_sqm' => $palletArea,
'storage_rate_per_sqm_month' => $sqmRate,
'inbound_tons' => round($inboundTons, 4),
'inbound_fee' => $inboundFee,
'outbound_qty' => round($outboundQtyTotal, 2),
'outbound_fee' => $outboundFee,
'storage_fee' => $storageFee,
'memo_fee' => 0.0,
'loss_fee' => 0.0,
'compensation_fee' => 0.0,
'total_fee' => $totalFee,
],
'daily' => $dailyRows,
'per_sku' => $perSku,
'daily_sku' => $dailySku,
];
}
}

View File

@ -9,7 +9,9 @@ use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Repositories\InstallationDetailMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReportRepository;
use Modules\EcommerceFinance\Repositories\StoreAccountMirrorRepository;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Throwable;
class UpstreamBusinessInformation
{
@ -21,6 +23,7 @@ class UpstreamBusinessInformation
private readonly InstallationDetailMirrorRepository $installationMirror,
// 兼容仍按旧构造方式调用的代码;容器解析时会正常注入仓储。
private readonly ?StoreReportRepository $storeReports = null,
private readonly ?StoreAccountMirrorRepository $storeAccounts = null,
) {}
public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator
@ -32,10 +35,12 @@ class UpstreamBusinessInformation
$payload = match ($key) {
'company-management/basic-information' => $this->companyPage($currentPage, $perPage),
'store-management/basic-information' => $this->storePage($currentPage, $perPage),
'store-management/store-accounts' => $this->storeAccountPage($currentPage, $perPage),
'store-management/store-report' => $this->storeReportPage($currentPage, $perPage),
'company-management/subject-operating-report' => $this->subjectOperatingReportPage($currentPage, $perPage),
'trademark-management/information' => $this->trademarkPage($currentPage, $perPage),
'financial-operations/installation-details' => $this->installationPage($currentPage, $perPage),
'financial-risk-control/process-statistics' => $this->shopInfoFlowPage($currentPage, $perPage),
default => ['data' => [], 'total' => 0],
};
@ -51,6 +56,26 @@ class UpstreamBusinessInformation
);
}
/**
* 财务经营风险管控 › 流程统计:店铺申请流程(开店 / 变更 / 关店)只读镜像。
*
* @return array{data:list<array<string,mixed>>,total:int}
*/
private function shopInfoFlowPage(int $page, int $perPage): array
{
try {
return app(\Modules\EcommerceFinance\Repositories\ShopInfoFlowMirrorRepository::class)->paginate(
$page,
$perPage,
$this->pageFilters('financial-risk-control', 'process-statistics'),
);
} catch (\RuntimeException $exception) {
report($exception);
throw new ServiceUnavailableHttpException(null, '店铺申请流程共享镜像尚未完成首次同步。');
}
}
private function installationPage(int $page, int $perPage): array
{
$payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
@ -84,10 +109,12 @@ class UpstreamBusinessInformation
private function companyPage(int $page, int $perPage): array
{
$filters = $this->pageFilters('company-management', 'basic-information');
return $this->page(
'company-information.read',
$page,
$perPage,
$filters,
fn () => $this->snapshot('company-information.read', [
'page' => $page,
'perPage' => $perPage,
@ -129,10 +156,12 @@ SQL);
private function storePage(int $page, int $perPage): array
{
$filters = $this->pageFilters('store-management', 'basic-information');
return $this->page(
'store-information.read',
$page,
$perPage,
$filters,
fn () => $this->snapshot('store-information.read', [
'page' => $page,
'perPage' => $perPage,
@ -142,67 +171,386 @@ SQL);
private function storeReportPage(int $page, int $perPage): array
{
$stores = $this->allStores();
try {
$repository = $this->storeReports ?? app(StoreReportRepository::class);
$rows = $repository->rows($stores['data']);
} catch (\RuntimeException $exception) {
throw new ServiceUnavailableHttpException(null, $exception->getMessage(), $exception);
}
$filters = StoreReportRepository::requestFilters();
$rows = $this->storeReportRows($filters);
// 所有店铺该月都没有业绩的月份列默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
request()->attributes->set(
'store-report.default_hidden_columns',
$this->columnsWithoutData($rows, 'sales_', zeroCountsAsData: false),
);
$offset = ($page - 1) * $perPage;
return ['data' => array_slice($rows, $offset, $perPage), 'total' => count($rows)];
}
/** @return array{data:array<int,array<string,mixed>>,total:int} */
private function allStores(): array
private function storeAccountPage(int $page, int $perPage): array
{
try {
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->paginate(
$page,
$perPage,
$this->pageFilters('store-management', 'store-accounts'),
);
} catch (\RuntimeException $exception) {
report($exception);
throw new ServiceUnavailableHttpException(null, '店铺账号共享镜像尚未完成首次同步。');
}
}
/** @param array<string,string> $filters @return array{data:array<int,array<string,mixed>>,total:int} */
private function allStores(array $filters = []): array
{
if ($this->mirror->ready('store-information.read')) {
$rows = $this->mirror->allStores();
return [
'data' => array_map(fn (array $row): array => $this->mapStore($row), $rows),
'total' => count($rows),
];
$rows = $this->mirror->allStores($filters);
return ['data' => array_map(fn (array $row): array => $this->mapStore($row), $rows), 'total' => count($rows)];
}
$cached = $this->caches->forSource(self::SOURCE)->remember('store-information.read', ['all' => true],
fn () => $this->storeSnapshotAll(new UpstreamReadOnlyQuery(self::SOURCE)));
$rows = array_values(array_filter(
$cached['data'],
fn (array $row): bool => $this->storeMatchesFilters($row, $filters),
));
return ['data' => $rows, 'total' => count($rows)];
}
/**
* 镜像不可用时的兜底路径:在已映射的快照行上应用同样的店铺字段条件。
*
* @param array<string,mixed> $row
* @param array<string,string> $filters
*/
private function storeMatchesFilters(array $row, array $filters): bool
{
foreach (['erp_store_name', 'store_name', 'store_id', 'profit_center', 'store_manager', 'department_manager'] as $key) {
$expected = trim((string) ($filters[$key] ?? ''));
if ($expected === '') {
continue;
}
$actual = trim((string) ($row[$key] ?? ''));
if (! str_contains(mb_strtolower($actual), mb_strtolower($expected))) {
return false;
}
}
$companyType = trim((string) ($filters['company_type'] ?? ''));
if ($companyType !== '' && trim((string) ($row['company_type'] ?? '')) !== $companyType) {
return false;
}
if ((app(\App\Utilities\SystemDatabaseConfiguration::class)->current()['mode'] ?? '') === 'bootstrap'
&& ! config('upstreams.local_configuration.enabled', false)) {
throw new ServiceUnavailableHttpException(null, '当前仍使用本机临时引导库。请先连接团队共享数据库,然后重新登录。');
$statusMap = ['0' => '正常', '3' => '关闭中', '6' => '已关闭'];
$status = trim((string) ($filters['status'] ?? ''));
if ($status !== '' && (string) ($row['store_status'] ?? '') !== ($statusMap[$status] ?? $status)) {
return false;
}
return $this->caches->forSource(self::SOURCE)->remember(
'store-information.read',
['all' => true],
fn () => $this->storeSnapshotAll(new UpstreamReadOnlyQuery(self::SOURCE)),
);
$from = trim((string) ($filters['opened_at_from'] ?? ''));
$to = trim((string) ($filters['opened_at_to'] ?? ''));
if ($from !== '' || $to !== '') {
$value = trim((string) ($row['opened_at'] ?? ''));
if ($value === '') {
return false;
}
if ($from !== '' && strcmp($value, $from) < 0) {
return false;
}
if ($to !== '' && strcmp($value, $to) >= 0) {
return false;
}
}
return true;
}
private function storeSnapshotAll(UpstreamReadOnlyQuery $query): array
{
$rows = $query->select(<<<SQL
SELECT s.Id AS id, s.sotreId AS store_id, s.openTime AS opened_at, s.renewalTime AS renewed_at,
s.erpShopName AS erp_store_name, s.shopName AS store_name, s.unifiedSocialCreditCode AS unified_social_credit_code,
c.businessLicenseName AS company_name, c.businessLicenseType AS company_type,
s.trademarkNumber AS trademark_number, t.trademarkName AS trademark_name, s.platform AS platform,
s.responsibleDepartment AS profit_center, NULL AS store_manager, NULL AS department_manager,
s.shopState AS store_status, s.shopBankAccount AS withdrawal_account, s.shopType AS store_type, s.channelType AS channel_mode
FROM rpt.fys_shopInfo AS s
OUTER APPLY (SELECT TOP 1 businessLicenseName,businessLicenseType FROM rpt.fys_companyInfo WHERE unifiedSocialCreditCode=NULLIF(s.unifiedSocialCreditCode,'') ORDER BY Id DESC) c
OUTER APPLY (SELECT TOP 1 trademarkName FROM rpt.fys_TrademarkInfo WHERE trademarkNumber=NULLIF(s.trademarkNumber,'') AND isDelete=0 ORDER BY Id DESC) t
ORDER BY s.Id DESC
SQL);
$rows = array_map(fn (array $row): array => $this->mapStore($row), $rows);
return ['data' => $rows, 'total' => count($rows)];
}
private function subjectOperatingReportPage(int $page, int $perPage): array
{
$payload = $this->storePage($page, $perPage);
$filters = $this->pageFilters('company-management', 'subject-operating-report');
$rows = $this->storeReportRows(array_intersect_key($filters, array_flip(['company_type'])));
$entities = [];
return [
'data' => array_map(static function (array $row): array {
return [
'city' => null,
'entity' => $row['company_name'] ?? null,
'company_type' => $row['company_type'] ?? null,
'erp_store_name' => $row['erp_store_name'] ?? null,
'profit_center' => $row['profit_center'] ?? null,
'store_manager' => $row['store_manager'] ?? null,
'closing_manager' => null,
'opened_at' => $row['opened_at'] ?? null,
'status' => $row['store_status'] ?? null,
'recent_three_month_sales' => null,
'store_sales_12m' => null,
'entity_sales_12m' => null,
'can_change_entity' => null,
'plan' => null,
'new_entity' => null,
];
}, $payload['data']),
'total' => $payload['total'],
foreach ($rows as $row) {
$entity = trim((string) ($row['entity'] ?? ''));
if ($entity === '') {
continue;
}
$entities[$entity] ??= [
'city' => null,
'entity' => $entity,
'company_type' => $row['company_type'] ?? null,
'rolling_12m_income' => null,
'can_change_entity' => null,
'plan' => null,
'new_entity' => null,
];
if (empty($entities[$entity]['company_type']) && ! empty($row['company_type'])) {
$entities[$entity]['company_type'] = $row['company_type'];
}
$this->sumNullable($entities[$entity], 'rolling_12m_income', $row['rolling_12m_income'] ?? null);
for ($month = 1; $month <= 12; $month++) {
$key = sprintf('income_%02d', $month);
$entities[$entity][$key] ??= null;
$this->sumNullable($entities[$entity], $key, $row[$key] ?? null);
}
}
$rows = array_values($entities);
$details = $this->companyDetails(array_column($rows, 'entity'));
foreach ($rows as &$row) {
$row['company_details'] = $details[$row['entity']] ?? [];
$row['city'] = $this->cityFromDetails($row['company_details']);
}
unset($row);
// 所有主体该月收入均为空时,该月份列在页面上默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
request()->attributes->set(
'subject-operating-report.default_hidden_columns',
$this->columnsWithoutData($rows, 'income_', zeroCountsAsData: true),
);
$rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters)));
$this->sortSubjectRows($rows, $filters);
$offset = ($page - 1) * $perPage;
$pageRows = array_slice($rows, $offset, $perPage);
return ['data' => $pageRows, 'total' => count($rows)];
}
/**
* 没有数据的月份列:所有行该月都没有数值时返回该列名,页面据此默认隐藏。
*
* 收入(income_)以 null/空字符串表示无账单,金额 0 也算有账单;
* 销量(sales_)没有空值标记,数值 0 视为当月没有业绩。
*
* @param array<int,array<string,mixed>> $rows
* @return array<int,string>
*/
private function columnsWithoutData(array $rows, string $prefix, bool $zeroCountsAsData): array
{
$columns = [];
for ($month = 1; $month <= 12; $month++) {
$key = sprintf('%s%02d', $prefix, $month);
foreach ($rows as $row) {
$value = $row[$key] ?? null;
if ($value === null || $value === '') {
continue;
}
if (! $zeroCountsAsData && (float) $value === 0.0) {
continue;
}
continue 2;
}
$columns[] = $key;
}
return $columns;
}
/** @param array<string,mixed> $row @param array<string,string> $filters */
private function subjectMatchesFilters(array $row, array $filters): bool
{
if (($filters['company_type'] ?? '') !== '' && (string) ($row['company_type'] ?? '') !== (string) $filters['company_type']) {
return false;
}
foreach (['city', 'entity', 'plan', 'new_entity'] as $key) {
$needle = trim((string) ($filters[$key] ?? ''));
if ($needle !== '' && mb_stripos((string) ($row[$key] ?? ''), $needle) === false) {
return false;
}
}
foreach (['rolling_12m_income', ...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12))] as $key) {
$needle = str_replace(',', '', trim((string) ($filters[$key] ?? '')));
if ($needle !== '' && (! is_numeric($needle) || ! is_numeric($row[$key] ?? null)
|| abs((float) $row[$key] - (float) $needle) > 0.000001)) {
return false;
}
}
$canChange = trim((string) ($filters['can_change_entity'] ?? ''));
return $canChange === '' || (string) ($row['can_change_entity'] ?? '') === $canChange;
}
/** @param array<int,array<string,mixed>> $rows @param array<string,string> $filters */
private function sortSubjectRows(array &$rows, array $filters): void
{
$key = (string) ($filters['_sort'] ?? 'entity');
$allowed = [
'city', 'entity', 'company_type', 'rolling_12m_income',
...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12)),
'can_change_entity', 'plan', 'new_entity',
];
if (! in_array($key, $allowed, true)) {
return;
}
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1;
usort($rows, static function (array $left, array $right) use ($key, $direction): int {
$leftValue = $left[$key] ?? null;
$rightValue = $right[$key] ?? null;
if (is_numeric($leftValue) && is_numeric($rightValue)) {
$comparison = ((float) $leftValue <=> (float) $rightValue) * $direction;
} else {
$comparison = strcmp((string) ($leftValue ?? ''), (string) ($rightValue ?? '')) * $direction;
}
return $comparison !== 0 ? $comparison : strcmp((string) ($left['entity'] ?? ''), (string) ($right['entity'] ?? ''));
});
}
private function sumNullable(array &$row, string $key, mixed $value): void
{
if ($value === null || $value === '') {
return;
}
$row[$key] = (float) ($row[$key] ?? 0) + (float) $value;
}
/**
* 「市」来自主体基础资料的注册地址:取第一条能解析出地级市的记录
* (详情按 Id 倒序,即优先最新资料);资料缺失或地址无法识别时返回 null,
* 页面按统一缺失值展示。
*
* @param array<int,array<string,mixed>> $details
*/
private function cityFromDetails(array $details): ?string
{
foreach ($details as $detail) {
$city = RegisteredAddressCity::parse($detail['registered_address'] ?? null);
if ($city !== null) {
return $city;
}
}
return null;
}
/** @return array<string,array<int,array<string,mixed>>> */
private function companyDetails(array $names): array
{
$names = array_values(array_unique(array_filter(array_map('trim', $names))));
if ($names === []) {
return [];
}
try {
if ($this->mirror->ready('company-information.read')) {
$rows = $this->mirror->companiesByNames($names);
} else {
$placeholders = implode(',', array_fill(0, count($names), '?'));
$rawRows = (new UpstreamReadOnlyQuery(self::SOURCE))->select(
"SELECT * FROM rpt.fys_companyInfo WHERE businessLicenseName IN ($placeholders) ORDER BY Id DESC",
$names,
);
$rows = [];
foreach ($rawRows as $rawRow) {
$rows[(string) ($rawRow['businessLicenseName'] ?? '')][] = $rawRow;
}
}
} catch (Throwable) {
return [];
}
$result = [];
foreach ($rows as $name => $companyRows) {
$result[$name] = array_map(fn (array $row): array => $this->mapCompanyDetail($row), $companyRows);
}
return $result;
}
private function mapCompanyDetail(array $row): array
{
return $this->mapCompany([
'id' => $row['Id'] ?? $row['id'] ?? null,
'unified_social_credit_code' => $row['unifiedSocialCreditCode'] ?? $row['unified_social_credit_code'] ?? null,
'company_name' => $row['businessLicenseName'] ?? $row['company_name'] ?? null,
'subscribed_capital' => $row['registeredCapital'] ?? $row['subscribed_capital'] ?? null,
'company_type' => $row['businessLicenseType'] ?? $row['company_type'] ?? null,
'establishment_date' => $row['IncorporationDate'] ?? $row['establishment_date'] ?? null,
'legal_representative' => $row['legalRepresentative'] ?? $row['legal_representative'] ?? null,
'registered_address' => $row['registeredAddress'] ?? $row['registered_address'] ?? null,
'business_scope' => $row['businessScope'] ?? $row['business_scope'] ?? null,
'basic_deposit_account_number' => $row['accountSerialNumber'] ?? $row['basic_deposit_account_number'] ?? null,
'subscribed_at' => $row['registeredCapitalVaildTime'] ?? $row['subscribed_at'] ?? null,
'company_status' => $row['companyStatus'] ?? $row['company_status'] ?? null,
'financial_officer' => $row['financialManager'] ?? $row['financial_officer'] ?? null,
'supervisor' => $row['supervisor'] ?? null,
'taxpayer_category' => $row['taxCategoryAndType'] ?? $row['taxpayer_category'] ?? null,
'shareholders' => $row['shareholders'] ?? null,
]);
}
/** @param array<string,string> $filters @return array<int,array<string,mixed>> */
private function storeReportRows(array $filters = []): array
{
// 店铺负责人、部门负责人、利润中心展示店铺账号镜像(PostgreSQL)中的值;
// 这三个字段的筛选条件随行级过滤按覆盖后的值匹配,不再走镜像 SQL。
$ownership = $this->storeOwnership();
$storeFilters = array_diff_key($filters, array_flip(['store_manager', 'department_manager', 'profit_center']));
$stores = $this->allStores($storeFilters);
foreach ($stores['data'] as &$store) {
$match = $ownership[trim((string) ($store['erp_store_name'] ?? ''))] ?? null;
if ($match === null) {
continue;
}
// 未匹配到账号或字段为空时保留经营分析副本库原值。
foreach ([
'store_manager' => $match['operation_owner'],
'department_manager' => $match['operation_director'],
'profit_center' => $match['profit_center'],
] as $field => $value) {
if ($value !== '') {
$store[$field] = $value;
}
}
}
unset($store);
try {
return ($this->storeReports ?? app(StoreReportRepository::class))->rows($stores['data'], null, $filters);
} catch (\RuntimeException $exception) {
report($exception);
throw new ServiceUnavailableHttpException(null, '店铺报表数据暂时无法读取,请稍后重试。');
}
}
/** @return array<string,array{operation_owner:string,operation_director:string,profit_center:string}> */
private function storeOwnership(): array
{
try {
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->ownershipByStoreName();
} catch (\RuntimeException | \InvalidArgumentException $exception) {
// 账号镜像未登记或未就绪时保留经营分析副本库原值,不阻断店铺报表。
return [];
}
}
private function storeSnapshot(UpstreamReadOnlyQuery $query, int $page, int $perPage): array
@ -215,6 +563,7 @@ SELECT
s.openTime AS opened_at,
s.renewalTime AS renewed_at,
s.erpShopName AS erp_store_name,
s.shopName AS store_name,
s.unifiedSocialCreditCode AS unified_social_credit_code,
c.businessLicenseName AS company_name,
c.businessLicenseType AS company_type,
@ -252,56 +601,14 @@ SQL);
];
}
/** @return array{data:array<int,array<string,mixed>>,total:int} */
private function storeSnapshotAll(UpstreamReadOnlyQuery $query): array
{
$rows = $query->select(<<<SQL
SELECT
s.Id AS id,
s.sotreId AS store_id,
s.openTime AS opened_at,
s.renewalTime AS renewed_at,
s.erpShopName AS erp_store_name,
s.unifiedSocialCreditCode AS unified_social_credit_code,
c.businessLicenseName AS company_name,
c.businessLicenseType AS company_type,
s.trademarkNumber AS trademark_number,
t.trademarkName AS trademark_name,
s.platform AS platform,
s.responsibleDepartment AS profit_center,
s.shopHead AS store_manager,
s.customerDeptManager AS department_manager,
s.shopState AS store_status,
s.shopBankAccount AS withdrawal_account,
s.shopType AS store_type,
s.channelType AS channel_mode
FROM rpt.fys_shopInfo AS s
OUTER APPLY (
SELECT TOP 1 company.businessLicenseName, company.businessLicenseType
FROM rpt.fys_companyInfo AS company
WHERE company.unifiedSocialCreditCode = NULLIF(s.unifiedSocialCreditCode, '')
ORDER BY company.Id DESC
) AS c
OUTER APPLY (
SELECT TOP 1 trademark.trademarkName
FROM rpt.fys_TrademarkInfo AS trademark
WHERE trademark.trademarkNumber = NULLIF(s.trademarkNumber, '')
AND trademark.isDelete = 0
ORDER BY trademark.Id DESC
) AS t
ORDER BY s.Id DESC
SQL);
$rows = array_map(fn (array $row): array => $this->mapStore($row), $rows);
return ['data' => $rows, 'total' => count($rows)];
}
private function trademarkPage(int $page, int $perPage): array
{
$filters = $this->pageFilters('trademark-management', 'information');
return $this->page(
'trademark-information.read',
$page,
$perPage,
$filters,
fn () => $this->snapshot('trademark-information.read', [
'page' => $page,
'perPage' => $perPage,
@ -338,10 +645,14 @@ SQL);
];
}
private function page(string $scope, int $page, int $perPage, callable $resolver): array
private function page(string $scope, int $page, int $perPage, array $filters, callable $resolver): array
{
if ($this->mirror->ready($scope)) {
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage));
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage, $filters));
}
if ($filters !== []) {
throw new ServiceUnavailableHttpException(null, '当前数据源暂不支持所选条件查询,请检查共享业务镜像状态。');
}
if ((app(\App\Utilities\SystemDatabaseConfiguration::class)->current()['mode'] ?? '') === 'bootstrap'
@ -356,6 +667,47 @@ SQL);
});
}
/** @return array<string,string|list<string>> */
private function pageFilters(string $section, string $page): array
{
$fields = collect(config('business_navigation'))
->firstWhere('slug', $section)['pages'] ?? [];
$pageConfig = collect($fields)->flatMap(fn (array $item) => [$item, ...($item['children'] ?? [])])->firstWhere('slug', $page);
$allowed = collect($pageConfig['directory']['fields'] ?? [])
->reject(fn (array $field) => ($field['kind'] ?? '') === 'secret' && ($field['name'] ?? '') !== 'withdrawal_account')
->pluck('name')->all();
if ($section === 'company-management' && $page === 'basic-information') {
$allowed = array_values(array_unique([...$allowed, 'company_type', 'related_people']));
}
$filters = [];
foreach ($allowed as $name) {
foreach ([$name, $name . '_from', $name . '_to'] as $key) {
$raw = request($key, '');
// 多选筛选(xxx[])传进来是数组:去重后原样带下去,由各自的仓库取并集。
if (is_array($raw)) {
$values = [];
foreach ($raw as $item) {
if (! is_scalar($item)) continue;
$item = mb_substr(trim((string) $item), 0, 191);
if ($item !== '') $values[$item] = true;
if (count($values) >= 50) break;
}
if ($values !== []) $filters[$key] = array_keys($values);
continue;
}
$value = trim((string) $raw);
if ($value !== '') $filters[$key] = mb_substr($value, 0, 191);
}
}
$sort = trim((string) request('sort', ''));
if (in_array($sort, $allowed, true)) {
$filters['_sort'] = $sort;
$filters['_direction'] = request('direction') === 'desc' ? 'desc' : 'asc';
}
return $filters;
}
/** @param array{data: array<int,array<string,mixed>>, total: int} $payload */
private function normalize(string $scope, array $payload): array
{
@ -377,6 +729,7 @@ SQL);
$row = $this->toUtf8($row);
$row['establishment_date'] = $this->date($row['establishment_date'] ?? null);
$row['subscribed_at'] = $this->date($row['subscribed_at'] ?? null);
$row['city'] = RegisteredAddressCity::parse($row['registered_address'] ?? null);
$row['company_status'] = match ((string) ($row['company_status'] ?? '')) {
'0' => '正常',
'1' => '已注销',

View File

@ -0,0 +1,293 @@
<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
class WithdrawalManagementReport
{
private const SORT_FIELDS = [
'platform',
'erp_store_name',
'store_id',
'store_entity',
'withdrawal_path',
'withdrawal_account',
'bank_card',
'balance',
'reserve_standard',
'withdrawal_amount',
'compliance',
];
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
}
/**
* @return array{
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string},
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
* summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
* balanceColumnLabel:string,balanceDate:string,balanceDates:list<string>,sort:array{field:string,direction:string}
* }
*/
public function results(Request $request): array
{
$filters = $this->filters($request);
$sort = $this->sorting($request);
$platforms = $this->options('store_platform');
$paths = $this->options('receiving_method');
$storeEntities = $this->options('store_entity_name');
$query = StoreReceivingAccount::query();
if ($filters['platform'] !== '') {
$query->where('store_platform', $filters['platform']);
}
if ($filters['withdrawalPath'] !== '') {
$query->where('receiving_method', $filters['withdrawalPath']);
}
if ($filters['storeEntity'] !== '') {
$query->where('store_entity_name', $filters['storeEntity']);
}
if ($filters['keyword'] !== '') {
$query->where('store_name', 'like', '%' . addcslashes($filters['keyword'], '%_\\') . '%');
}
$today = now()->startOfDay();
// 余额快照按日期长期保留:默认显示最新余额日期,也可指定历史日期回看。
$latestBalanceDate = $this->shopBalances->latestBalanceDate($today);
$balanceDate = $filters['balanceDate'] !== ''
? $filters['balanceDate']
: ($latestBalanceDate ?? $today->copy()->subDay()->toDateString());
$labelDate = $filters['balanceDate'] !== ''
? CarbonImmutable::parse($balanceDate)
: $today;
$rows = $query->orderByDesc('id')->get();
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate);
$balanceCounts = collect($balances)->countBy(
static fn (array $match): string => $match['status'],
);
if ($filters['balanceStatus'] !== '') {
$rows = $rows->filter(
static fn (StoreReceivingAccount $row): bool => ($balances[(int) $row->id]['status'] ?? 'not_ready') === $filters['balanceStatus'],
);
}
$reserveStandards = $rows->mapWithKeys(
static fn (StoreReceivingAccount $row): array => [
(int) $row->id => WithdrawalReserveStandard::amountFor(
$row->store_platform,
$row->erp_store_name,
),
],
)->all();
$withdrawalAmounts = $rows->mapWithKeys(
fn (StoreReceivingAccount $row): array => [
(int) $row->id => $this->withdrawalAmountFor($row, $balanceDate),
],
)->all();
$compliance = $rows->mapWithKeys(
fn (StoreReceivingAccount $row): array => [
(int) $row->id => $this->complianceFor(
$balances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null],
$reserveStandards[(int) $row->id] ?? null,
$withdrawalAmounts[(int) $row->id] ?? null,
),
],
)->all();
$rows = $sort['field'] === ''
? $this->sortByBalanceStatus($rows, $balances)
: $this->sortRows($rows, $sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance);
return [
'filters' => $filters,
'platforms' => $platforms,
'paths' => $paths,
'storeEntities' => $storeEntities,
'rows' => $rows,
'balances' => $balances,
'reserveStandards' => $reserveStandards,
'withdrawalAmounts' => $withdrawalAmounts,
'compliance' => $compliance,
'summary' => [
'source' => $this->shopBalances->countForDate($balanceDate),
'matched' => (int) $balanceCounts->get('matched', 0),
'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
],
'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额',
'balanceDate' => $balanceDate,
'balanceDates' => $this->shopBalances->availableDates($today),
'sort' => $sort,
];
}
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string} */
public function filters(Request $request): array
{
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
$balanceStatus = $clean($request->query('balance_status'));
$balanceDate = $clean($request->query('balance_date'), 10);
if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
$balanceStatus = '';
}
$validDate = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $balanceDate, $parts) === 1
&& checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]);
if (! $validDate || $balanceDate > now()->toDateString()) {
$balanceDate = '';
}
return [
'platform' => $clean($request->query('platform')),
'withdrawalPath' => $clean($request->query('withdrawal_path')),
'storeEntity' => $clean($request->query('store_entity'), 191),
'keyword' => $clean($request->query('keyword'), 191),
'balanceStatus' => $balanceStatus,
'balanceDate' => $balanceDate,
];
}
/** @return list<string> */
private function options(string $column): array
{
return StoreReceivingAccount::query()
->whereNotNull($column)
->where($column, '<>', '')
->distinct()
->orderBy($column)
->pluck($column)
->filter()
->values()
->all();
}
/** @return array{field:string,direction:string} */
private function sorting(Request $request): array
{
$field = trim((string) $request->query('sort'));
$direction = strtolower(trim((string) $request->query('direction')));
return [
'field' => in_array($field, self::SORT_FIELDS, true) ? $field : '',
'direction' => in_array($direction, ['asc', 'desc'], true) ? $direction : 'asc',
];
}
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @return Collection<int,StoreReceivingAccount>
*/
private function sortByBalanceStatus(Collection $rows, array $balances): Collection
{
$statusOrder = ['matched' => 0, 'ambiguous' => 1, 'unmatched' => 2, 'not_ready' => 3];
return $rows->sort(
static function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($balances, $statusOrder): int {
$leftStatus = $balances[(int) $left->id]['status'] ?? 'not_ready';
$rightStatus = $balances[(int) $right->id]['status'] ?? 'not_ready';
$statusComparison = ($statusOrder[$leftStatus] ?? 99) <=> ($statusOrder[$rightStatus] ?? 99);
return $statusComparison !== 0
? $statusComparison
: ((int) $right->id <=> (int) $left->id);
},
)->values();
}
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array{field:string,direction:string} $sort
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @param array<int,int|null> $reserveStandards
* @param array<int,float|null> $withdrawalAmounts
* @param array<int,string|null> $compliance
* @return Collection<int,StoreReceivingAccount>
*/
private function sortRows(Collection $rows, array $sort, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): Collection
{
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance): int {
$leftValue = $this->sortValue($left, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
$rightValue = $this->sortValue($right, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
$comparison = $this->compareSortValues($leftValue, $rightValue, $sort['direction']);
return $comparison !== 0
? $comparison
: ((int) $right->id <=> (int) $left->id);
})->values();
}
/**
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @param array<int,int|null> $reserveStandards
* @param array<int,float|null> $withdrawalAmounts
* @param array<int,string|null> $compliance
*/
private function sortValue(StoreReceivingAccount $row, string $field, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): mixed
{
return match ($field) {
'platform' => $row->store_platform,
'erp_store_name' => $row->erp_store_name,
'store_id' => $row->store_id,
'store_entity' => $row->store_entity_name,
'withdrawal_path' => $row->withdrawal_path,
'withdrawal_account' => $row->withdrawal_account_name,
'bank_card' => $row->bank_card_last_four,
'balance' => ($balances[(int) $row->id]['status'] ?? null) === 'matched'
? $balances[(int) $row->id]['balance']
: null,
'reserve_standard' => $reserveStandards[(int) $row->id] ?? null,
'withdrawal_amount' => $withdrawalAmounts[(int) $row->id] ?? null,
'compliance' => $compliance[(int) $row->id] ?? null,
default => null,
};
}
private function withdrawalAmountFor(StoreReceivingAccount $row, string $balanceDate): ?float
{
if ($row->withdrawal_balance_date !== $balanceDate || $row->withdrawal_amount === null) {
return null;
}
return is_numeric($row->withdrawal_amount) ? (float) $row->withdrawal_amount : null;
}
/** @param array{status:string,balance:?float} $balance */
private function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
{
if (($balance['status'] ?? '') !== 'matched' || $balance['balance'] === null || $reserve === null || $withdrawalAmount === null) {
return null;
}
$remainingCents = (int) round((float) $balance['balance'] * 100)
- (int) round($withdrawalAmount * 100);
return $remainingCents <= $reserve * 100 ? 'yes' : 'no';
}
private function compareSortValues(mixed $left, mixed $right, string $direction): int
{
$leftEmpty = $left === null || $left === '';
$rightEmpty = $right === null || $right === '';
if ($leftEmpty || $rightEmpty) {
return $leftEmpty === $rightEmpty ? 0 : ($leftEmpty ? 1 : -1);
}
$comparison = is_numeric($left) && is_numeric($right)
? ((float) $left <=> (float) $right)
: strnatcasecmp((string) $left, (string) $right);
return $direction === 'desc' ? -$comparison : $comparison;
}
}

View File

@ -0,0 +1,486 @@
<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class WithdrawalManagementSpreadsheet
{
private const CONFIG_HEADERS = [
'店铺平台',
'ERP店铺名称',
'平台店铺ID',
'店铺主体',
'提现路径',
'提现账户名称',
'银行卡号(后四位)',
];
private const CLEAR_VALUE = '[清空]';
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
}
/**
* @param array{
* rows:\Illuminate\Support\Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
* balanceColumnLabel:string,balanceDate:string
* } $report
*/
public function export(array $report): StreamedResponse
{
$spreadsheet = $this->workbook($report);
return $this->download(
$spreadsheet,
'withdrawal_management_' . str_replace('-', '', $report['balanceDate']) . '_' . now()->format('His') . '.xlsx',
);
}
public function template(): StreamedResponse
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('提现管理');
$this->writeHeader($sheet, [...self::CONFIG_HEADERS, '提现金额']);
$this->sizeColumns($sheet, [14, 30, 20, 32, 16, 24, 22, 18]);
$instructions = $spreadsheet->createSheet();
$instructions->setTitle('填写说明');
$instructions->fromArray([
['提现管理导入说明'],
['1. 店铺平台、ERP店铺名称用于定位店铺;有平台店铺ID时优先按平台和ID匹配。'],
['2. 店铺主体、提现路径、提现账户名称、银行卡号后四位和提现金额为可更新字段。'],
['3. 可更新字段留空表示保持原值,填写“' . self::CLEAR_VALUE . '”表示清空原值。'],
['4. 提现金额须为非负数,最多两位小数,只用于当前页面显示的余额数据日期(最近 5 天内最新可用日期);上传后先预览再确认。'],
], null, 'A1');
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
$instructions->getColumnDimension('A')->setWidth(88);
$instructions->getStyle('A1:A5')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
foreach (range(1, 5) as $row) {
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 34);
}
$spreadsheet->setActiveSheetIndex(0);
return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx');
}
/**
* @return array{
* total:int,changed:int,unchanged:int,
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string}>,
* plan:list<array{id:int,updated_at:string,changes:array<string,string|null>}>
* }
*/
public function preview(UploadedFile $file, int $companyId): array
{
try {
$spreadsheet = IOFactory::load($file->getRealPath());
$sheet = $spreadsheet->getSheetByName('提现管理') ?? $spreadsheet->getActiveSheet();
$matrix = $sheet->toArray(null, false, true, false);
$spreadsheet->disconnectWorksheets();
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
]);
}
[$headerIndex, $headers] = $this->headers($matrix);
$indexes = array_flip($headers);
$balanceDate = $this->currentBalanceDate();
$accounts = StoreReceivingAccount::withoutGlobalScopes()
->where('company_id', $companyId)
->whereNull('deleted_at')
->get();
[$byId, $byName] = $this->indexes($accounts);
$errors = [];
$plan = [];
$preview = [];
$seen = [];
$total = 0;
$unchanged = 0;
foreach (array_slice($matrix, $headerIndex + 1, null, true) as $matrixIndex => $row) {
$values = [];
foreach (self::CONFIG_HEADERS as $header) {
$values[$header] = trim((string) ($row[$indexes[$header]] ?? ''));
}
$values['提现金额'] = isset($indexes['提现金额'])
? trim((string) ($row[$indexes['提现金额']] ?? ''))
: '';
if (count(array_filter($values, static fn (string $value): bool => $value !== '')) === 0) {
continue;
}
$total++;
if ($total > 5000) {
throw ValidationException::withMessages(['import' => '单次最多导入 5000 行,请拆分文件后重试。']);
}
$line = $matrixIndex + 1;
$platform = mb_substr($values['店铺平台'], 0, 80);
$erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191);
$storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191);
if ($platform === '' || $erpStoreName === '') {
$errors[] = "第 {$line} 行缺少店铺平台或 ERP 店铺名称";
continue;
}
$matches = $storeId !== ''
? ($byId[$this->key($platform, $storeId)] ?? [])
: ($byName[$this->key($platform, $erpStoreName)] ?? []);
if (count($matches) === 0) {
$identity = $storeId !== '' ? "平台店铺ID {$storeId}" : "ERP 店铺名称 {$erpStoreName}";
$errors[] = "第 {$line} 行未找到 {$platform} / {$identity}";
continue;
}
if (count($matches) > 1) {
$errors[] = "第 {$line} 行匹配到多家店铺,请补充或核对平台店铺ID";
continue;
}
/** @var StoreReceivingAccount $account */
$account = $matches[0];
if (isset($seen[(int) $account->id])) {
$errors[] = "第 {$line} 行与第 {$seen[(int) $account->id]} 行重复指向同一家店铺";
continue;
}
$seen[(int) $account->id] = $line;
try {
$changes = $this->changes($account, $values, $balanceDate);
} catch (\InvalidArgumentException $exception) {
$errors[] = "第 {$line} 行" . $exception->getMessage();
continue;
}
if ($changes === []) {
$unchanged++;
continue;
}
$labels = [
'store_entity_name' => '店铺主体',
'receiving_method' => '提现路径',
'receiving_account_name' => '提现账户名称',
'bank_card_last_four' => '银行卡号后四位',
'withdrawal_amount' => '提现金额',
'withdrawal_balance_date' => '提现金额',
];
$changedLabels = array_unique(array_map(
static fn (string $field): string => $labels[$field],
array_keys($changes),
));
$plan[] = [
'id' => (int) $account->id,
'updated_at' => (string) $account->getRawOriginal('updated_at'),
'changes' => $changes,
];
if (count($preview) < 20) {
$preview[] = [
'platform' => $platform,
'erp_store_name' => $erpStoreName,
'store_id' => $storeId,
'changes' => implode('、', $changedLabels),
];
}
}
if ($total === 0) {
throw ValidationException::withMessages(['import' => '导入文件中没有可识别的数据行。']);
}
if ($errors !== []) {
$visible = array_slice($errors, 0, 10);
if (count($errors) > count($visible)) {
$visible[] = '另有 ' . (count($errors) - count($visible)) . ' 项错误';
}
throw ValidationException::withMessages(['import' => implode(';', $visible) . '。']);
}
return [
'total' => $total,
'changed' => count($plan),
'unchanged' => $unchanged,
'preview' => $preview,
'plan' => $plan,
];
}
/** @param list<array{id:int,updated_at:string,changes:array<string,string|null>}> $plan */
public function commit(array $plan, int $companyId): int
{
return DB::transaction(function () use ($plan, $companyId): int {
$updated = 0;
$currentBalanceDate = null;
foreach ($plan as $item) {
/** @var StoreReceivingAccount|null $account */
$account = StoreReceivingAccount::withoutGlobalScopes()
->where('company_id', $companyId)
->whereNull('deleted_at')
->whereKey((int) $item['id'])
->lockForUpdate()
->first();
if ($account === null || (string) $account->getRawOriginal('updated_at') !== (string) $item['updated_at']) {
throw ValidationException::withMessages([
'import' => '预览后店铺配置发生变化,请重新上传并预览。',
]);
}
if (isset($item['changes']['withdrawal_balance_date'])
&& $item['changes']['withdrawal_balance_date'] !== null) {
$currentBalanceDate ??= $this->currentBalanceDate();
if ($item['changes']['withdrawal_balance_date'] !== $currentBalanceDate) {
throw ValidationException::withMessages([
'import' => '余额日期已滚动,请重新上传并预览提现金额。',
]);
}
}
foreach ($item['changes'] as $field => $value) {
if ($field === 'bank_card_last_four') {
$account->bank_card_last_four = $value;
} else {
$account->setAttribute($field, $value);
}
}
$account->save();
$updated++;
}
return $updated;
});
}
private function workbook(array $report): Spreadsheet
{
$headers = [...self::CONFIG_HEADERS, $report['balanceColumnLabel'], '预留标准', '提现金额', '是否达标'];
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('提现管理');
$this->writeHeader($sheet, $headers);
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 18, 18, 14]);
$rowNumber = 2;
foreach ($report['rows'] as $row) {
$match = $report['balances'][(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null];
$reserve = $report['reserveStandards'][(int) $row->id] ?? null;
$withdrawalAmount = $report['withdrawalAmounts'][(int) $row->id] ?? null;
$compliance = $report['compliance'][(int) $row->id] ?? null;
$textValues = [
1 => (string) ($row->store_platform ?? ''),
2 => (string) ($row->erp_store_name ?? ''),
3 => (string) ($row->store_id ?? ''),
4 => (string) ($row->store_entity_name ?? ''),
5 => (string) ($row->withdrawal_path ?? ''),
6 => (string) ($row->withdrawal_account_name ?? ''),
7 => (string) ($row->bank_card_last_four ?? ''),
];
foreach ($textValues as $column => $value) {
$sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING);
}
if ($match['status'] === 'matched') {
$sheet->setCellValue([8, $rowNumber], (float) $match['balance']);
} else {
$sheet->setCellValueExplicit([8, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
}
if ($reserve === null) {
$sheet->setCellValueExplicit([9, $rowNumber], '待配置', DataType::TYPE_STRING);
} else {
$sheet->setCellValue([9, $rowNumber], $reserve);
}
if ($withdrawalAmount === null) {
$sheet->setCellValueExplicit([10, $rowNumber], '待接入', DataType::TYPE_STRING);
} else {
$sheet->setCellValue([10, $rowNumber], $withdrawalAmount);
}
$sheet->setCellValueExplicit([11, $rowNumber], match ($compliance) {
'yes' => '是',
'no' => '否',
default => '待核验',
}, DataType::TYPE_STRING);
$rowNumber++;
}
if ($rowNumber > 2) {
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
}
$sheet->getStyle('C2:C' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
$sheet->getStyle('G2:G' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
$spreadsheet->getProperties()->setTitle('提现管理')->setDescription('余额数据日期:' . $report['balanceDate']);
return $spreadsheet;
}
/** @return array{0:int,1:list<string>} */
private function headers(array $matrix): array
{
foreach (array_slice($matrix, 0, 10, true) as $index => $row) {
$headers = array_map(static fn (mixed $value): string => trim((string) $value), $row);
if (in_array('店铺平台', $headers, true) && in_array('ERP店铺名称', $headers, true)) {
foreach (self::CONFIG_HEADERS as $required) {
if (count(array_keys($headers, $required, true)) !== 1) {
throw ValidationException::withMessages([
'import' => "导入表头必须且只能包含一个“{$required}”列,请下载最新模板。",
]);
}
}
if (count(array_keys($headers, '提现金额', true)) > 1) {
throw ValidationException::withMessages(['import' => '导入表头只能包含一个“提现金额”列。']);
}
return [(int) $index, $headers];
}
}
throw ValidationException::withMessages([
'import' => '未找到提现管理导入表头,请下载最新模板。',
]);
}
/** @return array{0:array<string,list<StoreReceivingAccount>>,1:array<string,list<StoreReceivingAccount>>} */
private function indexes($accounts): array
{
$byId = [];
$byName = [];
foreach ($accounts as $account) {
$platform = trim((string) $account->store_platform);
$storeId = trim((string) ($account->store_id ?: $account->wallet_number));
$erpStoreName = trim((string) $account->erp_store_name);
if ($platform !== '' && $storeId !== '') {
$byId[$this->key($platform, $storeId)][] = $account;
}
if ($platform !== '' && $erpStoreName !== '') {
$byName[$this->key($platform, $erpStoreName)][] = $account;
}
}
return [$byId, $byName];
}
/** @param array<string,string> $values @return array<string,string|null> */
private function changes(StoreReceivingAccount $account, array $values, string $balanceDate): array
{
$fields = [
'店铺主体' => ['field' => 'store_entity_name', 'current' => $account->store_entity_name, 'max' => 191, 'clear' => ''],
'提现路径' => ['field' => 'receiving_method', 'current' => $account->withdrawal_path, 'max' => 191, 'clear' => ''],
'提现账户名称' => ['field' => 'receiving_account_name', 'current' => $account->withdrawal_account_name, 'max' => 191, 'clear' => null],
'银行卡号(后四位)' => ['field' => 'bank_card_last_four', 'current' => $account->bank_card_last_four, 'max' => 4, 'clear' => null],
];
$changes = [];
foreach ($fields as $header => $definition) {
$raw = trim((string) ($values[$header] ?? ''));
if ($raw === '') {
continue;
}
$value = $raw === self::CLEAR_VALUE ? $definition['clear'] : $raw;
if ($header === '银行卡号(后四位)' && $value !== null) {
if (preg_match('/^\d{4}$/', $value) !== 1) {
throw new \InvalidArgumentException('的银行卡号必须填写后四位数字');
}
}
if ($value !== null && mb_strlen($value) > $definition['max']) {
throw new \InvalidArgumentException("的{$header}超过 {$definition['max']} 个字符");
}
$current = trim((string) ($definition['current'] ?? '')) ?: null;
$comparisonValue = $value === '' ? null : $value;
if ($current !== $comparisonValue) {
$changes[$definition['field']] = $value;
}
}
$amount = $values['提现金额'] ?? '';
if ($amount !== '' && $amount !== '待接入') {
if ($amount === self::CLEAR_VALUE) {
if ($account->withdrawal_amount !== null || $account->withdrawal_balance_date !== null) {
$changes['withdrawal_amount'] = null;
$changes['withdrawal_balance_date'] = null;
}
} else {
if (preg_match('/^\d{1,10}(?:\.\d{1,2})?$/', $amount) !== 1) {
throw new \InvalidArgumentException('的提现金额须为非负数,最多两位小数');
}
$normalized = number_format((float) $amount, 2, '.', '');
if ($account->withdrawal_amount !== $normalized || $account->withdrawal_balance_date !== $balanceDate) {
$changes['withdrawal_amount'] = $normalized;
$changes['withdrawal_balance_date'] = $balanceDate;
}
}
}
return $changes;
}
private function currentBalanceDate(): string
{
$today = now()->startOfDay();
return $this->shopBalances->latestBalanceDate($today)
?? $today->copy()->subDay()->toDateString();
}
private function key(string $platform, string $identity): string
{
return trim($platform) . "\0" . trim($identity);
}
private function balanceStatus(string $status): string
{
return match ($status) {
'ambiguous' => '多条匹配',
'unmatched' => '未匹配',
default => '待同步',
};
}
private function writeHeader($sheet, array $headers): void
{
$lastColumn = Coordinate::stringFromColumnIndex(count($headers));
$sheet->fromArray($headers, null, 'A1');
$sheet->freezePane('A2');
$sheet->setAutoFilter("A1:{$lastColumn}1");
$sheet->getRowDimension(1)->setRowHeight(26);
$sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle("A1:{$lastColumn}1")->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('FF856632');
$sheet->getStyle("A1:{$lastColumn}1")->getAlignment()->setVertical(Alignment::VERTICAL_CENTER);
}
private function sizeColumns($sheet, array $widths): void
{
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
}
private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse
{
return response()->streamDownload(function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Business;
final class WithdrawalReserveStandard
{
private const TMALL_HIGH_RESERVE_STORES = [
'天猫-亿家达旗舰店',
'天猫-欧意朗旗舰店',
'天猫-卓禾家具旗舰店',
];
private const PLATFORM_DEFAULTS = [
'天猫' => 20000,
'淘宝' => 10000,
'淘宝分销' => 10000,
'阿里巴巴' => 10000,
'京东' => 5000,
'京喜' => 5000,
'抖音' => 5000,
'拼多多' => 5000,
];
public static function amountFor(?string $platform, ?string $erpStoreName): ?int
{
$platform = trim((string) $platform);
$erpStoreName = trim((string) $erpStoreName);
if ($platform === '天猫' && in_array($erpStoreName, self::TMALL_HIGH_RESERVE_STORES, true)) {
return 50000;
}
return self::PLATFORM_DEFAULTS[$platform] ?? null;
}
}

View File

@ -0,0 +1,324 @@
<?php
namespace App\Business;
use Generator;
use RuntimeException;
use ZipArchive;
/**
* xlsx 工作表流式读取器(按表头取列)。
*
* 主要给几十万行的大表用(聚水潭导出订单、快递运费明细等),直接套常规 Excel 读法会踩坑:
* - 字符串用 inlineStr 内联存储(sharedStrings 里是空的);
* - worksheet 的 <dimension> 只写一个 A1,靠它判断行数会把整表读成空;
* - 一行订单一个商品明细,单文件常见上万行 × 上百列,解压后几十 MB。
*
* 所以这里用 ZipArchive 取压缩流 + expat 增量解析,只保留调用方点名的列,
* 内存占用与文件大小无关(导出 20 万行也不会整表进内存)。
*/
class XlsxSheetReader
{
private const READ_CHUNK = 262144;
/**
* 逐行读取导出文件(跳过表头行)。
*
* @param list<string|list<string>> $columns 需要取出的列(缺列会报错)。给字符串就是精确匹配表头;
* 给数组表示同一列有多个可选表头(取第一个命中的,
* 返回键用数组里的第一个名字)。
* @param list<string|list<string>> $optionalColumns 可选列:文件里有就取,没有就跳过(不报错)。
* @return Generator<int, array<string, string>>
*/
public function rows(string $path, array $columns, array $optionalColumns = []): Generator
{
if (! is_file($path)) {
throw new RuntimeException('订单导出文件不存在:' . $path);
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('无法打开订单导出文件(不是有效的 xlsx):' . $path);
}
$stream = $zip->getStream($this->worksheetPath($zip));
if ($stream === false) {
$zip->close();
throw new RuntimeException('订单导出文件里没有可读的工作表。');
}
try {
yield from $this->parseSheet($stream, $this->sharedStrings($zip), $columns, $optionalColumns);
} finally {
fclose($stream);
$zip->close();
}
}
/**
* @param resource $stream
* @param list<string> $shared
* @param list<string|list<string>> $columns
* @param list<string|list<string>> $optionalColumns
* @return Generator<int, array<string, string>>
*/
private function parseSheet($stream, array $shared, array $columns, array $optionalColumns): Generator
{
$state = [
'row' => [],
'column' => -1,
'type' => null,
'value' => '',
'inValue' => false,
'rowNumber' => 0,
'wanted' => [],
'pending' => [],
];
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
xml_set_element_handler(
$parser,
static function ($parser, string $name, array $attributes) use (&$state): void {
switch ($name) {
case 'row':
$state['row'] = [];
$state['column'] = -1;
break;
case 'c':
$state['column'] = self::columnIndex($attributes['r'] ?? null, $state['column'] + 1);
$state['type'] = $attributes['t'] ?? null;
$state['value'] = '';
break;
case 'v':
case 't':
$state['inValue'] = true;
break;
}
},
static function ($parser, string $name) use (&$state, $shared, $columns, $optionalColumns): void {
switch ($name) {
case 'v':
case 't':
$state['inValue'] = false;
break;
case 'c':
$state['row'][$state['column']] = self::cellValue($state['type'], $state['value'], $shared);
break;
case 'row':
$state['rowNumber']++;
if ($state['rowNumber'] === 1) {
$state['wanted'] = self::wantedColumns($state['row'], $columns, $optionalColumns);
break;
}
$values = [];
foreach ($state['wanted'] as $index => $column) {
$values[$column] = $state['row'][$index] ?? '';
}
$state['pending'][] = $values;
break;
}
}
);
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$state): void {
if ($state['inValue']) {
$state['value'] .= $data;
}
});
$first = true;
try {
while (! feof($stream)) {
$chunk = fread($stream, self::READ_CHUNK);
if ($chunk === false || $chunk === '') {
break;
}
if ($first) {
// 聚水潭导出的 XML 带 UTF-8 BOM,expat 会当成非法前缀。
$chunk = preg_replace('/^\xEF\xBB\xBF/', '', $chunk, 1);
$first = false;
}
if (xml_parse($parser, $chunk, false) === 0) {
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
}
while ($state['pending'] !== []) {
yield array_shift($state['pending']);
}
}
if (xml_parse($parser, '', true) === 0) {
throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser)));
}
} finally {
xml_parser_free($parser);
}
while ($state['pending'] !== []) {
yield array_shift($state['pending']);
}
}
/**
* 表头行 -> [列下标 => 列名],并校验需要的列都在;可选列缺了不算错。
*
* @param array<int, string> $header
* @param list<string|list<string>> $columns
* @param list<string|list<string>> $optionalColumns
* @return array<int, string>
*/
private static function wantedColumns(array $header, array $columns, array $optionalColumns = []): array
{
$byName = [];
foreach ($header as $index => $name) {
$name = trim($name);
if ($name !== '' && ! isset($byName[$name])) {
$byName[$name] = $index;
}
}
$wanted = [];
foreach ($columns as $column) {
$names = is_array($column) ? array_values($column) : [$column];
$matched = null;
foreach ($names as $name) {
if (isset($byName[$name])) {
$matched = $name;
break;
}
}
if ($matched === null) {
throw new RuntimeException('文件缺少列:' . implode(' / ', $names));
}
$wanted[$byName[$matched]] = $names[0];
}
foreach ($optionalColumns as $column) {
$names = is_array($column) ? array_values($column) : [$column];
$matched = null;
foreach ($names as $name) {
if (isset($byName[$name])) {
$matched = $name;
break;
}
}
if ($matched !== null && ! isset($wanted[$byName[$matched]])) {
$wanted[$byName[$matched]] = $names[0];
}
}
return $wanted;
}
/**
* @param list<string> $shared
*/
private static function cellValue(?string $type, string $value, array $shared): string
{
if ($type === 's') {
return $shared[(int) $value] ?? '';
}
return trim($value);
}
/** 单元格引用(如 AV12)-> 0 基列下标。 */
private static function columnIndex(?string $reference, int $fallback): int
{
if ($reference === null || preg_match('/^([A-Z]+)/i', $reference, $matches) !== 1) {
return $fallback;
}
$letters = strtoupper($matches[1]);
$index = 0;
for ($i = 0, $length = strlen($letters); $i < $length; $i++) {
$index = $index * 26 + (ord($letters[$i]) - 64);
}
return $index - 1;
}
private function worksheetPath(ZipArchive $zip): string
{
$workbook = $zip->getFromName('xl/workbook.xml');
$relationships = $zip->getFromName('xl/_rels/workbook.xml.rels');
if (is_string($workbook) && is_string($relationships)
&& preg_match('/<sheet[^>]*r:id="([^"]+)"/', $workbook, $sheet) === 1
&& preg_match('/Id="' . preg_quote($sheet[1], '/') . '"[^>]*Target="([^"]+)"/', $relationships, $target) === 1) {
$path = ltrim($target[1], '/');
return str_starts_with($path, 'xl/') ? $path : 'xl/' . $path;
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = (string) $zip->getNameIndex($i);
if (preg_match('#^xl/worksheets/[^/]+\.xml$#', $name) === 1) {
return $name;
}
}
return 'xl/worksheets/sheet1.xml';
}
/**
* 读取共享字符串表;聚水潭导出通常为空,但标准 xlsx 会用到。
*
* @return list<string>
*/
private function sharedStrings(ZipArchive $zip): array
{
$xml = $zip->getFromName('xl/sharedStrings.xml');
if (! is_string($xml) || $xml === '') {
return [];
}
$out = [];
$index = -1;
$buffer = '';
$inside = false;
$parser = xml_parser_create('UTF-8');
xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0);
xml_set_element_handler(
$parser,
static function ($parser, string $name) use (&$inside, &$buffer, &$index): void {
if ($name === 'si') {
$index++;
$buffer = '';
} elseif ($name === 't') {
$inside = true;
}
},
static function ($parser, string $name) use (&$inside, &$buffer, &$out): void {
if ($name === 't') {
$inside = false;
} elseif ($name === 'si') {
$out[] = $buffer;
}
}
);
xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$inside, &$buffer): void {
if ($inside) {
$buffer .= $data;
}
});
try {
xml_parse($parser, preg_replace('/^\xEF\xBB\xBF/', '', $xml, 1), true);
} finally {
xml_parser_free($parser);
}
return $out;
}
}

View File

@ -0,0 +1,282 @@
<?php
namespace App\Console\Commands;
use GuzzleHttp\Psr7\StreamWrapper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Modules\EcommerceFinance\Repositories\ColumnarBusinessMirrorRepository;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessDataPlacement;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\ClickHouseClient;
use Modules\EcommerceFinance\Services\ClickHouseSchemaManager;
use Psr\Http\Message\StreamInterface;
use RuntimeException;
use Throwable;
class BackfillBusinessDataColumnar extends Command
{
protected $signature = 'business-data:backfill-columnar
{dataset : config/upstream_mirrors.php 中的数据集键}
{--batch=5000 : 每批从现有 MariaDB 镜像读取的行数}
{--max-batches=10 : 本轮最多批次数,0 表示直到完成}
{--restart : 使用新代次从头重建,不删除旧代次}
{--archive : 对账通过后将 32 个哈希分区分别写入 Parquet 归档}';
protected $description = '从现有 MariaDB 镜像断点复制到 ClickHouse,不访问上游、不删除原表';
public function handle(
BusinessMirrorDatasetRegistry $datasets,
ClickHouseSchemaManager $clickHouseSchemas,
ColumnarBusinessMirrorRepository $columnar,
BusinessDataPlacement $placements,
ClickHouseClient $clickHouse,
): int {
$datasetKey = (string) $this->argument('dataset');
try {
$dataset = $datasets->get($datasetKey);
$targetStore = $placements->targetStore($datasetKey);
if (! in_array($targetStore, ['clickhouse', 'parquet'], true)) {
throw new RuntimeException('business_data_dataset_not_columnar');
}
if (! $clickHouse->enabled() || ! $clickHouse->ping()) {
throw new RuntimeException('business_clickhouse_unavailable');
}
$connection = $placements->connection();
$schema = Schema::connection($connection->getName());
if (! $schema->hasTable('business_data_placements') || ! $schema->hasTable($dataset->targetTable())) {
throw new RuntimeException('business_data_source_or_state_table_missing');
}
$columns = $clickHouseSchemas->ensure($dataset);
$columnNames = array_map(static fn (array $column): string => (string) $column['column_name'], $columns);
$state = $placements->state($datasetKey);
$restart = (bool) $this->option('restart');
$verified = ! $restart
&& is_array($state)
&& in_array((string) ($state['phase'] ?? ''), ['verified', 'active'], true)
&& ! empty($state['backfill_generation'])
&& ! empty($state['verified_at']);
if ($verified) {
$generation = (string) $state['backfill_generation'];
$this->info(sprintf(
'%s:复用已对账代次 %s(%d 行);MariaDB 原表仍保留。',
$datasetKey,
$generation,
(int) ($state['target_rows'] ?? 0),
));
if ((bool) $this->option('archive')) {
$this->archive($datasetKey, $dataset, $generation, $columnar, $placements);
}
return self::SUCCESS;
}
$resume = ! $restart
&& is_array($state)
&& in_array((string) ($state['phase'] ?? ''), ['backfilling', 'failed'], true)
&& ! empty($state['backfill_generation']);
$generation = $resume ? (string) $state['backfill_generation'] : (string) Str::uuid();
$cursor = $resume ? (string) ($state['backfill_cursor'] ?? '') : '';
$cutoff = $resume && ! empty($state['source_cutoff_at'])
? (string) $state['source_cutoff_at']
: now()->format('Y-m-d H:i:s.u');
$sourceQuery = $connection->table($dataset->targetTable())
->where('_mirror_synced_at', '<=', $cutoff);
$sourceRows = $resume ? (int) $state['source_rows'] : (int) (clone $sourceQuery)->count();
$backfilledRows = $resume ? (int) $state['backfilled_rows'] : 0;
$placements->save($datasetKey, [
'target_store' => $targetStore,
'phase' => 'backfilling',
'backfill_generation' => $generation,
'backfill_cursor' => $cursor === '' ? null : $cursor,
'source_cutoff_at' => $cutoff,
'source_rows' => $sourceRows,
'backfilled_rows' => $backfilledRows,
'target_rows' => 0,
'verified_at' => null,
'last_error_code' => null,
]);
$batchSize = min(max((int) $this->option('batch'), 100), 10000);
$maxBatches = max((int) $this->option('max-batches'), 0);
$batches = 0;
while ($maxBatches === 0 || $batches < $maxBatches) {
$query = $connection->table($dataset->targetTable())
->where('_mirror_synced_at', '<=', $cutoff)
->orderBy('_mirror_key')
->limit($batchSize);
if ($cursor !== '') {
$query->where('_mirror_key', '>', $cursor);
}
$rows = $query->get(['_mirror_key', ...$columnNames, '_mirror_synced_at'])
->map(static fn (object $row): array => (array) $row)
->all();
if ($rows === []) {
break;
}
$columnar->storeBackfillBatch($dataset, $columns, $rows, $generation);
$cursor = (string) $rows[array_key_last($rows)]['_mirror_key'];
$backfilledRows += count($rows);
$batches++;
$placements->save($datasetKey, [
'phase' => 'backfilling',
'backfill_generation' => $generation,
'backfill_cursor' => $cursor,
'source_cutoff_at' => $cutoff,
'source_rows' => $sourceRows,
'backfilled_rows' => $backfilledRows,
'last_error_code' => null,
]);
if (count($rows) < $batchSize) {
break;
}
}
if ($backfilledRows < $sourceRows) {
$this->info(sprintf('%s:本轮 %d 批,已复制 %d/%d 行;可重复执行续传。', $datasetKey, $batches, $backfilledRows, $sourceRows));
return self::SUCCESS;
}
$targetRows = $columnar->count($dataset, $generation);
$distinctKeys = $columnar->distinctKeys($dataset, $generation);
if ($targetRows !== $sourceRows || $distinctKeys !== $sourceRows) {
$mismatch = sprintf(
'business_data_backfill_count_mismatch:%d:%d:%d',
$sourceRows,
$targetRows,
$distinctKeys,
);
$placements->save($datasetKey, [
'phase' => 'failed',
'target_rows' => $targetRows,
'last_error_code' => $mismatch,
]);
throw new RuntimeException($mismatch);
}
$placements->save($datasetKey, [
'phase' => 'verified',
'target_rows' => $targetRows,
'verified_at' => now(),
'last_error_code' => null,
]);
$this->info(sprintf('%s:MariaDB 与 ClickHouse 已对账,代次 %s 共 %d 行;尚未切换页面或删除原表。', $datasetKey, $generation, $targetRows));
if ((bool) $this->option('archive')) {
$this->archive($datasetKey, $dataset, $generation, $columnar, $placements);
}
return self::SUCCESS;
} catch (Throwable $exception) {
$errorCode = preg_match('/^(business_|clickhouse_)[A-Za-z0-9_:.-]+$/', $exception->getMessage()) === 1
? $exception->getMessage()
: $exception::class;
if (isset($placements) && Schema::connection($placements->connection()->getName())->hasTable('business_data_placements')) {
try {
$placements->save($datasetKey, [
'phase' => 'failed',
'last_error_code' => mb_substr($errorCode, 0, 120),
]);
} catch (Throwable) {
// Preserve the original failure when the control store is unavailable.
}
}
report($exception);
$this->error($datasetKey . ':列式回填失败(' . $errorCode . '),现有 MariaDB 数据未删除、页面未切换。');
return self::FAILURE;
}
}
private function archive(
string $datasetKey,
BusinessMirrorDataset $dataset,
string $generation,
ColumnarBusinessMirrorRepository $columnar,
BusinessDataPlacement $placements,
): void {
$diskName = (string) config('business_data.archive.disk', 'business_uploads_qiniu');
$prefix = trim((string) config('business_data.archive.prefix', 'business-data-archive'), '/');
$slug = trim((string) preg_replace('/[^A-Za-z0-9_.-]+/', '-', $datasetKey), '-');
$disk = Storage::disk($diskName);
for ($partition = 0; $partition < 32; $partition++) {
$partitionKey = sprintf('hash-%02d', $partition);
$objectKey = sprintf('%s/%s/%s/%s.parquet', $prefix, $slug, $generation, $partitionKey);
$existing = $placements->connection()->table('business_data_archives')
->where('dataset_key', $datasetKey)
->where('generation', $generation)
->where('partition_key', $partitionKey)
->first();
if ($existing !== null
&& (string) ($existing->status ?? '') === 'verified'
&& $disk->exists($objectKey)
&& (int) $disk->size($objectKey) === (int) ($existing->byte_size ?? 0)) {
continue;
}
$stream = $columnar->parquetPartition($dataset, $generation, $partition);
$resource = $this->seekableResource($stream);
try {
$disk->writeStream($objectKey, $resource);
} finally {
fclose($resource);
}
$bytes = (int) $disk->size($objectKey);
$stats = $columnar->partitionStats($dataset, $generation, $partition);
$rowCount = $stats['row_count'];
if ($bytes <= 0 || ($rowCount > 0 && $bytes < 8)) {
throw new RuntimeException('business_data_archive_verification_failed');
}
$placements->connection()->table('business_data_archives')->updateOrInsert(
['dataset_key' => $datasetKey, 'generation' => $generation, 'partition_key' => $partitionKey],
[
'object_key' => $objectKey,
'row_count' => $rowCount,
'byte_size' => $bytes,
'logical_checksum' => $stats['logical_checksum'],
'status' => 'verified',
'verified_at' => now(),
'created_at' => now(),
'updated_at' => now(),
],
);
}
$this->info($datasetKey . ':32 个 Parquet 哈希分区已写入对象存储并登记清单;MariaDB 原表仍保留。');
}
/** @return resource */
private function seekableResource(StreamInterface $stream): mixed
{
$source = StreamWrapper::getResource($stream);
$target = tmpfile();
if ($target === false) {
fclose($source);
throw new RuntimeException('business_data_archive_temp_file_unavailable');
}
try {
if (stream_copy_to_stream($source, $target) === false || ! rewind($target)) {
throw new RuntimeException('business_data_archive_spool_failed');
}
} catch (Throwable $exception) {
fclose($target);
throw $exception;
} finally {
fclose($source);
}
return $target;
}
}

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,89 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\BusinessDataPlacement;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Throwable;
class BusinessDataPlan extends Command
{
protected $signature = 'business-data:plan {--json : 输出机器可读 JSON}';
protected $description = '只读核对业务镜像数据集的页面用途、当前体量和目标存储层';
public function handle(BusinessMirrorDatasetRegistry $datasets, BusinessDataPlacement $placements): int
{
try {
$registered = $datasets->all();
$policies = $placements->policies();
$codeOwned = array_keys((array) config('upstream_mirrors.datasets', []));
$missing = array_values(array_diff($codeOwned, array_keys($policies)));
$unknown = array_values(array_diff(array_keys($policies), $codeOwned));
$sizes = $this->tableSizes($placements);
$rows = [];
foreach ($registered as $key => $dataset) {
$policy = in_array($key, $missing, true)
? ['role' => 'unclassified', 'consumer' => '', 'target_store' => 'unclassified', 'sync' => 'unknown']
: $placements->policy($key);
$size = $sizes[$dataset->targetTable()] ?? ['rows' => null, 'bytes' => null];
$state = $placements->state($key);
$rows[] = [
'dataset' => $key,
'table' => $dataset->targetTable(),
'role' => $policy['role'] ?? 'unclassified',
'consumer' => $policy['consumer'] ?? '',
'target_store' => $policy['target_store'] ?? 'unclassified',
'sync' => $policy['sync'] ?? 'always',
'estimated_rows' => $size['rows'],
'size_mib' => $size['bytes'] === null ? null : round($size['bytes'] / 1024 / 1024, 1),
'migration_phase' => $state['phase'] ?? 'not_started',
];
}
$payload = ['datasets' => $rows, 'missing_policies' => $missing, 'unknown_policies' => $unknown];
if ($this->option('json')) {
$this->line(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
} else {
$this->table(
['数据集', '当前表', '角色', '页面/用途', '目标层', '同步', '估算行数', 'MiB', '迁移阶段'],
array_map(static fn (array $row): array => array_values($row), $rows),
);
if ($missing !== []) {
$this->error('缺少存储策略:' . implode(', ', $missing));
}
if ($unknown !== []) {
$this->error('存在无对应数据集的存储策略:' . implode(', ', $unknown));
}
}
return $missing === [] && $unknown === [] ? self::SUCCESS : self::FAILURE;
} catch (Throwable $exception) {
report($exception);
$this->error('无法读取业务数据分层计划,请确认共享系统库和业务镜像库连接可用。');
return self::FAILURE;
}
}
/** @return array<string,array{rows:?int,bytes:?int}> */
private function tableSizes(BusinessDataPlacement $placements): array
{
$connection = $placements->connection();
if ($connection->getDriverName() !== 'mysql') {
return [];
}
$database = $connection->getDatabaseName();
return $connection->table('information_schema.TABLES')
->where('TABLE_SCHEMA', $database)
->get(['TABLE_NAME', 'TABLE_ROWS', 'DATA_LENGTH', 'INDEX_LENGTH'])
->mapWithKeys(static fn (object $row): array => [
(string) $row->TABLE_NAME => [
'rows' => $row->TABLE_ROWS === null ? null : (int) $row->TABLE_ROWS,
'bytes' => (int) $row->DATA_LENGTH + (int) $row->INDEX_LENGTH,
],
])->all();
}
}

View File

@ -0,0 +1,301 @@
<?php
namespace App\Console\Commands;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 按取数逻辑计算某个月的更改价格、定价状态、采购单价、期初金额、入库金额和加权平均单价。
*/
class ComputeProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:compute {month : 报表月份,例如 2026-07}';
protected $description = '按取数逻辑计算商品月加权平均单价';
public function handle(): int
{
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = 'product_monthly_weighted_average_rows';
$deadline = $month->addMonthNoOverflow()->day(2)->startOfDay();
$query = DB::connection($connection)->table($table)->where('report_month', $month->toDateString());
$codes = (clone $query)->distinct()->pluck('product_code')->map(fn ($code) => trim((string) $code))->unique()->values()->all();
$this->info('待计算 SKU:' . count($codes));
$cost = $this->costPrices($connection, $codes);
$work = $this->workPlatformPrices($connection, $codes);
$change = $this->priceChanges($connection, $codes);
$inbound = $this->inboundAggregate($connection, $month);
$costRecordSkus = $this->costPriceRecordSkus($connection, $codes);
$total = (clone $query)->count();
$computed = 0;
$now = now();
for ($offset = 0; $offset < $total; $offset += 1000) {
$rows = (clone $query)->orderBy('row_key')->offset($offset)->limit(1000)
->get([
'row_key', 'barcode_warehouse', 'warehouse_short_name', 'product_code', 'base_source', 'imported_at',
'opening_quantity', 'opening_unit_price', 'previous_pricing_status', 'purchase_quantity',
]);
$updates = [];
foreach ($rows as $row) {
// 普通商品资料与进销存的商品编码大小写不一致,统一按大写匹配。
$code = mb_strtoupper(trim((string) $row->product_code));
$costPrice = is_array($cost) ? ($cost[$code] ?? null) : null;
$workPrice = is_array($work) ? ($work[$code] ?? null) : null;
$changePrice = is_array($change) ? ($change[$code] ?? null) : null;
$inboundKey = $code . "\0" . trim((string) $row->warehouse_short_name);
$payload = $this->calculate(
$row,
$costPrice,
$workPrice,
$changePrice,
$deadline,
$inbound[$inboundKey] ?? null,
isset($costRecordSkus[$code]),
);
$updates[] = array_merge([
'report_month' => $month->toDateString(),
'row_key' => $row->row_key,
'barcode_warehouse' => $row->barcode_warehouse,
'warehouse_short_name' => $row->warehouse_short_name,
'product_code' => $row->product_code,
'base_source' => $row->base_source,
'imported_at' => $row->imported_at,
'computed_at' => $now,
], $payload);
$computed++;
}
if ($updates !== []) {
DB::connection($connection)->table($table)->upsert(
$updates,
['report_month', 'row_key'],
['adjusted_unit_price', 'opening_amount', 'purchase_quantity', 'purchase_unit_price', 'purchase_amount', 'weighted_average_unit_price', 'pricing_status', 'computed_at'],
);
}
}
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
['report_month' => $month->toDateString()],
['status' => 'computed', 'computed_rows' => $computed, 'last_computed_at' => $now, 'updated_at' => $now, 'created_at' => $now],
);
$this->info($month->format('Y-m') . " 计算完成:{$computed} 行");
return self::SUCCESS;
}
/**
* @return array<string,float>
*/
private function costPrices(string $connection, array $codes): array
{
$map = [];
foreach (array_chunk($codes, 2000) as $chunk) {
foreach (DB::connection($connection)->table('ops_jst_sku')
->whereIn('sku_id', $chunk)->whereNotNull('cost_price')->get(['sku_id', 'cost_price']) as $row) {
$map[mb_strtoupper(trim((string) $row->sku_id))] = (float) $row->cost_price;
}
}
return $map;
}
/**
* @return array<string,array{price:float,effect:?string}>
*/
private function workPlatformPrices(string $connection, array $codes): array
{
$map = [];
$now = now();
foreach (array_chunk($codes, 2000) as $chunk) {
$rows = DB::connection($connection)->table('js_pm_price_adjustment')
->whereIn('skuCode', $chunk)
->whereNotNull('purchasePrice')
->where(function ($query) use ($now): void {
$query->whereNull('effectTime')->orWhere('effectTime', '<=', $now);
})
->orderBy('effectTime')
->get(['skuCode', 'purchasePrice', 'effectTime']);
foreach ($rows as $row) {
$map[mb_strtoupper(trim((string) $row->skuCode))] = [
'price' => (float) $row->purchasePrice,
'effect' => $row->effectTime === null ? null : (string) $row->effectTime,
];
}
}
return $map;
}
/**
* @return array<string,array{price:float,time:?string}>
*/
/**
* 入库明细汇总:按「大写条码 + 仓库简称」汇总库存变动量与总计(金额 + 预估金额)。
*
* @return array<string,array{quantity:float,total:float}>
*/
private function inboundAggregate(string $connection, CarbonImmutable $month): array
{
$map = [];
if (! \Illuminate\Support\Facades\Schema::connection($connection)->hasTable('inventory_inbound_detail_rows')) {
return $map;
}
foreach (DB::connection($connection)->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->selectRaw('warehouse_short_name, upper(barcode) as code, sum(quantity) as quantity, sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)) as total')
->groupBy('warehouse_short_name')
->groupByRaw('upper(barcode)')
->get() as $row) {
$key = mb_strtoupper(trim((string) $row->code)) . "\0" . trim((string) $row->warehouse_short_name);
$map[$key] = ['quantity' => (float) $row->quantity, 'total' => (float) $row->total];
}
return $map;
}
/**
* 有"聚水潭成本价记录"(operated_at 非空)的商品编码集合。
*
* @return array<string,bool>
*/
private function costPriceRecordSkus(string $connection, array $codes): array
{
$map = [];
if (! \Illuminate\Support\Facades\Schema::connection($connection)->hasTable('jushuitan_cost_price_history_rows')) {
return $map;
}
foreach (array_chunk($codes, 2000) as $chunk) {
$rows = DB::connection($connection)->table('jushuitan_cost_price_history_rows')
->whereNotNull('operated_at')
->where(function ($query) use ($chunk): void {
$query->whereIn('sku_id', $chunk)->orWhereIn('sku_id_normalized', $chunk);
})
->get(['sku_id', 'sku_id_normalized']);
foreach ($rows as $row) {
foreach ([$row->sku_id, $row->sku_id_normalized] as $value) {
$key = mb_strtoupper(trim((string) $value));
if ($key !== '') {
$map[$key] = true;
}
}
}
}
return $map;
}
private function priceChanges(string $connection, array $codes): array
{
$map = [];
foreach (array_chunk($codes, 2000) as $chunk) {
$rows = DB::connection($connection)->table('js_pm_price_adjustment_log')
->whereIn('skucode', $chunk)
->whereNotNull('afterPrice')
->orderBy('effectStartTime')
->get(['skucode', 'afterPrice', 'originalPrice', 'effectStartTime', 'createTime']);
foreach ($rows as $row) {
$map[mb_strtoupper(trim((string) $row->skucode))] = [
'price' => (float) $row->afterPrice,
'before' => $row->originalPrice === null ? null : (float) $row->originalPrice,
'time' => $row->effectStartTime === null ? null : (string) $row->effectStartTime,
'created' => $row->createTime === null ? null : (string) $row->createTime,
];
}
}
return $map;
}
/**
* @param array{price:float,effect:?string}|null $work
* @param array{price:float,time:?string}|null $change
* @return array<string,mixed>
*/
private function calculate(object $row, ?float $cost, ?array $work, ?array $change, CarbonImmutable $deadline, ?array $inbound = null, bool $hasCostRecord = false): array
{
$openingQuantity = $row->opening_quantity === null ? 0.0 : (float) $row->opening_quantity;
$purchaseQuantity = $row->purchase_quantity === null ? 0.0 : (float) $row->purchase_quantity;
$openingUnitPrice = $row->opening_unit_price === null ? null : (float) $row->opening_unit_price;
$previousStatus = trim((string) ($row->previous_pricing_status ?? ''));
$changePrice = $change['price'] ?? null;
$workPrice = $work['price'] ?? null;
// 最终规则:生效开始日期为空 → 未匹配到工作平台价格;
// 生效开始日期 > 本月,或记录时间 > 次月2号(记录时间为空视为 ≤ 次月2号)→ 新价当月未生效,取变价前价格。
$changeTime = $change['time'] ?? null;
$changeCreated = $change['created'] ?? null;
$monthStart = $deadline->startOfMonth();
$hasWorkPlatformRecord = $changePrice !== null && $changeTime !== null;
$newPricePending = false;
if ($hasWorkPlatformRecord) {
$newPricePending = CarbonImmutable::parse($changeTime)->greaterThanOrEqualTo($monthStart)
|| ($changeCreated !== null && CarbonImmutable::parse($changeCreated)->greaterThan($deadline));
$changePrice = $newPricePending ? ($change['before'] ?? $changePrice) : $changePrice;
}
$previousStatus = in_array($previousStatus, ['预估价', '未定价'], true) ? '未定价' : $previousStatus;
// 更改价格 + 定价状态(表2 第10条)
if ($openingUnitPrice !== null) {
if ($previousStatus !== '未定价') {
$adjusted = $openingUnitPrice;
$status = $previousStatus !== '' ? $previousStatus : '已定价';
} elseif ($hasWorkPlatformRecord) {
$adjusted = $changePrice;
$status = $newPricePending ? '未定价' : '已定价';
} else {
$adjusted = $openingUnitPrice;
$status = '未定价';
}
} elseif ($hasWorkPlatformRecord) {
$adjusted = $changePrice;
$status = $newPricePending ? '未定价' : '已定价';
} elseif ($cost !== null) {
// 方案②:只有"聚水潭成本价记录"里有生效记录(operated_at 非空)才算有生效日期 → 已定价。
$adjusted = $cost;
$status = $hasCostRecord ? '已定价' : '未定价';
} else {
$adjusted = null;
$status = '待人工核查';
}
// 采购数量/入库金额来自入库明细(大写条码 + 仓库简称汇总),采购单价 = 入库金额 ÷ 采购数量
$purchaseQuantity = (float) ($inbound['quantity'] ?? 0);
// 加权平均单价按“先除再舍入”计算,因此这里保留未舍入的金额参与除法。
$purchaseAmountRaw = $inbound === null ? 0.0 : (float) ($inbound['total'] ?? 0);
$purchaseAmount = $inbound === null ? null : round($purchaseAmountRaw, 2);
$purchaseUnitPrice = ($inbound === null || $purchaseQuantity == 0.0)
? null
: round((float) ($inbound['total'] ?? 0) / $purchaseQuantity, 2);
$openingAmountRaw = $adjusted === null ? 0.0 : $openingQuantity * $adjusted;
$openingAmount = $adjusted === null ? null : round($openingAmountRaw, 2);
$denominator = $openingQuantity + $purchaseQuantity;
if ($denominator === 0.0) {
// 四项全 0:加权平均单价取"更改价格"(按两位舍入后的值)。
$weighted = $adjusted === null ? null : round($adjusted, 2);
} else {
// 先除再舍入:用未舍入的期初金额与入库金额相除,最后统一保留两位。
$weighted = round(($openingAmountRaw + $purchaseAmountRaw) / $denominator, 4);
}
return [
'purchase_quantity' => $purchaseQuantity,
'adjusted_unit_price' => $adjusted === null ? null : round($adjusted, 2),
'opening_amount' => $openingAmount,
'purchase_unit_price' => $purchaseUnitPrice === null ? null : round($purchaseUnitPrice, 2),
'purchase_amount' => $purchaseAmount,
'weighted_average_unit_price' => $weighted,
'pricing_status' => $status,
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\Jushuitan\ShippingReplicaSynchronizer;
/**
* 一次性:全表扫描,仅删除同订单、同发货日有干净单号对应的 @ 包裹。
* php artisan jst:dedup-at
*/
class DedupAt extends Command
{
protected $signature = 'jst:dedup-at';
protected $description = '去除同订单、同发货日且有干净单号对应的 @ 包裹';
public function handle(): int
{
@ini_set('memory_limit', '1024M');
@set_time_limit(0);
$removed = ShippingReplicaSynchronizer::make()->dedupAtPrefix();
$this->info('去除@重复单号:' . $removed . ' 条');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,164 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSchemaManager;
use Throwable;
/**
* 让登记过的数据集自动跟随上游新增字段。
*
* 上游视图/表新增列时,数据字典(upstream_schema_columns)会先刷新出来,这里再把新列
* 补进数据集定义、建好镜像列;下一次常规同步就会把数据带进来,页面也会自动多出这一列。
* 只做"新增":不删除、不改写已有字段,敏感字段名一律跳过。
*/
class FollowUpstreamColumns extends Command
{
protected $signature = 'business-mirror:follow-upstream-columns
{--dataset= : 只处理指定数据集}';
protected $description = '把上游新增字段补进登记过的镜像数据集(页面字段自动跟随)';
private const DENIED_KEYWORDS = ['password', 'passwd', 'secret', 'token', 'cipher', 'credential', 'private'];
public function handle(
BusinessMirrorDatasetRegistry $datasets,
BusinessMirrorSchemaManager $schemas,
): int {
$keys = array_values(array_filter(array_map(
static fn (mixed $key): string => trim((string) $key),
(array) ($this->option('dataset') ?: array_values((array) config('upstream_mirrors.auto_follow_columns', []))),
), static fn (string $key): bool => $key !== ''));
if ($keys === []) {
$this->info('没有登记需要自动跟随上游字段的数据集。');
return self::SUCCESS;
}
// 先刷新数据字典,否则新字段根本不在目录里。该命令自带 24 小时新鲜度判断,
// 已经同步过会直接跳过。
foreach (array_unique($this->sourcesOf($keys)) as $source) {
try {
// --due:数据字典新鲜(24 小时内同步过)就不再重复扫上游。
Artisan::call('upstream:schema:sync', ['source' => $source, '--due' => true]);
} catch (Throwable $exception) {
report($exception);
$this->warn($source .':数据字典刷新失败,本轮跳过新字段检测。');
}
}
$followed = 0;
foreach ($keys as $key) {
try {
$added = $this->follow($key, $datasets, $schemas);
if ($added !== []) {
$followed += count($added);
$this->info($key . ':新增 ' . implode('、', $added) . ' 并已建列,下次同步即带回数据。');
Log::info('业务镜像自动跟随上游新增字段。', ['dataset' => $key, 'columns' => $added]);
}
} catch (Throwable $exception) {
report($exception);
$this->error($key . ':跟随失败 —— ' . $exception->getMessage());
}
}
$this->info($followed > 0 ? sprintf('共补入 %d 个新字段。', $followed) : '没有发现上游新增字段。');
return self::SUCCESS;
}
/** @param list<string> $keys @return list<string> */
private function sourcesOf(array $keys): array
{
$sources = [];
foreach (DB::table('business_mirror_datasets')->whereIn('dataset_key', $keys)->get() as $row) {
$definition = json_decode((string) $row->definition, true) ?: [];
$source = trim((string) ($definition['source_key'] ?? ''));
if ($source !== '') {
$sources[] = $source;
}
}
return $sources;
}
/**
* @return list<string> 本次补入的字段
*/
private function follow(string $key, BusinessMirrorDatasetRegistry $datasets, BusinessMirrorSchemaManager $schemas): array
{
$row = DB::table('business_mirror_datasets')->where('dataset_key', $key)->first();
if ($row === null) {
return [];
}
$definition = json_decode((string) $row->definition, true) ?: [];
$source = trim((string) ($definition['source_key'] ?? ''));
$schema = trim((string) ($definition['schema'] ?? ''));
$object = trim((string) ($definition['object'] ?? ''));
$current = array_values(array_filter(array_map(
static fn (mixed $column): string => trim((string) $column),
(array) ($definition['columns'] ?? []),
), static fn (string $column): bool => $column !== ''));
if ($source === '' || $schema === '' || $object === '' || $current === []) {
return [];
}
$upstream = DB::table('upstream_schema_columns')
->where('source_key', $source)
->whereRaw('LOWER(schema_name) = ?', [strtolower($schema)])
->whereRaw('LOWER(object_name) = ?', [strtolower($object)])
->orderBy('ordinal_position')
->pluck('column_name')
->map(static fn (mixed $name): string => (string) $name)
->all();
$added = [];
foreach ($upstream as $column) {
if (in_array($column, $current, true) || str_starts_with($column, '_mirror') || $this->isSensitive($column)) {
continue;
}
$added[] = $column;
}
if ($added === []) {
return [];
}
$definition['columns'] = array_values(array_merge($current, $added));
// 记下"跟随进来的字段":页面据此自动补列,不会把历史上刻意没展示的字段带出来。
$definition['followed_columns'] = array_values(array_unique(array_merge(
(array) ($definition['followed_columns'] ?? []),
$added,
)));
DB::table('business_mirror_datasets')->where('dataset_key', $key)->update([
'definition' => json_encode($definition, JSON_THROW_ON_ERROR),
'updated_at' => now(),
]);
// 立刻建列,下一次同步就能写入;页面在数据到达前显示为空即可。
$schemas->ensure($datasets->get($key));
return $added;
}
private function isSensitive(string $column): bool
{
$name = strtolower($column);
foreach (self::DENIED_KEYWORDS as $keyword) {
if (str_contains($name, $keyword)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use App\Models\Common\Company;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierImporter;
use Modules\EcommerceFinance\Services\AccessoryReshipCourierStore;
class ImportAccessoryReshipCourierConfigs extends Command
{
protected $signature = 'accessory-reship:import-courier-configs {file} {--company= : 导入所属公司ID} {--dry-run : 仅检查文件及输出条数,不写入}';
protected $description = '导入用户提供的存量快递补发扣款比例配置(重复项跳过、冲突整批回滚)';
public function handle(AccessoryReshipCourierImporter $importer, AccessoryReshipCourierStore $store): int
{
$companyId = (int) $this->option('company');
if ($companyId <= 0 || !Company::query()->where('id', $companyId)->exists()) {
$this->error('请指定有效的 --company,公司配置不会跨公司写入。');
return self::FAILURE;
}
$path = $this->argument('file');
$rows = $importer->read($path);
if ($this->option('dry-run')) {
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
return self::SUCCESS;
}
$result = $store->import($companyId, $rows, '用户授权存量配置导入', hash_file('sha256', $path));
$this->info('导入完成:新增' . $result['added'] . '条,重复跳过' . $result['skipped'] . '条。');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
/**
* 导入「平台 | 账单项目 | 总账备注」映射表,供店铺收款账户账单的「总账备注」列使用。
*/
class ImportAccountLedgerRemarks extends Command
{
protected $signature = 'account-ledger-remarks:import {path : xlsx/csv 文件} {--sheet=1 : 工作表序号(从 1 开始)或名称} {--replace : 先清空再导入(默认按平台+账单项目覆盖)}';
protected $description = '导入账单项目对应的总账备注映射表';
public function handle(AccountLedgerRemarkMap $map): int
{
$path = (string) $this->argument('path');
if (! is_file($path)) {
$this->error('文件不存在:' . $path);
return self::FAILURE;
}
try {
$rows = $this->readRows($path);
} catch (Throwable $e) {
$this->error('读取失败:' . $e->getMessage());
return self::FAILURE;
}
if ($rows === []) {
$this->error('没有解析到有效数据(需要表头:平台 / 账单项目 / 总账备注)。');
return self::FAILURE;
}
$now = now();
DB::transaction(function () use ($rows, $now): void {
if ($this->option('replace')) {
DB::table('ec_account_ledger_remarks')->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
DB::table('ec_account_ledger_remarks')->upsert(
array_map(static fn (array $row): array => $row + ['created_at' => $now, 'updated_at' => $now], $chunk),
['platform', 'bill_item'],
['ledger_remark', 'updated_at'],
);
}
});
$map->forget();
$platforms = [];
foreach ($rows as $row) {
$platforms[$row['platform']] = ($platforms[$row['platform']] ?? 0) + 1;
}
ksort($platforms);
$this->info(sprintf('已导入 %d 条总账备注映射。', count($rows)));
foreach ($platforms as $platform => $count) {
$this->line(sprintf(' %s:%d 条', $platform, $count));
}
return self::SUCCESS;
}
/**
* @return list<array{platform:string,bill_item:string,ledger_remark:string}>
*/
private function readRows(string $path): array
{
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$readerType = match ($extension) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
default => throw new \InvalidArgumentException('只支持 xlsx / xls / csv 文件。'),
};
$reader = IOFactory::createReader($readerType);
$reader->setReadDataOnly(true);
if ($readerType === 'Csv') {
$reader->setInputEncoding('UTF-8');
}
$book = $reader->load($path);
$sheetOption = trim((string) $this->option('sheet'));
if ($sheetOption !== '' && ctype_digit($sheetOption)) {
$index = (int) $sheetOption - 1;
if ($index < 0 || $index >= $book->getSheetCount()) {
throw new \InvalidArgumentException('工作表序号超出范围:' . $sheetOption);
}
$sheet = $book->getSheet($index);
} else {
$sheet = $sheetOption === '' ? $book->getActiveSheet() : $book->getSheetByName($sheetOption);
if ($sheet === null) {
throw new \InvalidArgumentException('找不到工作表:' . $sheetOption);
}
}
$rows = [];
$header = null;
foreach ($sheet->getRowIterator() as $row) {
$cells = [];
$cellIterator = $row->getCellIterator();
$cellIterator->setIterateOnlyExistingCells(false);
foreach ($cellIterator as $cell) {
$cells[] = trim((string) $cell->getValue());
}
if ($header === null) {
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', $cells[0] ?? '');
$header = $cells;
continue;
}
$platform = trim($cells[0] ?? '');
$item = trim($cells[1] ?? '');
$remark = trim($cells[2] ?? '');
if ($platform === '' || $item === '') {
continue;
}
$rows[] = [
'platform' => mb_substr($platform, 0, 32),
'bill_item' => mb_substr($item, 0, 191),
'ledger_remark' => mb_substr($remark, 0, 191),
];
}
return $rows;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AccountMappingStore;
/**
* 把科目映射表的旧文件存储(storage/app/account-mapping/rules.json / events.json)
* 导入共享系统数据库(ec_account_mapping_rules / ec_account_mapping_events)。
*
* 幂等:以文件为准覆盖数据库内容,可重复执行。
*/
class ImportAccountMappingFiles extends Command
{
protected $signature = 'account-mapping:import-files {--events : 一并导入操作记录}';
protected $description = '把科目映射表的旧文件存储导入共享数据库';
public function handle(AccountMappingStore $store): int
{
$result = $store->syncFromFiles((bool) $this->option('events'));
$this->info(
'已导入规则 ' . $result['rules'] . ' 条'
. ($result['events'] > 0 ? ',操作记录 ' . $result['events'] . ' 条' : '')
. '。'
);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AlibabaAccountBalanceWorkbookImporter;
use Throwable;
class ImportAlibabaAccountBalances extends Command
{
protected $signature = 'business:import-alibaba-account-balances
{path : 阿里巴巴账户余额 Excel 文件路径}
{--source-name= : 原始文件名,用于导入来源追溯}
{--dry-run : 只校验和统计,不写入共享业务库}';
protected $description = '校验并导入阿里巴巴账户月末余额快照';
public function handle(AlibabaAccountBalanceWorkbookImporter $importer): int
{
$path = (string) $this->argument('path');
try {
$sourceName = trim((string) $this->option('source-name'));
$result = $importer->import(
$path,
$sourceName !== '' ? $sourceName : basename($path),
(bool) $this->option('dry-run'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$action = $this->option('dry-run') ? '校验完成' : '导入完成';
$this->info(sprintf(
'%s:共 %d 条,新增 %d 条,更新 %d 条。',
$action,
$result['total'],
$result['created'],
$result['updated'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Business\CourierFreightImporter;
use Illuminate\Console\Command;
use Throwable;
class ImportCourierFreight extends Command
{
protected $signature = 'courier-freight:import
{file : xlsx / csv 文件路径}
{--company=1 : 公司 ID}
{--replace : 先清空该公司已有的快递运费明细}';
protected $description = '导入「快递运费」数据(财务经营报表 › 快递运费)';
public function handle(CourierFreightImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('file'),
(int) $this->option('company'),
(bool) $this->option('replace'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$sources = $result['sources'];
$result['sources'] = count($sources);
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
foreach ($sources as $name => $count) {
$this->line(sprintf(' %s:%d 行', $name === '' ? '(空来源)' : $name, $count));
}
return self::SUCCESS;
}
}

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,72 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\DewuSettlementBillImporter;
use RuntimeException;
use Throwable;
class ImportDewuSettlementBills extends Command
{
protected $signature = 'dewu-bills:import-authoritative
{path : 聚胜算导出的得物完整账单 XLSX}
{--from= : 账期开始日期,格式 YYYY-MM-DD}
{--to= : 账期结束日期,格式 YYYY-MM-DD}
{--expected-count= : 完整账单权威笔数}
{--expected-income= : 完整账单权威收入}
{--expected-expense= : 完整账单权威支出}
{--batch=1000 : 分批写入行数}';
protected $description = '导入并核验聚胜算得物完整账单(保留全部渠道)';
public function handle(DewuSettlementBillImporter $importer): int
{
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
if (trim((string) $this->option($option)) === '') {
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
return self::FAILURE;
}
}
try {
$result = $importer->import(
(string) $this->argument('path'),
(string) $this->option('from'),
(string) $this->option('to'),
(int) $this->option('expected-count'),
(string) $this->option('expected-income'),
(string) $this->option('expected-expense'),
(int) $this->option('batch'),
);
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
} catch (Throwable $exception) {
report($exception);
$this->error('得物完整账单导入失败,详细原因已记录到受保护日志。');
return self::FAILURE;
}
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
$result['status'],
$result['row_count'],
$result['excluded_rows'],
$result['store_count'],
$result['bill_item_count'],
$result['income_total'],
$result['expense_total'],
$result['net_total'],
$result['period_start'] . ' 至 ' . $result['period_end'],
]]);
$this->line('渠道:' . collect($result['channel_counts'])
->map(fn (int $count, string $channel): string => "{$channel} {$count}")
->implode(';'));
$this->line('SHA-256: ' . $result['source_sha256']);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\DouyinAuthoritativeBillImporter;
use Throwable;
class ImportDouyinAuthoritativeBills extends Command
{
protected $signature = 'business-mirror:import-douyin-authoritative
{path : 胜算原始明细 UTF-8 CSV} {month : 所属月份 YYYY-MM}
{--count= : 核对笔数} {--income= : 核对收入合计} {--expense= : 核对支出合计}
{--through= : 月初至此日期的完整数据 YYYY-MM-DD,省略时为整月}';
protected $description = '校验并原子启用指定月份或月初至截止日的胜算抖音明细,保留旧版本用于回滚';
public function handle(DouyinAuthoritativeBillImporter $importer): int
{
try {
$result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'),
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'), $this->option('through'));
} catch (Throwable $e) {
$this->error('胜算抖音明细未启用,保留上一版数据。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件和镜像连接。'));
return self::FAILURE;
}
$this->info('胜算抖音明细已启用,范围:[' . $result['period_start'] . ', ' . $result['period_end'] . '),笔数:' . $result['row_count']);
return self::SUCCESS;
}
}

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,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\InternalFlowWorkbookImporter;
use Throwable;
class ImportInternalFlowReport extends Command
{
protected $signature = 'internal-flow:import
{path : 「一般纳税人内部流转」工作簿路径(.xlsx)}';
protected $description = '导入内部流转报表明细,整份替换共享业务镜像中的上一版数据';
public function handle(InternalFlowWorkbookImporter $importer): int
{
try {
$result = $importer->import((string) $this->argument('path'));
} catch (Throwable $exception) {
report($exception);
$this->error('内部流转报表导入失败:' . $exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'内部流转报表导入完成:明细 %s 行,跳过合计/空行 %s 行,来源文件 %s。',
number_format($result['imported']),
number_format($result['skipped']),
$result['source_file'],
));
return self::SUCCESS;
}
}

View File

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

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\InventoryReportAuthoritativeImporter;
use Throwable;
class ImportInventoryReportAuthoritativeMonth extends Command
{
protected $signature = 'business-mirror:import-inventory-report-month
{path : 聚水潭按商品、按仓库、按月多维分析完整 XLSX 导出文件}';
protected $description = '导入进销存报表权威月明细,保留仓储方和子仓维度';
public function handle(InventoryReportAuthoritativeImporter $importer): int
{
try {
$result = $importer->import((string) $this->argument('path'));
} catch (Throwable $exception) {
report($exception);
$this->error('进销存权威月报导入失败:' . $exception->getMessage());
$this->warn('已保留上一版有效数据。');
return self::FAILURE;
}
$this->info(sprintf(
'进销存权威月报导入完成:月份 %s,明细 %d 行。',
$result['source_month'],
$result['source_rows'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\JdDailyBillImporter;
use Throwable;
class ImportJdDailyBills extends Command
{
protected $signature = 'business-mirror:import-jd-daily
{path : 胜算京东日账单指定月份月初至截止日期 UTF-8 CSV} {month : YYYY-MM}
{--through= : 截止日期 YYYY-MM-DD,默认月末} {--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}';
protected $description = '核对并启用胜算京东日账单月份镜像,保留旧版本';
public function handle(JdDailyBillImporter $importer): int
{
$lock = Cache::lock('jd-daily-bill-import', 21600);
if (! $lock->get()) {
$this->error('京东日账单正在导入,请稍后重试。');
return self::FAILURE;
}
try {
$result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'),
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'), $this->option('through'));
$this->info('京东日账单已启用,笔数:' . $result['row_count']);
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('京东日账单未启用,保留上一版本。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件、重复记录及镜像连接。'));
return self::FAILURE;
} finally {
$lock->release();
}
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\JdWalletBillImporter;
use Throwable;
class ImportJdWalletBills extends Command
{
protected $signature = 'business-mirror:import-jd-wallet
{path : 胜算京东钱包指定月份月初至截止日期 UTF-8 CSV} {month : YYYY-MM}
{--through= : 截止日期 YYYY-MM-DD,默认月末} {--count= : 核对笔数} {--income= : 核对收入} {--expense= : 核对支出}';
protected $description = '核对并启用胜算京东钱包月份镜像,保留旧版本';
public function handle(JdWalletBillImporter $importer): int
{
$lock = Cache::lock('jd-wallet-bill-import', 21600);
if (! $lock->get()) {
$this->error('京东钱包正在导入,请稍后重试。');
return self::FAILURE;
}
try {
$result = $importer->import((string) $this->argument('path'), (string) $this->argument('month'),
(int) $this->option('count'), (string) $this->option('income'), (string) $this->option('expense'), $this->option('through'));
$this->info('京东钱包已启用,笔数:' . $result['row_count']);
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('京东钱包未启用,保留上一版本。' . ($e instanceof \InvalidArgumentException ? $e->getMessage() : '请检查文件、重复记录及镜像连接。'));
return self::FAILURE;
} finally {
$lock->release();
}
}
}

View File

@ -0,0 +1,146 @@
<?php
namespace App\Console\Commands;
use App\Business\JstOrderLabelPlantingRules;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
/**
* 导入「标签 | 是否种菜订单」映射表,供聚水潭订单标签明细的「是否种菜订单」列使用。
*/
class ImportJstOrderLabelPlantingRules extends Command
{
protected $signature = 'jst-order-labels:import-planting-rules
{path : xlsx/csv 文件}
{--company= : 公司ID(默认使用当前公司或 1)}
{--sheet=1 : 工作表序号(从 1 开始)或名称}
{--replace : 先清空该公司已有映射再导入(默认按标签覆盖)}';
protected $description = '导入聚水潭订单标签明细的「是否种菜订单」映射表';
public function handle(JstOrderLabelPlantingRules $rules): int
{
$path = (string) $this->argument('path');
if (! is_file($path)) {
$this->error('文件不存在:' . $path);
return self::FAILURE;
}
$companyId = (int) ($this->option('company') ?: (function_exists('company_id') && company_id() ? company_id() : 1));
if ($companyId <= 0) {
$this->error('公司ID无效,请用 --company 指定。');
return self::FAILURE;
}
try {
$rows = $this->readRows($path);
} catch (Throwable $e) {
$this->error('读取失败:' . $e->getMessage());
return self::FAILURE;
}
if ($rows === []) {
$this->error('没有解析到有效数据(需要表头:标签 / 是否种菜订单)。');
return self::FAILURE;
}
$now = now();
DB::transaction(function () use ($rows, $now, $companyId): void {
if ($this->option('replace')) {
DB::table('jst_order_label_planting_rules')->where('company_id', $companyId)->delete();
}
foreach (array_chunk($rows, 500) as $chunk) {
DB::table('jst_order_label_planting_rules')->upsert(
array_map(static fn (array $row): array => $row + [
'company_id' => $companyId,
'created_at' => $now,
'updated_at' => $now,
], $chunk),
['company_id', 'label'],
['is_planting', 'updated_at'],
);
}
});
$rules->forget($companyId);
$planting = count(array_filter($rows, static fn (array $row): bool => $row['is_planting'] === '是'));
$this->info(sprintf('已导入 %d 条「是否种菜订单」映射(公司 %d):种菜 %d 条,非种菜 %d 条。', count($rows), $companyId, $planting, count($rows) - $planting));
return self::SUCCESS;
}
/**
* @return list<array{label:string,is_planting:string}>
*/
private function readRows(string $path): array
{
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$readerType = match ($extension) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
default => throw new \InvalidArgumentException('只支持 xlsx / xls / csv 文件。'),
};
$reader = IOFactory::createReader($readerType);
$reader->setReadDataOnly(true);
if ($readerType === 'Csv') {
$reader->setInputEncoding('UTF-8');
}
$book = $reader->load($path);
$sheetOption = trim((string) $this->option('sheet'));
if ($sheetOption !== '' && ctype_digit($sheetOption)) {
$index = (int) $sheetOption - 1;
if ($index < 0 || $index >= $book->getSheetCount()) {
throw new \InvalidArgumentException('工作表序号超出范围:' . $sheetOption);
}
$sheet = $book->getSheet($index);
} else {
$sheet = $sheetOption === '' ? $book->getActiveSheet() : $book->getSheetByName($sheetOption);
if ($sheet === null) {
throw new \InvalidArgumentException('找不到工作表:' . $sheetOption);
}
}
$rows = [];
$header = null;
foreach ($sheet->getRowIterator() as $row) {
$cells = [];
$cellIterator = $row->getCellIterator();
$cellIterator->setIterateOnlyExistingCells(false);
foreach ($cellIterator as $cell) {
$cells[] = trim((string) $cell->getValue());
}
if ($header === null) {
$cells[0] = preg_replace('/^\xEF\xBB\xBF/', '', $cells[0] ?? '');
$header = $cells;
continue;
}
$label = trim($cells[0] ?? '');
$flag = trim($cells[1] ?? '');
if ($label === '') {
continue;
}
$rows[] = [
'label' => mb_substr($label, 0, 191),
'is_planting' => in_array(mb_strtolower($flag), ['否', 'n', 'no', '0', 'false'], true) ? '否' : '是',
];
}
return $rows;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Business\JstOrderLabelImporter;
use Illuminate\Console\Command;
use Throwable;
class ImportJstOrderLabels extends Command
{
protected $signature = 'jst-order-labels:import
{--from= : 起始下单日期 YYYY-MM-DD}
{--to= : 结束下单日期 YYYY-MM-DD}
{--file= : 聚水潭「导出订单」Excel 路径;给了它就从文件导入(镜像没有的历史区间用)}
{--company=1 : 公司 ID}
{--replace : 先清空该区间已有行}';
protected $description = '把聚水潭订单按标签拆行导入「聚水潭订单标签明细」:默认读共享镜像,给 --file 则读聚水潭导出的订单 Excel';
public function handle(JstOrderLabelImporter $importer): int
{
$from = (string) $this->option('from');
$to = (string) $this->option('to');
$file = trim((string) $this->option('file'));
try {
$companyId = (int) $this->option('company');
$replace = (bool) $this->option('replace');
$result = $file === ''
? $importer->import($from, $to, $companyId, $replace, 'jst-order-label-import')
: $importer->importFromFile($file, $from ?: null, $to ?: null, $companyId, $replace);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
use Throwable;
class ImportJushuitanShopBalances extends Command
{
protected $signature = 'business:import-jushuitan-shop-balances
{path : 聚水潭店铺余额接口 JSON 或 XLSX/XLS 导出文件}
{--balance-date= : 导入日期,格式 YYYY-MM-DD;多日期导出必须指定}
{--scope=all : 来源范围,同一日期同一范围会被原子替换}
{--dry-run : 只校验和统计,不写入共享业务库}';
protected $description = '校验并导入聚水潭平台原始账单中的店铺余额快照';
public function handle(JushuitanShopBalanceImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('path'),
$this->option('balance-date') === null ? null : (string) $this->option('balance-date'),
(string) $this->option('scope'),
(bool) $this->option('dry-run'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'%s:%s 共 %d 条,替换原有 %d 条。',
$result['dry_run'] ? '校验完成' : '导入完成',
$result['balance_date'],
$result['total'],
$result['replaced'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
use RuntimeException;
use Throwable;
class ImportPinduoduoSettlementBills extends Command
{
protected $signature = 'pinduoduo-bills:import-authoritative
{path : 聚胜算导出的拼多多完整账单 XLSX}
{--from= : 账期开始日期,格式 YYYY-MM-DD}
{--to= : 账期结束日期,格式 YYYY-MM-DD}
{--expected-count= : 完整账单权威笔数}
{--expected-income= : 完整账单权威收入}
{--expected-expense= : 完整账单权威支出}
{--batch=1000 : 分批写入行数}';
protected $description = '导入并核验聚胜算拼多多完整账单(保留全部账户类型)';
public function handle(PinduoduoSettlementBillImporter $importer): int
{
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
if (trim((string) $this->option($option)) === '') {
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
return self::FAILURE;
}
}
try {
$result = $importer->import(
(string) $this->argument('path'),
(string) $this->option('from'),
(string) $this->option('to'),
(int) $this->option('expected-count'),
(string) $this->option('expected-income'),
(string) $this->option('expected-expense'),
(int) $this->option('batch'),
);
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
} catch (Throwable $exception) {
report($exception);
$this->error('拼多多完整货款账单导入失败,详细原因已记录到受保护日志。');
return self::FAILURE;
}
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
$result['status'],
$result['row_count'],
$result['excluded_account_rows'],
$result['store_count'],
$result['bill_item_count'],
$result['income_total'],
$result['expense_total'],
$result['net_total'],
$result['period_start'] . ' 至 ' . $result['period_end'],
]]);
$this->line('账户类型:' . collect($result['account_type_counts'])
->map(fn (int $count, string $account): string => "{$account} {$count}")
->implode(';'));
$this->line('SHA-256: ' . $result['source_sha256']);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Business\PlantingDetailImporter;
use Illuminate\Console\Command;
use Throwable;
class ImportPlantingDetails extends Command
{
protected $signature = 'planting-details:import
{file : xlsx / xls / csv 文件路径}
{--company=1 : 公司 ID}
{--replace : 先清空该公司已有的种菜明细}';
protected $description = '导入「种菜明细表」数据(账单流水数据 › 种菜明细表)';
public function handle(PlantingDetailImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('file'),
(int) $this->option('company'),
(bool) $this->option('replace'),
null,
'planting-detail-import',
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info(json_encode($result, JSON_UNESCAPED_UNICODE));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,333 @@
<?php
namespace App\Console\Commands;
use App\Models\Business\InventoryWarehouseNameMapping;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* 商品月加权平均单价:导入某个月的期初基数。
*
* 基数来源两类:
* 1) 首个可用月份(7月)来自财务提供的《商品月末加权平均单价》表1,包含仓库简称、商品编码和上月加权平均单价;
* 2) 其余月份由上月算出的加权平均单价滚动生成(--from-previous)。
* 无论哪一类,都会并入进销存总表里"代发仓"的商品(代发仓没有库存,采购数量取销售出库)。
*/
class ImportProductMonthlyWeightedAverageBase extends Command
{
protected $signature = 'product:monthly-weighted-average:base
{month : 报表月份,例如 2026-07}
{--file= : 期初基数 Excel(表1),首月使用}
{--from-previous : 用上月的加权平均单价作为本月原始价格}';
protected $description = '导入/生成商品月加权平均单价的期初基数';
public function handle(): int
{
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$rowsTable = 'product_monthly_weighted_average_rows';
$now = now();
$inventory = $this->inventoryAggregate($connection, $month);
$jiangxiPurchase = $this->jiangxiDeliveryPurchase($connection, $month);
$nanchangOpening = $this->nanchangOpeningQuantity($connection, $month);
$this->info('进销存(排除不看)合计 ' . count($inventory) . ' 个 仓库简称+商品编码');
DB::connection($connection)->table($rowsTable)->where('report_month', $month->toDateString())->delete();
$payload = [];
$baseRows = 0;
$deliveryRows = 0;
$mirrorRows = 0;
$file = (string) ($this->option('file') ?? '');
if ($file !== '') {
foreach ($this->readBaseFile($file) as $row) {
$payload[$this->rowKey($month, $row['warehouse_short_name'], $row['product_code'])] = [
'report_month' => $month->toDateString(),
'row_key' => $this->rowKey($month, $row['warehouse_short_name'], $row['product_code']),
'barcode_warehouse' => $row['barcode_warehouse'],
'warehouse_short_name' => $row['warehouse_short_name'],
'product_code' => $row['product_code'],
'opening_unit_price' => $row['opening_unit_price'],
'previous_pricing_status' => $row['previous_pricing_status'],
'base_source' => 'base',
'imported_at' => $now,
];
$baseRows++;
}
} elseif ($this->option('from-previous')) {
$previous = $month->subMonthNoOverflow();
foreach (DB::connection($connection)->table($rowsTable)
->where('report_month', $previous->toDateString())
->get(['barcode_warehouse', 'warehouse_short_name', 'product_code', 'pricing_status', 'weighted_average_unit_price']) as $row) {
$key = $this->rowKey($month, $row->warehouse_short_name, $row->product_code);
$payload[$key] = [
'report_month' => $month->toDateString(),
'row_key' => $key,
'barcode_warehouse' => $row->barcode_warehouse,
'warehouse_short_name' => $row->warehouse_short_name,
'product_code' => $row->product_code,
'opening_unit_price' => $row->weighted_average_unit_price,
'previous_pricing_status' => $row->pricing_status,
'base_source' => 'base',
'imported_at' => $now,
];
$baseRows++;
}
} else {
$this->error('请给出 --file=表1.xlsx(首月)或 --from-previous(滚动月份)。');
return self::FAILURE;
}
// 代发仓:没有库存,但必须出现在这张表里,采购数量取销售出库。
foreach ($inventory as $key => $item) {
$rowKey = $this->rowKey($month, $item['warehouse_short_name'], $item['product_code']);
if (isset($payload[$rowKey])) {
continue;
}
$isDelivery = $this->isDeliveryWarehouse($item['warehouse_short_name']);
$payload[$rowKey] = [
'report_month' => $month->toDateString(),
'row_key' => $rowKey,
// 条码+仓库 = 商品编码 + 仓库简称
'barcode_warehouse' => $item['product_code'] . $item['warehouse_short_name'],
'warehouse_short_name' => $item['warehouse_short_name'],
'product_code' => $item['product_code'],
'opening_unit_price' => null,
'previous_pricing_status' => null,
'base_source' => $isDelivery ? 'delivery' : 'mirror',
'imported_at' => $now,
];
$isDelivery ? $deliveryRows++ : $mirrorRows++;
}
// 南昌樵舍仓的采购数量取自"简尚-江西仓送货主仓",这些条码也要一并纳入行维度,
// 否则只存在于江西仓送货的单据会因为表里没有对应行而丢掉采购数量。
foreach (array_keys($jiangxiPurchase) as $code) {
$rowKey = $this->rowKey($month, '南昌樵舍仓', $code);
if (isset($payload[$rowKey])) {
continue;
}
$payload[$rowKey] = [
'report_month' => $month->toDateString(),
'row_key' => $rowKey,
'barcode_warehouse' => $code . '南昌樵舍仓',
'warehouse_short_name' => '南昌樵舍仓',
'product_code' => $code,
'opening_unit_price' => null,
'previous_pricing_status' => null,
'base_source' => 'mirror',
'imported_at' => $now,
];
$mirrorRows++;
}
$matched = 0;
foreach ($payload as $key => $row) {
$item = $inventory[$row['warehouse_short_name'] . "\0" . $row['product_code']] ?? null;
// 代发仓判定不能只看 base_source(滚动月份会丢标记),按仓库简称再兜一层。
$isDelivery = $row['base_source'] === 'delivery'
|| str_contains((string) $row['warehouse_short_name'], '代发仓');
$isNanchang = $row['warehouse_short_name'] === '南昌樵舍仓';
// 原始数量只取"主仓"的期初数量(仓库名称含主仓字样)。
$openingQuantity = $isDelivery ? 0.0 : (float) ($item['opening_quantity'] ?? 0);
if ($isNanchang) {
// 南昌樵舍仓是特殊仓库:期初数量取"三方-南昌樵舍仓主仓"对应商品的期初数量。
$openingQuantity = (float) ($nanchangOpening[$row['product_code']] ?? 0);
}
$purchaseQuantity = $isDelivery
? abs((float) ($item['sales_out'] ?? 0))
: ($isNanchang
// 南昌樵舍仓是特殊仓库:采购数量取"简尚-江西仓送货主仓"对应商品的采购进仓数量。
? (float) ($jiangxiPurchase[$row['product_code']] ?? 0)
: (float) ($item['purchase_in'] ?? 0));
if ($item !== null) {
$matched++;
}
$payload[$key]['product_name'] = $item['category'] ?? null;
$payload[$key]['opening_quantity'] = $openingQuantity;
$payload[$key]['purchase_quantity'] = $purchaseQuantity;
$payload[$key]['opening_original_amount'] = $row['opening_unit_price'] === null
? null
: round($openingQuantity * (float) $row['opening_unit_price'], 2);
}
foreach (array_chunk($payload, 1000, true) as $chunk) {
DB::connection($connection)->table($rowsTable)->insert(array_values($chunk));
}
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
['report_month' => $month->toDateString()],
[
'status' => 'base_imported',
'base_filename' => $file !== '' ? basename($file) : null,
'base_rows' => $baseRows,
'delivery_rows' => $deliveryRows,
'matched_rows' => $matched,
'last_error' => null,
'updated_at' => $now,
'created_at' => $now,
],
);
$this->info(sprintf(
'%s 基数导入完成:基表 %d 行 + 代发仓 %d 行,合计 %d 行,其中 %d 行在进销存总表匹配到数据。',
$month->format('Y-m'),
$baseRows,
$deliveryRows,
count($payload),
$matched,
));
return self::SUCCESS;
}
/** @return array<string,array{warehouse_short_name:string,product_code:string,opening_quantity:string,purchase_in:string,sales_out:string,category:string}> */
private function inventoryAggregate(string $connection, CarbonImmutable $month): array
{
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
->where('source_month', $month->toDateString())
->value('active_generation');
if (! $generation) {
throw new \RuntimeException($month->format('Y-m') . ' 的进销存总表尚未导入。');
}
$mappingTable = (new InventoryWarehouseNameMapping)->getConnection()->getDatabaseName()
. '.' . DB::connection((new InventoryWarehouseNameMapping)->getConnectionName())->getTablePrefix()
. (new InventoryWarehouseNameMapping)->getTable();
$sql = "select m.warehouse_short_name as short_name, r.product_code as product_code,
sum(case when r.warehouse like '%主仓%' then r.opening_quantity else 0 end) as opening_quantity,
sum(r.purchase_in) as purchase_in,
sum(r.sales_out) as sales_out, max(r.category) as category
from inventory_report_authoritative_rows r
join {$mappingTable} m
on m.warehouse_provider = r.warehouse_provider
and m.warehouse = r.warehouse
and m.company_id = 1
and m.deleted_at is null
where r.generation = ?
and m.warehouse_short_name not like '%不看%'
and r.warehouse like '%主仓%'
and m.warehouse_short_name <> '南昌樵舍仓'
group by m.warehouse_short_name, r.product_code";
$aggregate = [];
foreach (DB::connection($connection)->select($sql, [$generation]) as $row) {
$aggregate[$row->short_name . "\0" . mb_strtoupper(trim((string) $row->product_code))] = [
'warehouse_short_name' => (string) $row->short_name,
// 商品编码统一按大写建索引,避免与表1/普通商品资料大小写不一致导致匹配不上。
'product_code' => mb_strtoupper(trim((string) $row->product_code)),
'opening_quantity' => (string) $row->opening_quantity,
'purchase_in' => (string) $row->purchase_in,
'sales_out' => (string) $row->sales_out,
'category' => (string) ($row->category ?? ''),
];
}
return $aggregate;
}
/** @return \Generator<int,array{barcode_warehouse:string,warehouse_short_name:string,product_code:string,opening_unit_price:?float}> */
private function readBaseFile(string $file): \Generator
{
$reader = IOFactory::createReaderForFile($file);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($file);
$sheet = $spreadsheet->getSheetByName('7月份期初基数') ?? $spreadsheet->getSheet(0);
foreach ($sheet->getRowIterator(3) as $row) {
$cells = [];
foreach ($row->getCellIterator('A', 'P') as $cell) {
$cells[] = $cell->getFormattedValue();
}
$shortName = trim((string) ($cells[2] ?? ''));
$productCode = trim((string) ($cells[4] ?? ''));
if ($shortName === '' || $productCode === '') {
continue;
}
$price = trim((string) ($cells[7] ?? ''));
$previousStatus = trim((string) ($cells[15] ?? ''));
yield [
// 条码+仓库统一为「仓库简称 + 商品编码」,不再沿用表1 里的单元格写法。
'barcode_warehouse' => $shortName . $productCode,
'warehouse_short_name' => $shortName,
'product_code' => mb_strtoupper($productCode),
// 原始价格保留源表精度(不提前舍入),保证加权平均单价与手工表一致;页面展示仍按两位。
'opening_unit_price' => $price === '' ? null : (float) str_replace(',', '', $price),
'previous_pricing_status' => $previousStatus === '' ? null : $previousStatus,
];
}
}
private function isDeliveryWarehouse(string $shortName): bool
{
return str_contains($shortName, '代发');
}
/**
* 南昌樵舍仓的采购数量取"简尚-江西仓送货主仓"对应商品的采购进仓数量。
*
* @return array<string,string>
*/
private function jiangxiDeliveryPurchase(string $connection, CarbonImmutable $month): array
{
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
->where('source_month', $month->toDateString())
->value('active_generation');
if (! $generation) {
return [];
}
$map = [];
foreach (DB::connection($connection)->table('inventory_report_authoritative_rows')
->where('generation', $generation)
->where('warehouse', '简尚-江西仓送货主仓')
->selectRaw('product_code, sum(purchase_in) as quantity')
->groupBy('product_code')
->get() as $row) {
$map[mb_strtoupper(trim((string) $row->product_code))] = (string) $row->quantity;
}
return $map;
}
/**
* 南昌樵舍仓的期初数量取"三方-南昌樵舍仓主仓"对应商品的期初数量。
*
* @return array<string,string>
*/
private function nanchangOpeningQuantity(string $connection, CarbonImmutable $month): array
{
$generation = DB::connection($connection)->table('inventory_report_authoritative_months')
->where('source_month', $month->toDateString())
->value('active_generation');
if (! $generation) {
return [];
}
$map = [];
foreach (DB::connection($connection)->table('inventory_report_authoritative_rows')
->where('generation', $generation)
->where('warehouse', '三方-南昌樵舍仓主仓')
->selectRaw('product_code, sum(opening_quantity) as quantity')
->groupBy('product_code')
->get() as $row) {
$map[mb_strtoupper(trim((string) $row->product_code))] = (string) $row->quantity;
}
return $map;
}
private function rowKey(CarbonImmutable $month, string $shortName, string $productCode): string
{
return sha1($month->format('Y-m') . '|' . $shortName . '|' . $productCode);
}
}

View File

@ -1,44 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReportDataImporter;
use Throwable;
class ImportStoreReportData extends Command
{
protected $signature = 'store-report:import-data
{--bills= : 五个平台原始账单 XLSX 文件或目录}
{--lifecycles= : 店铺生命周期 XLSX 文件}
{--batch=500 : 单批写入共享业务镜像的行数}';
protected $description = '将平台原始账单和店铺生命周期原子导入团队共享业务镜像';
public function handle(StoreReportDataImporter $importer): int
{
$bills = trim((string) $this->option('bills'));
$lifecycles = trim((string) $this->option('lifecycles'));
if ($bills === '' && $lifecycles === '') {
$this->error('请至少指定 --bills 或 --lifecycles。');
return self::INVALID;
}
try {
if ($bills !== '') {
$result = $importer->importBills($bills, (int) $this->option('batch'));
$this->info('平台账单共享镜像导入完成:' . number_format($result['row_count']) . ' 行。');
}
if ($lifecycles !== '') {
$result = $importer->importLifecycles($lifecycles, (int) $this->option('batch'));
$this->info('店铺生命周期共享镜像导入完成:' . number_format($result['row_count']) . ' 行。');
}
} catch (Throwable $exception) {
report($exception);
$this->error('店铺维度报表数据导入失败,已保留上一版共享数据。');
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,187 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreViolationRuleRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
/**
* 店铺违规信息预警:违规字典维护(Excel 导入 / 更新,按 平台+违规类型 幂等)。
*
* 表头(顺序不限):平台、违规分类、违规类型、匹配关键词、预警级别、是否需要提醒、
* 建议处理动作、处理时效、提醒方式、排序、启用、备注。
* 导入后按需执行 store-violations:recalc-alerts 让存量记录套用新字典。
*/
class ImportStoreViolationRules extends Command
{
protected $signature = 'store-violations:import-rules
{file : xlsx / xls / csv 文件路径}
{--dry-run : 只解析并统计,不写库}';
protected $description = '导入违规字典(预警级别、提醒规则等)';
private const HEADERS = [
'platform' => ['平台', 'platform'],
'violation_category' => ['违规分类', '分类', 'violation_category'],
'violation_type' => ['违规类型', '类型', 'violation_type'],
'match_keywords' => ['匹配关键词', '关键词', 'match_keywords'],
'alert_level' => ['预警级别', '级别', '预警等级', 'alert_level'],
'needs_alert' => ['是否需要提醒', '需提醒', '需要提醒', 'needs_alert'],
'suggested_action' => ['建议处理动作', '处理建议', '建议动作', 'suggested_action'],
'handling_hours' => ['处理时效', '时效', 'handling_hours'],
'remind_channel' => ['提醒方式', 'remind_channel'],
'sort' => ['排序', 'sort'],
'enabled' => ['启用', 'enabled'],
'remark' => ['备注', 'remark'],
];
private const PLATFORM_MAP = [
'淘系' => 'taobao', '淘宝' => 'taobao', '天猫' => 'taobao',
'京东' => 'jd', '京喜' => 'jingxi', '抖音' => 'douyin',
'拼多多' => 'pinduoduo', '阿里巴巴' => 'alibaba',
];
public function handle(): int
{
$file = (string) $this->argument('file');
if (! is_file($file)) {
$this->error('文件不存在:' . $file);
return self::FAILURE;
}
try {
$reader = IOFactory::createReaderForFile($file);
if (method_exists($reader, 'setReadDataOnly')) {
$reader->setReadDataOnly(true);
}
if (method_exists($reader, 'setReadFilter')) {
// 字典文件通常只有几十行;部分导出文件带巨大空白维度,限制读取范围。
$reader->setReadFilter(new class implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter {
public function readCell($columnAddress, $row, $worksheetName = ''): bool
{
return $row <= 5000;
}
});
}
$matrix = $reader->load($file)->getActiveSheet()->toArray(null, true, true, false);
} catch (Throwable $exception) {
$this->error('无法读取文件:' . $exception->getMessage());
return self::FAILURE;
}
if ($matrix === []) {
$this->error('文件为空。');
return self::FAILURE;
}
$map = [];
foreach ($matrix[0] as $index => $cell) {
$label = mb_strtolower(trim((string) $cell));
foreach (self::HEADERS as $key => $aliases) {
foreach ($aliases as $alias) {
if ($label === mb_strtolower($alias)) {
$map[$key] = $index;
break 2;
}
}
}
}
if (! isset($map['platform'], $map['violation_type'])) {
$this->error('缺少必需表头:平台、违规类型。');
return self::FAILURE;
}
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! app(StoreViolationRuleRepository::class)->available()) {
$this->error('违规字典表尚未建立,请先执行数据库迁移。');
return self::FAILURE;
}
$created = 0;
$updated = 0;
$skipped = 0;
$sort = 0;
foreach (array_slice($matrix, 1) as $row) {
$value = static fn (string $key): string => isset($map[$key]) ? trim((string) ($row[$map[$key]] ?? '')) : '';
$platform = $value('platform');
$platform = self::PLATFORM_MAP[$platform] ?? mb_strtolower($platform);
$type = $value('violation_type');
if ($platform === '' || $type === '') {
$skipped++;
continue;
}
$sort += 10;
$levelRaw = strtoupper($value('alert_level'));
$level = in_array($levelRaw, ['P0', 'P1', 'P2'], true) ? $levelRaw : null;
$needsAlertRaw = mb_strtolower($value('needs_alert'));
$needsAlert = ! in_array($needsAlertRaw, ['否', 'no', '0', 'false'], true);
$hoursRaw = $value('handling_hours');
$hours = is_numeric($hoursRaw) ? max((int) $hoursRaw, 0) : null;
$payload = [
'platform' => $platform,
'violation_category' => $value('violation_category') !== '' ? mb_substr($value('violation_category'), 0, 64) : null,
'violation_type' => mb_substr($type, 0, 500),
'match_keywords' => $value('match_keywords') !== '' ? mb_substr($value('match_keywords'), 0, 500) : null,
'alert_level' => $needsAlert ? $level : null,
'needs_alert' => $needsAlert ? 1 : 0,
'suggested_action' => $value('suggested_action') !== '' ? mb_substr($value('suggested_action'), 0, 500) : null,
'handling_hours' => $hours,
'remind_channel' => $value('remind_channel') !== '' ? mb_substr($value('remind_channel'), 0, 191) : null,
'sort' => is_numeric($value('sort')) ? (int) $value('sort') : $sort,
'enabled' => ! in_array(mb_strtolower($value('enabled')), ['否', 'no', '0', 'false'], true) ? 1 : 0,
'remark' => $value('remark') !== '' ? mb_substr($value('remark'), 0, 191) : null,
'updated_at' => now(),
];
$exists = $connection->table(StoreViolationRuleRepository::TABLE)
->where('platform', $platform)
->where('violation_type', $payload['violation_type'])
->exists();
if ($this->option('dry-run')) {
$exists ? $updated++ : $created++;
continue;
}
if ($exists) {
$connection->table(StoreViolationRuleRepository::TABLE)
->where('platform', $platform)
->where('violation_type', $payload['violation_type'])
->update($payload);
$updated++;
} else {
$connection->table(StoreViolationRuleRepository::TABLE)->insert($payload + [
'created_at' => now(),
]);
$created++;
}
}
$this->info(sprintf(
'字典导入%s:新增 %d 条,更新 %d 条,跳过 %d 条。',
$this->option('dry-run') ? '(试运行,未写库)' : '完成',
$created,
$updated,
$skipped,
));
$this->line('提示:需要让存量记录套用新字典时,执行 store-violations:recalc-alerts。');
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

@ -10,7 +10,8 @@ class ImportTaobaoAlipayBills extends Command
{
protected $signature = 'business:import-taobao-alipay-bills
{file : 淘系支付宝账户 Excel 文件路径}
{--company=1 : 数据所属公司 ID}';
{--company=1 : 数据所属公司 ID}
{--replace : 校验通过后替换该公司的全部淘系支付宝账单}';
protected $description = '从标准附件导入店铺收款账户账单的淘系支付宝账户数据';
@ -20,6 +21,7 @@ class ImportTaobaoAlipayBills extends Command
$result = $importer->import(
(string) $this->argument('file'),
max(1, (int) $this->option('company')),
(bool) $this->option('replace'),
);
} catch (Throwable $e) {
$this->components->error($e->getMessage());
@ -28,7 +30,7 @@ class ImportTaobaoAlipayBills extends Command
}
$this->components->info(
"导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,跳过 {$result['skipped']} 条。"
"导入完成:新增 {$result['inserted']} 条,更新 {$result['updated']} 条,替换旧数据 {$result['replaced']} 条,跳过 {$result['skipped']} 条。"
);
return self::SUCCESS;

View File

@ -0,0 +1,71 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\TaobaoAlipaySettlementBillImporter;
use RuntimeException;
use Throwable;
class ImportTaobaoAlipaySettlementBills extends Command
{
protected $signature = 'taobao-alipay-bills:import-authoritative
{path : 聚胜算导出的淘系支付宝完整账单 XLSX}
{--from= : 账期开始日期,格式 YYYY-MM-DD}
{--to= : 账期结束日期,格式 YYYY-MM-DD}
{--expected-count= : 完整账单权威笔数}
{--expected-income= : 完整账单权威收入}
{--expected-expense= : 完整账单权威支出}
{--batch=1000 : 分批写入行数}';
protected $description = '导入并核验聚胜算淘系支付宝完整账单';
public function handle(TaobaoAlipaySettlementBillImporter $importer): int
{
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
if (trim((string) $this->option($option)) === '') {
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
return self::FAILURE;
}
}
try {
$result = $importer->import(
(string) $this->argument('path'),
(string) $this->option('from'),
(string) $this->option('to'),
(int) $this->option('expected-count'),
(string) $this->option('expected-income'),
(string) $this->option('expected-expense'),
(int) $this->option('batch'),
);
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
} catch (Throwable $exception) {
report($exception);
$this->error('淘系支付宝完整账单导入失败,详细原因已记录到受保护日志。');
return self::FAILURE;
}
$this->table(['状态', '账单行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
$result['status'],
$result['row_count'],
$result['store_count'],
$result['bill_item_count'],
$result['income_total'],
$result['expense_total'],
$result['net_total'],
$result['period_start'] . ' 至 ' . $result['period_end'],
]]);
$this->line('业务类型:' . collect($result['business_type_counts'])
->map(fn (int $count, string $type): string => "{$type} {$count}")
->implode(';'));
$this->line('SHA-256: ' . $result['source_sha256']);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\TaobaoWechatSettlementBillImporter;
use RuntimeException;
use Throwable;
class ImportTaobaoWechatSettlementBills extends Command
{
protected $signature = 'taobao-wechat-bills:import-authoritative
{path : 聚胜算导出的淘系微信完整账单 XLSX}
{--from= : 账期开始日期,格式 YYYY-MM-DD}
{--to= : 账期结束日期,格式 YYYY-MM-DD}
{--expected-count= : 完整账单权威笔数}
{--expected-income= : 完整账单权威收入}
{--expected-expense= : 完整账单权威支出}
{--batch=1000 : 分批写入行数}';
protected $description = '导入并核验聚胜算淘系微信完整账单';
public function handle(TaobaoWechatSettlementBillImporter $importer): int
{
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
if (trim((string) $this->option($option)) === '') {
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
return self::FAILURE;
}
}
try {
$result = $importer->import(
(string) $this->argument('path'),
(string) $this->option('from'),
(string) $this->option('to'),
(int) $this->option('expected-count'),
(string) $this->option('expected-income'),
(string) $this->option('expected-expense'),
(int) $this->option('batch'),
);
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
} catch (Throwable $exception) {
report($exception);
$this->error('淘系微信完整账单导入失败,详细原因已记录到受保护日志。');
return self::FAILURE;
}
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
$result['status'],
$result['row_count'],
$result['excluded_rows'],
$result['store_count'],
$result['bill_item_count'],
$result['income_total'],
$result['expense_total'],
$result['net_total'],
$result['period_start'] . ' 至 ' . $result['period_end'],
]]);
$this->line('入账类型:' . collect($result['entry_type_counts'])
->map(fn (int $count, string $type): string => "{$type} {$count}")
->implode(';'));
$this->line('SHA-256: ' . $result['source_sha256']);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\ThirdPartyWarehouseStorageAuthoritativeAnchorImporter;
use Throwable;
class ImportThirdPartyWarehouseStorageAuthoritativeAnchor extends Command
{
protected $signature = 'business-mirror:import-third-party-warehouse-anchor
{path : 聚水潭三方仓按商品加按天多维分析完整 XLSX 导出文件}';
protected $description = '导入三方仓权威日进销存明细及月末库存锚点,用于稳定历史余额';
public function handle(ThirdPartyWarehouseStorageAuthoritativeAnchorImporter $importer): int
{
try {
$result = $importer->import((string) $this->argument('path'));
} catch (Throwable $exception) {
report($exception);
$this->error('三方仓权威进销存导入失败,已保留上一版有效数据。');
return self::FAILURE;
}
$this->info(sprintf(
'三方仓权威日明细导入完成:校验 %s 条明细,生成 %s 个商品锚点,锚点时刻 %s。',
number_format($result['source_rows']),
number_format($result['anchor_skus']),
$result['anchor_at'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class JstCount extends Command
{
protected $signature = 'jst:count';
protected $description = '发货数据本地表统计';
public function handle(): int
{
$c = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
$t = fn () => $c->table('jst_oms_shipping');
$this->line('明细行数 total='.$t()->count());
$this->line('包裹数(distinct source_id)='.$t()->distinct()->count('source_id'));
$this->line('自动发货仓行(source>=1e9)='.$t()->where('source_id', '>=', 1000000000)->count());
$this->line('补差残留='.$t()->where('product_name', 'like', '%补收差价%')->count());
$this->line('快递单号非空='.$t()->whereNotNull('express_no')->where('express_no', '<>', '')->count());
$this->line('件数pkg_count非空='.$t()->whereNotNull('pkg_count')->where('pkg_count', '<>', '')->count());
// 多件包裹数(pkg_count 不是 1/1)
$multi = $t()->where('pkg_count', 'not like', '1/1')->where('pkg_count', 'like', '1/%')->distinct()->count('source_id');
$this->line('多件包裹数(件数≠1/1)='.$multi);
// 一个多件包裹样例
$sample = $t()->where('pkg_count', 'like', '1/%')->where('pkg_count', '<>', '1/1')->orderByDesc('source_id')->first();
if ($sample) {
$rows = $t()->where('source_id', $sample->source_id)->get(['barcode', 'product_name', 'qty', 'pkg_count', 'actual_weight', 'freight', 'volume_cm3']);
$this->line('--- 样例多件包裹 source_id='.$sample->source_id.' 快递单号='.$sample->express_no.' ---');
foreach ($rows as $r) {
$this->line(sprintf(' 条码=%s 名称=%s 数量=%s 件数=%s 实称=%s 运费=%s 体积=%s',
$r->barcode, mb_substr((string) $r->product_name, 0, 12), $r->qty, $r->pkg_count, $r->actual_weight, $r->freight, $r->volume_cm3));
}
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
class JstDump extends Command
{
protected $signature = 'jst:dump {--mode=merged} {--from=} {--to=}';
protected $description = '把 caiwu 发货数据(合并/明细)以 CSV 输出到 STDOUT,供比对';
public function handle(ShippingOrderRepository $repo): int
{
@ini_set('memory_limit', '1024M');
@set_time_limit(0);
$mode = $this->option('mode') === 'detail' ? 'detail' : 'merged';
$filters = [
'ship_from' => (string) $this->option('from'),
'ship_to' => (string) $this->option('to'),
];
$cols = [
['express_no', '快递单号'], ['trade_no', '交易单号'], ['item_id', '商品ID'], ['barcode', '条形码'],
['product_name', '商品名称'], ['qty', '数量'], ['pkg_count', '件数'], ['warehouse_name', '仓库'],
['supplier_name', '供应商'], ['platform', '平台'], ['shop_name', '店铺名称'],
['volume_cm3', '体积'], ['length_cm', '长'], ['width_cm', '宽'], ['height_cm', '高'],
['actual_weight', '实称重量'], ['online_weight', '线上重量'], ['freight', '运费'],
['ship_time', '发货时间'], ['pay_time', '付款时间'], ['product_code', '货号'],
['receiver_state', '省'], ['receiver_city', '市'], ['receiver_district', '区'],
['package_no', '配货单号'], ['amount', '金额'], ['express_name1', '快递名称'],
];
$fh = fopen('php://output', 'w');
fwrite($fh, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($fh, array_map(fn ($c) => $c[1], $cols));
$n = 0;
foreach ($repo->exportRows($filters, 2000, $mode) as $row) {
fputcsv($fh, array_map(fn ($c) => (string) ($row[$c[0]] ?? ''), $cols));
$n++;
}
fflush($fh);
fwrite(STDERR, 'wrote '.$n.' rows ('.$mode.')'.PHP_EOL);
return self::SUCCESS;
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class JstRow extends Command
{
protected $signature = 'jst:row {--trade=}';
protected $description = '看本地表某交易单号的行';
public function handle(): int
{
$c = DB::connection((string) config('jushuitan.connection', 'jushuitan_local'));
$rows = $c->table('jst_oms_shipping')->where('trade_no', (string) $this->option('trade'))
->get(['express_no', 'barcode', 'product_name', 'qty', 'pkg_count', 'supplier_name', 'length_cm', 'volume_cm3']);
$this->line('行数='.count($rows));
foreach ($rows as $r) {
$this->line(' '.json_encode($r, JSON_UNESCAPED_UNICODE));
}
return self::SUCCESS;
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace App\Console\Commands;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Services\Esign\EsignClient;
use App\Support\CourierAgreementSignatureService;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
class PollEsignSignatures extends Command
{
protected $signature = 'esign:poll-signatures
{--limit= : 本轮最多查询的签署流程数}
{--min-age= : 同一流程两次查询之间的最少秒数}';
protected $description = '在不使用回调地址时,定时查询 E 签宝流程状态并归档已签文件';
public function handle(
CourierQuoteSignatureService $quoteSignatures,
CourierAgreementSignatureService $agreementSignatures,
): int {
if (! (bool) config('services.esign.polling_enabled', false)) {
$this->components->info('E 签宝定时轮询未启用。');
return self::SUCCESS;
}
if (! EsignClient::configured()) {
$this->components->warn('E 签宝尚未正确配置,已跳过定时轮询。');
return self::SUCCESS;
}
$limit = min(max((int) ($this->option('limit')
?? config('services.esign.poll_batch_size', 25)), 1), 100);
$minAge = max((int) ($this->option('min-age')
?? config('services.esign.poll_min_age_seconds', 120)), 0);
$cutoff = now()->subSeconds($minAge);
$candidates = $this->candidates($limit, $cutoff);
$succeeded = 0;
$failed = 0;
foreach ($candidates as $candidate) {
$model = $candidate['model'];
try {
if ($candidate['type'] === 'quote') {
$quoteSignatures->synchronize($model, (int) ($model->updated_by ?: $model->created_by));
} else {
$agreementSignatures->synchronize($model, (int) ($model->updated_by ?: $model->created_by));
}
$succeeded++;
} catch (Throwable $exception) {
$failed++;
Log::warning('E 签宝签署流程定时查询失败。', [
'signature_type' => $candidate['type'],
'signature_id' => $model->getKey(),
'exception' => $exception::class,
]);
}
}
$this->components->info(sprintf(
'E 签宝轮询完成:候选 %d,成功 %d,失败 %d。',
$candidates->count(),
$succeeded,
$failed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/** @return Collection<int,array{type:string,model:CourierQuoteSignature|CourierAgreementSignature}> */
private function candidates(int $limit, \DateTimeInterface $cutoff): Collection
{
$environment = strtolower(trim((string) config('services.esign.environment', 'sandbox')));
$quotes = CourierQuoteSignature::allCompanies()
->where('environment', $environment)
->whereNotNull('esign_sign_flow_id')
->where('esign_sign_flow_id', '<>', '')
->whereIn('status', [
CourierQuoteSignature::STATUS_SIGNING,
CourierQuoteSignature::STATUS_COMPLETED,
])
->where(fn (Builder $query) => $query
->whereNull('last_synced_at')
->orWhere('last_synced_at', '<=', $cutoff))
->orderByRaw('CASE WHEN last_synced_at IS NULL THEN 0 ELSE 1 END')
->orderBy('last_synced_at')
->orderBy('id')
->limit($limit)
->get()
->map(fn (CourierQuoteSignature $signature): array => [
'type' => 'quote',
'model' => $signature,
]);
$agreements = CourierAgreementSignature::allCompanies()
->disableCache()
->where('environment', $environment)
->whereNotNull('esign_sign_flow_id')
->where('esign_sign_flow_id', '<>', '')
->whereIn('status', [
CourierAgreementSignature::STATUS_SIGNING,
CourierAgreementSignature::STATUS_UNKNOWN,
CourierAgreementSignature::STATUS_COMPLETED,
])
->where(fn (Builder $query) => $query
->whereNull('last_synced_at')
->orWhere('last_synced_at', '<=', $cutoff))
->orderByRaw('CASE WHEN last_synced_at IS NULL THEN 0 ELSE 1 END')
->orderBy('last_synced_at')
->orderBy('id')
->limit($limit)
->get()
->map(fn (CourierAgreementSignature $signature): array => [
'type' => 'agreement',
'model' => $signature,
]);
return $quotes
->concat($agreements)
->sortBy(fn (array $candidate): string => sprintf(
'%020d:%s:%020d',
$candidate['model']->last_synced_at?->getTimestamp() ?? 0,
$candidate['type'],
$candidate['model']->getKey(),
))
->take($limit)
->values();
}
}

View File

@ -0,0 +1,683 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Throwable;
/**
* 按「附加费报价维护」里启用的超长费规则,从销售出库单拉取结算行。
*
* 口径(第一期只做超长费,且只处理“按尺寸/最长边”的规则):
* - 规则状态必须是 active;
* - 生效条件解析出单边阈值(如“单边超160cm”);
* - 计价标准解析出每单金额(如“8元/单”);
* - 出库单按 logistics_company = 快递名称 匹配,运单号取 l_id,只取已分配运单号的单;
* - 长宽高按明细条形码 sku_id 到商品及库存管理(商品资料 jst_sku)换算,一票多件取最大边;
* - 查件类型/查件原因/退回单号/平台商品ID 在出库单与商品资料里取不到,留空。
*/
class PullCourierSurchargeOverlength extends Command
{
protected $signature = 'courier-surcharge:pull-overlength
{--days=90 : 默认拉取最近多少天(无 --from/--to 时生效)}
{--from= : 出库日期起(含),Y-m-d}
{--to= : 出库日期止(不含),Y-m-d}
{--rule=* : 只跑指定规则 ID,可多次传入}
{--chunk=300 : 每批处理的出库单数量}';
protected $description = '按启用的超长费报价规则,从销售出库单拉取附加费结算行';
public function handle(): int
{
$from = $this->option('from') !== null && $this->option('from') !== ''
? Carbon::parse((string) $this->option('from'))->startOfDay()
: now()->subDays(max((int) $this->option('days'), 1))->startOfDay();
$to = $this->option('to') !== null && $this->option('to') !== ''
? Carbon::parse((string) $this->option('to'))->startOfDay()
: now()->addDay()->startOfDay();
$onlyRules = array_filter(array_map('intval', (array) $this->option('rule')));
$chunkSize = min(max((int) $this->option('chunk'), 50), 500);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = DB::connection($connection)->getSchemaBuilder();
if (! $schema->hasTable('courier_surcharge_settlement_rows')) {
$this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。');
return self::FAILURE;
}
$rules = $this->overlengthRules($onlyRules);
if ($rules === []) {
$this->warn('没有启用且可自动计算的超长费规则。');
return self::SUCCESS;
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
$totalWritten = 0;
foreach ($rules as $rule) {
$threshold = $rule['threshold'];
$amount = $rule['amount'];
$this->info(sprintf(
'规则 #%d %s:单边超 %scm 计 %s 元/单,取数 %s ~ %s',
$rule['id'],
$rule['courier'],
$threshold,
$amount,
$from->toDateString(),
$to->toDateString(),
));
$orders = $this->pullOrders($upstream, $rule['courier'], $from, $to, $chunkSize);
if ($orders === []) {
$this->line(' 没有运单号非空的出库单,跳过。');
continue;
}
$written = $this->writeSettlementRows($connection, $rule, $orders, $threshold, $amount, $from, $to);
$totalWritten += $written;
$this->line(' 出库单 ' . count($orders) . ' 单,命中超长并写入 ' . $written . ' 行。');
}
$this->info('完成,共写入 ' . $totalWritten . ' 行结算数据。');
return self::SUCCESS;
}
/**
* @param list<int> $onlyRules
* @return list<array{id:int,courier:string,threshold:float,amount:float}>
*/
private function overlengthRules(array $onlyRules): array
{
$rules = [];
foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) {
$types = json_decode((string) $rule->types, true) ?: [];
if (! in_array('超长费', $types, true)) {
continue;
}
if (! str_contains((string) $rule->mode, '按尺寸') && ! str_contains((string) $rule->mode, '最长边')) {
continue;
}
if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) {
continue;
}
$condition = (string) $rule->condition;
$standard = (string) $rule->standard;
if (preg_match('/单边超\s*(\d+(?:\.\d+)?)\s*(?:cm|厘米|CM|㎝)/u', $condition, $matched) !== 1) {
$this->warn('规则 #' . $rule->id . ' 的生效条件解析不出单边阈值,已跳过:' . $condition);
continue;
}
$threshold = (float) $matched[1];
if (preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/?\s*单/u', $standard, $priceMatched) !== 1) {
$this->warn('规则 #' . $rule->id . ' 的计价标准解析不出每单金额,已跳过:' . $standard);
continue;
}
$rules[] = [
'id' => (int) $rule->id,
'courier' => trim((string) $rule->courier),
'threshold' => $threshold,
'amount' => (float) $priceMatched[1],
];
}
return $rules;
}
/**
* 拉取出库单(只取已分配运单号的单)。
*
* @return list<array<string,mixed>>
*/
private function pullOrders(
UpstreamReadOnlyQuery $upstream,
string $courier,
Carbon $from,
Carbon $to,
int $chunkSize,
): array {
$orders = [];
$lastId = 0;
while (true) {
$rows = $upstream->select(
'SELECT TOP ' . $chunkSize . ' Id, io_id, o_id, l_id, so_id, shop_name, io_date, weight, f_weight '
. 'FROM dbo.jst_salesOutboundInfo '
. 'WHERE logistics_company = ? AND io_date >= ? AND io_date < ? '
. "AND LEN(ISNULL(l_id, '')) > 0 AND Id > ? "
. 'ORDER BY Id',
[$courier, $from->toDateTimeString(), $to->toDateTimeString(), $lastId],
);
if ($rows === []) {
break;
}
foreach ($rows as $row) {
$row = (array) $row;
$orders[] = $row;
$lastId = max($lastId, (int) $row['Id']);
}
if (count($rows) < $chunkSize) {
break;
}
}
return $orders;
}
/**
* @param list<array<string,mixed>> $orders
*/
private function writeSettlementRows(
string $connection,
array $rule,
array $orders,
float $threshold,
float $amount,
Carbon $from,
Carbon $to,
): int {
$ioIds = array_values(array_filter(array_map(
static fn (array $order): int => (int) ($order['io_id'] ?? 0),
$orders,
), static fn (int $value): bool => $value > 0));
$items = $this->pullItems($ioIds);
$dimensions = $this->dimensionMap($items);
$platformProductIds = $this->platformProductIds($items, $orders);
$onlineOrderNumbers = $this->onlineOrderNumbers($orders);
$now = now();
$written = 0;
$rows = [];
foreach ($orders as $order) {
$ioId = (int) ($order['io_id'] ?? 0);
if ($ioId <= 0) {
continue;
}
$best = null;
foreach ($items[$ioId] ?? [] as $item) {
$skuId = trim((string) ($item['sku_id'] ?? ''));
$dim = $dimensions[$skuId] ?? null;
if ($dim === null) {
continue;
}
$maxSide = max($dim['l'], $dim['w'], $dim['h']);
if ($best === null || $maxSide > $best['max_side']) {
$best = [
'max_side' => $maxSide,
'sku_id' => $skuId,
'i_id' => trim((string) ($item['i_id'] ?? '')),
'length' => $dim['l'],
'width' => $dim['w'],
'height' => $dim['h'],
'platform_product_id' => $platformProductIds[$ioId][$skuId] ?? ($platformProductIds[$ioId][''] ?? null),
];
}
}
if ($best === null || $best['max_side'] <= $threshold) {
continue;
}
$rows[] = [
'rule_id' => $rule['id'],
'surcharge_type' => '超长费',
'courier_name' => $rule['courier'],
'waybill' => trim((string) ($order['l_id'] ?? '')),
'outbound_io_id' => $ioId,
'platform_product_id' => $best['platform_product_id'],
'product_code' => $best['sku_id'] !== '' ? $best['sku_id'] : ($best['i_id'] !== '' ? $best['i_id'] : null),
'shop_name' => mb_substr(trim((string) ($order['shop_name'] ?? '')), 0, 191) ?: null,
'online_order_no' => $onlineOrderNumbers[(int) ($order['o_id'] ?? 0)] ?? null,
'length' => $best['length'],
'width' => $best['width'],
'height' => $best['height'],
'volume' => round($best['length'] * $best['width'] * $best['height'], 2),
'inspection_type' => null,
'inspection_reason' => null,
'return_waybill_no' => null,
'settlement_weight' => null,
'settlement_amount' => $amount,
'settlement_status' => '已计算',
'evidence' => mb_substr(sprintf(
'条形码 %s 尺寸 %s×%s×%s,最长边 %scm > %scm',
$best['sku_id'] !== '' ? $best['sku_id'] : '(无)',
$this->trimNumber($best['length']),
$this->trimNumber($best['width']),
$this->trimNumber($best['height']),
$this->trimNumber($best['max_side']),
$this->trimNumber($threshold),
), 0, 500),
'shipped_at' => $order['io_date'] ?? null,
'pulled_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$written++;
}
$target = DB::connection($connection)->table('courier_surcharge_settlement_rows');
// 同一规则、同一取数窗口内重跑时先清掉旧行,避免残留历史判断。
$target->where('rule_id', $rule['id'])
->where('shipped_at', '>=', $from->toDateTimeString())
->where('shipped_at', '<', $to->toDateTimeString())
->delete();
foreach (array_chunk($rows, 500) as $chunk) {
$target->insert($chunk);
}
return $written;
}
/**
* @param list<int> $ioIds
* @return array<int,list<array<string,mixed>>>
*/
private function pullItems(array $ioIds): array
{
if ($ioIds === []) {
return [];
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
$items = [];
foreach (array_chunk($ioIds, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
$rows = $upstream->select(
'SELECT io_id, sku_id, i_id, name, qty FROM dbo.jst_salesOutboundItem WHERE io_id IN (' . $placeholders . ')',
$chunk,
);
foreach ($rows as $row) {
$row = (array) $row;
$items[(int) $row['io_id']][] = $row;
}
}
return $items;
}
/**
* 线上订单号:优先用出库单自带 so_id,为空时按 o_id 到「未发货订单」表兜底。
*
* 部分渠道(如阿里巴巴-常州玖阔)的出库单 so_id 为空,但发货中订单表
* jst_nosend_orders 里仍能按 o_id 找到线上单号。
*
* @param list<array<string,mixed>> $orders
* @return array<int,string>
*/
private function onlineOrderNumbers(array $orders): array
{
$map = [];
$missing = [];
foreach ($orders as $order) {
$oId = (int) ($order['o_id'] ?? 0);
$soId = trim((string) ($order['so_id'] ?? ''));
if ($soId !== '') {
$map[$oId] = mb_substr($soId, 0, 191);
} elseif ($oId > 0) {
$missing[$oId] = true;
}
}
if ($missing === []) {
return $map;
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
foreach (array_chunk(array_keys($missing), 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
"SELECT o_id, so_id FROM dbo.jst_nosend_orders WHERE o_id IN (" . $placeholders . ')'
. " AND LEN(ISNULL(so_id, '')) > 0",
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
// 仍未补到的(多为已发货、已离开未发货订单表的单),到订单视图兜底;
// 该视图覆盖部分不在 jst_Orders 主表里的店铺(如阿里巴巴-常州玖阔)。
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
foreach (array_chunk($remaining, 500) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
"SELECT o_id, so_id FROM dbo.jst_ordersView WHERE o_id IN (" . $placeholders . ')'
. " AND LEN(ISNULL(so_id, '')) > 0",
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
// 再兜底:发货数据视图在订单离开 jst_Orders/ordersView/nosend 后仍保留 o_id → so_id,
// 例如 2026-07-31 新佰广1688 已归档的出库单。
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
foreach (array_chunk($remaining, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT DISTINCT o_id, so_id FROM dbo.Jst_SendData_View WHERE o_id IN (' . $placeholders . ')'
. ' AND LEN(so_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
// 最后兜底:全渠道订单列表按月归档,部分天猫订单只在该表保留 o_id → so_id。
$remaining = array_values(array_diff(array_keys($missing), array_keys($map)));
if ($remaining !== []) {
$monthById = [];
foreach ($orders as $order) {
$oId = (int) ($order['o_id'] ?? 0);
$ioDate = trim((string) ($order['io_date'] ?? ''));
if ($oId <= 0 || $ioDate === '') {
continue;
}
try {
$monthById[$oId] = Carbon::parse($ioDate)->format('Ym');
} catch (Throwable) {
// io_date 无法解析时只查当前表。
}
}
$tables = ['jst_qmcust_orderlist' => array_values($remaining)];
foreach ($remaining as $oId) {
$month = $monthById[$oId] ?? null;
if ($month !== null) {
$tables['jst_qmcust_orderlist_' . $month][] = $oId;
}
}
foreach ($tables as $table => $ids) {
foreach (array_chunk($ids, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT o_id, so_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')'
. ' AND LEN(so_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$row = (array) $row;
$map[(int) $row['o_id']] = mb_substr(trim((string) $row['so_id']), 0, 191);
}
}
}
}
return $map;
}
/**
* 平台商品ID:查件工单里的 platformItemId 与订单/发货明细的 shop_i_id 同源。
* 优先按 io_id + sku_id 从发货明细视图取;缺行时按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。
*
* @param array<int,list<array<string,mixed>>> $items
* @param list<array<string,mixed>> $orders
* @return array<int,array<string,string>>
*/
private function platformProductIds(array $items, array $orders): array
{
$map = [];
$ioIds = array_keys($items);
if ($ioIds === []) {
return $map;
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('jiansun');
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);
$skuId = trim((string) ($row['sku_id'] ?? ''));
$shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191);
if ($ioId <= 0 || $shopIId === '') {
continue;
}
if ($skuId !== '') {
$map[$ioId][$skuId] = $shopIId;
}
$map[$ioId][''] ??= $shopIId;
}
}
// 剩余按 o_id 到全渠道订单明细(当前表 + 月度归档)兜底。
$oIdByIoId = [];
$monthByIoId = [];
foreach ($orders as $order) {
$ioId = (int) ($order['io_id'] ?? 0);
$oId = (int) ($order['o_id'] ?? 0);
$ioDate = trim((string) ($order['io_date'] ?? ''));
if ($ioId <= 0 || $oId <= 0) {
continue;
}
$oIdByIoId[$ioId] = $oId;
if ($ioDate === '') {
continue;
}
try {
$monthByIoId[$ioId] = Carbon::parse($ioDate)->format('Ym');
} catch (Throwable) {
// io_date 无法解析时只查当前表。
}
}
$missing = [];
foreach ($ioIds as $ioId) {
if (($map[$ioId] ?? []) !== []) {
continue;
}
$oId = $oIdByIoId[$ioId] ?? 0;
if ($oId > 0) {
$missing[$ioId] = $oId;
}
}
if ($missing === []) {
return $map;
}
$tables = ['jst_qmcust_orderlist_item' => $missing];
foreach ($missing as $ioId => $oId) {
$month = $monthByIoId[$ioId] ?? null;
if ($month !== null) {
$tables['jst_qmcust_orderlist_item_' . $month][$ioId] = $oId;
}
}
foreach ($tables as $table => $idsByIo) {
$oIds = array_values(array_unique(array_values($idsByIo)));
foreach (array_chunk($oIds, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
try {
$rows = $upstream->select(
'SELECT o_id, sku_id, shop_i_id FROM dbo.' . $table . ' WHERE o_id IN (' . $placeholders . ')'
. ' AND LEN(shop_i_id) > 0',
$chunk,
);
} catch (Throwable) {
continue;
}
$byOrder = [];
foreach ($rows as $row) {
$row = (array) $row;
$skuId = trim((string) ($row['sku_id'] ?? ''));
$shopIId = mb_substr(trim((string) ($row['shop_i_id'] ?? '')), 0, 191);
if ($shopIId === '') {
continue;
}
$byOrder[(int) ($row['o_id'] ?? 0)][] = ['sku_id' => $skuId, 'shop_i_id' => $shopIId];
}
foreach ($idsByIo as $ioId => $oId) {
foreach ($byOrder[$oId] ?? [] as $entry) {
if ($entry['sku_id'] !== '') {
$map[$ioId][$entry['sku_id']] = $entry['shop_i_id'];
}
$map[$ioId][''] ??= $entry['shop_i_id'];
}
}
}
}
return $map;
}
/**
* @param array<int,list<array<string,mixed>>> $items
* @return array<string,array{l:float,w:float,h:float}>
*/
private function dimensionMap(array $items): array
{
$skuIds = [];
foreach ($items as $rows) {
foreach ($rows as $row) {
$skuId = trim((string) ($row['sku_id'] ?? ''));
if ($skuId !== '') {
$skuIds[$skuId] = true;
}
}
}
$skuIds = array_keys($skuIds);
$dimensions = [];
// 商品及库存管理(商品资料)已在共享镜像里,直接读镜像,不再压上游。
foreach (array_chunk($skuIds, 500) as $chunk) {
try {
$rows = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'))
->table('ops_jst_sku')
->whereIn('sku_id', $chunk)
->get(['sku_id', 'l', 'w', 'h']);
} catch (Throwable) {
continue;
}
foreach ($rows as $row) {
$l = (float) $row->l;
$w = (float) $row->w;
$h = (float) $row->h;
if ($l > 0 && $w > 0 && $h > 0) {
$dimensions[trim((string) $row->sku_id)] = ['l' => $l, 'w' => $w, 'h' => $h];
}
}
}
return $dimensions;
}
private function trimNumber(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
}

View File

@ -0,0 +1,727 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Throwable;
/**
* 按「附加费报价维护」里启用的退回费 / 拦截费规则,从查件扣单拉取结算行。
*
* 口径:
* - 规则状态 active、生效条件含「查件扣单」;
* - 退回费:types 含「退回费」,支持 固定单价 / 按重量;已退回(售后退货退款
* receive_date 进仓时间 + r_qty 实退数量)按标准计价,未退回计 0;
* 结算重量取商品资料 ops_jst_sku.weight,按实退数量汇总;未退回时按发货数量展示;
* - 拦截费:types 含「拦截费」且模式为固定单价,查件扣单即按标准金额计价;
* - 扣单来源:共享镜像 js_jst_work_order_info 中 title=查件、查件类型=扣单 的工单。
*/
class PullCourierSurchargeReturn extends Command
{
private const CHECK_TYPE_FIELD = 'SELECT_CASCADEField1413856620250813172526';
protected $signature = 'courier-surcharge:pull-return
{--days=90 : 默认拉取最近多少天(无 --from/--to 时生效,按查件工单创建时间)}
{--from= : 查件工单创建起(含),Y-m-d}
{--to= : 查件工单创建止(不含),Y-m-d}
{--rule=* : 只跑指定规则 ID,可多次传入}
{--type=* : 只跑指定附加费类型(退回费/拦截费),可多次传入}
{--limit=0 : 调试用,最多处理多少张扣单工单,0 表示不限}';
protected $description = '按启用的退回费/拦截费报价规则,从查件扣单拉取结算行';
public function handle(): int
{
$from = $this->option('from') !== null && $this->option('from') !== ''
? Carbon::parse((string) $this->option('from'))->startOfDay()
: now()->subDays(max((int) $this->option('days'), 1))->startOfDay();
$to = $this->option('to') !== null && $this->option('to') !== ''
? Carbon::parse((string) $this->option('to'))->startOfDay()
: now()->addDay()->startOfDay();
$onlyRules = array_filter(array_map('intval', (array) $this->option('rule')));
$onlyTypes = array_values(array_filter(
array_map(static fn ($type): string => trim((string) $type), (array) $this->option('type')),
static fn (string $type): bool => in_array($type, ['退回费', '拦截费'], true),
));
$limit = max((int) $this->option('limit'), 0);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = DB::connection($connection)->getSchemaBuilder();
if (! $schema->hasTable('courier_surcharge_settlement_rows')) {
$this->error('结算表 courier_surcharge_settlement_rows 不存在,请先执行迁移。');
return self::FAILURE;
}
if (! $schema->hasTable('js_jst_work_order_info')) {
$this->error('查件工单镜像 js_jst_work_order_info 不存在,请先完成镜像同步。');
return self::FAILURE;
}
$rules = $this->rules($onlyRules, $onlyTypes);
if ($rules === []) {
$this->warn('没有启用且可自动计算的退回费/拦截费规则。');
return self::SUCCESS;
}
$orders = $this->pullDeductOrders($connection, $from, $to, $limit);
$this->info(sprintf('取数 %s ~ %s,扣单工单 %d 单。', $from->toDateString(), $to->toDateString(), count($orders)));
if ($orders === []) {
return self::SUCCESS;
}
$ordersByCourier = [];
foreach ($orders as $order) {
$ordersByCourier[$order['courier']][] = $order;
}
$totalWritten = 0;
foreach ($rules as $rule) {
$matched = $ordersByCourier[$rule['courier']] ?? [];
if ($matched === []) {
$this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . ':没有匹配的扣单工单,跳过。');
continue;
}
$written = $this->writeSettlementRows($connection, $rule, $matched);
$totalWritten += $written;
$this->line('规则 #' . $rule['id'] . ' ' . $rule['courier'] . '(' . $rule['type'] . '/' . $rule['mode'] . '):扣单 ' . count($matched) . ' 单,写入 ' . $written . ' 行。');
}
$this->info('完成,共写入 ' . $totalWritten . ' 行附加费结算数据。');
return self::SUCCESS;
}
/**
* @param list<int> $onlyRules
* @param list<string> $onlyTypes
* @return list<array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string}>
*/
private function rules(array $onlyRules, array $onlyTypes): array
{
$rules = [];
foreach (DB::table('business_courier_surcharge_quotes')->where('status', 'active')->orderBy('id')->get() as $rule) {
$types = json_decode((string) $rule->types, true) ?: [];
$candidateTypes = array_values(array_intersect(['退回费', '拦截费'], $types));
if ($candidateTypes === []) {
continue;
}
if ($onlyRules !== [] && ! in_array((int) $rule->id, $onlyRules, true)) {
continue;
}
$condition = trim((string) $rule->condition);
if (! str_contains($condition, '扣单')) {
$this->warn('规则 #' . $rule->id . ' 的生效条件不含「扣单」,已跳过:' . $condition);
continue;
}
$mode = trim((string) $rule->mode);
$standard = trim((string) $rule->standard);
foreach ($candidateTypes as $type) {
if ($onlyTypes !== [] && ! in_array($type, $onlyTypes, true)) {
continue;
}
if ($type === '退回费') {
if ($mode === '固定单价') {
$value = $this->parseFixedAmount($standard);
} elseif ($mode === '按重量') {
$value = $this->parseWeightRate($standard);
} else {
$this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的退回费模式「' . $mode . '」暂不支持自动计算,已跳过。');
continue;
}
} else {
if ($mode === '固定单价') {
$value = $this->parseFixedAmount($standard);
} else {
$this->line('规则 #' . $rule->id . ' ' . $rule->courier . ' 的拦截费模式「' . $mode . '」暂不支持自动计算,已跳过。');
continue;
}
}
if ($value === null) {
$this->warn('规则 #' . $rule->id . ' 的' . $type . '计价标准解析失败,已跳过:' . $standard);
continue;
}
$rules[] = [
'id' => (int) $rule->id,
'type' => $type,
'courier' => trim((string) $rule->courier),
'mode' => $mode,
'value' => $value,
'effective_at' => $rule->effective_at !== null && $rule->effective_at !== ''
? (string) $rule->effective_at
: null,
];
}
}
return $rules;
}
private function parseFixedAmount(string $standard): ?float
{
return preg_match('/(\d+(?:\.\d+)?)\s*(?:元)?\s*\/?\s*单/u', $standard, $matched) === 1
? (float) $matched[1]
: null;
}
private function parseWeightRate(string $standard): ?float
{
return preg_match('/(\d+(?:\.\d+)?)\s*元\s*\/\s*(?:kg|KG|公斤|千克)/u', $standard, $matched) === 1
? (float) $matched[1]
: null;
}
/**
* 从查件工单镜像按创建时间拉取扣单工单。
*
* @return list<array<string,mixed>>
*/
private function pullDeductOrders(string $connection, Carbon $from, Carbon $to, int $limit): array
{
$orders = [];
$lastId = 0;
$batchSize = 500;
while (true) {
$rows = DB::connection($connection)->table('js_jst_work_order_info')
->where('title', '查件')
->where('businessFieldList', 'like', '%"value":"扣单/%')
->where('created', '>=', $from->toDateTimeString())
->where('created', '<', $to->toDateTimeString())
->where('Id', '>', $lastId)
->orderBy('Id')
->limit($batchSize)
->get(['Id', 'woNo', 'created', 'businessFieldList']);
if ($rows->isEmpty()) {
break;
}
foreach ($rows as $row) {
$lastId = max($lastId, (int) $row->Id);
$order = $this->parseWorkOrder((string) $row->businessFieldList, (string) $row->woNo, (string) $row->created);
if ($order !== null) {
$orders[] = $order;
}
if ($limit > 0 && count($orders) >= $limit) {
break 2;
}
}
if ($rows->count() < $batchSize) {
break;
}
}
return $orders;
}
/**
* @return array<string,mixed>|null
*/
private function parseWorkOrder(string $json, string $woNo, string $created): ?array
{
$fields = json_decode($json, true);
if (! is_array($fields)) {
return null;
}
$flat = [];
foreach ($fields as $field) {
if (is_array($field) && isset($field['fieldId'])) {
$flat[(string) $field['fieldId']] = $field;
}
}
$waybill = trim((string) ($flat['lIdField']['value'] ?? ''));
$oId = (int) ($flat['o_id']['value'] ?? 0);
$courier = trim((string) ($flat['logisticsField']['value'] ?? ''));
$cascade = trim((string) ($flat[self::CHECK_TYPE_FIELD]['value'] ?? ''));
if ($waybill === '' || $courier === '' || ! str_starts_with($cascade, '扣单')) {
return null;
}
[$inspectionType, $inspectionReason] = array_pad(explode('/', $cascade, 2), 2, '');
$items = [];
foreach (($flat['itemInfo']['childFieldList'] ?? []) as $group) {
if (! is_array($group)) {
continue;
}
$item = [];
foreach ($group as $field) {
if (is_array($field) && isset($field['fieldId'])) {
$item[(string) $field['fieldId']] = $field['value'] ?? null;
}
}
$skuId = strtoupper(trim((string) ($item['skuId'] ?? '')));
if ($skuId === '') {
continue;
}
$items[] = [
'sku_id' => $skuId,
'qty' => (float) ($item['qty'] ?? 0),
'platform_product_id' => mb_substr(trim((string) ($item['platformItemId'] ?? '')), 0, 191),
'i_id' => trim((string) ($item['iid'] ?? '')),
'name' => mb_substr(trim((string) ($item['name'] ?? '')), 0, 191),
];
}
$createdAt = null;
try {
$createdAt = Carbon::parse($created);
} catch (Throwable) {
// created 无法解析时仍保留工单,仅规则生效时间过滤会放宽。
}
return [
'wo_no' => $woNo,
'waybill' => $waybill,
'o_id' => $oId,
'courier' => $courier,
'online_order_no' => mb_substr(trim((string) ($flat['orderField']['value'] ?? '')), 0, 191),
'shop_name' => mb_substr(trim((string) ($flat['shopField']['valueLabel'] ?? ($flat['shopField']['value'] ?? ''))), 0, 191),
'inspection_type' => mb_substr(trim((string) $inspectionType), 0, 60),
'inspection_reason' => mb_substr(trim((string) $inspectionReason), 0, 120),
'send_time' => trim((string) ($flat['orderSendTime']['value'] ?? '')),
'created_at' => $createdAt,
'items' => $items,
];
}
/**
* @param array{id:int,courier:string,mode:string,value:float,effective_at:?string} $rule
* @param list<array<string,mixed>> $orders
*/
private function writeSettlementRows(string $connection, array $rule, array $orders): int
{
$effectiveAt = null;
if (($rule['effective_at'] ?? null) !== null) {
try {
$effectiveAt = Carbon::parse((string) $rule['effective_at'])->startOfDay();
} catch (Throwable) {
// 规则生效时间无法解析时不做下界过滤。
}
}
if ($effectiveAt !== null) {
$orders = array_values(array_filter(
$orders,
static fn (array $order): bool => ! $order['created_at'] instanceof Carbon
|| $order['created_at']->greaterThanOrEqualTo($effectiveAt),
));
}
if ($orders === []) {
return 0;
}
$oIds = [];
$waybills = [];
$skuIds = [];
foreach ($orders as $order) {
if ((int) ($order['o_id'] ?? 0) > 0) {
$oIds[(int) $order['o_id']] = true;
}
$waybills[(string) $order['waybill']] = true;
foreach ($order['items'] as $item) {
$skuIds[(string) $item['sku_id']] = true;
}
}
$type = (string) $rule['type'];
$returns = $type === '退回费' ? $this->pullReturns($orders) : [];
$weights = $this->pullWeights($connection, array_keys($skuIds));
$now = now();
$rows = [];
$hasReturnByWaybill = [];
foreach ($orders as $order) {
$matched = [];
$receiveDates = [];
$returnWaybills = [];
$hasReturn = false;
$settlementWeight = 0.0;
$orderWeight = 0.0;
foreach ($order['items'] as $item) {
$weight = $weights[(string) $item['sku_id']] ?? null;
if ($weight !== null) {
$orderWeight += (float) $item['qty'] * $weight;
}
}
if ($type === '拦截费') {
// 拦截费口径:查件扣单即按固定单价结算,不要求退货进仓。
$amount = (float) $rule['value'];
$status = '已计算';
} else {
foreach ($returns[(int) ($order['o_id'] ?? 0)] ?? [] as $return) {
$rQty = (float) ($return['r_qty'] ?? 0);
$receiveDate = trim((string) ($return['receive_date'] ?? ''));
if ($rQty <= 0 || $receiveDate === '') {
continue;
}
$hasReturn = true;
$matched[] = $return;
$receiveDates[] = $receiveDate;
$returnWaybill = trim((string) ($return['l_id'] ?? ''));
if ($returnWaybill !== '') {
$returnWaybills[$returnWaybill] = true;
}
}
$returnedWeight = 0.0;
foreach ($matched as $return) {
$weight = $weights[strtoupper(trim((string) ($return['sku_id'] ?? '')))] ?? null;
if ($weight !== null) {
$returnedWeight += (float) $return['r_qty'] * $weight;
}
}
$settlementWeight = $hasReturn
? ($returnedWeight > 0 ? $returnedWeight : $orderWeight)
: $orderWeight;
if ($rule['mode'] === '固定单价') {
$amount = $hasReturn ? (float) $rule['value'] : 0.0;
$status = '已计算';
} elseif (! $hasReturn) {
$amount = 0.0;
$status = '已计算';
} elseif ($settlementWeight > 0) {
$amount = round($settlementWeight * (float) $rule['value'], 2);
$status = '已计算';
} else {
$amount = 0.0;
$status = '待核价';
}
}
$primary = $this->primaryItem($order['items'], $matched);
$waybill = (string) $order['waybill'];
// 同一运单可能有多张扣单工单;只保留一行,优先保留有退回记录的那行。
if (($hasReturnByWaybill[$waybill] ?? null) === true && ! $hasReturn) {
continue;
}
$rows[$waybill] = [
'rule_id' => $rule['id'],
'surcharge_type' => $type,
'courier_name' => $rule['courier'],
'waybill' => $waybill,
'outbound_io_id' => 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' => $order['shop_name'] !== '' ? $order['shop_name'] : null,
'online_order_no' => $order['online_order_no'] !== '' ? $order['online_order_no'] : null,
'length' => null,
'width' => null,
'height' => null,
'volume' => null,
'inspection_type' => $order['inspection_type'],
'inspection_reason' => $order['inspection_reason'],
'return_waybill_no' => $returnWaybills === []
? null
: mb_substr(implode(',', array_keys($returnWaybills)), 0, 191),
'settlement_weight' => $settlementWeight > 0 ? round($settlementWeight, 2) : null,
'settlement_amount' => $amount,
'settlement_status' => $status,
'evidence' => mb_substr($this->evidence($rule, $order, $hasReturn, $matched, $receiveDates, $settlementWeight, $amount), 0, 500),
'shipped_at' => $this->shippedAt($order),
'pulled_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$hasReturnByWaybill[$waybill] = $hasReturn;
}
foreach (array_chunk(array_keys($waybills), 500) as $chunk) {
DB::connection($connection)->table('courier_surcharge_settlement_rows')
->where('rule_id', $rule['id'])
->where('surcharge_type', $type)
->whereIn('waybill', $chunk)
->delete();
}
foreach (array_chunk(array_values($rows), 500) as $chunk) {
DB::connection($connection)->table('courier_surcharge_settlement_rows')->insert($chunk);
}
return count($rows);
}
/**
* 售后退货退款:进仓时间取 receive_date,实退数量取 r_qty。
*
* @param list<array<string,mixed>> $orders
* @return array<int,list<array<string,mixed>>>
*/
private function pullReturns(array $orders): array
{
$map = [];
$oIds = [];
$oIdByWaybill = [];
foreach ($orders as $order) {
$oId = (int) ($order['o_id'] ?? 0);
$waybill = trim((string) ($order['waybill'] ?? ''));
if ($oId > 0) {
$oIds[$oId] = true;
}
if ($oId > 0 && $waybill !== '') {
$oIdByWaybill[$waybill] = $oId;
}
}
$upstream = app(UpstreamReadOnlyQueryFactory::class)->forSource('operations_replica');
foreach (array_chunk(array_keys($oIds), 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->selectReturns($upstream, 'a.o_id IN (' . $placeholders . ')', $chunk) as $row) {
$oId = (int) ($row['o_id'] ?? 0);
if ($oId > 0) {
$map[$oId][] = $row;
}
}
}
// 部分售后单的 o_id 与查件工单不一致,按原始运单号再兜底一次。
$remainingWaybills = [];
foreach ($oIdByWaybill as $waybill => $oId) {
if (($map[$oId] ?? []) === []) {
$remainingWaybills[] = $waybill;
}
}
foreach (array_chunk($remainingWaybills, 200) as $chunk) {
$placeholders = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->selectReturns($upstream, 'a.l_id IN (' . $placeholders . ')', $chunk) as $row) {
$oId = $oIdByWaybill[trim((string) ($row['l_id'] ?? ''))] ?? 0;
if ($oId > 0) {
$map[$oId][] = $row;
}
}
}
return $map;
}
/**
* @param list<mixed> $bindings
* @return list<array<string,mixed>>
*/
private function selectReturns(UpstreamReadOnlyQuery $upstream, string $where, array $bindings): array
{
try {
$rows = $upstream->select(
'SELECT a.o_id, a.as_id, a.l_id, a.status, CONVERT(varchar(19), a.confirm_date, 120) AS confirm_date, '
. 'ri.sku_id, ri.qty, 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 ' . $where,
$bindings,
);
} catch (Throwable) {
return [];
}
return array_map(static fn ($row): array => (array) $row, $rows);
}
/**
* @param list<string> $skuIds
* @return array<string,float>
*/
private function pullWeights(string $connection, array $skuIds): array
{
$map = [];
foreach (array_chunk($skuIds, 500) as $chunk) {
$rows = DB::connection($connection)->table('ops_jst_sku')
->whereIn('sku_id', $chunk)
->get(['sku_id', 'weight']);
foreach ($rows as $row) {
$skuId = strtoupper(trim((string) $row->sku_id));
$weight = $row->weight;
if ($skuId === '' || $weight === null || $weight === '' || isset($map[$skuId])) {
continue;
}
$map[$skuId] = (float) $weight;
}
}
return $map;
}
/**
* @param list<array<string,mixed>> $items
* @param list<array<string,mixed>> $returns
* @return array{sku_id:string,i_id:string,platform_product_id:string,name:string}
*/
private function primaryItem(array $items, array $returns): array
{
$returnSkus = [];
foreach ($returns as $return) {
$skuId = strtoupper(trim((string) ($return['sku_id'] ?? '')));
if ($skuId !== '') {
$returnSkus[$skuId] = true;
}
}
foreach ($items as $item) {
if (isset($returnSkus[$item['sku_id']])) {
return $item;
}
}
foreach ($items as $item) {
if ($item['platform_product_id'] !== '') {
return $item;
}
}
return $items[0] ?? ['sku_id' => '', 'i_id' => '', 'platform_product_id' => '', 'name' => ''];
}
/**
* @param array{id:int,type:string,courier:string,mode:string,value:float,effective_at:?string} $rule
* @param array<string,mixed> $order
* @param list<array<string,mixed>> $matched
* @param list<string> $receiveDates
*/
private function evidence(
array $rule,
array $order,
bool $hasReturn,
array $matched,
array $receiveDates,
float $settlementWeight,
float $amount,
): string {
$parts = ['查件扣单/' . $order['inspection_reason']];
if (($rule['type'] ?? '退回费') === '拦截费') {
$parts[] = '拦截费规则 ' . $this->trimNumber((float) $rule['value']) . '元/单';
$parts[] = '结算金额 ' . number_format($amount, 2, '.', '');
return implode(';', $parts);
}
if ($hasReturn) {
$returnQty = 0.0;
$confirmDates = [];
foreach ($matched as $return) {
$returnQty += (float) ($return['r_qty'] ?? 0);
$confirmDate = trim((string) ($return['confirm_date'] ?? ''));
if ($confirmDate !== '') {
$confirmDates[] = $confirmDate;
}
}
if ($confirmDates !== []) {
$parts[] = '退款确认 ' . max($confirmDates);
}
$parts[] = '售后退货退款已进仓 ' . ($receiveDates === [] ? '' : max($receiveDates));
$parts[] = '实退 ' . $this->trimNumber($returnQty);
} else {
$parts[] = '售后退货退款无进仓/实退记录';
}
$parts[] = '商品资料重量合计 ' . $this->trimNumber($settlementWeight) . 'kg';
$parts[] = $rule['mode'] === '固定单价'
? '规则 ' . $this->trimNumber((float) $rule['value']) . '元/单'
: '规则 ' . $this->trimNumber((float) $rule['value']) . '元/kg';
$parts[] = '结算金额 ' . number_format($amount, 2, '.', '');
return implode(';', $parts);
}
/**
* @param array<string,mixed> $order
*/
private function shippedAt(array $order): ?string
{
$sendTime = trim((string) ($order['send_time'] ?? ''));
if ($sendTime !== '') {
try {
return Carbon::parse($sendTime)->toDateTimeString();
} catch (Throwable) {
// 发货时间无法解析时回退到查件工单创建时间。
}
}
return $order['created_at'] instanceof Carbon ? $order['created_at']->toDateTimeString() : null;
}
private function trimNumber(float $value): string
{
$text = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
return $text === '' ? '0' : $text;
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationNotifier;
use Illuminate\Console\Command;
/**
* 店铺违规信息预警:P0/P1 即时推送(每天 09:00 起由生产 cron 每 5 分钟触发)。
*/
class PushStoreViolationAlerts extends Command
{
protected $signature = 'store-violations:push-instant
{--limit=200 : 单轮最多推送条数}
{--retry-failed : 同时重试推送失败的记录}';
protected $description = '推送店铺违规预警即时消息(P0/P1,含责任人@与去认领链接)';
public function handle(StoreViolationNotifier $notifier): int
{
if (! $notifier->enabled()) {
$this->line('推送未配置,跳过。');
return self::SUCCESS;
}
$summary = $notifier->pushInstant(
max((int) $this->option('limit'), 1),
(bool) $this->option('retry-failed'),
);
$this->info(sprintf(
'即时推送:待推 %d 条,成功 %d 条,失败 %d 条。',
$summary['attempted'],
$summary['succeeded'],
$summary['failed'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationNotifier;
use Illuminate\Console\Command;
/**
* 店铺违规信息预警:P2 每日 10:00 汇总日报。
*/
class PushStoreViolationDigest extends Command
{
protected $signature = 'store-violations:push-digest';
protected $description = '推送店铺违规预警每日汇总(P2 汇总与超时未认领点名)';
public function handle(StoreViolationNotifier $notifier): int
{
if (! $notifier->enabled()) {
$this->line('推送未配置,跳过。');
return self::SUCCESS;
}
$summary = $notifier->pushDigest();
$this->info(sprintf(
'日报推送:%s;P0 %d / P1 %d / P2 %d,超时未认领 %d 条。',
$summary['sent'] ? '已发送' : '发送失败',
(int) ($summary['pending']['P0'] ?? 0),
(int) ($summary['pending']['P1'] ?? 0),
(int) ($summary['pending']['P2'] ?? 0),
(int) $summary['overdue'],
));
return self::SUCCESS;
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreViolationAlertCalculator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\StoreViolationRecordRepository;
/**
* 店铺违规信息预警:按当前字典/归属重新计算全部记录的预警派生字段。
*
* 用于字典维护或店铺归属调整后的校准;不触碰处理状态、忽略原因与推送状态。
*/
class RecalcStoreViolationAlerts extends Command
{
protected $signature = 'store-violations:recalc-alerts
{--platform= : 只重算指定平台}
{--chunk=500 : 每批更新行数}';
protected $description = '重算店铺违规记录的预警级别、责任人与处理截止';
public function handle(StoreViolationAlertCalculator $calculator, StoreViolationRecordRepository $repository): int
{
if (! $repository->available()) {
$this->error('统一表 store_violation_records 尚未建立。');
return self::FAILURE;
}
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$chunk = max(min((int) $this->option('chunk'), 2000), 100);
$platform = trim((string) $this->option('platform'));
$total = 0;
$query = $connection->table(StoreViolationRecordRepository::TABLE)
->select(['id', 'platform', 'violation_no', 'shop_name', 'violation_type', 'violation_reason', 'violation_date', 'appeal_deadline'])
->orderBy('id');
if ($platform !== '') {
$query->where('platform', $platform);
}
$query->chunk($chunk, function ($rows) use ($calculator, $connection, &$total): void {
$updates = [];
foreach ($rows as $row) {
$matchType = trim((string) ($row->violation_type ?? '')) !== ''
? (string) $row->violation_type
: ($row->violation_reason ?? null);
// upsert 的插入侧会校验非空列,带上 platform / violation_no 以通过严格模式;
// 命中已有记录时只会更新派生字段(update 列表)。
$updates[] = [
'id' => $row->id,
'platform' => (string) $row->platform,
'violation_no' => (string) ($row->violation_no ?? ''),
] + $calculator->calculate(
(string) $row->platform,
$row->shop_name,
$matchType,
$row->violation_date ? (string) $row->violation_date : null,
$row->appeal_deadline ? (string) $row->appeal_deadline : null,
);
}
if ($updates !== []) {
$connection->table(StoreViolationRecordRepository::TABLE)->upsert(
$updates,
['id'],
StoreViolationRecordRepository::ALERT_DERIVED_COLUMNS,
);
$total += count($updates);
}
});
$this->info(sprintf('预警重算完成:%d 行。', $total));
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

@ -15,10 +15,10 @@ class RegisterBusinessMirror extends Command
{--keys= : 逗号分隔的稳定唯一键}
{--columns= : 逗号分隔的明确同步字段,必须包含稳定键及增量字段}
{--watermark= : 可靠更新时间字段;留空为仅按主键追加}
{--enable-sync : 启用并批准可断点首次同步及后续增量}
{--enable-sync : 启用可断点首次同步及后续增量}
{--disable-sync : 停用此数据集的自动同步,保留镜像数据}';
protected $description = '登记声明式镜像数据集;生产启用前需获得同步范围授权';
protected $description = '登记声明式镜像数据集;核对范围并备份后可直接启用同步';
public function handle(BusinessMirrorDatasetRegistry $datasets, UpstreamSourceRegistry $sources): int
{

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

@ -0,0 +1,209 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\JdBillPlatform;
use Modules\EcommerceFinance\Repositories\DewuSettlementBillRepository;
use Modules\EcommerceFinance\Repositories\JdDailyBillRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingAlipayBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingDewuBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingDouyinBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingJdWalletBillRepository;
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\TaobaoWechatSettlementBillRepository;
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
use Modules\EcommerceFinance\Services\AccountLedgerRemarkMap;
use Throwable;
/**
* 后台执行「店铺收款账户账单」导出任务:生成 CSV 并更新任务状态,供页面列表下载。
*/
class RunAccountBillsExportTask extends Command
{
protected $signature = 'account-bills:export-run {token : 导出任务 token}';
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);
if ($task === null) {
$this->error('导出任务不存在:' . $token);
return self::FAILURE;
}
if (($task['status'] ?? '') !== 'queued') return self::SUCCESS;
$task = $tasks->patch($task, ['status' => 'running', 'started_at' => AccountBillsExportTasks::beijingNow()]);
try {
if (! empty($task['cancel'])) {
$tasks->patch($task, ['status' => 'cancelled', 'finished_at' => AccountBillsExportTasks::beijingNow()]);
$this->info('任务已取消。');
return self::SUCCESS;
}
$columns = array_values((array) ($task['columns'] ?? []));
if ($columns === []) {
throw new \RuntimeException('导出任务没有可用字段。');
}
$csv = fopen($tasks->csvPath($task), 'w');
if ($csv === false) {
throw new \RuntimeException('无法创建导出文件。');
}
// 让「行数」列能显示真实百分比:镜像连接改为不缓冲取数,
// 行一取到就写盘,进度按 已写行数 / 总行数 实时刷新。
$this->streamRowsWithoutBuffering($remarkMap);
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_map(static fn (array $column): string => (string) ($column['label'] ?? ''), $columns));
$written = 0;
foreach ($this->rows($task, $remarkMap) as $row) {
fputcsv($csv, array_map(
static fn (array $column): string => self::cellValue($row, $column),
$columns,
));
$written++;
if ($written % 2000 === 0) {
// 用户在弹窗里点了「取消」:停止导出并删掉半成品文件。
$fresh = $tasks->find((int) $task['user_id'], (string) $task['token']);
if (! empty($fresh['cancel'])) {
fclose($csv);
@unlink($tasks->csvPath($task));
$tasks->patch($task, ['status' => 'cancelled', 'rows' => $written, 'finished_at' => AccountBillsExportTasks::beijingNow(), 'size' => 0]);
$this->info('任务已取消(已写入 ' . $written . ' 行后停止)。');
return self::SUCCESS;
}
$task = $tasks->patch($task, ['status' => 'running', 'rows' => $written]);
}
}
fclose($csv);
$task = $tasks->patch($task, [
'status' => 'done',
'rows' => $written,
'size' => (int) @filesize($tasks->csvPath($task)),
'finished_at' => AccountBillsExportTasks::beijingNow(),
'error' => null,
]);
$this->info('导出完成:' . $written . ' 行');
return self::SUCCESS;
} catch (Throwable $exception) {
$tasks->patch($task, [
'status' => 'failed',
'finished_at' => AccountBillsExportTasks::beijingNow(),
'error' => mb_substr($exception->getMessage(), 0, 200),
]);
$this->error('导出失败:' . $exception->getMessage());
return self::FAILURE;
}
}
private function findTask(AccountBillsExportTasks $tasks, string $token): ?array
{
$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) {
return $task;
}
}
return null;
}
/** @return iterable<int,array<string,mixed>> */
private function rows(array $task, AccountLedgerRemarkMap $remarkMap): iterable
{
$tab = (string) ($task['tab'] ?? '');
$filters = (array) ($task['filters'] ?? []);
// 后台导出固定按「发生时间」取数:该列上有 (occurred_at, record_code) 联合索引,
// 数据库能按索引顺序边扫边返回,首行几百毫秒内到达,进度可实时刷新;
// 按「记录编码」走主键反向扫描过滤日期,要扫完整表才出首行,进度会长时间停在 0%。
$sort = 'occurred_at';
$dir = strtolower((string) ($filters['dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
unset($filters['sort'], $filters['dir'], $filters['export'], $filters['token'], $filters['total'], $filters['columns']);
$rows = match ($tab) {
'taobao-alipay' => app(StoreReceivingAlipayBillMirrorRepository::class)->exportRows($filters, $sort, $dir),
'taobao-wechat' => app(TaobaoWechatSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir),
'pinduoduo' => app(PinduoduoSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir),
'dewu' => app(DewuSettlementBillRepository::class)->exportAccountBills($filters, $sort, $dir),
'jd-transactions', 'jingxi' => JdBillPlatform::isJdBillTab($tab)
? app(JdDailyBillRepository::class)->forPlatform(JdBillPlatform::platformForBillType($tab))->exportRows($filters, $sort, $dir)
: [],
'jd-wallet' => app(StoreReceivingJdWalletBillRepository::class)->exportRows($filters, $sort, $dir),
'douyin' => app(StoreReceivingDouyinBillMirrorRepository::class)->exportRows($filters, $sort, $dir),
default => throw new \RuntimeException('该页签暂不支持后台导出:' . $tab),
};
return $remarkMap->decorateAll($rows, $tab);
}
/**
* 把镜像连接切成「不缓冲」并预热总账备注映射,使取数阶段逐批返回行。
*
* Web/CLI 默认用缓冲查询:整段结果集在数据库端算完才返回,进度会长时间停在 0%,
* 最后一次性跳到 100%。切到不缓冲后行随查询推进到达,进度即真实百分比。
*/
private function streamRowsWithoutBuffering(AccountLedgerRemarkMap $remarkMap): void
{
try {
// 预热:避免逐行装饰时中途再去读缓存/文件。
$remarkMap->map();
$connection = app(\Illuminate\Database\DatabaseManager::class)
->connection((string) config('upstream_mirrors.connection', 'business_mirror'));
if (! $connection instanceof \Illuminate\Database\MySqlConnection) {
return;
}
$pdo = $connection->getPdo();
$pdo->setAttribute(\PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
// 读连接与写连接是两套句柄,这里统一成同一个不缓冲句柄。
$connection->setReadPdo($pdo);
} catch (Throwable) {
// 拿不到句柄时退回默认行为,不影响导出结果。
}
}
/** @param array<string,mixed> $row */
private static function cellValue(array $row, array $column): string
{
$field = (string) ($column['field'] ?? '');
$value = str_starts_with($field, 'payload.')
? data_get($row['payload'] ?? [], substr($field, 8))
: data_get($row, $field);
$text = trim((string) ($value ?? ''));
if ($text === '') {
return '';
}
if (preg_match('/^\d{12,}$/', $text) === 1) {
return '="' . $text . '"';
}
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
}
}

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