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

40 KiB
Raw Blame History

工资结算业务设计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_infoplay_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 → ClosedReady↔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_infoorderStatus=3 完成;order_settlement_state0/1order_end_timeestimatedRevenue(按比例计算的店员应得)。
  • 结算单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_snapshotDAILY/WEEKLY/MONTHLY/CUSTOM、该结算单统计区间 [startDate, endDate)
    • 冻结期:cooling_hours_snapshot
    • 计入范围:include_types_snapshotREGULAR/RANDOM/REWARD…、优惠券承担策略、收入口径按订单入库比例 vs. 当前比例);
    • 结算规则:rounding_snapshotmin_payout_snapshotrefund_policy_snapshotOFFSET_NEXT/IMMEDIATE_RECOVERtimezone_snapshot
    • 计划版本:plan_versiontrigger_time_snapshot
    • 资格边界时间:eligibility_cutoff_at(当次计算使用的“冻结边界时间点”)。

二、为什么要有(目的)

  • 保证历史不漂移:配置改了,历史金额/明细不变,便于对账与审计。
  • 可控重算:未发放时默认用快照重算(稳定),也可显式用新配置重算(改变口径),并生成差异快照。
  • 审计留痕:能还原“当时按什么规则产生了这张结算单”。

三、怎么保存(形态)

  • 字段式:在 play_clerk_wages_info 上增加若干 *_snapshot 字段与 eligibility_cutoff_at
  • 或 JSONconfig_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}/recalculateBody { 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_atconfig_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_atenabled=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);
  }
}

关键 SQLMySQL 示例)

  1. 预查询“候选订单”(预览用,不加锁)
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
  1. 条件更新“抢占订单”(提交用,幂等关键)
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)
  1. 回读本次实际吃到的订单(用于分组、插明细)
SELECT *
FROM   play_order_info
WHERE  tenant_id = :tenantId
  AND  settlement_id = :batchId
  1. 插入结算汇总(工资单头)
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()
)
  1. 批量插入结算明细(唯一约束兜底)
-- 方案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;
  1. 索引与约束(建议)
-- 明细唯一:防止一笔订单进入两张结算单
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);
  1. 事务与隔离(建议)
  • 将“条件更新 + 回读 + 插明细 + 插汇总/更新状态”置于同一事务。
  • 数据库隔离级别可用 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或减少且不会出现同一订单进两张结算单由唯一索引与条件更新共同保证
  • 重试幂等:在网络/服务异常时重试提交,不会重复消费同一订单,也不会重复插入明细。