Files
peipei-backend/salary_design.md
2025-10-06 23:55:08 -04:00

743 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 工资结算业务设计PeiPei
## 心智模型Mental Model
- 冻结期之后入池订单完成并超过冻结期Cooling-Off进入“可结算池”eligible pool
- 结算即消费并打标:无论是手动还是周期性自动结算,都是从同一个池中“消费”订单;提交时通过条件更新把订单从“未结算→已结算”,每个订单只会被消费一次。
- 并发先到先得:自动与手动可能并发提交,谁先成功把订单标记为已结算,谁拿到该订单;另一次提交只会结算剩余的订单,并看到差异提示。
- 历史不回溯:新结算不会影响已结算(尤其是已发放)的工资单;已发放的结算单不可变更,只能通过后续“负向调整”处理异常。
- 配置有快照:每张结算单保存生成时的配置快照(周期、冻结期、退款策略等);后续修改配置不会影响历史结算。未发放的结算单可选择按“快照”或“最新配置”重算。
- 退款有策略:
- 未发放:可重算并剔除该订单。
- 已发放不改历史生成负向调整按策略“冲减下一期OFFSET_NEXT”或“立即回收IMMEDIATE_RECOVER”。
- 边界一致性:统一 UTC 存储、按租户时区展示;统计窗口用闭开区间 [start, end) 保持口径一致。
- 审计与幂等:订单可记录 settlement_id明细表对 (tenant_id, order_id) 做唯一约束,保障“只结算一次、可追溯可审计”。
## 设计目标
- 清晰可追溯:店员能看到“某段时间我应得多少”,并能追溯到每一单与计算口径。
- 灵活可控:店主既可手动结算(挑人/时间/单),也可设置自动结算计划(周期、触发时间、冻结期、是否自动发放)。
- 可追责与可回滚:每次结算保留规则、范围、计算与调整留痕;退款/重算/冲销有明确策略闭环。
## 核心概念
- 结算周期 Pay Period一段明确起止的统计窗口如 2025-09-01 ~ 2025-09-07
- 结算单 Settlement工资单周期内“已完成且过冻结期”的订单汇总形成“应结金额”进入确认与发放流程。
- 结算明细 Line Item逐单记录订单号、完成时间、实付、分成比例、预计收入、优惠券扣减、调整等
- 冻结期 Cooling-Off订单完成后 X 小时/天内可退款不计入工资;过期后进入“可结算池”。
- 调整 Adjustment正/负向校正(奖惩、税费、异常修正等),与证据与操作人绑定。
## 角色与诉求
- 店员
- 未结算:我有哪些单即将入池/已入池,合计多少,预计何时发放。
- 已结算:分周期查看应结/调整/实发金额与发放渠道/时间,下载工资条。
- 可解释:看到优惠券扣减、比例、退款影响与调整缘由。
- 店主
- 手动结算:指定店员/分组/全部,指定时间段,预览→(可加调整)→生成结算单→发放。
- 自动结算:按日/周/月/自定义计划,到点生成草稿或直接发放。
- 异常闭环:跨期退款、重算、冲销策略;权限与审计;报表与导出。
## 结算规则与口径
- 计入范围
- 订单状态已完成orderStatus=3
- 冻结期:完成时间超过配置的冻结期(默认 24h
- 收入口径:以订单落库时的“店员预计收入 estimatedRevenue / 比例”为准(已含优惠券承担策略)。
- 跨期退款
- 未结算时退款:从可结算池剔除。
- 已结算后退款:生成负向调整(绑定原订单),策略:
- OFFSET_NEXT冲减下一期默认
- IMMEDIATE_RECOVER立即冲销并回收账户余额
- 舍入与门槛
- 金额保留 2 位、四舍五入(可配置);最低发放门槛(如 <10 元累计至下期)。
- 锁定与重算
- 结算单状态Draft Ready Paid Closed
- Paid 锁定Draft/Ready 允许重算保留差异快照与版本号)。
- 多租户与权限
- 全流程按租户隔离店主/财务具备结算与调整权限店员仅可见本人
## 业务流程
- 自动结算
1) 到点触发计划按配置周期/时间
2) 扫描已完成且过冻结期且未结算的订单按店员聚合生成 Draft 结算单
3) 若开启自动发放直接进入 Paid写发放流水与时间并通知否则停留 Ready待人工发放
- 手动结算
1) 选择时间范围/店员/分组/全员预览待结算订单 + 初步金额”;
2) 可添加正/负调整需备注与可选证据
3) 生成结算单Ready)→ 选择发放渠道余额/线下)→ 发放Paid)。
- 订单入池
- 订单完成冻结计时过期入未结算池”→被结算消耗写入工资明细并把订单标记为已结算与结算时间
- 通知
- 店员生成/发放通知与工资条链接
- 店主自动结算摘要与异常负向冲销/重算日报
## 配置中心(租户级)
- 周期与时间DAILY/WEEKLY/MONTHLY/CUSTOM触发时间点 03:00自然周/月或滚动周期
- 冻结期单位小时/可按下单类型指定/随机/打赏细分可选)。
- 分成口径是否使用订单入库时的预计收入”(默认是否允许按当前比例重算”(默认关闭)。
- 优惠券承担策略与订单侧一致变更通过结算统一调整进行口径对齐
- 退款冲销策略OFFSET_NEXT IMMEDIATE_RECOVER是否回收余额与方式
- 金额规则舍入模式最低发放门槛是否预扣税/平台费与比例可选)。
- 自动发放开关发放渠道默认失败重试与人工补发流程
---
## 简易 API 设计(草案)
路径均以租户隔离与鉴权为前提店员端与店主端分权控制
### 店主侧(结算与配置)
- 预览结算按条件聚合
- POST `/api/payroll/settlements/preview`
- Body`{ startDate, endDate, scope: {type: 'ALL'|'GROUP'|'CLERK', ids:[]}, includeTypes?: ['REGULAR','RANDOM','REWARD'], coolingHours?: number }`
- Resp`[{ clerkId, clerkName, orders:[{orderId,orderNo,finishTime,finalAmount,revenueRatio,estimatedRevenue,couponDeduction}], sum:{orderCount,finalAmount,estimatedRevenue} }]`
- 生成结算单可带调整
- POST `/api/payroll/settlements`
- Body`{ period:{startDate,endDate}, items:[{clerkId, adjustment?:{amount,reason,evidenceUrl}}], fromPreviewId?:string }`
- Resp`{ settlementIds:[...], status:'READY' }`
- 发放/支付结算单单个/批量
- POST `/api/payroll/settlements/{id}/pay`
- Body`{ channel:'BALANCE'|'OFFLINE', payRef?:string }`
- Resp`{ id,status:'PAID', paidAt, channel, payRef }`
- 重算未发放结算单
- POST `/api/payroll/settlements/{id}/recalculate`
- Resp`{ id, diff:{amountBefore,amountAfter,ordersChanged:[]}, version }`
- 查询结算单列表/详情
- GET `/api/payroll/settlements?status=READY&clerkId=&groupId=&from=&to=&page=&size=`
- GET `/api/payroll/settlements/{id}`含明细与调整快照
- 添加/撤销调整
- POST `/api/payroll/adjustments`
- Body`{ settlementId, clerkId, type:'PLUS'|'MINUS', amount, reason, evidenceUrl }`
- DELETE `/api/payroll/adjustments/{id}`
- 自动结算配置
- GET `/api/payroll/config`
- PUT `/api/payroll/config`
- Body`{ cycle:'DAILY'|'WEEKLY'|'MONTHLY'|'CUSTOM', triggerTime:'HH:mm', coolingHours:number, autoPay:boolean, minPayout:number, rounding:'HALF_UP'|'DOWN'|..., refundPolicy:'OFFSET_NEXT'|'IMMEDIATE_RECOVER', includeTypes?:[...] }`
### 店员侧(自助查询)
- 我的未结算汇总
- GET `/api/payroll/me/summary` `{ unsettled:{orderCount,finalAmount,estimatedRevenue}, nextExpectedPayTime }`
- 我的结算单列表/详情
- GET `/api/payroll/me/settlements?from=&to=&status=&page=&size=`
- GET `/api/payroll/me/settlements/{id}`含订单明细调整发放信息
---
## 表结构设计(草案)
说明系统已有表 `play_clerk_wages_info``play_clerk_wages_details_info` 与订单 `play_order_info`为支持完整结算流程建议补充或扩展字段向后兼容)。以下为建议DDL伪SQL仅供参考)。
### 1) 店员工资结算汇总(扩展)
`play_clerk_wages_info`
- 现有主要字段`id, tenant_id, clerk_id, start_count_date, end_count_date, settlement_date, order_number, final_amount, estimated_revenue, order_continue_* , orders_expired_number, serial_number, version, deleted`
- 新增建议字段
- `status` varchar(16) COMMENT 'Draft|Ready|Paid|Closed'
- `pay_amount` decimal(10,2) DEFAULT 0.00 COMMENT '实发金额=estimated_revenue+adjustment_amount受门槛/舍入影响'
- `adjustment_amount` decimal(10,2) DEFAULT 0.00 COMMENT '调整合计可负'
- `pay_channel` varchar(16) NULL COMMENT 'BALANCE|OFFLINE|...'
- `pay_time` datetime NULL
- `pay_reference_no` varchar(64) NULL COMMENT '发放流水号/凭证'
- `payroll_no` varchar(32) NULL COMMENT '结算单号可读性'
- `remarks` varchar(512) NULL
- 索引`idx_wages_tenant_status (tenant_id,status,pay_time)``uniq_payroll_no (tenant_id,payroll_no)`
### 2) 店员工资结算明细(扩展)
`play_clerk_wages_details_info`
- 现有字段`id, tenant_id, wages_id, clerk_id, order_id, order_no, final_amount, estimated_revenue, end_order_time, ...`
- 新增建议字段
- `revenue_ratio` int NULL COMMENT '订单时的分成比例%'
- `coupon_deduction` decimal(10,2) DEFAULT 0.00 COMMENT '与店员相关的优惠扣减'
- `adjustment_amount` decimal(10,2) DEFAULT 0.00 COMMENT '单笔调整如异常修正'
- `first_order` varchar(1) NULL COMMENT '0/1'
- `place_type` varchar(1) NULL COMMENT '-1/0/1/2'
- 索引`idx_details_wages (tenant_id,wages_id)``idx_details_order (tenant_id,order_id)`
### 3) 结算调整记录(新增)
`play_payroll_adjustment`
- `id` varchar(32) PK
- `tenant_id` varchar(32)
- `settlement_id` varchar(32) NOT NULL
- `clerk_id` varchar(32) NOT NULL
- `type` varchar(8) NOT NULL COMMENT 'PLUS|MINUS'
- `amount` decimal(10,2) NOT NULL
- `reason` varchar(512) NULL
- `evidence_url` varchar(512) NULL
- `operator_id` varchar(32) NOT NULL
- `operator_name` varchar(64) NULL
- `created_time` datetime NOT NULL
- 索引`idx_adj_settlement (tenant_id,settlement_id)`
### 4) 自动结算计划(新增)
`play_payroll_plan`
- `id` varchar(32) PK
- `tenant_id` varchar(32) NOT NULL
- `name` varchar(64) NOT NULL
- `cycle` varchar(16) NOT NULL COMMENT 'DAILY|WEEKLY|MONTHLY|CUSTOM'
- `trigger_time` varchar(8) NOT NULL COMMENT 'HH:mm'
- `cooling_hours` int NOT NULL DEFAULT 24
- `auto_pay` tinyint(1) NOT NULL DEFAULT 0
- `min_payout` decimal(10,2) DEFAULT 0.00
- `rounding` varchar(16) DEFAULT 'HALF_UP'
- `refund_policy` varchar(32) DEFAULT 'OFFSET_NEXT' COMMENT 'OFFSET_NEXT|IMMEDIATE_RECOVER'
- `include_types` varchar(64) NULL COMMENT 'REGULAR,RANDOM,REWARD'
- `enabled` tinyint(1) NOT NULL DEFAULT 1
- `created_time` datetime, `updated_time` datetime
- 索引`idx_plan_tenant_enabled (tenant_id,enabled)`
### 5) 计划运行日志(新增)
`play_payroll_run_log`
- `id` varchar(32) PK
- `tenant_id` varchar(32) NOT NULL
- `plan_id` varchar(32) NOT NULL
- `run_time` datetime NOT NULL
- `status` varchar(16) NOT NULL COMMENT 'SUCCESS|PARTIAL|FAILED'
- `generated_settlements` int DEFAULT 0
- `paid_settlements` int DEFAULT 0
- `message` varchar(1024) NULL
- 索引`idx_runlog_plan (tenant_id,plan_id,run_time)`
---
## 状态机(结算单)
- Draft生成草稿未锁定可重算与编辑调整
- Ready待发放可批量发放可重算
- Paid已发放并锁定仅允许附加备注或生成负向调整进入下期冲销
- Closed业务上关闭被替代的版本或已归档)。
迁移Draft Ready Paid ClosedReadyDraft仅未发放时)。
## 报表与导出
- 维度按店员/分组/周期产出应结调整实发订单数退款冲销TOP 列表与趋势
- 导出结算单工资条 PDF/Excel汇总对账单
## MVP 落地建议
1) 先用订单落库时的 estimatedRevenue做口径
2) 实现预览生成结算Ready)→发放Balance)→订单打已结算
3) 自动结算先不自动发放
4) 提供负向调整/冲销冲减下一期”;
5) 店员/店主端最小信息集与导出
> 备注:本文为业务与数据结构草案,开发落地需与现有表结构与代码对齐(如 MyBatis-Plus 实体、租户拦截、定时任务与权限模型)。
---
## 防错与冲突处理(关键业务规则)
### 核心原则
- 唯一归属与幂等每个订单最多被一个结算单消费;“提交结算用条件更新order_settlement_state=0实现天然去重。
- 配置快照结算单保存当时配置周期冻结期退款策略…),后续改配置不影响已生成/已发放单重算默认沿用快照可选按新配置重算
- 非重叠窗口同一店员任意时刻最多存在一个活动Draft/Ready)”结算窗口避免重复覆盖
### 典型冲突与处理策略
- 自动刚结算紧接着手动结算
- 以提交时 eligible 订单为准未结算且过冻结期自动已消费的订单不会再次计入
- 预览页提示部分订单已在X结算单中结算”,提交后回显差异
- 自动与手动同时提交竞态
- 不做预留锁提交时抢占条件更新谁先成功谁消费另一次提交金额可能为0并提示
- 同店员多活动工资单
- 校验并限制同店员同租户仅允许一个 Draft/Ready新建前需发放/关闭旧草稿窗口不可交叠
- 改变结算周期如周结半月
- 默认从下次触发起生效”;旧计划跑完新计划按新周期起算不跨期追溯
- 提供期中迁移向导”:把未结算池按新周期切割预览一键生成 Ready 结算单
- 改变冻结期24h12h/48h
- 默认前向生效仅影响变更后完成的订单已完成订单按旧值判定
- 可选追溯重算向导”:列出因此新增/剔除的订单允许一次性处理
- 手动窗口与自动计划重叠
- 基于未结算池天然去重但为避免碎片化仍限制窗口交叠并提供对齐计划边界快捷选项
- 预览符合提交时变化跨边界/被他方消费
- 以提交为准返回变化提示摘要新增/剔除订单与金额差)。
- 已发放后发生退款
- 生成负向调整”(绑定原订单策略可配置
- OFFSET_NEXT冲减下一期可选是否即时回收余额
- IMMEDIATE_RECOVER即时回收并通知下一期不再重复扣减
- 重算策略未发放
- 允许重算默认沿用快照可选应用最新配置保留差异快照与版本号
- 时区/边界
- 服务端统一 UTC 存储按租户时区展示周期用闭开区间 [start, end)。
### 关键防错机制与索引
- 订单唯一消费
- 在工资明细表增加唯一索引 `uniq_tenant_order (tenant_id, order_id)`
- 或在订单表增加 `settlement_id` 字段并用 `where order_settlement_state=0` 条件更新确保幂等
- 活动物资单约束
- 逻辑层保证同店员同租户仅一个 Draft/Ready可在表层加入部分索引辅助查询 `idx_wages_tenant_status`)。
- 配置快照字段
- `plan_cycle_snapshot, trigger_time_snapshot, cooling_hours_snapshot, refund_policy_snapshot, include_types_snapshot, plan_version`
- 计划版本化
- 变更计划旧计划 disabled新计划 enabled(effective_at)定时仅拉取有效版本运行日志写 `plan_id/version`
- 提交前后差异校验
- 提交端使用与预览相同条件+条件更新回显影响行数与差异为0时提示无可结算”。
### 配置变更落地策略(默认)
- 周期变更从下次触发起生效提供期中迁移向导”。
- 冻结期变更默认前向生效提供追溯重算向导”。
- 自动 vs 手动以未结算池为准天然去重限制同店员仅一个活动结算单
---
## 细则
### 整体心法
- 一个订单只能被一个结算单吃掉”。“未结算池”= 已完成 + 过冻结期 + 未结算 的订单集合
- 结算单保存配置快照”,后续改配置不影响历史未发放时可重算默认沿用快照也可显式用新配置
- 同一店员同一时间最多一个活动结算窗口”(Draft/Ready避免重复与碎片
### 核心对象
- 订单play_order_info`orderStatus=3` 完成`order_settlement_state`0/1`order_end_time``estimatedRevenue`按比例计算的店员应得)。
- 结算单play_clerk_wages_info新增/建议字段 `status/pay_amount/adjustment_amount/pay_channel/pay_time/payroll_no` + 配置快照字段
- 结算明细play_clerk_wages_details_info逐单记录建议唯一索引 `(tenant_id, order_id)` 防重复
- 调整play_payroll_adjustment/负调整 `reason/evidence/operator` 审计
- 计划play_payroll_plan自动结算的周期/时间/冻结期/自动发放含版本与运行日志
### 冻结期理解
- 作用完成后短期可退款需等待过了冻结期才进入未结算池”。
- 判定`order_end_time <= now - coolingHours`
- 变更默认前向生效”;若需追溯追溯重算向导”,列出新增/剔除候选供确认
### 流程:预览 → 提交
- 预览查询符合条件的订单不加锁展示店员聚合的金额/列表
- 提交幂等/抢占
1) 事务内条件更新将订单 `settlement_state:0→1` `settlement_time/settlement_id`
2) 回读本次吃到的订单集合
3) 插入结算明细与汇总保存快照
4) 自动发放则记账与流水否则置 Ready
5) 返回差异摘要”(与预览的差别)。
- 明细表唯一索引兜底避免重复插入
### 自动 vs 手动 冲突
- 自动吃走一部分后手动提交手动提交按剩余 eligible 订单结算金额自然减少并提示已被结算单X消费”。
- 同时提交谁先成功谁消费另一次影响行数为0或金额变小提示差异
### 活动作业窗口与非重叠
- 同店员同租户仅一个 Draft/Ready若支持多窗口则窗口时间不可交叠闭开区间 [start,end))。
### 配置快照的意义
- 保存 `cycle/trigger_time/cooling_hours/refund_policy/include_types/plan_version`保证历史不受新配置影响重算默认用快照
### 周期与冻结期的改变
- 周期默认从下次触发起生效提供期中迁移向导对未结算池按新周期切割并生成 Ready
- 冻结期默认前向提供追溯重算向导列出会新增/剔除的候选供一次性处理
### 退款处理(不同阶段)
- 冻结期内未入池直接不计入
- 已入池未发放允许重算剔除该订单并更新金额
- 已发放生成负向调整”,策略二选一
- OFFSET_NEXT下一期冲减可配置是否立即回收余额
- IMMEDIATE_RECOVER立即回收并通知下一期不重复扣
### 舍入与最低发放
- 发放阶段统一舍入默认保留2位四舍五入写入 `pay_amount`低于门槛的留到下一期
### 时区与边界
- 存储 UTC展示按租户时区周期用闭开区间 `[start, end)` 统一口径
### 审计与可追责
- 明细唯一`uniq_tenant_order (tenant_id, order_id)`订单可写 `settlement_id` 追溯
- 计划版本化禁用旧计划启用新计划effective_at运行日志含 `plan_id/version` 与统计
- 结算单/调整记录操作人与备注完整保留
### 示例时序
- 周二 10:00 订单A完成冻结24h周三 10:00 可入池
- 周三 03:00 自动计划跑A 未过冻结期 不吃到
- 周三 10:05 手动预览看到 A
- 周三 10:06 他人触发自动提交并成功吃到 A
- 周三 10:07 店主手动提交影响行数0提示A已被结算单#123消费”。
### 实现模式(可复用)
- 预览`SELECT ... WHERE status=3 AND settlement_state=0 AND end_time<=:cutoff`
- 提交
- `UPDATE ... SET settlement_state=1, settlement_time=NOW(), settlement_id=:sid WHERE ... AND settlement_state=0 AND end_time<=:cutoff AND id IN (:previewIds)`
- 回读实际吃到的订单插入明细与汇总
- 事务单个结算单为单位避免大事务
### 测试清单
- 竞态两并发提交同批订单断言仅一方成功影响行数>0
- 冻结边界cutoff ±1 秒。
- 退款路径:冻结内/外、已发放与未发放。
- 配置变更:周期/冻结期前向与向导追溯。
- 调整项:正负组合、累计多次,实发金额与报表一致。
- 时区:跨日/跨周边界。
- 幂等重试:提交阶段服务异常重试不重复消费订单。
---
## 配置快照Snapshot细则
### 一、是什么(定义)
- 配置快照 = 结算单生成时,把与计算相关的关键配置“拍个照”保存在结算单里,用于追溯与稳定重算。
- 建议纳入快照的要素:
- 周期与窗口:`cycle_snapshot`DAILY/WEEKLY/MONTHLY/CUSTOM、该结算单统计区间 `[startDate, endDate)`
- 冻结期:`cooling_hours_snapshot`
- 计入范围:`include_types_snapshot`REGULAR/RANDOM/REWARD…、优惠券承担策略、收入口径按订单入库比例 vs. 当前比例);
- 结算规则:`rounding_snapshot``min_payout_snapshot``refund_policy_snapshot`OFFSET_NEXT/IMMEDIATE_RECOVER`timezone_snapshot`
- 计划版本:`plan_version``trigger_time_snapshot`
- 资格边界时间:`eligibility_cutoff_at`(当次计算使用的“冻结边界时间点”)。
### 二、为什么要有(目的)
- 保证历史不漂移:配置改了,历史金额/明细不变,便于对账与审计。
- 可控重算:未发放时默认用快照重算(稳定),也可显式用新配置重算(改变口径),并生成差异快照。
- 审计留痕:能还原“当时按什么规则产生了这张结算单”。
### 三、怎么保存(形态)
- 字段式:在 `play_clerk_wages_info` 上增加若干 `*_snapshot` 字段与 `eligibility_cutoff_at`
- 或 JSON`config_snapshot` JSON 一列集中存储全部配置;
- 二者可并用(关键字段单列 + 完整 JSON 便于扩展)。
### 四、如何使用(生成与重算)
- 首次生成:
1) 读取当前租户配置,确定 `[startDate, endDate)`
2) 计算 `eligibility_cutoff_at = now - coolingHours`(或约定的边界);
3) 查询 eligible 订单:`status=3 ∧ settlement_state=0 ∧ endTime ≤ eligibility_cutoff_at`
4) 生成明细与汇总,写入快照与 cutoff。
- 重算(仅未发放)两种模式:
1) 沿用快照(默认):坚持用快照中的窗口与 `eligibility_cutoff_at` 重跑,不纳入新过冻结期的订单;
2) 应用最新配置(需显式):用当前配置计算新 cutoff但仅纳入“未结算 ∧ 仍处于本结算窗口”的订单;返回“前后差异”。
### 五、与新结算关系(不回溯)
- 已发放Paid结算单不可变更新生成的结算总是按“当前配置”历史不被改写。
### 六、为何快照 cutoffeligibility_cutoff_at很关键
- 冻结判断依赖“当前时间”,若不快照具体时间点,重算会“随时间漂移”。
- 快照固定 cutoff → 沿用快照重算时集合稳定;只有“应用最新配置重算”时才计算新 cutoff。
### 七、例子
- 24h 改 12h
- 默认(沿用快照):不变;
- 用最新重算:新 cutoff 更靠近当前,可能多纳入订单;生成差异快照并审计。
- 周结改半月结:
- 历史按“周结”保留;
- 新计划按“半月”生成;
- 未发放的“周结草稿”可通过“期中迁移”用新配置重算并对齐边界(校验不交叠)。
- 优惠券承担策略变化:
- 历史按老策略;
- 新单按新策略;
- 旧草稿若要改,需“应用最新配置重算”,并记录差异。
### 八、API 建议
- 结算详情返回 `generatedConfig`(快照)与 `currentConfig`(现配),以及“快照 vs 现配”的差异摘要。
- 重算接口:`POST /api/payroll/settlements/{id}/recalculate`Body `{ applyLatest: boolean }`返回前后金额与订单差异、使用基线snapshot/latest
- 权限:`applyLatest=true` 需更高权限或审批通过。
### 九、数据表字段映射(建议)
- `play_clerk_wages_info`
- 汇总:`status, pay_amount, adjustment_amount, pay_channel, pay_time, payroll_no`
- 快照:`cycle_snapshot, cooling_hours_snapshot, refund_policy_snapshot, include_types_snapshot, rounding_snapshot, min_payout_snapshot, timezone_snapshot, plan_version, eligibility_cutoff_at``config_snapshot`
- `play_clerk_wages_details_info`:唯一索引 `uniq_tenant_order(tenant_id, order_id)`
- `play_order_info`:可选 `settlement_id` 便于追溯。
### 十、一句话总结
- “快照让当时怎么算就永远怎么算;未发放可选稳定重算或按新口径重算;已发放绝不回溯。”
---
## 配置存放与生效(真源)
### 层次结构
- 系统默认只读application.yml 作为默认值兜底,不作为运行时真源。
- 租户当前配置(真源):表 `play_payroll_config` 保存“计算规则”当前有效版本。
- 自动结算计划(真源):表 `play_payroll_plan` 保存“调度/周期/触发/自动发放”等计划项。
- 运行时缓存可选Redis key `payroll:config:{tenantId}``payroll:plan:{tenantId}` 缓存 JSON更新时失效缓存。
### 表play_payroll_config建议
- 主键与公共:`id, tenant_id, version, effective_at, enabled, updated_by, updated_time`
- 计算规则:
- `cooling_hours` int 默认 24
- `include_types` varchar(64) 例REGULAR,RANDOM,REWARD
- `refund_policy` varchar(32) OFFSET_NEXT|IMMEDIATE_RECOVER
- `rounding` varchar(16) HALF_UP|DOWN|…
- `min_payout` decimal(10,2)
- `timezone` varchar(32)
- `use_order_snapshot_ratio` tinyint(1) 默认 1按订单入库比例结算
- 查询规则:取“当前有效一条”(`enabled=1 AND effective_at<=NOW()` 按版本/时间取最新)。
### 表play_payroll_plan与计算规则的职责边界
- 计划项:`cycle (DAILY|WEEKLY|MONTHLY|CUSTOM)`, `trigger_time (HH:mm)`, `auto_pay (0/1)`, `name`, 版本/effective_at/enabled。
- 仅用于“何时、以何周期触发结算”,不承载“计算规则”(由 config 表负责)。
### 覆盖与优先级(可选扩展)
- 表:`play_payroll_override`
- 字段:`id, tenant_id, scope ('GROUP'|'CLERK'), scope_id, key, value, effective_at, enabled`
- 合并优先级:店员 > 组 > 租户配置 > 系统默认。
### 读取顺序(伪代码)
```
conf = getTenantConfig(tenantId) // play_payroll_config 当前有效
conf = applyOverrides(conf, groupId, clerkId) // 可选:覆盖层
plan = getTenantPlan(tenantId) // play_payroll_plan 当前有效
```
### 配置变更落地流程
- 改“计算规则”:在 `play_payroll_config` 写新版本(设置 `effective_at`,建议未来时刻),置 `enabled=1`;失效缓存;旧版本可保留但 `enabled=0`
- 改“计划/调度”:在 `play_payroll_plan` 写新版本(设置 `effective_at``enabled=1`),定时器仅拉“当前有效计划”。
### 与快照的关系
- 生成结算单时把“当时的配置config + plan 的关键项)”拍成快照写入结算单,并保存 `eligibility_cutoff_at`
- 未发放结算单重算:
- 默认沿用快照;
- 如选择按最新配置重算,则读取 `play_payroll_config` 当前版本(与可选覆盖),计算新 cutoff 并产出差异快照。
- 已发放结算单不受新配置影响。
---
## 核心结算逻辑Service 层伪代码 + SQL
### Java 伪代码(按店员分组结算,支持并发幂等)
```java
@Service
public class PayrollSettlementService {
@Transactional
public SettlementResult settle(SettlementRequest req) {
// 1) 读取“当前有效配置”并合并覆盖(或使用传入的快照配置用于重算)
PayrollConfig current = configRepo.loadEffectiveConfig(req.getTenantId());
PayrollConfig baseConf = req.applyLatest() ? current : req.snapshotOr(current);
// 2) 计算结算窗口与冻结边界cutoff
LocalDate start = req.getStartDate();
LocalDate end = req.getEndDate(); // [start, end)
Instant cutoff = req.applyLatest()
? clock.now().minus(baseConf.getCoolingHours(), HOURS)
: req.getEligibilityCutoffAtOr(clock.now().minus(baseConf.getCoolingHours(), HOURS));
// 3) 预查询“候选订单”(不加锁)
List<OrderRow> candidates = orderDao.findEligible(
req.getTenantId(), start.atStartOfDay(), end.atStartOfDay(), cutoff, req.getScope());
if (candidates.isEmpty()) return SettlementResult.empty();
// 4) 生成本次结算批次ID用于订单回读与明细追溯
String batchId = Ids.uuid();
// 5) 条件更新“抢占订单”(幂等关键点)
int affected = orderDao.markSettled(
req.getTenantId(), cutoff, batchId, candidates.stream().map(OrderRow::getId).toList());
if (affected == 0) {
return SettlementResult.nothingToDo();
}
// 6) 回读“本次实际吃到的订单”(防并发差异)
List<OrderRow> consumed = orderDao.findByBatch(req.getTenantId(), batchId);
// 7) 按店员分组,分别生成结算单头与明细
Map<String, List<OrderRow>> byClerk = consumed.stream().collect(groupingBy(OrderRow::getClerkId));
List<SettlementHeader> headers = new ArrayList<>();
for (Map.Entry<String, List<OrderRow>> e : byClerk.entrySet()) {
String clerkId = e.getKey();
List<OrderRow> rows = e.getValue();
// 7.1 汇总(金额、订单数、续单等可扩展)
BigDecimal sumFinal = rows.stream().map(OrderRow::getFinalAmount).reduce(ZERO, BigDecimal::add);
BigDecimal sumEstimated = rows.stream().map(OrderRow::getEstimatedRevenue).reduce(ZERO, BigDecimal::add);
// 7.2 生成结算单号与快照
String wagesId = Ids.uuid();
SettlementHeader header = SettlementHeader.builder()
.id(wagesId)
.tenantId(req.getTenantId())
.clerkId(clerkId)
.status(Ready)
.startCountDate(start)
.endCountDate(end)
.settlementDate(LocalDate.now())
.orderNumber(rows.size())
.finalAmount(sumFinal)
.estimatedRevenue(sumEstimated)
.cycleSnapshot(baseConf.getCycle())
.coolingHoursSnapshot(baseConf.getCoolingHours())
.refundPolicySnapshot(baseConf.getRefundPolicy())
.includeTypesSnapshot(baseConf.getIncludeTypes())
.roundingSnapshot(baseConf.getRounding())
.minPayoutSnapshot(baseConf.getMinPayout())
.timezoneSnapshot(baseConf.getTimezone())
.planVersion(baseConf.getPlanVersion())
.eligibilityCutoffAt(cutoff)
.payrollNo(NoGen.next("PY"))
.build();
headers.add(header);
// 7.3 插入汇总表
wagesInfoRepo.insert(header);
// 7.4 批量插入明细(带唯一约束 (tenant_id, order_id) 兜底)
List<WagesDetail> details = rows.stream().map(r -> WagesDetail.builder()
.id(Ids.uuid())
.tenantId(req.getTenantId())
.wagesId(wagesId)
.clerkId(clerkId)
.orderId(r.getId())
.orderNo(r.getOrderNo())
.finalAmount(r.getFinalAmount())
.estimatedRevenue(r.getEstimatedRevenue())
.revenueRatio(r.getEstimatedRevenueRatio())
.couponDeduction(r.getCouponDeduction())
.firstOrder(r.getFirstOrder())
.placeType(r.getPlaceType())
.endOrderTime(r.getOrderEndTime())
.build()).toList();
wagesDetailRepo.batchInsertIgnore(details);
// 7.5(可选)若开启自动发放:发放到余额、写流水,更新 header.status=Paid & pay_time
if (req.isAutoPayEnabled()) {
payoutService.payToBalance(header, details);
wagesInfoRepo.updatePaid(header.getId(), req.getDefaultPayChannel(), clock.now(), req.getPayRef());
}
}
return SettlementResult.success(headers, consumed, affected);
}
}
```
### 关键 SQLMySQL 示例)
1) 预查询“候选订单”(预览用,不加锁)
```sql
SELECT id, tenant_id, accept_by AS clerk_id, order_no,
final_amount, estimated_revenue, estimated_revenue_ratio,
coupon_deduction, first_order, place_type, order_end_time
FROM play_order_info
WHERE tenant_id = :tenantId
AND order_status = '3' -- 已完成
AND order_settlement_state = '0' -- 未结算
AND purchaser_time >= :startDate
AND purchaser_time < :endDate -- [start, end)
AND order_end_time <= :eligibilityCutoffAt
AND (:clerkId IS NULL OR accept_by = :clerkId)
AND (:groupId IS NULL OR group_id = :groupId)
ORDER BY order_end_time ASC
```
2) 条件更新“抢占订单”(提交用,幂等关键)
```sql
UPDATE play_order_info
SET order_settlement_state = '1',
order_settlement_time = NOW(),
settlement_id = :batchId
WHERE tenant_id = :tenantId
AND order_status = '3'
AND order_settlement_state = '0'
AND order_end_time <= :eligibilityCutoffAt
AND id IN (:candidateIds)
```
3) 回读本次实际吃到的订单(用于分组、插明细)
```sql
SELECT *
FROM play_order_info
WHERE tenant_id = :tenantId
AND settlement_id = :batchId
```
4) 插入结算汇总(工资单头)
```sql
INSERT INTO play_clerk_wages_info (
id, tenant_id, clerk_id, historical_statistics, serial_number, ranking_index,
start_count_date, end_count_date, settlement_date,
order_number, final_amount, estimated_revenue,
status, payroll_no, pay_amount, adjustment_amount, pay_channel, pay_time, pay_reference_no,
cycle_snapshot, cooling_hours_snapshot, refund_policy_snapshot, include_types_snapshot,
rounding_snapshot, min_payout_snapshot, timezone_snapshot, plan_version, eligibility_cutoff_at,
created_time
) VALUES (
:id, :tenantId, :clerkId, '0', 0, NULL,
:startDate, :endDate, CURDATE(),
:orderNumber, :finalAmount, :estimatedRevenue,
:status, :payrollNo, 0.00, 0.00, NULL, NULL, NULL,
:cycleSnapshot, :coolingHoursSnapshot, :refundPolicySnapshot, :includeTypesSnapshot,
:roundingSnapshot, :minPayoutSnapshot, :timezoneSnapshot, :planVersion, :eligibilityCutoffAt,
NOW()
)
```
5) 批量插入结算明细(唯一约束兜底)
```sql
-- 方案AINSERT IGNORE存在唯一冲突时忽略
INSERT IGNORE INTO play_clerk_wages_details_info (
id, tenant_id, wages_id, clerk_id, order_id, order_no,
final_amount, estimated_revenue, revenue_ratio, coupon_deduction,
end_order_time, created_time
) VALUES
-- 批量 values (...), (...), ...
-- 方案BON DUPLICATE KEY UPDATE 空更新(等效忽略)
INSERT INTO play_clerk_wages_details_info (...columns...)
VALUES (...values...)
ON DUPLICATE KEY UPDATE id = id;
```
6) 索引与约束(建议)
```sql
-- 明细唯一:防止一笔订单进入两张结算单
ALTER TABLE play_clerk_wages_details_info
ADD UNIQUE KEY uniq_tenant_order (tenant_id, order_id);
-- 汇总检索:按状态与时间快速筛选
CREATE INDEX idx_wages_tenant_status ON play_clerk_wages_info (tenant_id, status, settlement_date);
```
7) 事务与隔离(建议)
- 将“条件更新 + 回读 + 插明细 + 插汇总/更新状态”置于同一事务。
- 数据库隔离级别可用 READ COMMITTED并发去重依赖条件更新与唯一索引不依赖悲观锁。
---
## 验收要求Acceptance Criteria
角色店员Clerk、租户管理员/店主Admin。以下场景均在租户隔离前提下验证。
### 店员视图
- 订单完成后未过冻结期:
- 在“即将入池”区域看到该订单,显示预计入池倒计时(基于 coolingHours
- 未计入“未结算汇总”的数量与金额。
- 订单过冻结期且未结算:
- “未结算汇总”显示订单数量、finalAmount 合计与 estimatedRevenue 合计;
- 订单列表展示订单号、完成时间、分成比例、优惠券扣减、预计收入。
- 生成结算单Ready 或 Paid
- “我的结算单”列表出现新记录,包含周期 [startDate, endDate)、应结金额estimatedRevenue、调整合计、实发金额、状态Draft/Ready/Paid/Closed与生成时间
- 进入详情可见订单明细、调整明细、配置快照cycle/cooling/refundPolicy…、eligibility_cutoff_at
- 若为 Paid展示发放渠道、发放时间、流水号并且店员余额已增加如渠道为 Balance
- 退款处理:
- 若订单在未发放结算单内被退款并重算:该订单从该结算单移除,金额相应减少;
- 若订单已在 Paid 结算单内生成负向调整店员在下一期结算单看到负向条目OFFSET_NEXT或即时回收余额IMMEDIATE_RECOVER并收到通知。
- 最低发放门槛:
- 当本期应发小于 min_payout 时,状态显示“待累计/低于门槛”,不发放;下一期累计显示应发金额已合并。
### 租户管理员视图
- 预览结算:
- 选择时间窗口/范围(全员/分组/单人)后,预览页返回候选订单与按店员聚合的汇总;
- 并发情况下提交时如有差异,返回“差异摘要”(新增/剔除订单、金额差),并仅以实际抢占成功的订单生成结算;
- 预览不改变数据,提交后被消费的订单 `order_settlement_state` 置为1并写 `order_settlement_time/settlement_id`
- 生成结算单:
- 系统按店员分组生成若干结算单头与明细;
- 结算单头包含配置快照与 eligibility_cutoff_at
- 明细插入受唯一索引 `(tenant_id, order_id)` 保护;
- 生成完成后可在列表中按状态/日期筛选看到新结算单。
- 发放:
- 对 Ready 的结算单可选择渠道发放Balance/Offline
- 发放成功后结算单状态变为 Paid记录 pay_time/pay_channel/pay_reference_noBalance 渠道时,对应店员余额有一笔入账流水)。
- 自动计划:
- 配置 cycle/trigger_time/auto_pay 后,到点生成结算单并写运行日志(成功/部分/失败、结算单数量、错误信息);
- 若 auto_pay=true则结算单直接 Paid否则为 Ready 待人工发放。
- 重算:
- 对未发放结算单Draft/Ready可重算默认沿用快照也可选择“使用最新配置重算”系统返回前后金额与订单差异
- 已发放结算单不可重算,仅能追加备注或按退款策略产生负向调整。
- 配置变更:
- 修改 `play_payroll_config`(冷却期/门槛/退款策略等)后,新的结算使用新配置;历史结算不受影响;
- 修改 `play_payroll_plan`(周期/触发时间/自动发放)后,从下一个触发时刻起生效;
- 如选择“期中迁移/追溯重算”,系统提供候选清单与确认步骤。
- 导出与审计:
- 可导出结算单工资条与汇总对账单;
- 结算单/调整/计划运行日志具备操作者、时间、备注与配置快照,满足审计追溯。
### 并发与一致性(验收要点)
- 两个并发提交同一时间窗的结算请求:仅一个请求的条件更新返回影响行数>0另一请求返回0或减少且不会出现同一订单进两张结算单由唯一索引与条件更新共同保证
- 重试幂等:在网络/服务异常时重试提交,不会重复消费同一订单,也不会重复插入明细。