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:
liuwenxin 2026-10-19 11:59:49 +08:00
parent 7a460cb682
commit 76ded6d5ec
15 changed files with 495 additions and 48 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 import sqlalchemy as sa
from alembic import op from alembic import op
revision = "c7d2e5a81f30" revision: str = "c7d2e5a81f30"
down_revision = "3bca400421c6" down_revision: str | None = "3bca400421c6"
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View File

@ -4,6 +4,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import PerformanceLedger from app.models import PerformanceLedger
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
from app.schemas.calc import LedgerQuery from app.schemas.calc import LedgerQuery
@ -73,6 +74,27 @@ def mark_inactive(session: Session, row: PerformanceLedger) -> None:
session.flush() 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: def next_version(session: Session, receipt_id: int) -> int:
"""某回款的下一个核算版本号(max+1;无行时 1)。""" """某回款的下一个核算版本号(max+1;无行时 1)。"""
current = session.execute( current = session.execute(

View File

@ -92,3 +92,21 @@ def update_ledger_settlement_id(
.values(settlement_id=settlement_id) .values(settlement_id=settlement_id)
) )
session.flush() 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

@ -112,6 +112,12 @@ def calc_receipt(
elif receipt.settlement_status == SETTLEMENT_DONE: elif receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)") 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) business = session.get(Business, receipt.business_id, with_for_update=True)
if business is None: if business is None:
raise BadRequestError("回款关联的业务不存在") raise BadRequestError("回款关联的业务不存在")
@ -408,7 +414,7 @@ def recalc_receipt(
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。 """重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定 薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
的拦截在月结(T5)接入后补入本函数。 的拦截在 calc_receipt 内(核算与重算同口径)。
""" """
if not reason or not reason.strip(): if not reason or not reason.strip():
raise BadRequestError("重算必须填写原因") raise BadRequestError("重算必须填写原因")

View File

@ -291,6 +291,12 @@ def update_receipt(
if receipt.settlement_status == SETTLEMENT_DONE: if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止修改") raise BadRequestError("已核算回款禁止修改")
business = _ensure_business_writable(session, receipt.business_id, "修改") 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 # 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join # 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
shipment_no, ship_date = _ensure_shipment_of_business( shipment_no, ship_date = _ensure_shipment_of_business(
@ -342,6 +348,10 @@ def delete_receipt(
if receipt.settlement_status == SETTLEMENT_DONE: if receipt.settlement_status == SETTLEMENT_DONE:
raise BadRequestError("已核算回款禁止删除") raise BadRequestError("已核算回款禁止删除")
business = _ensure_business_writable(session, receipt.business_id, "删除") 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 batch_no = receipt.receipt_batch_no
receipt_repository.delete(session, receipt) receipt_repository.delete(session, receipt)

View File

@ -9,6 +9,8 @@
""" """
import logging import logging
from calendar import monthrange
from datetime import date
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select from sqlalchemy import select
@ -25,14 +27,17 @@ from app.models import (
User, User,
) )
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT 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 ( from app.models.settlement import (
SETTLEMENT_STATUS_APPROVED, SETTLEMENT_STATUS_APPROVED,
SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_DRAFT,
SETTLEMENT_STATUS_PENDING, SETTLEMENT_STATUS_PENDING,
SETTLEMENT_STATUS_REJECTED, SETTLEMENT_STATUS_REJECTED,
) )
from app.repositories import settlement_repository from app.repositories import performance_ledger_repository, settlement_repository
from app.schemas.settlement import ( from app.schemas.settlement import (
SettlementDetailOut, SettlementDetailOut,
SettlementOut, SettlementOut,
@ -81,6 +86,13 @@ def generate(
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成") 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) ledger_rows = _confirm_scope(session, month)
if not ledger_rows: if not ledger_rows:
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)") raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
@ -128,7 +140,7 @@ def generate(
for (_, _), bucket in sorted(by_person.items()): for (_, _), bucket in sorted(by_person.items()):
person = session.get(Personnel, bucket["personnel_id"]) person = session.get(Personnel, bucket["personnel_id"])
basic = person.salary_standard if person else None 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 = ( net = (
monthly_salary(bucket["payable_sum"], basic, mode) monthly_salary(bucket["payable_sum"], basic, mode)
if person and person.is_probation != 1 if person and person.is_probation != 1
@ -137,7 +149,7 @@ def generate(
note = None note = None
if person and person.is_probation == 1: if person and person.is_probation == 1:
note = ( note = (
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session)} 元部分" f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分"
"年底一次性结算" "年底一次性结算"
) )
items.append( items.append(
@ -174,9 +186,19 @@ def generate(
) )
settlement.person_count = len({i.personnel_id for i in items}) settlement.person_count = len({i.personnel_id for i in items})
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE} 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() session.commit()
logger.info("月结生成 | month=%s | items=%s", month, len(items)) logger.info("月结生成 | month=%s | items=%s", month, len(items))
return _detail_of(session, settlement) return detail
def submit( def submit(
@ -214,13 +236,16 @@ def submit(
settlement.approval_order_no = order.order_no settlement.approval_order_no = order.order_no
if order.status == SETTLEMENT_STATUS_APPROVED: if order.status == SETTLEMENT_STATUS_APPROVED:
mark_locked(session, settlement, operator=operator) mark_locked(session, settlement, operator=operator)
session.commit()
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month) 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() session.commit()
return _detail_of(session, settlement) return detail
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None: 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: def _confirm_scope(session: Session, month: str) -> list:
"""确认回款口径(#8 策略点):本月有效台账行(未入结)。""" """确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
settlement_id,不排除会在重生成时与新聚合行重复计入。
"""
from app.models import PerformanceLedger from app.models import PerformanceLedger
return list( return list(
@ -283,6 +312,7 @@ def _confirm_scope(session: Session, month: str) -> list:
select(PerformanceLedger) select(PerformanceLedger)
.where( .where(
PerformanceLedger.settlement_month == month, PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
PerformanceLedger.is_active == 1, PerformanceLedger.is_active == 1,
PerformanceLedger.settlement_id.is_(None), 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["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name) bucket["names"].add(row.personnel_name)
from datetime import date
from app.repositories import business_role_repository from app.repositories import business_role_repository
from app.services.param_service import parse_fraction, resolve_snapshot from app.services.param_service import parse_fraction, resolve_snapshot
# 取参锚定所属月月末(不是生成日):补生成历史月份时结果不随操作日期漂移
param_date = _month_end(month)
aggregates: list = [] aggregates: list = []
snapshot = resolve_snapshot(session, date.today()) snapshot = resolve_snapshot(session, param_date)
# 参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5, # 参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
# manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍) # manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)
team_text = snapshot.get("manager.share.team") 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, company_pct=company_pct,
) )
member_names = "、".join(sorted(bucket["names"])) or "(无成员)" member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
aggregates.append( # 聚合行必须落库(模型约定的 calc_type=2 台账行):月结明细的
# ledger_ids 与报表下钻都要能从台账反查来源
row = performance_ledger_repository.add(
session,
PerformanceLedger( PerformanceLedger(
receipt_id=None, receipt_id=None,
calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重)
calc_type=CALC_TYPE_MANAGER_AGGREGATE, calc_type=CALC_TYPE_MANAGER_AGGREGATE,
business_id=bucket["business_id"], business_id=bucket["business_id"],
business_no=bucket["business_no"], business_no=bucket["business_no"],
@ -373,7 +407,7 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
role_code="sales_manager", role_code="sales_manager",
customer_type="personal", customer_type="personal",
product_category="mixed", product_category="mixed",
receipt_date=date.today(), receipt_date=param_date,
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真) # 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
rate_text=f"聚合:{team_text}%+{company_text}%", rate_text=f"聚合:{team_text}%+{company_text}%",
converted_profit=None, converted_profit=None,
@ -386,28 +420,38 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
"team_base": str(bucket["performance"]), "team_base": str(bucket["performance"]),
"company_base": str(company_base), "company_base": str(company_base),
"members": member_names, "members": member_names,
"params_as_of": str(param_date),
"note": "#12 暂口径:基数不含经理个人户业绩;9.8 方案公式", "note": "#12 暂口径:基数不含经理个人户业绩;9.8 方案公式",
}, },
) ),
) )
aggregates.append(row)
return aggregates return aggregates
def _salary_mode_of(session: Session, role_code: str) -> str | None: def _month_end(month: str) -> date:
from datetime import 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 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 return snapshot.get("salary.basic.mode", role_code=role_code) or None
def _probation_threshold(session: Session) -> Decimal: def _probation_threshold(session: Session, month: str) -> Decimal:
from datetime import date
from app.services.param_service import parse_fraction, resolve_snapshot 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 from app.services.formula import to_decimal
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold"))) return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))

View File

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

View File

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

View File

@ -69,6 +69,26 @@ def _create_with_allocations(client: TestClient, headers: dict, allocations: lis
return resp.json()["data"] 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: def _detail(client: TestClient, headers: dict, business_id: int) -> dict:
resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers) resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
@ -239,6 +259,8 @@ class TestDeleteProtection:
assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"] assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None: def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None:
# 显式关开关:开启时审批单在途、删业务被 400 拦截(产品行为,另有用例覆盖)
_set_switch(client, admin["headers"], "0")
p = _person(client, admin["headers"]) p = _person(client, admin["headers"])
data = _create_with_allocations( data = _create_with_allocations(
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}] client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]

View File

@ -16,7 +16,7 @@ from fastapi.testclient import TestClient
from sqlalchemy import select from sqlalchemy import select
from app.core.db import get_session_factory 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 from tests.integration.factories import business_payload, customer
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@ -167,6 +167,15 @@ class TestGenerate:
select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10") select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10")
).scalar_one() ).scalar_one()
assert settled.person_count >= 1 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: def test_无核算记录生成400(self, client: TestClient, admin: dict) -> None:
resp = client.post( resp = client.post(
@ -369,11 +378,12 @@ class TestSwitchOn:
class TestManagerAggregate: class TestManagerAggregate:
"""经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。""" """经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。"""
def test_聚合金额按实际比例计入(self, client: TestClient, admin: dict) -> None: def _scene(self, client: TestClient, headers: dict) -> tuple[dict, dict]:
manager = _person(client, admin["headers"], role_code="sales_manager") """经理(带一名业务员)→ 业务 → 发货 → 已核算回款,返回 (经理, 回款)。"""
sales = _person(client, admin["headers"], manager_id=manager["id"]) manager = _person(client, headers, role_code="sales_manager")
sales = _person(client, headers, manager_id=manager["id"])
cust = customer(client, admin["headers"]) cust = customer(client, headers)
resp = client.post( resp = client.post(
_BUSINESSES, _BUSINESSES,
json=business_payload( json=business_payload(
@ -381,7 +391,7 @@ class TestManagerAggregate:
owner_personnel_id=sales["id"], owner_personnel_id=sales["id"],
items=[dict(_GOLD_ITEMS[0], product_name=f"t_聚合_{uuid.uuid4().hex[:6]}")], items=[dict(_GOLD_ITEMS[0], product_name=f"t_聚合_{uuid.uuid4().hex[:6]}")],
), ),
headers=admin["headers"], headers=headers,
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
biz = resp.json()["data"] biz = resp.json()["data"]
@ -392,7 +402,7 @@ class TestManagerAggregate:
"ship_date": "2026-10-01", "ship_date": "2026-10-01",
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}], "items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
}, },
headers=admin["headers"], headers=headers,
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
resp = client.post( resp = client.post(
@ -403,22 +413,24 @@ class TestManagerAggregate:
"amount": "20000.00", "amount": "20000.00",
"shipment_id": resp.json()["data"]["id"], "shipment_id": resp.json()["data"]["id"],
}, },
headers=admin["headers"], headers=headers,
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
receipt = resp.json()["data"] receipt = resp.json()["data"]
assert ( assert (
client.post( client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers).status_code
f"{_CALC}/receipts/{receipt['id']}/calc", headers=admin["headers"]
).status_code
== 200 == 200
) )
return manager, receipt
def test_聚合金额按实际比例计入且落库回填(self, client: TestClient, admin: dict) -> None:
manager, _receipt = self._scene(client, admin["headers"])
resp = client.post( resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"] f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
items = resp.json()["data"]["items"] out = resp.json()["data"]
items = out["items"]
manager_items = [i for i in items if i["calc_type"] == 2] manager_items = [i for i in items if i["calc_type"] == 2]
assert len(manager_items) == 1 assert len(manager_items) == 1
# 业务员绩效 39.11;经理 = 39.11×12.5% + 39.11×5% = 6.84425 → 6.84 # 业务员绩效 39.11;经理 = 39.11×12.5% + 39.11×5% = 6.84425 → 6.84
@ -427,6 +439,81 @@ class TestManagerAggregate:
assert manager_items[0]["performance_sum"] == "6.84" assert manager_items[0]["performance_sum"] == "6.84"
assert manager_items[0]["payable_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"
class TestAdjustment: class TestAdjustment:
"""调整单:已锁定月份补发/扣减的唯一通道。""" """调整单:已锁定月份补发/扣减的唯一通道。"""
@ -450,3 +537,132 @@ class TestAdjustment:
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
out = resp.json()["data"] out = resp.json()["data"]
assert out["status"] == 2 # 已生效 assert out["status"] == 2 # 已生效
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,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) CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; )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, create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT pk_performance_ledgers PRIMARY KEY (id), 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_ledgers_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_ledgers_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 fk_performance_ledgers_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 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; )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调整单'; 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';