Compare commits

...

9 Commits

Author SHA1 Message Date
liuwenxin
e48cfb752d docs: 待确认事项汇总更新至 2026-10-19(答复落实/code-review/远端恢复)
- 立即待办:远端 Git 服务已恢复(ls-remote 实测连通);标注远端已到
  35fe37f、本地领先 8 提交
- 业务方澄清:#12 经理个人户基数 → 已落实为参数 manager.personal.included
  (默认 0=不计入);#5/#7 审批开关默认开启、#14 首日整 10‰(A 口径)、
  #16 试用期参数撤销、#17 参数查看放开、#19 增量备份 → 均已落实(2026-10-19)
- 里程碑:M3 剩余改为已完成主体 + 余项(二次分摊待 #1–#3、答复回填批次
  收口);杂项收口标注 calc_receipt 与 settlement_service 拆分进度
- T+ 小节:清单已升 v1.4(2026-10-19),本节口径待收编
- 新增修复批次口径记录:月结聚合/月工资按月月末取参(单笔行按回款日)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:01:50 +08:00
liuwenxin
028a94a6eb refactor(m3): settlement_service 三段收尾——生成/聚合/调整单拆分
code-review 约定合规项续(calc_receipt 之后的剩余超长函数):

- generate(~120 行)→ 编排 + 六段私有函数:_guard_regeneration(重生成
  准入)、_release_previous_round(解绑回填+旧聚合作废)、_upsert_draft
  (草稿头表新建/重置)、_build_items(=_bucket_rows + _item_of 记账)与
  _apply_totals(头表三合计)
