40 KiB
40 KiB
工资结算业务设计(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 允许重算(保留差异快照与版本号)。
- 多租户与权限
- 全流程按租户隔离;店主/财务具备结算与调整权限;店员仅可见本人。
业务流程
- 自动结算
- 到点触发计划(按配置周期/时间);
- 扫描“已完成且过冻结期且未结算”的订单,按店员聚合生成 Draft 结算单;
- 若开启自动发放:直接进入 Paid,写发放流水与时间并通知;否则停留 Ready,待人工发放。
- 手动结算
- 选择时间范围/店员/分组/全员→预览“待结算订单 + 初步金额”;
- 可添加正/负调整(需备注与可选证据);
- 生成结算单(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
-
生成结算单(可带调整)
- POST
/api/payroll/settlements - Body:
{ period:{startDate,endDate}, items:[{clerkId, adjustment?:{amount,reason,evidenceUrl}}], fromPreviewId?:string } - Resp:
{ settlementIds:[...], status:'READY' }
- POST
-
发放/支付结算单(单个/批量)
- POST
/api/payroll/settlements/{id}/pay - Body:
{ channel:'BALANCE'|'OFFLINE', payRef?:string } - Resp:
{ id,status:'PAID', paidAt, channel, payRef }
- POST
-
重算未发放结算单
- POST
/api/payroll/settlements/{id}/recalculate - Resp:
{ id, diff:{amountBefore,amountAfter,ordersChanged:[]}, version }
- POST
-
查询结算单列表/详情
- GET
/api/payroll/settlements?status=READY&clerkId=&groupId=&from=&to=&page=&size= - GET
/api/payroll/settlements/{id}(含明细与调整、快照)
- GET
-
添加/撤销调整
- POST
/api/payroll/adjustments - Body:
{ settlementId, clerkId, type:'PLUS'|'MINUS', amount, reason, evidenceUrl } - DELETE
/api/payroll/adjustments/{id}
- POST
-
自动结算配置
- 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
店员侧(自助查询)
- 我的未结算汇总
- GET
/api/payroll/me/summary→{ unsettled:{orderCount,finalAmount,estimatedRevenue}, nextExpectedPayTime }
- GET
- 我的结算单列表/详情
- GET
/api/payroll/me/settlements?from=&to=&status=&page=&size= - GET
/api/payroll/me/settlements/{id}(含订单明细、调整、发放信息)
- GET
表结构设计(草案)
说明:系统已有表 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 - 新增建议字段:
statusvarchar(16) COMMENT 'Draft|Ready|Paid|Closed'pay_amountdecimal(10,2) DEFAULT 0.00 COMMENT '实发金额=estimated_revenue+adjustment_amount,受门槛/舍入影响'adjustment_amountdecimal(10,2) DEFAULT 0.00 COMMENT '调整合计(可负)'pay_channelvarchar(16) NULL COMMENT 'BALANCE|OFFLINE|...'pay_timedatetime NULLpay_reference_novarchar(64) NULL COMMENT '发放流水号/凭证'payroll_novarchar(32) NULL COMMENT '结算单号(可读性)'remarksvarchar(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_ratioint NULL COMMENT '订单时的分成比例%'coupon_deductiondecimal(10,2) DEFAULT 0.00 COMMENT '与店员相关的优惠扣减'adjustment_amountdecimal(10,2) DEFAULT 0.00 COMMENT '单笔调整(如异常修正)'first_ordervarchar(1) NULL COMMENT '0/1'place_typevarchar(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
idvarchar(32) PKtenant_idvarchar(32)settlement_idvarchar(32) NOT NULLclerk_idvarchar(32) NOT NULLtypevarchar(8) NOT NULL COMMENT 'PLUS|MINUS'amountdecimal(10,2) NOT NULLreasonvarchar(512) NULLevidence_urlvarchar(512) NULLoperator_idvarchar(32) NOT NULLoperator_namevarchar(64) NULLcreated_timedatetime NOT NULL- 索引:
idx_adj_settlement (tenant_id,settlement_id)
4) 自动结算计划(新增)
表:play_payroll_plan
idvarchar(32) PKtenant_idvarchar(32) NOT NULLnamevarchar(64) NOT NULLcyclevarchar(16) NOT NULL COMMENT 'DAILY|WEEKLY|MONTHLY|CUSTOM'trigger_timevarchar(8) NOT NULL COMMENT 'HH:mm'cooling_hoursint NOT NULL DEFAULT 24auto_paytinyint(1) NOT NULL DEFAULT 0min_payoutdecimal(10,2) DEFAULT 0.00roundingvarchar(16) DEFAULT 'HALF_UP'refund_policyvarchar(32) DEFAULT 'OFFSET_NEXT' COMMENT 'OFFSET_NEXT|IMMEDIATE_RECOVER'include_typesvarchar(64) NULL COMMENT 'REGULAR,RANDOM,REWARD'enabledtinyint(1) NOT NULL DEFAULT 1created_timedatetime,updated_timedatetime- 索引:
idx_plan_tenant_enabled (tenant_id,enabled)
5) 计划运行日志(新增)
表:play_payroll_run_log
idvarchar(32) PKtenant_idvarchar(32) NOT NULLplan_idvarchar(32) NOT NULLrun_timedatetime NOT NULLstatusvarchar(16) NOT NULL COMMENT 'SUCCESS|PARTIAL|FAILED'generated_settlementsint DEFAULT 0paid_settlementsint DEFAULT 0messagevarchar(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 落地建议
- 先用“订单落库时的 estimatedRevenue”做口径;
- 实现:预览→生成结算(Ready)→发放(Balance)→订单打“已结算”标;
- 自动结算先不自动发放;
- 提供负向调整/冲销“冲减下一期”;
- 店员/店主端最小信息集与导出。
备注:本文为业务与数据结构草案,开发落地需与现有表结构与代码对齐(如 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)。
- 逻辑层保证同店员同租户仅一个 Draft/Ready;可在表层加入部分索引辅助查询(如
- 配置快照字段:
plan_cycle_snapshot, trigger_time_snapshot, cooling_hours_snapshot, refund_policy_snapshot, include_types_snapshot, plan_version。
- 计划版本化:
- 变更计划→旧计划 disabled,新计划 enabled(effective_at);定时仅拉取有效版本;运行日志写
plan_id/version。
- 变更计划→旧计划 disabled,新计划 enabled(effective_at);定时仅拉取有效版本;运行日志写
- 提交前后差异校验:
- 提交端使用与预览相同条件+条件更新,回显影响行数与差异;为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。 - 变更:默认“前向生效”;若需追溯,走“追溯重算向导”,列出新增/剔除候选供确认。
流程:预览 → 提交
- 预览:查询符合条件的订单(不加锁),展示店员聚合的金额/列表。
- 提交(幂等/抢占):
- 事务内条件更新,将订单
settlement_state:0→1,写settlement_time/settlement_id; - 回读本次吃到的订单集合;
- 插入结算明细与汇总,保存快照;
- 自动发放则记账与流水;否则置 Ready;
- 返回“差异摘要”(与预览的差别)。
- 事务内条件更新,将订单
- 明细表唯一索引兜底,避免重复插入。
自动 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_snapshotJSON 一列集中存储全部配置; - 二者可并用(关键字段单列 + 完整 JSON 便于扩展)。
四、如何使用(生成与重算)
- 首次生成:
- 读取当前租户配置,确定
[startDate, endDate); - 计算
eligibility_cutoff_at = now - coolingHours(或约定的边界); - 查询 eligible 订单:
status=3 ∧ settlement_state=0 ∧ endTime ≤ eligibility_cutoff_at; - 生成明细与汇总,写入快照与 cutoff。
- 读取当前租户配置,确定
- 重算(仅未发放)两种模式:
- 沿用快照(默认):坚持用快照中的窗口与
eligibility_cutoff_at重跑,不纳入新过冻结期的订单; - 应用最新配置(需显式):用当前配置计算新 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_hoursint 默认 24include_typesvarchar(64) 例:REGULAR,RANDOM,REWARDrefund_policyvarchar(32) OFFSET_NEXT|IMMEDIATE_RECOVERroundingvarchar(16) HALF_UP|DOWN|…min_payoutdecimal(10,2)timezonevarchar(32)use_order_snapshot_ratiotinyint(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 伪代码(按店员分组结算,支持并发幂等)
@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 示例)
- 预查询“候选订单”(预览用,不加锁)
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
- 条件更新“抢占订单”(提交用,幂等关键)
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)
- 回读本次实际吃到的订单(用于分组、插明细)
SELECT *
FROM play_order_info
WHERE tenant_id = :tenantId
AND settlement_id = :batchId
- 插入结算汇总(工资单头)
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()
)
- 批量插入结算明细(唯一约束兜底)
-- 方案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;
- 索引与约束(建议)
-- 明细唯一:防止一笔订单进入两张结算单
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);
- 事务与隔离(建议)
- 将“条件更新 + 回读 + 插明细 + 插汇总/更新状态”置于同一事务。
- 数据库隔离级别可用 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或减少,且不会出现同一订单进两张结算单(由唯一索引与条件更新共同保证)。
- 重试幂等:在网络/服务异常时重试提交,不会重复消费同一订单,也不会重复插入明细。