chore: apply Spotless formatting
This commit is contained in:
742
salary_design.md
Normal file
742
salary_design.md
Normal file
@@ -0,0 +1,742 @@
|
||||
# 工资结算业务设计(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 → Closed;Ready↔Draft(仅未发放时)。
|
||||
|
||||
## 报表与导出
|
||||
- 维度:按店员/分组/周期,产出应结、调整、实发、订单数、退款冲销、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 结算单。
|
||||
- 改变冻结期(24h→12h/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)结算单不可变更;新生成的结算总是按“当前配置”,历史不被改写。
|
||||
|
||||
### 六、为何快照 cutoff(eligibility_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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 关键 SQL(MySQL 示例)
|
||||
|
||||
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
|
||||
-- 方案A:INSERT 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 (...), (...), ...
|
||||
|
||||
-- 方案B:ON 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_no;(Balance 渠道时,对应店员余额有一笔入账流水)。
|
||||
- 自动计划:
|
||||
- 配置 cycle/trigger_time/auto_pay 后,到点生成结算单并写运行日志(成功/部分/失败、结算单数量、错误信息);
|
||||
- 若 auto_pay=true,则结算单直接 Paid;否则为 Ready 待人工发放。
|
||||
- 重算:
|
||||
- 对未发放结算单(Draft/Ready)可重算:默认沿用快照;也可选择“使用最新配置重算”,系统返回前后金额与订单差异;
|
||||
- 已发放结算单不可重算,仅能追加备注或按退款策略产生负向调整。
|
||||
- 配置变更:
|
||||
- 修改 `play_payroll_config`(冷却期/门槛/退款策略等)后,新的结算使用新配置;历史结算不受影响;
|
||||
- 修改 `play_payroll_plan`(周期/触发时间/自动发放)后,从下一个触发时刻起生效;
|
||||
- 如选择“期中迁移/追溯重算”,系统提供候选清单与确认步骤。
|
||||
- 导出与审计:
|
||||
- 可导出结算单工资条与汇总对账单;
|
||||
- 结算单/调整/计划运行日志具备操作者、时间、备注与配置快照,满足审计追溯。
|
||||
|
||||
### 并发与一致性(验收要点)
|
||||
- 两个并发提交同一时间窗的结算请求:仅一个请求的条件更新返回影响行数>0,另一请求返回0或减少,且不会出现同一订单进两张结算单(由唯一索引与条件更新共同保证)。
|
||||
- 重试幂等:在网络/服务异常时重试提交,不会重复消费同一订单,也不会重复插入明细。
|
||||
Reference in New Issue
Block a user