From 6e829070e7229442915b6b67dfc2aea5f0993091 Mon Sep 17 00:00:00 2001 From: irving Date: Mon, 6 Oct 2025 23:55:08 -0400 Subject: [PATCH] chore: apply Spotless formatting --- .claude/settings.local.json | 22 ++ calculate_salary.sql | 39 ++ salary_design.md | 742 ++++++++++++++++++++++++++++++++++++ 3 files changed, 803 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 calculate_salary.sql create mode 100644 salary_design.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..f162988 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,22 @@ +{ + "permissions": { + "allow": [ + "Bash(java:*)", + "Bash(mvn spotless:*)", + "Bash(mvn clean:*)", + "Bash(mvn flyway:*)", + "mcp__context7__resolve-library-id", + "mcp__context7__get-library-docs", + "mcp__serena__activate_project", + "mcp__serena__check_onboarding_performed", + "mcp__serena__onboarding", + "mcp__serena__list_dir", + "mcp__serena__get_symbols_overview", + "mcp__serena__find_symbol", + "mcp__serena__search_for_pattern", + "mcp__serena__write_memory" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/calculate_salary.sql b/calculate_salary.sql new file mode 100644 index 0000000..2108825 --- /dev/null +++ b/calculate_salary.sql @@ -0,0 +1,39 @@ +-- calculate_salary.sql +-- Per-clerk salary statistics for completed orders in a time range +-- Data model references: +-- - play_order_info: per order fields including commission amount/ratio +-- - play_clerk_user_info: clerk profile (nickname) +-- +-- Usage: +-- 1) Replace YOUR_TENANT_ID_HERE with your tenant id (or SET it below) +-- 2) Adjust @start_time and @end_time to the desired window +-- 3) Run against the MySQL database + +-- Hardcoded parameters for compatibility (no CTE/SET required) +-- tenant_id: wwi22qfjt +-- time range: 2025-09-15 00:00:00 to 2025-09-22 23:59:59 + +-- Per-clerk statistics for completed, non-refunded normal orders in range +SELECT + cu.id AS clerk_id, + cu.nickname AS clerk_nickname, + COUNT(*) AS total_orders, + SUM(oi.final_amount) AS total_final_amount, + SUM(oi.estimated_revenue) AS total_salary, + ROUND(100 * SUM(oi.estimated_revenue) / NULLIF(SUM(oi.final_amount), 0), 2) AS effective_percentage, + ROUND(AVG(oi.estimated_revenue_ratio), 2) AS avg_percentage, + MIN(oi.estimated_revenue_ratio) AS min_percentage, + MAX(oi.estimated_revenue_ratio) AS max_percentage +FROM play_order_info oi +JOIN play_clerk_user_info cu + ON cu.id = oi.accept_by + AND cu.tenant_id = 'wwi22qfjt' +WHERE + oi.tenant_id = 'wwi22qfjt' + AND oi.order_status = '3' -- completed + AND oi.order_type = '2' -- normal order; excludes recharge/withdraw + AND oi.refund_type = '0' -- exclude refunded + AND oi.accept_by IS NOT NULL + AND oi.order_end_time BETWEEN '2025-09-15 00:00:00' AND '2025-09-22 23:59:59' +GROUP BY cu.id, cu.nickname +ORDER BY total_salary DESC; diff --git a/salary_design.md b/salary_design.md new file mode 100644 index 0000000..18cbc66 --- /dev/null +++ b/salary_design.md @@ -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 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 consumed = orderDao.findByBatch(req.getTenantId(), batchId); + + // 7) 按店员分组,分别生成结算单头与明细 + Map> byClerk = consumed.stream().collect(groupingBy(OrderRow::getClerkId)); + List headers = new ArrayList<>(); + for (Map.Entry> e : byClerk.entrySet()) { + String clerkId = e.getKey(); + List 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 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或减少,且不会出现同一订单进两张结算单(由唯一索引与条件更新共同保证)。 +- 重试幂等:在网络/服务异常时重试提交,不会重复消费同一订单,也不会重复插入明细。