PayrollManagementSystem/backend/app/services/settlement_service.py

775 lines
31 KiB
Python
Raw Normal View History

"""月度结算服务(M3 ④):生成快照 → 提交审批 → 通过即锁定。
确认回款口径(澄清 #8 策略点,2026-10-16 暂定):settlement_status=1 的
**有效台账行**(is_active=1、settlement_id IS NULL=未被任何月结固化),
按 settlement_month(回款日所在月)归集。答复后只改 ``_confirm_scope``。
通过即锁定(status=3 终态):锁定后源数据(回款/发货)写路径由
``ensure_month_not_locked`` 拦截;调整走调整单(T6)。
"""
import logging
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-18 23:59:49 -04:00
from datetime import date
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-18 23:25:21 -04:00
from decimal import ROUND_HALF_UP, Decimal
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
MODULE_SETTLEMENT,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.models import (
MonthlySettlement,
Personnel,
SettlementItem,
User,
)
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
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-18 23:59:49 -04:00
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,
)
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-18 23:59:49 -04:00
from app.repositories import performance_ledger_repository, settlement_repository
from app.schemas.settlement import (
SettlementDetailOut,
SettlementOut,
SettlementQuery,
)
from app.services import approval_service, audit_service, manager_share
from app.services.approval_service import AuditRecord, RequestMeta
from app.services.formula import manager_team_share, monthly_salary
logger = logging.getLogger(__name__)
# 审批对象类型引用(order_type=2 月结、3 调整单)
_SETTLEMENT_ORDER_TYPE = APPROVAL_TYPE_MONTHLY_SETTLEMENT
_SETTLEMENT_NOTE = "确认回款口径(#8 策略点):settlement_status=1 的有效台账行按回款日归月"
def generate(
session: Session,
month: str,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettlementOut:
"""生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。
- 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态
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 00:52:36 -04:00
重新生成」(_release_previous_round 解绑回填后清明细重算)
- 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见
``_build_manager_aggregates``)
本函数只做编排,各段独立私有函数(口径修改只动对应段)。
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="生成月结",
summary=f"生成 {month} 月度结算快照",
request_data={"month": month},
),
meta=meta,
):
from app.services import payroll_months
control = payroll_months.lock_month(session, month, operator)
existing = settlement_repository.get_by_month(session, month)
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 00:52:36 -04:00
_guard_regeneration(existing, month)
_release_previous_round(session, existing, month)
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-18 23:59:49 -04:00
ledger_rows = _confirm_scope(session, month)
from app.services.receipt_confirmation import ensure_confirmed
ensure_confirmed(session, [row.receipt_id for row in ledger_rows if row.receipt_id is not None])
from app.core.config import get_settings
if not ledger_rows and get_settings().payroll_allow_legacy_calculation:
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
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 00:52:36 -04:00
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)
if not items:
raise BadRequestError(f"{month} 无可发薪人员或核算记录")
from app.services.probation_service import persist_payments
persist_payments(session, settlement, items)
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 00:52:36 -04:00
_apply_totals(settlement, items)
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
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-18 23:59:49 -04:00
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id)
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
# 记成失败审计,与库中事实相反)
if control.generated_at is None:
from datetime import datetime
control.generated_at = datetime.now()
from app.core.config import get_settings
approval_enabled = get_settings().payroll_allow_legacy_calculation or (
get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 2)
)
settlement.snapshot_json = {
**settlement.snapshot_json,
"approval_required": approval_enabled,
}
if not approval_enabled:
# 2026-10-30 用户澄清:生成工资即完成,不建审批单或自动通过痕迹。
settlement.status = SETTLEMENT_STATUS_APPROVED
settlement.locked_at = control.generated_at
settlement.locked_by = control.maker_name
settlement.approval_order_no = None
settlement.snapshot_json = {
"note": "工资生成即完成并锁定,仅供查看,无需系统审核",
"approval_required": False,
}
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-18 23:59:49 -04:00
session.flush()
detail = _detail_of(session, settlement)
session.commit()
logger.info("月结生成 | month=%s | items=%s", month, len(items))
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-18 23:59:49 -04:00
return detail
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 00:52:36 -04:00
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
from sqlalchemy import delete
from app.models import ProbationPayment
session.execute(delete(ProbationPayment).where(ProbationPayment.settlement_id == existing.id))
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 00:52:36 -04:00
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 列表)。"""
from app.core.config import get_settings
if not get_settings().payroll_allow_legacy_calculation:
from app.services.salary_completion import build_items
return build_items(session, ledger_rows + aggregate_rows, month)
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 00:52:36 -04:00
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(
session: Session,
settlement_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> SettlementDetailOut:
"""提交审批:开关分流(关=自动通过并锁定;开=待财务复核)。"""
from app.core.config import get_settings
if (
not get_settings().payroll_allow_legacy_calculation
and not get_settings().payroll_approval_enabled
):
raise BadRequestError("工资生成后直接完成,仅供查看,无需系统审批")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="提交月结",
summary=f"提交月结 #{settlement_id}",
request_data={"settlement_id": settlement_id},
),
meta=meta,
):
feat(approval): 审批单内容快照与二次分配方案版本化 补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到 的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实 无处回溯。 - 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/ captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭 的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单 只读回退当前内容并显式告警,不回填历史快照 - 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为 business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认 只取生效行,业务详情新增 allocation_history - 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个 version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审) - 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取 - 月结提交取行加 for_update,防并发提交算出重复的审批单版本号 - 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00 - V11 迁移三件套同步(Alembic / db/migration / init-dev.sql) 测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。 Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 06:30:51 -04:00
settlement = settlement_repository.get_by_id(session, settlement_id, for_update=True)
if settlement is None:
raise NotFoundError("月结不存在")
if settlement.status not in (SETTLEMENT_STATUS_DRAFT, SETTLEMENT_STATUS_REJECTED):
raise BadRequestError("仅草稿或已驳回状态可提交")
try:
order = approval_service.submit_order(
session,
order_type=_SETTLEMENT_ORDER_TYPE,
ref_id=settlement.id,
refer_no=f"JS{settlement.settlement_month.replace('-', '')}",
submitter=operator,
# 月结明细在生成时已固化;提交时快照让复核者看到的是送审版本,
# 而不是审批期间若发生重生成后的内容
snapshot=approval_service.settlement_content(session, settlement.id),
)
except IntegrityError as exc:
# version_no 唯一键的并发冲突(提交前已锁月结行,此项理论不可达,
# 留作 DB 兜底):审计上下文会统一回滚,这里只把 1062 翻译成
# 可重试的业务提示,其余完整性错误原样上抛
if is_duplicate_entry(exc):
raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc
raise
settlement.approval_order_no = order.order_no
if order.status == SETTLEMENT_STATUS_APPROVED:
mark_locked(session, settlement, operator=operator)
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
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-18 23:59:49 -04:00
else:
settlement.status = SETTLEMENT_STATUS_PENDING
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-18 23:59:49 -04:00
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的
# 月结记成失败审计,与库中事实相反)
session.flush()
detail = _detail_of(session, settlement)
session.commit()
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-18 23:59:49 -04:00
return detail
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
"""审批通过钩子(由 approval_service.act 回调):置已审定+锁定信息。"""
from datetime import datetime
settlement.status = SETTLEMENT_STATUS_APPROVED
settlement.locked_at = datetime.now()
settlement.locked_by = operator.username
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
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-18 23:25:21 -04:00
"""审批驳回钩子:回草稿(可修改后重提)。
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
"""
settlement.status = SETTLEMENT_STATUS_DRAFT
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-18 23:25:21 -04:00
settlement.locked_at = None
settlement.locked_by = None
def ensure_month_not_locked(session: Session, month: str) -> None:
"""锁定拦截(回款/发货写路径调用):单据日期所在月已审定 → 400。"""
settlement = settlement_repository.get_by_month(session, month)
if settlement is not None and settlement.status == SETTLEMENT_STATUS_APPROVED:
raise BadRequestError(f"{month} 已月结锁定,源数据不可修改(调整走调整单)")
def get_detail(session: Session, settlement_id: int) -> SettlementDetailOut:
settlement = settlement_repository.get_by_id(session, settlement_id)
if settlement is None:
raise NotFoundError("月结不存在")
return _detail_of(session, settlement)
def list_page(session: Session, query: SettlementQuery):
from app.core.response import PageResult
from app.schemas.settlement import SettlementOut
rows = settlement_repository.list_settlements(session, query)
total = settlement_repository.count_settlements(session, query)
return PageResult[SettlementOut](
records=[SettlementOut.model_validate(r) for r in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
# ---------- 内部 ----------
def _confirm_scope(session: Session, month: str) -> list:
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-18 23:59:49 -04:00
"""确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
settlement_id,不排除会在重生成时与新聚合行重复计入。
"""
from app.models import PerformanceLedger
return list(
session.execute(
select(PerformanceLedger)
.where(
PerformanceLedger.settlement_month == month,
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-18 23:59:49 -04:00
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
PerformanceLedger.is_active == 1,
PerformanceLedger.settlement_id.is_(None),
)
.order_by(PerformanceLedger.id)
).scalars()
)
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
"""
return manager_share.manager_share_params(session, month)
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 00:52:36 -04:00
def _collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
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 00:52:36 -04:00
def _aggregate_row(
session: Session,
manager,
bucket: dict,
company_base: Decimal,
share: manager_share.ManagerShareParams,
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 00:52:36 -04:00
):
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
from app.models import PerformanceLedger
from app.services.probation_service import on_date
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 00:52:36 -04:00
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=int(on_date(manager, share.param_date)),
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 00:52:36 -04:00
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,
# 说明写给业务人员看:说清这一笔的基数是怎么凑出来的
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 00:52:36 -04:00
"note": (
f"团队分成:基数{'含' if share.include_manager_personal else '不含'}"
"经理本人名下业绩,按本团队与全公司两级比例合计计提"
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 00:52:36 -04:00
),
},
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 00:52:36 -04:00
),
)
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 00:52:36 -04:00
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
share = _manager_share_params(session, month)
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
aggregates: list = []
for manager_id, bucket in managers.items():
manager = session.get(Personnel, manager_id)
if manager is None:
continue
role = (
business_role_repository.get_by_id(session, manager.business_role_id)
if manager.business_role_id
else None
)
frozen_manager = any(
(r.snapshot_json or {}).get("engine") == "FIFO"
and (r.snapshot_json or {}).get("manager_id") == manager_id
for r in ledger_rows
)
if not frozen_manager and (role is None or role.role_code != "sales_manager"):
continue # 非营销经理的上级不参与聚合
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 00:52:36 -04:00
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
return aggregates
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-18 23:59:49 -04:00
def _month_end(month: str) -> date:
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
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-18 23:59:49 -04:00
"""
return manager_share.month_end(month)
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-18 23:59:49 -04:00
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
from app.services.param_service import resolve_snapshot
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-18 23:59:49 -04:00
snapshot = resolve_snapshot(session, _month_end(month))
return snapshot.get("salary.basic.mode", role_code=role_code) or None
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-18 23:59:49 -04:00
def _probation_threshold(session: Session, month: str) -> Decimal:
from app.services.param_service import parse_fraction, resolve_snapshot
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-18 23:59:49 -04:00
snapshot = resolve_snapshot(session, _month_end(month))
from app.services.formula import to_decimal
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))
def _detail_of(session: Session, settlement: MonthlySettlement) -> SettlementDetailOut:
items = settlement_repository.list_items(session, settlement.id)
detail = SettlementDetailOut.model_validate(settlement)
from app.schemas.settlement import SettlementItemOut
detail.items = [SettlementItemOut.model_validate(i) for i in items]
return detail
def create_allocation_adjustment(session, payload, *, operator, meta=None):
"""校验已生成工资的原台账,并将分配差额转换为当月调整单。"""
from app.models import PerformanceLedger
from app.schemas.settlement import AdjustmentCreateIn
from app.services import payroll_months
old = session.get(PerformanceLedger, payload.ledger_id)
if old is None or old.settlement_id is None:
raise BadRequestError("请选择已生成工资的原台账")
data = AdjustmentCreateIn(
settlement_month=payroll_months.current_month(),
personnel_id=payload.personnel_id,
direction=payload.direction,
amount=payload.amount,
reason=payload.reason,
source_ledger_id=old.id,
)
return create_adjustment(session, data, operator=operator, meta=meta)
def create_adjustment(
session: Session,
payload,
*,
operator: User,
meta: RequestMeta | None = None,
):
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
from app.core.constants import NO_PREFIX_ADJUSTMENT
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-18 23:25:21 -04:00
from app.models import AdjustmentOrder
from app.services.no_generator import next_no
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 00:52:36 -04:00
amount = _adjustment_amount(payload)
from app.core.config import get_settings
from app.models import PerformanceLedger
from app.services import payroll_months
source_id = getattr(payload, "source_ledger_id", None)
source = session.get(PerformanceLedger, source_id) if source_id else None
if source_id and (source is None or source.settlement_id is None):
raise BadRequestError("分配调整必须引用已生成工资的原台账")
if source_id:
from app.models import PayrollInput
snapshot = source.snapshot_json or {}
if source.calc_type != 1 or source.is_active != 1 or snapshot.get("engine") != "FIFO":
raise BadRequestError("分配调整必须引用有效的正式FIFO单笔台账")
original = session.get(PayrollInput, snapshot.get("input_id"))
if original is None:
raise BadRequestError("原台账的归属依据缺失")
allowed = {source.personnel_id}
latest = session.scalar(
select(PayrollInput)
.where(PayrollInput.business_id == source.business_id)
.order_by(PayrollInput.version.desc())
.limit(1)
)
values = (latest or original).data_json
business_key = snapshot.get("event", {}).get("business_key")
for handover in values.get("facts", {}).get("handovers", []):
if handover.get("business_key") == business_key:
for field in ("from_personnel_id", "to_personnel_id"):
target = (
values.get("maps", {}).get("personnel", {}).get(str(handover.get(field)))
)
if target is not None:
allowed.add(target)
if payload.personnel_id not in allowed:
raise BadRequestError("调整人员不属于该业务已登记的归属或交接范围")
adjustment_month = (
payroll_months.current_month()
if source_id or not get_settings().payroll_allow_legacy_calculation
else payload.settlement_month
)
person = session.get(Personnel, payload.personnel_id)
if person is None:
raise BadRequestError("调整人员不存在")
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_SETTLEMENT,
action="新建调整单",
summary=f"调整单 {payload.direction} {payload.amount} 元({person.personnel_name})",
request_data=payload.model_dump(),
),
meta=meta,
):
order = AdjustmentOrder(
order_no=next_no(
session,
model=AdjustmentOrder,
column_name="order_no",
prefix=NO_PREFIX_ADJUSTMENT,
),
settlement_month=adjustment_month,
adjustment_type="ALLOCATION" if source_id else "GENERAL",
source_ledger_id=source_id,
attribution_json={
"business_id": source.business_id,
"manager_id": (source.snapshot_json or {}).get("manager_id"),
"original_personnel_id": source.personnel_id,
}
if source
else None,
personnel_id=payload.personnel_id,
personnel_name=person.personnel_name,
direction=payload.direction,
amount=amount,
reason=payload.reason.strip(),
submitter_id=operator.id,
submitter_name=operator.username,
)
session.add(order)
session.flush()
if get_settings().payroll_allow_legacy_calculation or (
get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 3)
):
_wire_adjustment_approval(session, order, operator)
else:
from app.models.settlement import ADJUSTMENT_STATUS_EFFECTIVE
order.status = ADJUSTMENT_STATUS_EFFECTIVE
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 00:52:36 -04:00
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
session.commit()
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 00:52:36 -04:00
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,
feat(approval): 审批单内容快照与二次分配方案版本化 补齐 code-review 遗留 2 项:审批单此前只固化 ref_id/refer_no 指针,复核者看到 的是业务当前内容;二次分配方案在编辑替换与驳回清空时一律物理删除,方案事实 无处回溯。 - 审批单加 version_no 与 snapshot_json(信封 schema_version/order_type/ captured_at/data);二次分配、月结、调整单三类提交均固化送审内容,开关关闭 的自动通过路径同样写快照;详情按 snapshot/live/missing 三态回显,历史单 只读回退当前内容并显式告警,不回填历史快照 - 二次分配方案行改 append-only 版本化(version_no/is_active,唯一键扩为 business+version+personnel):编辑替换与驳回清空只置历史,核算与详情默认 只取生效行,业务详情新增 allocation_history - 送审内容未变不重提、方案未变不新增版本:判据统一为送审内容,两个 version_no 语义解耦(方案版本=内容第几版,审批单版本=第几次送审) - 审批单列表、工作台待办、审批超时预警三处 defer 快照列,避免整列拉取 - 月结提交取行加 for_update,防并发提交算出重复的审批单版本号 - 修复审批类型筛选上界 le=2(调整单无法筛选)、快照金额把空值写成 0.00 - V11 迁移三件套同步(Alembic / db/migration / init-dev.sql) 测试:单元 +16(方案内容比较 10、审批入参 6)、集成 +12;全量 891 passed。 Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 06:30:51 -04:00
snapshot=approval_service.adjustment_content(session, order.id),
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 00:52:36 -04:00
)
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]:
"""调整单列表(月份倒序)。"""
from sqlalchemy import select
from app.models import AdjustmentOrder
rows = (
session.execute(select(AdjustmentOrder).order_by(AdjustmentOrder.id.desc()).limit(200))
.scalars()
.all()
)
from app.models.settlement import adjustment_label # noqa: F401
return [
{
"id": r.id,
"order_no": r.order_no,
"settlement_month": r.settlement_month,
"personnel_name": r.personnel_name,
"direction": r.direction,
"amount": str(r.amount),
"reason": r.reason,
"status": r.status,
}
for r in rows
]