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>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-19 11:59:49 +08:00
1 parent 7a460cb682
commit 76ded6d5ec
15 files changed
+495 -48

No files matched your search

+7 -1
View File
@@ -112,6 +112,12 @@ def calc_receipt(
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("回款关联的业务不存在")
@@ -408,7 +414,7 @@ def recalc_receipt(
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
的拦截在月结(T5)接入后补入本函数。
的拦截在 calc_receipt 内(核算与重算同口径)。
"""
if not reason or not reason.strip():
raise BadRequestError("重算必须填写原因")
+10
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)
+67 -23
View File
@@ -9,6 +9,8 @@
"""
import logging
from calendar import monthrange
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
@@ -25,14 +27,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,
@@ -81,6 +86,13 @@ def generate(
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
# 草稿/已驳回:允许重新生成(清明细重算)
# 重生成前先释放上一轮的固化标记:台账行 settlement_id 回填后,
# 「未入结」过滤会把它们永久排除在候选集外;旧聚合行也要作废,
# 否则本轮重建的聚合行与旧行重复计入(两者都必须在 _confirm_scope 前)
if existing is not None:
settlement_repository.clear_ledger_settlement_id(session, existing.id)
performance_ledger_repository.deactivate_month_aggregates(session, month)
ledger_rows = _confirm_scope(session, month)
if not ledger_rows:
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
@@ -128,7 +140,7 @@ def generate(
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"])
mode = _salary_mode_of(session, bucket["role_code"], month)
net = (
monthly_salary(bucket["payable_sum"], basic, mode)
if person and person.is_probation != 1
@@ -137,7 +149,7 @@ def generate(
note = None
if person and person.is_probation == 1:
note = (
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session)} 元部分"
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分"
"年底一次性结算"
)
items.append(
@@ -174,9 +186,19 @@ def generate(
)
settlement.person_count = len({i.personnel_id for i in items})
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
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 submit(
@@ -214,13 +236,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:
@@ -275,7 +300,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(
@@ -283,6 +312,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),
)
@@ -322,13 +352,13 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
from datetime import date
from app.repositories import business_role_repository
from app.services.param_service import parse_fraction, resolve_snapshot
# 取参锚定所属月月末(不是生成日):补生成历史月份时结果不随操作日期漂移
param_date = _month_end(month)
aggregates: list = []
snapshot = resolve_snapshot(session, date.today())
snapshot = resolve_snapshot(session, param_date)
# 参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
# manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)
team_text = snapshot.get("manager.share.team")
@@ -362,9 +392,13 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
company_pct=company_pct,
)
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
aggregates.append(
# 聚合行必须落库(模型约定的 calc_type=2 台账行):月结明细的
# ledger_ids 与报表下钻都要能从台账反查来源
row = 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"],
@@ -373,7 +407,7 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
role_code="sales_manager",
customer_type="personal",
product_category="mixed",
receipt_date=date.today(),
receipt_date=param_date,
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
rate_text=f"聚合:{team_text}%+{company_text}%",
converted_profit=None,
@@ -386,28 +420,38 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
"team_base": str(bucket["performance"]),
"company_base": str(company_base),
"members": member_names,
"params_as_of": str(param_date),
"note": "#12 暂口径:基数不含经理个人户业绩;9.8 方案公式",
},
)
),
)
aggregates.append(row)
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")))
+15
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("已被回款批次指定,请先解除回款的批次指定")