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:
1 parent
7a460cb682
commit
76ded6d5ec
15 files changed
+495
-48
No files matched your search
@@ -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")))
|
||||
|
||||
Reference in new issue
Block a user