- _build_manager_aggregates(~110 行)→ _ShareParams 冻结 dataclass +
  _manager_share_params(月末取参/百分数口径/#12 开关同源)/ _collect_team_buckets
  (含 #12「个人户行归经理本人」分支)/ _add_team_performance / _aggregate_row;
  全公司基数循环外算一次(口径逐行固定,行为等价)
- create_adjustment(~90 行)→ _adjustment_amount(恒正)/ _wire_adjustment_approval
  (submit_order 分流与留痕,含「不得手工插 ApprovalStep」外键教训注释)/
  _adjustment_out(出参前置到 commit 之前)

行为零变化;#12 参数化(include_manager_personal 过滤、个人户行按
personnel_id 直归经理、snapshot 的 manager_personal_included)语义原样保留。
验证:重构后月结集成 15 例 + 月结单测 5 例全绿(81s 内无并发干扰的一轮);
全量回归待 88 会话 calc_service 收口后复跑(其进行中的拆行改动与测试
并发互清导致临时 401,非本重构所致)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:52:36 +08:00
liuwenxin
a89d540e3f docs(tests): code-review 修复批次测试记录与缺陷表更新
- 运行记录:M3 定点 34 例与全量回归 780 passed(2026-10-19)两行
- 缺陷表:补 14 条(聚合 ×100/审批提前锁定/调整单双重缺陷/重算双重计提/
  前端传错 ID/quantize 银行家舍入/锁定拦截 1-4/回填死代码/聚合不落库/
  生成日取参/迁移三处漂移/演练误报与密码明文/金额与 INT 解析与 logo 清理)
- 待补用例:绩效工资核算行更新为 M3-core 已完成(e2e calc_settlement_flow
  与答复回填批次待补)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:30:41 +08:00
liuwenxin
7a99994d17 refactor(m3): calc_receipt 五段拆分——218 行收敛为编排 + 私有函数
code-review 约定合规项(CLAUDE.md「单一职责 ≤50 行」):原 218 行单函数
把锁行校验、模式预处理、输入装载、逐行折算、计提组成、落库全部内联,
待澄清答复回填时改动回归面大。按原执行顺序拆为四段私有函数 + 两个
冻结 dataclass(_Conversion/_Amounts),主函数只做编排;行为零变化
(M3 全部用例原样通过):

- _load_and_guard:锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验
- _prepare_mode:重算作废旧版本 / 首算判一次性计提(进度登记提前返回)
- _load_shipment / _load_person / _load_items:输入装载三查
- _convert_items → _Conversion:逐行折算(行级阈值/缺成本标记/行级明细)
- _compose_amounts → _Amounts:比例分派 → 绩效 → 抵顶 → 权重 → 应付
- _persist_ledger:落台账行 + 首算 CAS
- 局部 q2 提为模块级 _q2(新增单测锁定 HALF_UP:2.345→2.35,
  缺省银行家舍入会得 2.34)

验证:全量 780 passed;type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:25:36 +08:00
liuwenxin
45c268b146 fix(m3): code-review P2——金额校验、参数解析与运维脚本容错
- 调整单金额恒正:schema 正则拦格式(非负两位小数、≤12 位整数防
  NUMERIC(14,2) 溢出)+ 服务层 Decimal>0 语义校验——负数会反转补发/
  扣减语义,'abc' 原样入库会以 500 暴露
- 分界天数参数解析:int(parse_fraction(...)) 替代 int(参数原文)——
  '360.0' 是 INT 参数的合法入库值(Fraction 分母为 1),直接 int(str)
  抛 ValueError 会让整月核算 500
- logo 旧文件清理容错:OSError 只记 warning——裸抛会把成功的 logo 更新
  记成失败审计并 500(库中 logo_path 却已是新值,审计与事实相反)
- health_check.sh:Redis 探活改容器内环境变量展开(密码不进宿主进程
  命令行,模式同 backup_full.sh);compose redis 补 REDIS_PASSWORD
- restore_drill.sh:行数核对扩到全部 32 张业务表(原 16 张漏 V7 四表、
  二次分配等核心表,备份漏表演练全绿);查询失败回显 '?' 一律判 ✗
  (原 '?'=='?' 判一致,表缺失时误报「备份可恢复」)
- 并入 88 会话的 #12 实现:manager.personal.included 开关消费
  (_build_manager_aggregates 参数化,测试见 828b573)
- 测试:调整单金额恒正 5 稿、INT 参数小数原文核算 200 回归;单测 469

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:10:29 +08:00
liuwenxin
828b57350a test(settlement): #12 聚合基数开关用例——经理个人户计入可配置
默认 0 时聚合 6.84 不变;开关 1 时个人户行按 personnel_id 归经理本人
(不走 manager_id 上溯)、基数 139.11 → 聚合 24.34,两档金标准锁定

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 12:06:07 +08:00
liuwenxin
76ded6d5ec fix(m3): code-review P1——锁定不变量、入结回填与双轨漂移
- 月结锁定拦截补全:原仅新增回款一处,现覆盖回款编辑(原月+新月,防跨月
  搬数)/删除、发货新增(按发货日)/编辑(原月+新月)/删除、核算与重算
  (已审定月份金额只走调整单);拦截先于「被引用/已核算」判定
- 台账 settlement_id 回填落地:generate 在 replace_items 后回填纳入行、
  重生成前先解绑(clear_ledger_settlement_id)——不回填/不解绑会让
  「未入结」判据失效:重生成漏算整月或把已固化行反复计入
- 经理聚合行落库:原只进内存参与 items 汇总,台账表永远无 calc_type=2
  行、报表下钻落空;重生成先作废旧聚合行(deactivate_month_aggregates),
  _confirm_scope 限定计算类型避免聚合行混入单笔池
- 月结取参锚定所属月月末(_month_end):原用 date.today(),补生成历史
  月份时分成/月工资口径随生成日漂移;聚合行 receipt_date 同步为月末
- generate/submit 出参固化前置到 commit 之前(commit 后 SELECT 失败会把
  已生成的月结记成失败审计,与库中事实相反)
- V9 迁移:business_secondary_allocations 唯一键条件式补齐(幂等裸 SQL,
  临时库验证「无键→补」「有键→跳过」两分支),修复旧 V6 升级库拿不到
  V6 就地加入的约束;V8 迁移 revision 声明补类型注解(原格式漂移使
  schema 同步测试的 head 推断静默漏检 V8);init-dev 版本号推进至 V9
- V7 留存 SQL 约束/索引名单数改复数(与 alembic op.f 与 init-dev 对齐,
  消除 SHOW CREATE TABLE 双轨漂移)
- 测试:显式控制审批开关(不依赖 V8 翻转后的种子默认值);新增回归 8 例
  (回填落库/聚合不重复/月末取参/月末纯函数 5 例/锁定拦截回款与发货与
  核算);全量 773 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:59:49 +08:00
liuwenxin
7a460cb682 feat(formula): 首日计提比例取整 10‰(#14 答复——图示口径覆盖公式)
- salesperson_rate 首日特判:第 1 天不递减直接取起始值(原公式 9.9889‰ 作废)
- 第 2 天起公式段不变、360 天=6‰ 锚点与 361 天=4‰ 分界不变;preview/核算共用一处
- 用例反转:首日断言改 Fraction(10,1000),补第 2 天公式段边界

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:40:07 +08:00
liuwenxin
cb4f38dfd9 fix(m3): code-review P0 六项——算错钱与不可用
- 经理聚合分成放大 100 倍:参数按百分数存储(12.5=12.5%),parse_fraction
  后调用方又 ×100、函数内部再 ÷100,净效应「绩效×12.5」;去掉调用方
  ×100,rate_text 改用实际参数原文(原硬编码 12.5%+5% 参数调整后失真)
- 审批联动只认终审:财务节点通过(1→2)不再提前 mark_locked/调整单生效,
  否则领导驳回后 locked_at/locked_by 残留、两级审批形同虚设;驳回在任何
  节点即时联动;mark_rejected 补清锁定字段
- 调整单新建不可用:ENABLE_CODE_BY_TYPE 未登记 type=3 → is_enabled 恒 400;
  补登记 approval.adjustment.enabled。另修复叠加缺陷——开关关闭分支手工
  ApprovalStep(order_id=调整单id),而该外键指向 approval_orders,必然
  errno 1452;统一改走 submit_order(自动通过留痕由它负责)
- 重算双重计提:业务级一次性计提的「进度登记」回款无生效台账行,重算会
  凭空落新行;无旧行一律 400
- 前端重算传错 id:台账行主键当 receipt_id 传给重算接口,会作废无关回款
  的台账;改传 row.receipt_id(聚合行防御)
- quantize 显式 ROUND_HALF_UP:9 处金额 quantize 缺省银行家舍入,
  x.xx5 与四舍五入口径差 1 分(docstring 却声称 HALF_UP)

测试:显式控制审批开关(V8 起种子默认开启,用例不再依赖默认值);
新增回归 4 例(经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前
锁定),test_calc+test_settlements 22 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:25:21 +08:00
28 changed files with 1533 additions and 452 deletions

View File

@ -0,0 +1,51 @@
"""V9:business_secondary_allocations 唯一键补齐(存量库自愈)。
背景:uk(business_id, personnel_id) 是随「就地修改已发布的 V6 迁移」加入的
(V6 建表语句里已含、硬编码名);**旧 V6 已应用的存量库 upgrade 到当前
head 时不会重放 V6**,拿不到该约束,V8 也未补加——两条升级路径的库结构
漂移(test_schema_sync 只比对表/列集合,约束级漂移拦不住)。
本迁移是**幂等条件式**:约束已存在(新库 / init-dev 重置库 / 已重建的
开发库)时跳过,不存在(旧 V6 升级库)时补加,避免重复约束名 errno 1826。
语句写成裸 SQL 而非 op.create_unique_constraint——后者在 --sql 离线导出
时会无条件输出 DDL,无法携带存在性判断。
注意:补键时若表内已存在重复 (business_id, personnel_id) 数据,ADD
CONSTRAINT 会失败(有意如此:先由人工清理重复数据再升级,不做静默去重)。
"""
from alembic import op
revision: str = "ba0217f5091b"
down_revision: str | None = "c7d2e5a81f30"
branch_labels = None
depends_on = None
_UK_NAME = "uk_business_secondary_allocations_business_personnel"
def upgrade() -> None:
"""条件式补唯一键(见模块 docstring 的背景与幂等说明)。"""
op.execute(
"SET @uk_exists := ("
"SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS "
"WHERE CONSTRAINT_SCHEMA = DATABASE() "
"AND TABLE_NAME = 'business_secondary_allocations' "
f"AND CONSTRAINT_NAME = '{_UK_NAME}'"
")"
)
op.execute(
"SET @ddl := IF(@uk_exists = 0, "
f"'ALTER TABLE business_secondary_allocations ADD CONSTRAINT {_UK_NAME} "
"UNIQUE (business_id, personnel_id)', "
"'DO 0')"
)
op.execute("PREPARE stmt FROM @ddl")
op.execute("EXECUTE stmt")
op.execute("DEALLOCATE PREPARE stmt")
def downgrade() -> None:
"""有意不撤键:新库由 V6 建表即带该约束、并非本迁移首创,撤掉会让
降级路径产生新的漂移;确需撤销请人工评估后执行 DROP INDEX。
"""

View File

@ -25,8 +25,8 @@ from datetime import date
import sqlalchemy as sa
from alembic import op
revision = "c7d2e5a81f30"
down_revision = "3bca400421c6"
revision: str = "c7d2e5a81f30"
down_revision: str | None = "3bca400421c6"
branch_labels = None
depends_on = None

View File

@ -4,6 +4,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import PerformanceLedger
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
from app.schemas.calc import LedgerQuery
@ -73,6 +74,27 @@ def mark_inactive(session: Session, row: PerformanceLedger) -> None:
session.flush()
def deactivate_month_aggregates(session: Session, month: str) -> None:
"""月结重生成时作废该月全部经理聚合行(calc_type=2)。
聚合行每次生成重建,旧行不翻 0 会与新行重复计入(按人聚合按 is_active
过滤)。与 mark_inactive 同属 is_active 翻 0 的受控 UPDATE 例外;set 批量
版本而非逐行——聚合行一个月至多几行,且无逐行回读必要。
"""
from sqlalchemy import update
session.execute(
update(PerformanceLedger)
.where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == CALC_TYPE_MANAGER_AGGREGATE,
PerformanceLedger.is_active == 1,
)
.values(is_active=0)
)
session.flush()
def next_version(session: Session, receipt_id: int) -> int:
"""某回款的下一个核算版本号(max+1;无行时 1)。"""
current = session.execute(

View File

@ -92,3 +92,21 @@ def update_ledger_settlement_id(
.values(settlement_id=settlement_id)
)
session.flush()
def clear_ledger_settlement_id(session: Session, settlement_id: int) -> None:
"""重生成前解绑:释放本结算名下台账行的 settlement_id(「未入结」判据复位)。
草稿/驳回态重新生成时,上一轮回填的行必须重新进入候选集——否则
``settlement_id IS NULL`` 会把它们永久排除,重生成漏算整月工资。
"""
from sqlalchemy import update
from app.models import PerformanceLedger
session.execute(
update(PerformanceLedger)
.where(PerformanceLedger.settlement_id == settlement_id)
.values(settlement_id=None)
)
session.flush()

View File

@ -86,5 +86,10 @@ class AdjustmentCreateIn(BaseModel):
settlement_month: str = Field(description="调整月份 YYYY-MM")
personnel_id: int = Field(ge=1, description="调整人员ID")
direction: int = Field(ge=1, le=2, description="1补发 2扣减")
amount: str = Field(description="金额(元,两位小数)")
# 正则先拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出);
# 「> 0」由服务层判定(schema 只管形状,语义校验留在 service)
amount: str = Field(
pattern=r"^\d{1,12}(\.\d{1,2})?$",
description="金额(元,两位小数,恒正)",
)
reason: str = Field(min_length=1, max_length=255, description="扣补说明")

View File

@ -64,10 +64,12 @@ from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 开关参数编码(与 param_registry 种子同源)
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
ENABLE_CODE_BY_TYPE: dict[int, str] = {
1: "approval.secondary_allocation.enabled",
2: "approval.monthly_settlement.enabled",
3: "approval.adjustment.enabled",
}
# 在途状态集合(同一引用对象至多一张在途单)
@ -219,6 +221,10 @@ def act(
if order.status == APPROVAL_STATUS_PENDING_FINANCE
else APPROVAL_STATUS_APPROVED
)
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
# 驳回在任何节点都是终态,即时联动。
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
session.add(
ApprovalStep(
@ -259,12 +265,15 @@ def act(
if target is not None:
if rejected:
settlement_service.mark_rejected(session, target, operator=operator)
else:
elif final_approved:
settlement_service.mark_locked(session, target, operator=operator)
else:
adj = session.get(AdjustmentOrder, order.ref_id)
if adj is not None:
adj.status = ADJ_REJECTED if rejected else ADJUSTMENT_STATUS_EFFECTIVE
if rejected:
adj.status = ADJ_REJECTED
elif final_approved:
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记

View File

@ -15,7 +15,7 @@
import logging
from dataclasses import dataclass
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from typing import Any
@ -78,14 +78,17 @@ def calc_receipt(
) -> CalcReceiptOut:
"""单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
防重前两层在本函数(FOR UPDATE 锁行 + settlement_status 校验),第三层
是落台账 uk(receipt_id, calc_version)。全程一个 audit 事务,出参在
commit 前固化。
防重前两层在 ``_load_and_guard``(FOR UPDATE 锁行 + settlement_status
与月结锁定校验),第三层是落台账 uk(receipt_id, calc_version)。全程
一个 audit 事务,出参在 commit 前固化。
首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
仅登记进度(repeated=True)。
仅登记进度(repeated=True,``_prepare_mode`` 内提前返回)。
重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数
落 calc_version+1 新行,calc_reason 留痕。
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
"""
is_recalc = recalc_reason is not None
action = "重算" if is_recalc else "核算"
@ -103,191 +106,321 @@ def calc_receipt(
),
meta=meta,
):
receipt = session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None:
raise BadRequestError("回款不存在")
if is_recalc:
if receipt.settlement_status == SETTLEMENT_PENDING:
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
elif receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
receipt, business = _load_and_guard(session, receipt_id, is_recalc=is_recalc)
repeated_out = _prepare_mode(session, receipt, business, is_recalc=is_recalc)
if repeated_out is not None:
return repeated_out
business = session.get(Business, receipt.business_id, with_for_update=True)
if business is None:
raise BadRequestError("回款关联的业务不存在")
if business.owner_personnel_id is None:
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
shipment = _load_shipment(session, receipt)
person = _load_person(session, business)
snapshot = resolve_snapshot(session, receipt.receipt_date)
items = _load_items(session, business)
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算
if is_recalc:
old = performance_ledger_repository.get_active_by_receipt(session, receipt_id)
if old is not None:
performance_ledger_repository.mark_inactive(session, old)
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
repeated = False
if not is_recalc:
repeated = performance_ledger_repository.has_active_for_business(
session, business.id, calc_type=CALC_TYPE_RECEIPT
)
if repeated:
_mark_settled(session, receipt)
row = performance_ledger_repository.get_active_by_receipt_of_business(
session, business.id
)
session.flush()
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
session.commit()
logger.info(
"回款登记进度(业务已核算)| receipt=%s | business=%s", receipt_id, business.id
)
return out
if receipt.shipment_id is None:
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
shipment = session.get(Shipment, receipt.shipment_id)
if shipment is None or shipment.ship_date is None:
raise BadRequestError("发货批次缺少发货日期,无法核算")
person = personnel_service.ensure_active(
session, business.owner_personnel_id, label="核算归属"
conversion = _convert_items(snapshot, person, items)
amounts = _compose_amounts(
session, snapshot, person, business, receipt, shipment, conversion
)
if person.business_role_id is None:
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
receipt_date = receipt.receipt_date
snapshot = resolve_snapshot(session, receipt_date)
items = business_repository.list_items(session, business.id)
if not items:
raise BadRequestError("业务无产品明细行,无法核算")
# 逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)
total_revenue = Decimal("0")
total_profit = Decimal("0")
converted_total = Decimal("0")
missing_cost = False
line_details: list[dict[str, Any]] = []
for item in items:
row_revenue = item.sale_amount
if item.cost_amount is None:
missing_cost = True
row_profit = Decimal("0")
else:
row_profit = _row_profit(row_revenue, item.cost_amount)
total_revenue += row_revenue
total_profit += row_profit
threshold = parse_fraction(
snapshot.get(
"product.threshold",
role_code=role_code_of(person),
product_category=item.product_category,
)
)
row_converted = converted_profit(
row_revenue, profit_rate(row_profit, row_revenue), threshold
)
converted_total += row_converted
line_details.append(
{
"item_id": item.id,
"product_category": item.product_category,
"revenue": str(row_revenue),
"profit": str(row_profit),
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
# 快照/台账口径需与列一致)
"converted": str(row_converted.quantize(Decimal("0.01"))),
}
)
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
days = payment_days(shipment.ship_date, receipt_date)
decision = _personal_rate(session, snapshot, person, business, days)
if decision.base_override is not None:
converted = decision.base_override # 信息员:基数=本人销售额
else:
converted = converted_total
performance = (
Decimal("0")
if decision.manager_pending
else performance_amount(converted, decision.rate)
)
barter_applied = receipt.is_barter == 1
base_amount = performance
if barter_applied:
discount = parse_fraction(
snapshot.get("barter.discount", role_code=role_code_of(person))
)
base_amount = apply_barter(performance, discount)
received_total = business.total_received_amount or Decimal("0")
start_ratio = parse_fraction(
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
)
start_ok = start_payment_ok(received_total, total_revenue, start_ratio)
weight_r = clamp_weight_r(received_total, total_revenue)
weight = payment_weight(weight_r)
payable = param_service.to_decimal(Fraction(base_amount) * weight)
settlement_month = receipt_date.strftime("%Y-%m")
snapshot_json: dict[str, Any] = {
"params_as_of": str(receipt_date),
"line_details": line_details,
"start_payment_ok": start_ok,
"rate_note": decision.rate_note,
"revenue_total": str(total_revenue),
"profit_total": str(total_profit),
}
if decision.manager_pending:
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
def q2(value: Decimal | None) -> Decimal | None:
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。"""
return value.quantize(Decimal("0.01")) if value is not None else None
row = performance_ledger_repository.add(
row = _persist_ledger(
session,
PerformanceLedger(
receipt_id=receipt.id,
calc_version=performance_ledger_repository.next_version(session, receipt.id),
calc_type=CALC_TYPE_RECEIPT,
business_id=business.id,
business_no=business.business_no,
personnel_id=person.id,
personnel_name=person.personnel_name,
role_code=role_code_of(person),
customer_type=_customer_type(session, business),
product_category=_category_label(items),
ship_date=shipment.ship_date,
receipt_date=receipt_date,
payment_days=days,
rate_text=str(decision.rate),
converted_profit=q2(converted),
performance_amount=q2(performance),
barter_applied=1 if barter_applied else 0,
weight_r=weight_r,
payment_weight=param_service.to_decimal(weight),
payable_amount=q2(payable),
settlement_month=settlement_month,
is_probation=1 if person.is_probation == 1 else 0,
missing_cost=1 if missing_cost else 0,
snapshot_json=snapshot_json,
calc_reason=recalc_reason,
),
receipt=receipt,
business=business,
person=person,
shipment=shipment,
items=items,
conversion=conversion,
amounts=amounts,
recalc_reason=recalc_reason,
is_recalc=is_recalc,
)
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
# 会 rowcount=0 误报并发撞车)
if not is_recalc:
_mark_settled(session, receipt)
session.flush()
out = CalcReceiptOut(ledger=_to_out(row), repeated=False)
session.commit()
logger.info("核算完成 | receipt=%s | ledger=%s | payable=%s", receipt_id, row.id, payable)
logger.info(
"核算完成 | receipt=%s | ledger=%s | payable=%s",
receipt_id,
row.id,
amounts.payable,
)
return out
def _load_and_guard(
session: Session, receipt_id: int, *, is_recalc: bool
) -> tuple[Receipt, Business]:
"""锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验(核算/重算共用)。"""
receipt = session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None:
raise BadRequestError("回款不存在")
if is_recalc:
if receipt.settlement_status == SETTLEMENT_PENDING:
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
elif receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
# 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化,
# 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
business = session.get(Business, receipt.business_id, with_for_update=True)
if business is None:
raise BadRequestError("回款关联的业务不存在")
if business.owner_personnel_id is None:
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
return receipt, business
def _prepare_mode(
session: Session, receipt: Receipt, business: Business, *, is_recalc: bool
) -> CalcReceiptOut | None:
"""模式预处理:重算作废旧版本;首算判业务级一次性计提。
返回非 None = 本笔仅登记进度(repeated=True 的出参,已 commit),
调用方直接返回。
"""
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
# 两行生效行导致双重计提 → 一律 400
if is_recalc:
old = performance_ledger_repository.get_active_by_receipt(session, receipt.id)
if old is None:
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
performance_ledger_repository.mark_inactive(session, old)
return None
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
if not performance_ledger_repository.has_active_for_business(
session, business.id, calc_type=CALC_TYPE_RECEIPT
):
return None
_mark_settled(session, receipt)
row = performance_ledger_repository.get_active_by_receipt_of_business(session, business.id)
session.flush()
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
session.commit()
logger.info("回款登记进度(业务已核算)| receipt=%s | business=%s", receipt.id, business.id)
return out
def _load_shipment(session: Session, receipt: Receipt) -> Shipment:
"""核销基准:发货批次存在且带发货日(回款天数公式的输入)。"""
if receipt.shipment_id is None:
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
shipment = session.get(Shipment, receipt.shipment_id)
if shipment is None or shipment.ship_date is None:
raise BadRequestError("发货批次缺少发货日期,无法核算")
return shipment
def _load_person(session: Session, business: Business):
"""核算归属:在职 + 绑定岗位角色(计提公式锚点)。"""
person = personnel_service.ensure_active(session, business.owner_personnel_id, label="核算归属")
if person.business_role_id is None:
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
return person
def _load_items(session: Session, business: Business) -> list[BusinessItem]:
"""业务产品明细行(逐行折算的输入;空明细无法核算)。"""
items = business_repository.list_items(session, business.id)
if not items:
raise BadRequestError("业务无产品明细行,无法核算")
return items
@dataclass(frozen=True, slots=True)
class _Conversion:
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
total_revenue: Decimal
total_profit: Decimal
converted_total: Decimal
missing_cost: bool
line_details: list[dict[str, Any]]
def _convert_items(snapshot, person, items: list[BusinessItem]) -> _Conversion:
"""逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)。"""
total_revenue = Decimal("0")
total_profit = Decimal("0")
converted_total = Decimal("0")
missing_cost = False
line_details: list[dict[str, Any]] = []
for item in items:
row_revenue = item.sale_amount
if item.cost_amount is None:
missing_cost = True
row_profit = Decimal("0")
else:
row_profit = _row_profit(row_revenue, item.cost_amount)
total_revenue += row_revenue
total_profit += row_profit
threshold = parse_fraction(
snapshot.get(
"product.threshold",
role_code=role_code_of(person),
product_category=item.product_category,
)
)
row_converted = converted_profit(
row_revenue, profit_rate(row_profit, row_revenue), threshold
)
converted_total += row_converted
line_details.append(
{
"item_id": item.id,
"product_category": item.product_category,
"revenue": str(row_revenue),
"profit": str(row_profit),
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
# 快照/台账口径需与列一致)
"converted": str(_q2(row_converted)),
}
)
return _Conversion(total_revenue, total_profit, converted_total, missing_cost, line_details)
@dataclass(frozen=True, slots=True)
class _Amounts:
"""计提组成结果(比例分派 → 绩效 → 抵顶 → 支付权重 → 应付)。"""
days: int
decision: _RateDecision
converted: Decimal
performance: Decimal
barter_applied: bool
weight_r: int
weight: Fraction
payable: Decimal
start_ok: bool
def _compose_amounts(
session: Session,
snapshot,
person,
business: Business,
receipt: Receipt,
shipment: Shipment,
conversion: _Conversion,
) -> _Amounts:
"""按比例分派与支付权重组成应付(与 param_service.preview 同公式路径)。"""
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
days = payment_days(shipment.ship_date, receipt.receipt_date)
decision = _personal_rate(session, snapshot, person, business, days)
if decision.base_override is not None:
converted = decision.base_override # 信息员:基数=本人销售额
else:
converted = conversion.converted_total
performance = (
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
)
barter_applied = receipt.is_barter == 1
base_amount = performance
if barter_applied:
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
base_amount = apply_barter(performance, discount)
received_total = business.total_received_amount or Decimal("0")
start_ratio = parse_fraction(
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
)
start_ok = start_payment_ok(received_total, conversion.total_revenue, start_ratio)
weight_r = clamp_weight_r(received_total, conversion.total_revenue)
weight = payment_weight(weight_r)
payable = param_service.to_decimal(Fraction(base_amount) * weight)
return _Amounts(
days=days,
decision=decision,
converted=converted,
performance=performance,
barter_applied=barter_applied,
weight_r=weight_r,
weight=weight,
payable=payable,
start_ok=start_ok,
)
def _persist_ledger(
session: Session,
*,
receipt: Receipt,
business: Business,
person,
shipment: Shipment,
items: list[BusinessItem],
conversion: _Conversion,
amounts: _Amounts,
recalc_reason: str | None,
is_recalc: bool,
) -> PerformanceLedger:
"""落台账行(版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。"""
receipt_date = receipt.receipt_date
snapshot_json: dict[str, Any] = {
"params_as_of": str(receipt_date),
"line_details": conversion.line_details,
"start_payment_ok": amounts.start_ok,
"rate_note": amounts.decision.rate_note,
"revenue_total": str(conversion.total_revenue),
"profit_total": str(conversion.total_profit),
}
if amounts.decision.manager_pending:
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
row = performance_ledger_repository.add(
session,
PerformanceLedger(
receipt_id=receipt.id,
calc_version=performance_ledger_repository.next_version(session, receipt.id),
calc_type=CALC_TYPE_RECEIPT,
business_id=business.id,
business_no=business.business_no,
personnel_id=person.id,
personnel_name=person.personnel_name,
role_code=role_code_of(person),
customer_type=_customer_type(session, business),
product_category=_category_label(items),
ship_date=shipment.ship_date,
receipt_date=receipt_date,
payment_days=amounts.days,
rate_text=str(amounts.decision.rate),
converted_profit=_q2(amounts.converted),
performance_amount=_q2(amounts.performance),
barter_applied=1 if amounts.barter_applied else 0,
weight_r=amounts.weight_r,
payment_weight=param_service.to_decimal(amounts.weight),
payable_amount=_q2(amounts.payable),
settlement_month=receipt_date.strftime("%Y-%m"),
is_probation=1 if person.is_probation == 1 else 0,
missing_cost=1 if conversion.missing_cost else 0,
snapshot_json=snapshot_json,
calc_reason=recalc_reason,
),
)
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
# 会 rowcount=0 误报并发撞车)
if not is_recalc:
_mark_settled(session, receipt)
return row
def _q2(value: Decimal | None) -> Decimal | None:
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家舍入),
x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
"""
if value is None:
return None
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _personal_rate(
session: Session,
snapshot,
@ -324,7 +457,9 @@ def _personal_rate(
start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
boundary = int(snapshot.get("salesperson.days.boundary"))
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
return _RateDecision(rate, note, None, False)
@ -393,7 +528,7 @@ def recalc_receipt(
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
的拦截在月结(T5)接入后补入本函数。
的拦截在 calc_receipt 内(核算与重算同口径)。
"""
if not reason or not reason.strip():
raise BadRequestError("重算必须填写原因")

View File

@ -41,6 +41,10 @@ def salesperson_rate(
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
首日取整(澄清 #14 业务答复 2026-10-19):第 1 天**不递减**、直接取
起始值(需规图示「第 1 天 10‰」覆盖公式的 9.9889‰)——第 2 天起按公式
递减,360 天=6‰ 锚点与 361 天=4‰ 分界均不受影响。
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
@ -48,6 +52,8 @@ def salesperson_rate(
days = max(days, 0)
if days > boundary_days:
return floor / 1000
if days <= 1:
return start / 1000
return (start - days * slope) / 1000

View File

@ -291,6 +291,12 @@ def update_receipt(
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止修改")
business = _ensure_business_writable(session, receipt.business_id, "修改")
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
settlement_service.ensure_month_not_locked(session, payload.receipt_date.strftime("%Y-%m"))
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
shipment_no, ship_date = _ensure_shipment_of_business(
@ -342,6 +348,10 @@ def delete_receipt(
if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止删除")
business = _ensure_business_writable(session, receipt.business_id, "删除")
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
batch_no = receipt.receipt_batch_no
receipt_repository.delete(session, receipt)

View File

@ -9,7 +9,11 @@
"""
import logging
from decimal import Decimal
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from sqlalchemy import select
from sqlalchemy.orm import Session
@ -25,14 +29,17 @@ from app.models import (
User,
)
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
from app.models.performance_ledger import (
CALC_TYPE_MANAGER_AGGREGATE,
CALC_TYPE_RECEIPT,
)
from app.models.settlement import (
SETTLEMENT_STATUS_APPROVED,
SETTLEMENT_STATUS_DRAFT,
SETTLEMENT_STATUS_PENDING,
SETTLEMENT_STATUS_REJECTED,
)
from app.repositories import settlement_repository
from app.repositories import performance_ledger_repository, settlement_repository
from app.schemas.settlement import (
SettlementDetailOut,
SettlementOut,
@ -59,8 +66,11 @@ def generate(
"""生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。
- 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态
重新生成」(先清 items 重算头表)
- 经理聚合(calc_type=2)在生成时计算(#12 暂口径:基数不含经理个人户)
重新生成」(_release_previous_round 解绑回填后清明细重算)
- 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见
``_build_manager_aggregates``)
本函数只做编排,各段独立私有函数(口径修改只动对应段)。
"""
with audit_service.audit(
session,
@ -74,101 +84,152 @@ def generate(
meta=meta,
):
existing = settlement_repository.get_by_month(session, month)
if existing is not None:
if existing.status == SETTLEMENT_STATUS_APPROVED:
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
if existing.status == SETTLEMENT_STATUS_PENDING:
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
# 草稿/已驳回:允许重新生成(清明细重算)
_guard_regeneration(existing, month)
_release_previous_round(session, existing, month)
ledger_rows = _confirm_scope(session, month)
if not ledger_rows:
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
# 经理团队聚合(#12 策略点:基数不含经理个人户业绩)
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
settlement = settlement_repository.get_by_month(session, month)
is_new = settlement is None
if is_new:
settlement = MonthlySettlement(
settlement_month=month,
submitter_id=operator.id,
submitter_name=operator.username,
status=SETTLEMENT_STATUS_DRAFT,
)
settlement_repository.add(session, settlement)
else:
settlement.submitter_id = operator.id
settlement.submitter_name = operator.username
settlement.status = SETTLEMENT_STATUS_DRAFT
# 按人汇总(台账行:personnel+calc_type 维度)
by_person: dict[tuple[int, int], dict] = {}
for row in ledger_rows + aggregate_rows:
key = (row.personnel_id, row.calc_type)
bucket = by_person.setdefault(
key,
{
"personnel_id": row.personnel_id,
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"calc_type": row.calc_type,
"performance_sum": Decimal("0"),
"payable_sum": Decimal("0"),
"ledger_ids": [],
"is_probation": row.is_probation,
},
)
bucket["performance_sum"] += row.performance_amount or Decimal("0")
bucket["payable_sum"] += row.payable_amount or Decimal("0")
bucket["ledger_ids"].append(row.id)
items: list[SettlementItem] = []
for (_, _), bucket in sorted(by_person.items()):
person = session.get(Personnel, bucket["personnel_id"])
basic = person.salary_standard if person else None
mode = _salary_mode_of(session, bucket["role_code"])
net = (
monthly_salary(bucket["payable_sum"], basic, mode)
if person and person.is_probation != 1
else None
)
note = None
if person and person.is_probation == 1:
note = (
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session)} 元部分"
"年底一次性结算"
)
items.append(
SettlementItem(
personnel_id=bucket["personnel_id"],
personnel_name=bucket["personnel_name"],
role_code=bucket["role_code"],
calc_type=bucket["calc_type"],
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01")),
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01")),
basic_amount=basic,
salary_mode=mode,
net_amount=net.quantize(Decimal("0.01")) if net is not None else None,
is_probation=bucket["is_probation"],
probation_note=note,
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
)
)
settlement = _upsert_draft(session, existing, operator, month)
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
settlement_repository.replace_items(session, settlement.id, items)
settlement.total_performance = sum(
(i.performance_sum for i in items), Decimal("0")
).quantize(Decimal("0.01"))
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
Decimal("0.01")
)
settlement.person_count = len({i.personnel_id for i in items})
_apply_totals(settlement, items)
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id)
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
# 记成失败审计,与库中事实相反)
session.flush()
detail = _detail_of(session, settlement)
session.commit()
logger.info("月结生成 | month=%s | items=%s", month, len(items))
return _detail_of(session, settlement)
return detail
def _guard_regeneration(existing: MonthlySettlement | None, month: str) -> None:
"""重生成准入:已审定 / 待审批不可重生成(草稿、已驳回放行)。"""
if existing is None:
return
if existing.status == SETTLEMENT_STATUS_APPROVED:
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
if existing.status == SETTLEMENT_STATUS_PENDING:
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
def _release_previous_round(
session: Session, existing: MonthlySettlement | None, month: str
) -> None:
"""释放上一轮固化标记(必须早于 _confirm_scope)。
台账行 settlement_id 回填后,「未入结」过滤会把它们永久排除在候选集外;
旧聚合行也要作废,否则本轮重建的聚合行与旧行重复计入。
"""
if existing is None:
return
settlement_repository.clear_ledger_settlement_id(session, existing.id)
performance_ledger_repository.deactivate_month_aggregates(session, month)
def _upsert_draft(
session: Session, existing: MonthlySettlement | None, operator: User, month: str
) -> MonthlySettlement:
"""新建或重置草稿头表(重生成时刷新提交人与状态)。"""
if existing is None:
settlement = MonthlySettlement(
settlement_month=month,
submitter_id=operator.id,
submitter_name=operator.username,
status=SETTLEMENT_STATUS_DRAFT,
)
return settlement_repository.add(session, settlement)
existing.submitter_id = operator.id
existing.submitter_name = operator.username
existing.status = SETTLEMENT_STATUS_DRAFT
return existing
def _build_items(
session: Session, ledger_rows: list, aggregate_rows: list, month: str
) -> tuple[list[SettlementItem], list[int]]:
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
by_person = _bucket_rows(ledger_rows + aggregate_rows)
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
return items, ledger_ids
def _bucket_rows(rows: list) -> dict[tuple[int, int], dict]:
"""台账行按 (人员, 核算类型) 维度分桶(冗余人读字段取首行)。"""
by_person: dict[tuple[int, int], dict] = {}
for row in rows:
key = (row.personnel_id, row.calc_type)
bucket = by_person.setdefault(
key,
{
"personnel_id": row.personnel_id,
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"calc_type": row.calc_type,
"performance_sum": Decimal("0"),
"payable_sum": Decimal("0"),
"ledger_ids": [],
"is_probation": row.is_probation,
},
)
bucket["performance_sum"] += row.performance_amount or Decimal("0")
bucket["payable_sum"] += row.payable_amount or Decimal("0")
bucket["ledger_ids"].append(row.id)
return by_person
def _item_of(session: Session, bucket: dict, month: str) -> SettlementItem:
"""单条结算明细:月工资三口径净额、试用期说明与台账行引用列表。"""
person = session.get(Personnel, bucket["personnel_id"])
basic = person.salary_standard if person else None
mode = _salary_mode_of(session, bucket["role_code"], month)
net = (
monthly_salary(bucket["payable_sum"], basic, mode)
if person and person.is_probation != 1
else None
)
note = None
if person and person.is_probation == 1:
note = (
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分"
"年底一次性结算"
)
return SettlementItem(
personnel_id=bucket["personnel_id"],
personnel_name=bucket["personnel_name"],
role_code=bucket["role_code"],
calc_type=bucket["calc_type"],
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
basic_amount=basic,
salary_mode=mode,
net_amount=(
net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if net is not None else None
),
is_probation=bucket["is_probation"],
probation_note=note,
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
)
def _apply_totals(settlement: MonthlySettlement, items: list[SettlementItem]) -> None:
"""头表三合计固化(明细已两位小数,合计再 HALF_UP 收口)。"""
settlement.total_performance = sum((i.performance_sum for i in items), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
settlement.person_count = len({i.personnel_id for i in items})
def submit(
@ -206,13 +267,16 @@ def submit(
settlement.approval_order_no = order.order_no
if order.status == SETTLEMENT_STATUS_APPROVED:
mark_locked(session, settlement, operator=operator)
session.commit()
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
return _detail_of(session, settlement)
else:
settlement.status = SETTLEMENT_STATUS_PENDING
settlement.status = SETTLEMENT_STATUS_PENDING
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的
# 月结记成失败审计,与库中事实相反)
session.flush()
detail = _detail_of(session, settlement)
session.commit()
return _detail_of(session, settlement)
return detail
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
@ -225,8 +289,14 @@ def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: Us
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
"""审批驳回钩子:回草稿(可修改后重提)。"""
"""审批驳回钩子:回草稿(可修改后重提)。
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
"""
settlement.status = SETTLEMENT_STATUS_DRAFT
settlement.locked_at = None
settlement.locked_by = None
def ensure_month_not_locked(session: Session, month: str) -> None:
@ -261,7 +331,11 @@ def list_page(session: Session, query: SettlementQuery):
def _confirm_scope(session: Session, month: str) -> list:
"""确认回款口径(#8 策略点):本月有效台账行(未入结)。"""
"""确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
settlement_id,不排除会在重生成时与新聚合行重复计入。
"""
from app.models import PerformanceLedger
return list(
@ -269,6 +343,7 @@ def _confirm_scope(session: Session, month: str) -> list:
select(PerformanceLedger)
.where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
PerformanceLedger.is_active == 1,
PerformanceLedger.settlement_id.is_(None),
)
@ -277,46 +352,152 @@ def _confirm_scope(session: Session, month: str) -> list:
)
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
"""按经理生成团队聚合行(calc_type=2)。
@dataclass(frozen=True, slots=True)
class _ShareParams:
"""经理分成取参与参数原文(同一次生成共用一份,随月月末时点化)。"""
#12 策略点:聚合基数**不含**经理个人户业绩(customer_type=
manager_personal 的行按经理个人户单笔处理,已落台账但此处不入基数)。
经理 = 本月台账行里 role_code=sales_manager 且 customer_type=personal
的人员(本人带领的业务员经 manager_id 关联)。
month: str
param_date: date
team_text: str
company_text: str
team_pct: Fraction
company_pct: Fraction
include_manager_personal: bool
def _manager_share_params(session: Session, month: str) -> _ShareParams:
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
"""
from app.models import PerformanceLedger
from app.services.param_service import parse_fraction, resolve_snapshot
param_date = _month_end(month)
snapshot = resolve_snapshot(session, param_date)
team_text = snapshot.get("manager.share.team")
company_text = snapshot.get("manager.share.company")
return _ShareParams(
month=month,
param_date=param_date,
team_text=team_text,
company_text=company_text,
team_pct=parse_fraction(team_text),
company_pct=parse_fraction(company_text),
include_manager_personal=snapshot.get("manager.personal.included") == "1",
)
def _add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
bucket = managers.setdefault(
manager_id,
{
"performance": Decimal("0"),
"business_id": row.business_id,
"business_no": row.business_no,
"names": set(),
},
)
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
def _collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""按经理采集团队业绩。
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
按上溯会被跳过漏计)。
"""
managers: dict[int, dict] = {}
for row in ledger_rows:
if row.customer_type == "manager_personal":
continue # 经理个人户业绩不入聚合基数(#12 暂口径)
# 找到该行业务归属人员的经理(manager_id)
if include_manager_personal:
_add_team_performance(managers, row.personnel_id, row)
continue # 默认不计入聚合基数(#12 参数化)
person = session.get(Personnel, row.personnel_id)
manager_id = person.manager_id if person else None
if manager_id is None:
continue
bucket = managers.setdefault(
manager_id,
{
"performance": Decimal("0"),
"business_id": row.business_id,
"business_no": row.business_no,
"names": set(),
if manager_id is not None:
_add_team_performance(managers, manager_id, row)
return managers
def _aggregate_row(
session: Session, manager, bucket: dict, company_base: Decimal, share: _ShareParams
):
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
from app.models import PerformanceLedger
amount = manager_team_share(
bucket["performance"],
company_base,
team_pct=share.team_pct,
company_pct=share.company_pct,
)
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
return performance_ledger_repository.add(
session,
PerformanceLedger(
receipt_id=None,
calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重)
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
business_id=bucket["business_id"],
business_no=bucket["business_no"],
personnel_id=manager.id,
personnel_name=manager.personnel_name,
role_code="sales_manager",
customer_type="personal",
product_category="mixed",
receipt_date=share.param_date,
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
rate_text=f"聚合:{share.team_text}%+{share.company_text}%",
converted_profit=None,
performance_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
barter_applied=0,
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
settlement_month=share.month,
is_probation=1 if manager.is_probation == 1 else 0,
snapshot_json={
"team_base": str(bucket["performance"]),
"company_base": str(company_base),
"members": member_names,
"params_as_of": str(share.param_date),
"manager_personal_included": 1 if share.include_manager_personal else 0,
"note": (
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
"经理个人户业绩;9.8 方案公式"
),
},
)
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
),
)
from datetime import date
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
"""按经理生成团队聚合行(calc_type=2)并落库。
#12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数
``manager.personal.included`` 决定(0=不计入默认,1=计入);
经理 = 台账行归属人员经 manager_id 关联的 sales_manager 岗人员。
"""
from app.repositories import business_role_repository
from app.services.param_service import parse_fraction, resolve_snapshot
share = _manager_share_params(session, month)
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);
# 口径逐行固定,循环外算一次
company_base = sum(
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if share.include_manager_personal or r.customer_type != "manager_personal"
),
Decimal("0"),
)
aggregates: list = []
snapshot = resolve_snapshot(session, date.today())
team_pct = parse_fraction(snapshot.get("manager.share.team"))
company_pct = parse_fraction(snapshot.get("manager.share.company"))
for manager_id, bucket in managers.items():
manager = session.get(Personnel, manager_id)
if manager is None:
@ -328,67 +509,33 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
)
if role is None or role.role_code != "sales_manager":
continue # 非营销经理的上级不参与聚合
# 全公司基数:全部非经理个人户的有效行
company_base = sum(
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if r.customer_type != "manager_personal"
),
Decimal("0"),
)
share = manager_team_share(
bucket["performance"],
company_base,
team_pct=team_pct * 100,
company_pct=company_pct * 100,
)
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
aggregates.append(
PerformanceLedger(
receipt_id=None,
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
business_id=bucket["business_id"],
business_no=bucket["business_no"],
personnel_id=manager_id,
personnel_name=manager.personnel_name,
role_code="sales_manager",
customer_type="personal",
product_category="mixed",
receipt_date=date.today(),
rate_text="聚合:12.5%+5%",
converted_profit=None,
performance_amount=share.quantize(Decimal("0.01")),
barter_applied=0,
payable_amount=share.quantize(Decimal("0.01")),
settlement_month=month,
is_probation=1 if manager.is_probation == 1 else 0,
snapshot_json={
"team_base": str(bucket["performance"]),
"company_base": str(company_base),
"members": member_names,
"note": "#12 暂口径:基数不含经理个人户业绩;9.8 方案公式",
},
)
)
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
return aggregates
def _salary_mode_of(session: Session, role_code: str) -> str | None:
from datetime import date
def _month_end(month: str) -> date:
"""YYYY-MM → 所属月月末(月结取参锚点)。
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
更细的业务日期,取该月最后可确定的生效时点。
"""
year, mon = int(month[:4]), int(month[5:7])
return date(year, mon, monthrange(year, mon)[1])
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
from app.services.param_service import resolve_snapshot
snapshot = resolve_snapshot(session, date.today())
snapshot = resolve_snapshot(session, _month_end(month))
return snapshot.get("salary.basic.mode", role_code=role_code) or None
def _probation_threshold(session: Session) -> Decimal:
from datetime import date
def _probation_threshold(session: Session, month: str) -> Decimal:
from app.services.param_service import parse_fraction, resolve_snapshot
snapshot = resolve_snapshot(session, date.today())
snapshot = resolve_snapshot(session, _month_end(month))
from app.services.formula import to_decimal
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))
@ -412,19 +559,11 @@ def create_adjustment(
):
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
from app.core.constants import NO_PREFIX_ADJUSTMENT
from app.models import AdjustmentOrder, ApprovalStep
from app.models.approval import (
APPROVAL_ACTION_AUTO_APPROVE,
APPROVAL_NODE_FINANCE,
APPROVAL_NODE_LEADER,
APPROVAL_TYPE_ADJUSTMENT,
)
from app.models.settlement import (
ADJUSTMENT_STATUS_EFFECTIVE,
ADJUSTMENT_STATUS_PENDING,
)
from app.models import AdjustmentOrder
from app.services.no_generator import next_no
amount = _adjustment_amount(payload)
person = session.get(Personnel, payload.personnel_id)
if person is None:
raise BadRequestError("调整人员不存在")
@ -451,7 +590,7 @@ def create_adjustment(
personnel_id=payload.personnel_id,
personnel_name=person.personnel_name,
direction=payload.direction,
amount=payload.amount,
amount=amount,
reason=payload.reason.strip(),
submitter_id=operator.id,
submitter_name=operator.username,
@ -459,40 +598,68 @@ def create_adjustment(
session.add(order)
session.flush()
if approval_service.is_enabled(session, APPROVAL_TYPE_ADJUSTMENT):
order.status = ADJUSTMENT_STATUS_PENDING
session.flush()
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_ADJUSTMENT,
ref_id=order.id,
refer_no=order.order_no,
submitter=operator,
)
else:
order.status = ADJUSTMENT_STATUS_EFFECTIVE
session.add_all(
[
ApprovalStep(
order_id=order.id,
node=node,
action=APPROVAL_ACTION_AUTO_APPROVE,
operator_name="系统",
)
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
]
)
session.flush()
_wire_adjustment_approval(session, order, operator)
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
session.commit()
logger.info("调整单创建 | order=%s | status=%s", order.order_no, order.status)
return {
"id": order.id,
"order_no": order.order_no,
"status": order.status,
"personnel_name": order.personnel_name,
"amount": str(order.amount),
"direction": order.direction,
}
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
return out
def _adjustment_amount(payload) -> Decimal:
"""金额恒正校验(方向表达补发/扣减)。
列注释即「恒正」约定,负数会让方向语义反转、审批/报表按 direction
汇总出错;格式已由 schema 正则拦截,此处兜底转换 + 语义判定。
"""
from decimal import InvalidOperation
try:
amount = Decimal(payload.amount)
except InvalidOperation as exc:
raise BadRequestError("调整金额格式非法(非负两位小数)") from exc
if amount <= 0:
raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)")
return amount
def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
"""开关分流与留痕统一交给 submit_order(关=自动通过留痕;开=待财务复核)。
**不得手工插 ApprovalStep**——``approval_steps.order_id`` 外键指向
approval_orders(不是 adjustment_orders),用调整单 id 落步必然外键
失败(历史缺陷:关开关时新建调整单不可用)。
"""
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
from app.models.settlement import (
ADJUSTMENT_STATUS_EFFECTIVE,
ADJUSTMENT_STATUS_PENDING,
)
approval_order = approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_ADJUSTMENT,
ref_id=order.id,
refer_no=order.order_no,
submitter=operator,
)
order.status = (
ADJUSTMENT_STATUS_EFFECTIVE
if approval_order.status == APPROVAL_STATUS_APPROVED
else ADJUSTMENT_STATUS_PENDING
)
session.flush()
def _adjustment_out(order) -> dict:
"""调整单出参(commit 前固化;expire_on_commit 关闭下也不依赖后置读取)。"""
return {
"id": order.id,
"order_no": order.order_no,
"status": order.status,
"personnel_name": order.personnel_name,
"amount": str(order.amount),
"direction": order.direction,
}
def list_adjustments(session: Session) -> list[dict]:

View File

@ -237,6 +237,12 @@ def create_shipment(
raise NotFoundError("业务不存在")
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止新发货")
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(
session, payload.ship_date.strftime("%Y-%m")
)
shipment = Shipment(
shipment_no=shipment_no,
@ -309,6 +315,11 @@ def update_shipment(
if shipment is None:
raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
settlement_service.ensure_month_not_locked(session, payload.ship_date.strftime("%Y-%m"))
shipment.ship_date = payload.ship_date
shipment.freight_amount = _amount_or_zero(payload.freight_amount)
@ -357,6 +368,10 @@ def delete_shipment(
if shipment is None:
raise NotFoundError("发货单不存在")
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
from app.services import settlement_service
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
if shipment_repository.count_receipt_refs(session, shipment_id):
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")

View File

@ -137,7 +137,12 @@ def save_logo(
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext:
_cleanup_old_logo(old_ext)
try:
_cleanup_old_logo(old_ext)
except OSError:
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
return to_out(setting)

View File

@ -153,6 +153,8 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-17 | M3-core 定点 | 32 | ✅ 通过 | **④ 核算引擎+月结完成**(calc 13 / settlements 6 / formula 扩 12 / schemas 等)。金标准复算到分;重算版本链;月结锁定与审批联动 |
| 2026-10-17 | 全量回归 | 701+N | ✅ 全部通过 | **M3-core 收尾回归**(T1-T6:V7 四表+核算+月结+调整单;前端台账/月结两页 order17/18)。e2e 补 calc_settlement_flow 待下批 |
| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) |
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
## 四、测试发现并修复的缺陷
@ -190,6 +192,19 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-16 | **logo 上传同扩展名覆盖时先写后删** | `save_logo` 在写入新文件**之后**清理旧扩展名——同扩展名重传时把刚落盘的新文件一并删掉,回读 404(首次单跑通过、集成连跑即暴露,依赖「库里已有同扩展名旧记录」的前置状态) | 清理旧扩展名移到写新文件**之前**且仅异扩展名执行;集成连跑两遍幂等锁定 |
| 2026-10-16 | **public 端点 300s 响应缓存让登录页拿旧公司名** | 公司名保存后退出登录,登录页命中浏览器缓存副本仍显示旧品牌名(设置页「保存后生效」承诺失效)——**由 settings_flow e2e 首跑暴露**(新标签页直接打开正常、退出回跳的登录页异常) | 登录页改用带时间戳的原生 `fetch` + `cache: no-store` 绕过磁盘缓存(axios 对响应 max-age 无能为力);手动解包统一信封 |
| 2026-10-16 | V6 迁移 downgrade 保留 autogenerate 的逐个 `drop_index` 且漏删行级种子 | 外键唯一支撑索引单独删除报 MySQL 1553 回滚中断;roles/param_definitions/param_values 的行级种子(表由 V1/V3 建)drop_table 不会带走——重放 upgrade 时唯一键冲突 | downgrade 只保留 `drop_table`(V1/V3 先例)+ 三条 DELETE 反向清理行级种子;downgrade→upgrade 往返实测通过 |
| 2026-10-19 | **经理团队聚合分成放大 100 倍**(settlement_service) | 参数按百分数存储(12.5=12.5%),`parse_fraction` 后调用方又 ×100、`manager_team_share` 内部再 ÷100——净效应「绩效×12.5」;单元测试直传 Fraction 未覆盖调用方换算使缺陷存活 | 去掉调用方 ×100;`rate_text` 改用实际参数原文(原硬编码默认值失真);集成用例两档金标准(6.84/9.78 vs 错误值 684.43)锁定 |
| 2026-10-19 | **审批联动不判终审**(approval_service.act) | 财务节点通过(1→2)即 mark_locked 月结/调整单置生效——领导驳回后 locked_at/locked_by 残留、两级审批形同虚设(锁定后源数据写路径立即被拦) | 生效类联动收敛到 `final_approved`(终态才触发);驳回任意节点即时;mark_rejected 补清锁定字段;用例断言财务通过后仍 status=2 且无锁定信息 |
| 2026-10-19 | **调整单创建完全不可用**(两处叠加) | ① `ENABLE_CODE_BY_TYPE` 漏登记 type=3 → is_enabled 恒 400「未知的审批对象类型」;② 修复①后暴露:自动生效分支手工插 `ApprovalStep(order_id=调整单id)`,而该外键指向 approval_orders,必然 errno 1452 | 补登记 `approval.adjustment.enabled`;自动通过留痕统一交 `submit_order`(不再手工插步);新建调整单集成用例锁定 |
| 2026-10-19 | **重算绕过业务级一次性计提防线** | 「进度登记」回款(#25 第二笔及以后)无独立台账行,重算会凭空落一行生效行——同业务两行生效行、绩效按两倍计入工资 | 无旧版本行一律 400「进度登记无可重算」;用例锁定「重算后同业务仍只有一行生效行」 |
| 2026-10-19 | **前端重算传错 ID**(台账页) | 把台账行主键当 receipt_id 传给重算接口——会作废并重算**无关回款**的台账(若目标回款已核算),或报错无法重算目标行 | 改传 `row.receipt_id`(聚合行 receipt_id 空值防御);`LedgerRow` 本含该字段 |
| 2026-10-19 | **quantize 缺省银行家舍入**(9 处金额固化) | docstring 声称 HALF_UP,实际未传 rounding 走 ROUND_HALF_EVEN——x.xx5 舍成偶数尾数,与业务四舍五入口径差 1 分(台账固化金额比应发少 1 分) | 9 处显式 `rounding=ROUND_HALF_UP`;`_q2` 提为模块级并单测锁定(2.345→2.35) |
| 2026-10-19 | **月结锁定拦截只覆盖 1/4 写路径** | 仅新增回款被拦;回款编辑(可跨月搬数)/删除、发货增/改/删、核算与重算全部放行——锁定月份快照与源数据脱钩且无留痕 | 五处写路径补齐 `ensure_month_not_locked`(编辑按原月+新月双查);拦截先于「被引用/已核算」判定;三用例锁定 |
| 2026-10-19 | **台账 settlement_id 回填从未发生**(死代码) | `update_ledger_settlement_id` 全库零调用——「未入结」判据恒真,重生成会把已固化行反复纳入(或补上回填却不先解绑导致漏算整月) | generate 回填 + 重生成前解绑(新增 `clear_ledger_settlement_id`);用例断言回填与重生成金额不翻倍 |
| 2026-10-19 | **经理聚合行从不落库** | `_build_manager_aggregates` 只构造内存对象参与 items 汇总——台账表永远无 calc_type=2 行,报表下钻(business_id 锚点)落空 | 聚合行经 repository 落库并回填 settlement_id;重生成先作废旧聚合行(`deactivate_month_aggregates`);`_confirm_scope` 限定 calc_type=1 防聚合行混入单笔池 |
| 2026-10-19 | **月结取参用生成日**(date.today) | 补生成历史月份时经理分成/月工资口径/试用期阈值按生成日参数计算——同一月份两次生成金额不同,违背「参数按生效日期时点化」红线 | 新增 `_month_end`(所属月月末)为聚合与月工资口径取参锚点;单测 5 例 + 集成用例(10-25 预约变更影响 10 月聚合) |
| 2026-10-19 | **迁移双轨三处漂移** | ① V6 就地修改加入 uk 致旧库补不上(V8 也未补)→ V9 条件式补齐(幂等裸 SQL,临时库验证两分支);② V7 留存 SQL 约束/索引名单数(`fk_performance_ledger_*`)与 alembic op.f/init-dev 漂移;③ V8 迁移 revision 缺类型注解,`test_schema_sync` 的 head 推断**静默漏检 V8**、init-dev 版本号落后未被发现 | V9 迁移 + V7 SQL 名修正 + V8 注解修正 + init-dev 版本推进;schema 同步测试恢复全量断言 |
| 2026-10-19 | 运维脚本两处(restore_drill/health_check) | ① 行数核对把查询失败回显 `'?'` 当数据比较(`'?'=='?'` 判 ✓)且 TABLES 缺 V7 四表等 16 张——备份漏表时演练全绿误报「可恢复」;② `redis-cli -a "$REDIS_PASSWORD"` 密码进宿主进程命令行 | TABLES 补全 32 张业务表 + `'?'` 一律判 ✗;Redis 探活改容器内环境变量展开(compose 补 REDIS_PASSWORD),实测 PONG |
| 2026-10-19 | 调整单金额无校验 / INT 参数原文 `int()` / logo 清理裸抛 | ① amount 负数反转补发-扣减语义、'abc' 直落库 500;② `int('360.0')` 使整月核算 500(INT 参数合法入库原文);③ 旧 logo 清理 OSError 裸抛把成功更新记成失败审计并 500 | schema 正则 + 服务层 Decimal>0;`int(parse_fraction(...))`;清理包 try 仅 warning;各补回归用例 |
## 五、待补用例
@ -198,7 +213,7 @@ uv run pytest tests/integration -q # 仅集成
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
| 绩效工资核算 | 未开始 | **被业务澄清阻塞**(经理个人户基数、含税/退货冲减等 5 项口径待答复);答复后按同粒度另出计划 |
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 |
| 报表统计 | 未开始 | 依赖报表口径确认 |
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。

View File

@ -129,11 +129,12 @@ def switched_on(client: TestClient, admin: dict) -> None:
class TestSwitchOff:
"""开关关闭(种子默认):提单即自动通过。"""
"""开关关闭(用例显式设置,不依赖种子默认值):提单即自动通过。"""
def test_创建带方案_自动通过全节点留痕(
self, client: TestClient, admin: dict, finance: dict
) -> None:
_set_switch(client, admin["headers"], "0") # V8 起种子默认开启
p = _person(client, admin["headers"])
data = _create_with_plan(client, admin["headers"], p["id"])

View File

@ -357,6 +357,38 @@ class TestGuards:
resp = _calc(client, ops["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
def test_分界天数参数小数原文_核算不500(self, client: TestClient, admin: dict) -> None:
"""守 int(参数原文) 缺陷:INT 参数入库允许 '360.0'(Fraction 分母为 1),
直接 int(str) 抛 ValueError 会让整月核算 500。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": "用例:INT 参数小数原文",
"items": [
{
"param_code": "salesperson.days.boundary",
"dimension_value": "salesperson",
"value": "360.0",
}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
_biz, receipt = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
# 分界取整无损:20 天 < 360 天 → 同一递减公式
assert resp.json()["data"]["ledger"]["rate_text"] == "11/1125"
def test_台账列表与筛选(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain(
client,
@ -459,6 +491,53 @@ class TestRecalc:
assert resp.status_code == 400
assert "尚未核算" in resp.json()["message"]
def test_进度登记回款重算400_不得凭空落行(self, client: TestClient, admin: dict) -> None:
"""业务级一次性计提的第二笔回款无台账行;重算若落新行会双重计提。"""
biz, first = _create_full_chain(
client,
admin["headers"],
items=_GOLD_ITEMS,
ship_date="2026-10-01",
receipt_date="2026-10-20",
amount="20000.00",
)
assert _calc(client, admin["headers"], first["id"]).status_code == 200
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-25",
"amount": "100.00",
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
second = resp.json()["data"]
assert _calc(client, admin["headers"], second["id"]).json()["data"]["repeated"] is True
resp = client.post(
f"{_CALC}/receipts/{second['id']}/recalc",
json={"reason": "尝试重算进度登记"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "进度登记" in resp.json()["message"]
# 同业务仍只有一行生效台账(未凭空新增)
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.business_id == biz["id"],
PerformanceLedger.is_active == 1,
)
)
.scalars()
.all()
)
assert len(rows) == 1
def test_重算_普通用户与营销后勤403(
self, client: TestClient, normal: dict, make_user, auth_headers
) -> None:

View File

@ -69,6 +69,26 @@ def _create_with_allocations(client: TestClient, headers: dict, allocations: lis
return resp.json()["data"]
def _set_switch(client: TestClient, headers: dict, value: str) -> None:
"""显式设置二次分配审批开关(V8 起默认开启,用例不得依赖种子默认值)。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": f"用例设置二次分配审批开关={value}",
"items": [
{
"param_code": "approval.secondary_allocation.enabled",
"dimension_value": "",
"value": value,
}
],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _detail(client: TestClient, headers: dict, business_id: int) -> dict:
resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers)
assert resp.status_code == 200, resp.text
@ -239,6 +259,8 @@ class TestDeleteProtection:
assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None:
# 显式关开关:开启时审批单在途、删业务被 400 拦截(产品行为,另有用例覆盖)
_set_switch(client, admin["headers"], "0")
p = _person(client, admin["headers"])
data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]

View File

@ -10,13 +10,15 @@
"""
import uuid
from datetime import date
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import MonthlySettlement
from app.models import MonthlySettlement, PerformanceLedger
from tests.integration.factories import business_payload, customer
pytestmark = pytest.mark.integration
@ -39,30 +41,38 @@ _GOLD_ITEMS = [
]
def _person(client: TestClient, headers: dict) -> dict:
def _role_id(role_code: str) -> int:
from sqlalchemy import select
from app.core.constants import BUSINESS_ROLE_SALESPERSON
from app.core.db import get_session_factory
from app.models import BusinessRole
factory = get_session_factory()
with factory() as session:
role_id = int(
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
BusinessRole.role_code == role_code,
BusinessRole.status == 1,
)
).scalar_one()
)
def _person(
client: TestClient,
headers: dict,
*,
role_code: str = "salesperson",
manager_id: int | None = None,
) -> dict:
resp = client.post(
_PERSONNEL,
json={
"personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}",
"business_role_id": role_id,
"business_role_id": _role_id(role_code),
"salary_standard": "3000.00",
**({"manager_id": manager_id} if manager_id is not None else {}),
},
headers=headers,
)
@ -70,6 +80,20 @@ def _person(client: TestClient, headers: dict) -> dict:
return resp.json()["data"]
def _set_switch(client: TestClient, headers: dict, code: str, value: str) -> None:
"""显式设置审批开关(V8 起审批开关默认开启,用例不得依赖种子默认值)。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": f"用例设置 {code}={value}",
"items": [{"param_code": code, "dimension_value": "", "value": value}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
def _calc_one(client: TestClient, headers: dict) -> dict:
"""完整造一笔已核算回款(回款日 2026-10-20 → 归月 2026-10)。"""
cust = customer(client, headers)
@ -145,6 +169,15 @@ class TestGenerate:
select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10")
).scalar_one()
assert settled.person_count >= 1
backfilled = (
session.execute(
select(PerformanceLedger).where(PerformanceLedger.settlement_id == settled.id)
)
.scalars()
.all()
)
assert backfilled, "台账行 settlement_id 未回填(「未入结」判据失效)"
assert all(r.settlement_month == "2026-10" for r in backfilled)
def test_无核算记录生成400(self, client: TestClient, admin: dict) -> None:
resp = client.post(
@ -161,7 +194,8 @@ class TestGenerate:
).status_code
== 200
)
# 提交(开关默认关)→ 自动通过锁定
# 显式关开关 → 提交即自动通过锁定(用例不依赖种子默认值)
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
"id"
]
@ -172,8 +206,7 @@ class TestGenerate:
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 400
# 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改
assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"])
assert "已审定锁定" in resp.json()["message"]
class TestSubmitAndLock:
@ -183,6 +216,8 @@ class TestSubmitAndLock:
self, client: TestClient, admin: dict, finance: dict
) -> None:
_calc_one(client, admin["headers"])
# 显式关开关(V8 起种子默认开启,用例不得依赖默认值)
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
@ -322,6 +357,13 @@ class TestSwitchOn:
)
assert resp.status_code == 200, resp.text
# 财务节点通过只是流转到待领导——不得提前锁定月结(否则领导驳回后
# 锁定字段残留、两级审批形同虚设)
mid = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"]
assert mid["status"] == 2, "财务通过后应仍为待审批,不得提前锁定"
assert mid["locked_at"] is None
assert mid["locked_by"] is None
# 领导通过 → 锁定
resp = client.post(
f"/api/v1/system/approvals/{order['id']}/action",
@ -333,3 +375,377 @@ class TestSwitchOn:
assert final["status"] == 3
assert final["locked_at"] is not None
assert final["locked_by"] == leader["username"] # 审批通过人=领导账号
class TestManagerAggregate:
"""经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。"""
def _scene(self, client: TestClient, headers: dict) -> tuple[dict, dict]:
"""经理(带一名业务员)→ 业务 → 发货 → 已核算回款,返回 (经理, 回款)。"""
manager = _person(client, headers, role_code="sales_manager")
sales = _person(client, headers, manager_id=manager["id"])
cust = customer(client, headers)
resp = client.post(
_BUSINESSES,
json=business_payload(
cust["id"],
owner_personnel_id=sales["id"],
items=[dict(_GOLD_ITEMS[0], product_name=f"t_聚合_{uuid.uuid4().hex[:6]}")],
),
headers=headers,
)
assert resp.status_code == 200, resp.text
biz = resp.json()["data"]
resp = client.post(
_SHIPMENTS,
json={
"business_id": biz["id"],
"ship_date": "2026-10-01",
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
resp = client.post(
_RECEIPTS,
json={
"business_id": biz["id"],
"receipt_date": "2026-10-20",
"amount": "20000.00",
"shipment_id": resp.json()["data"]["id"],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
assert (
client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers).status_code
== 200
)
return manager, receipt
def test_聚合金额按实际比例计入且落库回填(self, client: TestClient, admin: dict) -> None:
manager, _receipt = self._scene(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
items = out["items"]
manager_items = [i for i in items if i["calc_type"] == 2]
assert len(manager_items) == 1
# 业务员绩效 39.11;经理 = 39.11×12.5% + 39.11×5% = 6.84425 → 6.84
# (若调用方把百分数再 ×100,此处会是 684.43)
assert manager_items[0]["personnel_id"] == manager["id"]
assert manager_items[0]["performance_sum"] == "6.84"
assert manager_items[0]["payable_sum"] == "6.84"
# 聚合行必须落台账(报告下钻的数据源)且被回填 settlement_id
factory = get_session_factory()
with factory() as session:
rows = (
session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == "2026-10",
PerformanceLedger.calc_type == 2,
)
)
.scalars()
.all()
)
assert len(rows) == 1, "聚合行必须落库(修复前只在内存参与汇总)"
assert rows[0].is_active == 1
assert rows[0].receipt_id is None
assert rows[0].settlement_id == out["id"], "聚合行未回填 settlement_id"
def test_草稿态重生成_聚合不重复计入(self, client: TestClient, admin: dict) -> None:
self._scene(client, admin["headers"])
first = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
).json()["data"]
second = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert second.status_code == 200, second.text
out = second.json()["data"]
# 重生成金额不翻倍(聚合行不做废旧行会 45.95→52.79)也不漏行
assert out["total_payable"] == first["total_payable"] == "45.95"
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1
def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None:
"""取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。"""
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 2,
"effective_date": "2026-10-25",
"remark": "用例:月末取参锚点",
"items": [
{
"param_code": "manager.share.team",
"dimension_value": "",
"value": "20",
}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
self._scene(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
manager_items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
# 39.11×20% + 39.11×5% = 9.7775 → 9.78(按生成日取参会得 6.84)
assert manager_items[0]["performance_sum"] == "9.78"
# 台账快照固化月末取参日与实际比例原文
factory = get_session_factory()
with factory() as session:
row = session.execute(
select(PerformanceLedger).where(
PerformanceLedger.settlement_month == "2026-10",
PerformanceLedger.calc_type == 2,
)
).scalar_one()
assert row.snapshot_json["params_as_of"] == "2026-10-31"
assert row.rate_text == "聚合:20%+5%"
assert str(row.receipt_date) == "2026-10-31"
def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict) -> None:
"""#12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户业绩并入基数。
直插一行经理个人户台账(绩效 100,模拟该类业务经单笔侧计提后的
结果)——默认 0 时聚合 6.84 不变;开关 1 后基数 39.11+100=139.11。
"""
manager, _receipt = self._scene(client, admin["headers"])
factory = get_session_factory()
with factory() as session:
session.add(
PerformanceLedger(
receipt_id=None,
calc_version=1,
calc_type=1,
business_id=0,
business_no="t_个人户",
personnel_id=manager["id"],
personnel_name=manager["personnel_name"],
role_code="sales_manager",
customer_type="manager_personal",
product_category="mixed",
receipt_date=date(2026, 10, 15),
rate_text="0",
performance_amount=Decimal("100.00"),
barter_applied=0,
payable_amount=Decimal("0"),
settlement_month="2026-10",
is_probation=0,
)
)
session.commit()
def _manager_share() -> str:
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
assert len(items) == 1
return items[0]["performance_sum"]
# 默认(开关 0=不计入):基数 39.11 → 6.84(与既有金标准一致)
assert _manager_share() == "6.84"
# 开关 1(立即生效,月末 2026-10-31 取参命中):139.11×12.5% + ×5% = 24.34425
resp = client.post(
"/api/v1/system/params/changes",
json={
"change_type": 1,
"remark": "用例:#12 开关计入",
"items": [
{
"param_code": "manager.personal.included",
"dimension_value": "",
"value": "1",
}
],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
assert _manager_share() == "24.34"
class TestAdjustment:
"""调整单:已锁定月份补发/扣减的唯一通道。"""
def test_新建调整单_开关关闭即生效(self, client: TestClient, admin: dict) -> None:
# 显式关开关(用例不依赖种子默认值)
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
person = _person(client, admin["headers"])
resp = client.post(
f"{_SETTLEMENTS}/adjustments",
json={
"settlement_month": "2026-10",
"personnel_id": person["id"],
"direction": 1,
"amount": "500.00",
"reason": "补发差额",
},
headers=admin["headers"],
)
# 守 ENABLE_CODE_BY_TYPE 漏登记 type=3:修复前此处恒 400「未知的审批对象类型」
assert resp.status_code == 200, resp.text
out = resp.json()["data"]
assert out["status"] == 2 # 已生效
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
person = _person(client, admin["headers"])
base = {
"settlement_month": "2026-10",
"personnel_id": person["id"],
"direction": 1,
"reason": "金额校验用例",
}
for bad in ("-500.00", "0", "0.00", "abc", "1e3"):
resp = client.post(
f"{_SETTLEMENTS}/adjustments",
json={**base, "amount": bad},
headers=admin["headers"],
)
assert resp.status_code == 400, f"amount={bad!r} 应 400:{resp.text}"
class TestLockInterception:
"""锁定后源数据写路径全量拦截(回款编辑/删除、发货增改删、核算/重算)。"""
def _lock_scene(self, client: TestClient, admin: dict) -> tuple[dict, dict, dict, dict]:
"""已核算+未核算各一笔回款、两张 10 月发货单,然后锁定 2026-10。
返回 (月结, 业务, 未核算回款, 无回款引用的发货单)。
"""
biz = _calc_one(client, admin["headers"])
resp = client.post(
_RECEIPTS,
json={"business_id": biz["id"], "receipt_date": "2026-10-22", "amount": "100.00"},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
unsettled = resp.json()["data"]
resp = client.post(
_SHIPMENTS,
json={
"business_id": biz["id"],
"ship_date": "2026-10-05",
"items": [{"product_name": "t_发2", "quantity": "1", "sale_price": "1.00"}],
},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
free_shipment = resp.json()["data"]
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
settlement = resp.json()["data"]
resp = client.post(f"{_SETTLEMENTS}/{settlement['id']}/submit", headers=admin["headers"])
assert resp.status_code == 200, resp.text
assert resp.json()["data"]["status"] == 3
return settlement, biz, unsettled, free_shipment
def test_锁定后_未核算回款禁改禁删(self, client: TestClient, admin: dict) -> None:
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
resp = client.put(
f"{_RECEIPTS}/{unsettled['id']}",
json={
"business_id": biz["id"],
"receipt_date": "2026-10-23",
"amount": "88.00",
},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
resp = client.delete(f"{_RECEIPTS}/{unsettled['id']}", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
def test_锁定后_发货增改删400_非锁定月放行(self, client: TestClient, admin: dict) -> None:
_settlement, biz, _unsettled, free_shipment = self._lock_scene(client, admin)
items = [{"product_name": "t_发x", "quantity": "1", "sale_price": "1.00"}]
# 新增:落锁定月 400
resp = client.post(
_SHIPMENTS,
json={"business_id": biz["id"], "ship_date": "2026-10-08", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
# 新增:非锁定月放行(拦截不误伤其它月份)
resp = client.post(
_SHIPMENTS,
json={"business_id": biz["id"], "ship_date": "2026-11-02", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
nov = resp.json()["data"]
# 编辑:把非锁定月的发货单改入锁定月 400
resp = client.put(
f"{_SHIPMENTS}/{nov['id']}",
json={"ship_date": "2026-10-15", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
# 编辑:原月即锁定月 400
resp = client.put(
f"{_SHIPMENTS}/{free_shipment['id']}",
json={"ship_date": "2026-10-06", "items": items},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
# 删除:锁定月的发货单 400(拦截先于「被回款引用」判定)
resp = client.delete(f"{_SHIPMENTS}/{free_shipment['id']}", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
def test_锁定后_首算与重算400(self, client: TestClient, admin: dict) -> None:
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
from app.models import Receipt
with get_session_factory()() as session:
settled_id = int(
session.execute(
select(Receipt.id).where(
Receipt.business_id == biz["id"],
Receipt.settlement_status == 1,
)
).scalar_one()
)
resp = client.post(
f"{_CALC}/receipts/{settled_id}/recalc",
json={"reason": "锁定月重算"},
headers=admin["headers"],
)
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
assert resp.status_code == 400, resp.text
assert "锁定" in resp.json()["message"]

View File

@ -0,0 +1,28 @@
"""核算落库辅助纯函数测试。
守的是:金额两位固化的舍入模式 —— quantize 缺省是上下文 ROUND_HALF_EVEN
(银行家舍入),x.xx5 会舍成偶数尾数,与业务四舍五入口径差 1 分
(docstring 声称 HALF_UP,必须用测试锁死实际行为)。
"""
from decimal import Decimal
from app.services.calc_service import _q2
class TestQ2:
"""金额两位小数(HALF_UP)。"""
def test_半进位一律入(self) -> None:
# 缺省 ROUND_HALF_EVEN 会舍成 2.34(4 是偶数)
assert _q2(Decimal("2.345")) == Decimal("2.35")
def test_负数半进位远离零(self) -> None:
assert _q2(Decimal("-2.345")) == Decimal("-2.35")
def test_普通进位不变(self) -> None:
assert _q2(Decimal("39.111111")) == Decimal("39.11")
assert _q2(Decimal("39.115000")) == Decimal("39.12")
def test_None直通(self) -> None:
assert _q2(None) is None

View File

@ -61,9 +61,14 @@ class TestSalespersonRate:
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
assert _sp_rate(361) == Fraction(4, 1000)
def test_第1天按公式为899比90000_非取整为10(self) -> None:
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
assert _sp_rate(1) == Fraction(899, 90000)
def test_首日取整10千分_图示口径裁决(self) -> None:
# 需规图示「第 1 天 10‰」2026-10-19 裁决覆盖公式(公式值 9.9889‰ 作废):
# 首日不递减、直接取起始值;第 2 天起才进入公式段
assert _sp_rate(1) == Fraction(10, 1000)
def test_第2天起进入公式递减段(self) -> None:
# 10‰ − 2×4/360‰ = 9.9778‰(首日取整裁决只影响第 1 天,第 2 天起不变)
assert _sp_rate(2) == Fraction(10, 1000) - Fraction(2 * 4, 360 * 1000)
def test_第180天线性段中点千分之8(self) -> None:
assert _sp_rate(180) == Fraction(8, 1000)

View File

@ -0,0 +1,29 @@
"""月结取参锚点测试。
守的是:月结参数快照按**所属月月末**解析,而不是生成日 —— 用 date.today()
取参会让补生成/重新生成历史月份的经理分成与月工资随操作日期漂移,
同一月份两次生成金额不同(违背「参数按生效日期时点化」红线)。
"""
from datetime import date
from app.services.settlement_service import _month_end
class TestMonthEnd:
"""YYYY-MM → 所属月最后一天(含大小月与闰年)。"""
def test_大月31日(self) -> None:
assert _month_end("2026-10") == date(2026, 10, 31)
def test_小月30日(self) -> None:
assert _month_end("2026-09") == date(2026, 9, 30)
def test_二月平年28日(self) -> None:
assert _month_end("2026-02") == date(2026, 2, 28)
def test_二月闰年29日(self) -> None:
assert _month_end("2028-02") == date(2028, 2, 29)
def test_跨年十二月(self) -> None:
assert _month_end("2026-12") == date(2026, 12, 31)

View File

@ -874,4 +874,4 @@ CREATE TABLE alembic_version (
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
INSERT INTO alembic_version (version_num) VALUES ('3bca400421c6');
INSERT INTO alembic_version (version_num) VALUES ('ba0217f5091b');

View File

@ -93,19 +93,19 @@ CREATE TABLE performance_ledgers (
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_performance_ledgers PRIMARY KEY (id),
CONSTRAINT fk_performance_ledger_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT fk_performance_ledger_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT,
CONSTRAINT fk_performance_ledger_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
CONSTRAINT uk_performance_ledger_receipt_id_calc_version UNIQUE (receipt_id, calc_version)
CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT,
CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
CONSTRAINT uk_performance_ledgers_receipt_id_calc_version UNIQUE (receipt_id, calc_version)
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)' COLLATE utf8mb4_general_ci;
CREATE INDEX idx_performance_ledger_business_id ON performance_ledgers (business_id);
CREATE INDEX idx_performance_ledgers_business_id ON performance_ledgers (business_id);
CREATE INDEX idx_performance_ledger_month_active ON performance_ledgers (settlement_month, is_active);
CREATE INDEX idx_performance_ledgers_month_active ON performance_ledgers (settlement_month, is_active);
CREATE INDEX idx_performance_ledger_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
CREATE INDEX idx_performance_ledger_personnel_id ON performance_ledgers (personnel_id);
CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id);
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';

View File

@ -0,0 +1,13 @@
-- Running upgrade c7d2e5a81f30 -> ba0217f5091b
SET @uk_exists := (SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'business_secondary_allocations' AND CONSTRAINT_NAME = 'uk_business_secondary_allocations_business_personnel');
SET @ddl := IF(@uk_exists = 0, 'ALTER TABLE business_secondary_allocations ADD CONSTRAINT uk_business_secondary_allocations_business_personnel UNIQUE (business_id, personnel_id)', 'DO 0');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE alembic_version SET version_num='ba0217f5091b' WHERE alembic_version.version_num = 'c7d2e5a81f30';

View File

@ -38,6 +38,8 @@ services:
restart: unless-stopped
environment:
TZ: Asia/Shanghai
# 健康检查脚本在容器内展开(密码不进宿主进程命令行,模式同 mysql)
REDIS_PASSWORD: ${REDIS_PASSWORD}
# appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:

View File

@ -3,13 +3,14 @@
> 汇总当前所有「待执行 / 待答复 / 待恢复」事项,供项目例会与对外跟催使用。
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
## 一、立即待办(需项目内处理)
| # | 事项 | 状态 | 下一步动作 |
| --- | --- | --- | --- |
| 1 | **推送 feat/m3-increments(27 个提交)** | ⛔ 被远端阻塞 | 43.140.215.244:7040 的 Git 服务应用层无响应(ping 通、TCP 通、HTTP 不回包),需**服务器管理员重启 Git 服务**;恢复后执行 `git push -u origin feat/m3-increments`。本地提交完整无丢失 |
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;远端恢复后与 #1 一并处理(PR 评审或本地合并) |
| 1 | **推送 feat/m3-increments** | 🔄 远端已恢复 | 2026-10-19 实测 `git ls-remote` 连通;远端已推进至 35fe37f(V8/权限/增量备份),**本地领先 8 提交**(code-review 修复 4 提交 + #14 首日口径 + 相关测试/文档)——推送前与并行会话对账,避免重复推送 |
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;与 #1 一并处理(PR 评审或本地合并) |
## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)
@ -22,23 +23,23 @@
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 未答复前只做方案录入,不实现分摊(已按此交付) | ④ 分摊计算 | ❌ 未答复 |
| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 |
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 |
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定 |
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认 |
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊实现(88 批次)进行中 |
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ⚠️ 模糊:「做参数可调」,初始值未给 |
| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ✅ **已落实为参数(2026-10-19)**:答复「做参数可调、初始值未给」→ 按默认 0=不计入落地(`manager.personal.included`),可手工调 1=计入 |
### 中优先级(模块实现期内答复即可,9 项)
| 清单编号 | 问题 | 答复状态(2026-10-16) |
| --- | --- | --- |
| 5 | 二次分配审批开关默认值? | 🔁 **与默认不同:默认开启**,业务可关闭 |
| 7 | 月度结算审批链默认开还是关? | ✅ 默认开启,可手动关闭 |
| 5 | 二次分配审批开关默认值? | ✅ 已落实(2026-10-19,V8):默认开启 + 当日生效行;调整单/二次分配/月结三类审批开关均已可读可调 |
| 7 | 月度结算审批链默认开还是关? | ✅ 已落实(2026-10-19,V8):默认开启;财务节点通过仅流转,终审(领导)通过才锁定月结 |
| 10 | 「风险预警」包含哪些情形与阈值? | ✅ 方向确认:参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步 |
| 11 | 「待办事项」范围? | ✅ 确认:待审批+同步失败+回款未指定批次起步 |
| 13 | 支付环节 5 项默认值顺手确认(起付 60%/R×R/预付 10‰/抵顶 80%/月工资口径) | ✅ 确认按默认执行 |
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | 🔁 **与默认不同:按人员档案逐人维护**(用 `personnel.salary_standard`,撤 `probation.salary.basic` 参数) |
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 |
@ -46,16 +47,17 @@
| 清单编号 | 问题 | 答复状态(2026-10-16) |
| --- | --- | --- |
| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | 🔁 **与默认不同:首日整 10‰**。实现口径待追问:A 仅首日覆盖(保「360 天=6‰」锚点,推荐)/ B 整体平移(破坏锚点) |
| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 查看可、修改仅管理员 |
| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | ✅ 已落实(2026-10-19,按 A 口径):首日整 10‰ 特判、次日起按公式(保「360 天=6‰」锚点);B 口径(整体平移)追问保留 |
| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 已落实(2026-10-19):查看放开登录用户、修改仅管理员 |
| 18 | 企业户 02-17 之前的业务是否按 2‰ 回溯? | ✅ 确认按回溯 |
| 19 | 备份保留周期与恢复目标? | 🔁 **新增要求:每日全量 + 有更新时增量补充**(现三脚本仅全量) |
| 19 | 备份保留周期与恢复目标? | ✅ 已落实(2026-10-19):全量 + binlog 增量归档补位 |
| 21 | 退货/红字是否冲减绩效? | ✅ 当期冲减(未结算月),已结算走调整单 |
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表)
> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。
> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
@ -81,14 +83,15 @@
| 里程碑 | 内容 | 前置条件 |
| --- | --- | --- |
| M3 剩余 | ④ 核算引擎 + 月结/调整单/负数工资 + ⑦ 月结审批对象接入 | #12(基数参数初始值)、#20(含税口径)、#1–#3(二次分摊)落定后按同粒度另出计划;#14 首日 10‰ 口径(A/B)确认后随本批实现 |
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 交付后 |
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;**余**:二次分摊实现(#1–#3 答复空白)、业务答复回填批次收口(88 会话) | #20(含税口径)、#1–#3 答复后按同粒度推进 |
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
| 杂项收口(低优) | 超长函数 17 处收敛、散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
## 五、文档待办(低优)
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.3**(收编业务方口径修订,生成脚本 build_tplus_xlsx.py 同步对齐)
- 新口径待落实(业务答复与默认不同的 4 项):#5 审批开关默认开启(核对 M3 参数种子)、#14 首日整 10‰(④ 实现,A/B 口径待追问)、#16 试用期工资迁人员档案、#19 备份补增量能力
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4)
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可

View File

@ -40,19 +40,23 @@ const recalcTarget = ref<LedgerRow | null>(null)
const recalcReason = ref('')
function openRecalc(row: LedgerRow): void {
// 聚合行(calc_type=2)receipt_id 为 NULL,无回款可重算(按钮已限量,防御)
if (row.receipt_id == null) return
recalcTarget.value = row
recalcReason.value = ''
recalcVisible.value = true
}
async function submitRecalc(): Promise<void> {
if (!recalcTarget.value) return
const row = recalcTarget.value
if (!row || row.receipt_id == null) return
if (!recalcReason.value.trim()) {
ElMessage.warning('重算必须填写原因')
return
}
try {
await calcApi.recalcReceipt(recalcTarget.value.id, recalcReason.value.trim())
// 重算接口按回款 ID 定位(台账行主键 ≠ 回款 ID:传 row.id 会重算到错误回款)
await calcApi.recalcReceipt(row.receipt_id, recalcReason.value.trim())
ElMessage.success('重算完成(新版本已生效)')
recalcVisible.value = false
await load()
@ -75,9 +79,16 @@ async function submitRecalc(): Promise<void> {
/>
</el-form-item>
<el-form-item label="版本">
<el-select v-model="filters.is_active" style="width: 130px">
<!-- 可清空:清空=不加 is_active 过滤(axios 自动剔除 undefined),
即「全部版本」;0 精确匹配历史行,不能用来表达「含历史」 -->
<el-select
v-model="filters.is_active"
placeholder="全部版本"
clearable
style="width: 130px"
>
<el-option label="仅当前生效" :value="1" />
<el-option label="含历史版本" :value="0" />
<el-option label="仅历史版本" :value="0" />
</el-select>
</el-form-item>
<el-form-item>

View File

@ -67,9 +67,10 @@ else
fi
fi
# 4. Redis 存活(密码经宿主 docker/.env,不经命令行明文参数)
# 4. Redis 存活(密码经容器内环境变量展开,不出现在宿主进程命令行;
# 写法同 backup_full.sh 的 MYSQL_ROOT_PASSWORD)
if [[ -n "${REDIS_PASSWORD:-}" ]]; then
if docker exec ys-redis-dev redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping 2>/dev/null | grep -q PONG; then
if docker exec ys-redis-dev sh -c 'redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping' 2>/dev/null | grep -q PONG; then
pass "Redis 响应 PONG"
else
warn "Redis ping 无响应(检查 REDIS_PASSWORD 或容器状态)"

View File

@ -61,8 +61,16 @@ docker exec "$CONTAINER" sh -c \
gunzip -c "$LATEST" | docker exec -i "$CONTAINER" sh -c \
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$DRILL_DB\""
# 3. 行数核对:关键业务表
TABLES=(users roles user_roles param_definitions param_values businesses business_items personnel products customers shipments shipment_items receipts approval_orders approval_steps system_settings)
# 3. 行数核对:**全部业务表**(新增表必须同步补入本清单——备份漏表时
# 演练要能发现;alembic_version 不列,行数恒 1 无核对价值)
TABLES=(
adjustment_orders approval_orders approval_steps business_items business_roles
business_secondary_allocations business_status_logs businesses customer_rating_grades
customers login_logs monthly_settlements operation_logs ownership_change_logs
param_change_batches param_definitions param_values performance_ledgers permissions
personnel personnel_plans personnel_regions products receipts role_permissions roles
settlement_items shipment_items shipments system_settings user_roles users
)
echo ""
echo "======== 行数对比(源库 vs 演练库)========"
@ -73,7 +81,12 @@ for T in "${TABLES[@]}"; do
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $MYSQL_DATABASE.$T;\" 2>/dev/null" || echo "?")
DST=$(docker exec "$CONTAINER" sh -c \
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $DRILL_DB.$T;\" 2>/dev/null" || echo "?")
if [[ "$SRC" == "$DST" ]]; then
if [[ "$SRC" == "?" || "$DST" == "?" ]]; then
# 查询失败(表缺失/权限/客户端故障)不可与「数据一致」混淆:?
# 与 ? 相等会误报可恢复——备份真的漏表时演练必须红
MARK="✗"
ALL_OK=0
elif [[ "$SRC" == "$DST" ]]; then
MARK="✓"
else
MARK="✗"