diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index f162988..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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 deleted file mode 100644 index 2108825..0000000 --- a/calculate_salary.sql +++ /dev/null @@ -1,39 +0,0 @@ --- 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 deleted file mode 100644 index 18cbc66..0000000 --- a/salary_design.md +++ /dev/null @@ -1,742 +0,0 @@ -# 工资结算业务设计(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或减少,且不会出现同一订单进两张结算单(由唯一索引与条件更新共同保证)。 -- 重试幂等:在网络/服务异常时重试提交,不会重复消费同一订单,也不会重复插入明细。