Compare commits
9 Commits
35fe37fb0c
...
e48cfb752d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e48cfb752d | ||
|
|
028a94a6eb | ||
|
|
a89d540e3f | ||
|
|
7a99994d17 | ||
|
|
45c268b146 | ||
|
|
828b57350a | ||
|
|
76ded6d5ec | ||
|
|
7a460cb682 | ||
|
|
cb4f38dfd9 |
51
backend/alembic/versions/ba0217f5091b_v9_二次分配唯一键补齐.py
Normal file
51
backend/alembic/versions/ba0217f5091b_v9_二次分配唯一键补齐.py
Normal 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。
|
||||
"""
|
||||
@ -25,8 +25,8 @@ from datetime import date
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "c7d2e5a81f30"
|
||||
down_revision = "3bca400421c6"
|
||||
revision: str = "c7d2e5a81f30"
|
||||
down_revision: str | None = "3bca400421c6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PerformanceLedger
|
||||
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
|
||||
from app.schemas.calc import LedgerQuery
|
||||
|
||||
|
||||
@ -73,6 +74,27 @@ def mark_inactive(session: Session, row: PerformanceLedger) -> None:
|
||||
session.flush()
|
||||
|
||||
|
||||
def deactivate_month_aggregates(session: Session, month: str) -> None:
|
||||
"""月结重生成时作废该月全部经理聚合行(calc_type=2)。
|
||||
|
||||
聚合行每次生成重建,旧行不翻 0 会与新行重复计入(按人聚合按 is_active
|
||||
过滤)。与 mark_inactive 同属 is_active 翻 0 的受控 UPDATE 例外;set 批量
|
||||
版本而非逐行——聚合行一个月至多几行,且无逐行回读必要。
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
session.execute(
|
||||
update(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == CALC_TYPE_MANAGER_AGGREGATE,
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
.values(is_active=0)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def next_version(session: Session, receipt_id: int) -> int:
|
||||
"""某回款的下一个核算版本号(max+1;无行时 1)。"""
|
||||
current = session.execute(
|
||||
|
||||
@ -92,3 +92,21 @@ def update_ledger_settlement_id(
|
||||
.values(settlement_id=settlement_id)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def clear_ledger_settlement_id(session: Session, settlement_id: int) -> None:
|
||||
"""重生成前解绑:释放本结算名下台账行的 settlement_id(「未入结」判据复位)。
|
||||
|
||||
草稿/驳回态重新生成时,上一轮回填的行必须重新进入候选集——否则
|
||||
``settlement_id IS NULL`` 会把它们永久排除,重生成漏算整月工资。
|
||||
"""
|
||||
from sqlalchemy import update
|
||||
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
session.execute(
|
||||
update(PerformanceLedger)
|
||||
.where(PerformanceLedger.settlement_id == settlement_id)
|
||||
.values(settlement_id=None)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
@ -86,5 +86,10 @@ class AdjustmentCreateIn(BaseModel):
|
||||
settlement_month: str = Field(description="调整月份 YYYY-MM")
|
||||
personnel_id: int = Field(ge=1, description="调整人员ID")
|
||||
direction: int = Field(ge=1, le=2, description="1补发 2扣减")
|
||||
amount: str = Field(description="金额(元,两位小数)")
|
||||
# 正则先拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出);
|
||||
# 「> 0」由服务层判定(schema 只管形状,语义校验留在 service)
|
||||
amount: str = Field(
|
||||
pattern=r"^\d{1,12}(\.\d{1,2})?$",
|
||||
description="金额(元,两位小数,恒正)",
|
||||
)
|
||||
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
|
||||
|
||||
@ -64,10 +64,12 @@ from app.services.no_generator import next_no
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 开关参数编码(与 param_registry 种子同源)
|
||||
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
|
||||
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
|
||||
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||
1: "approval.secondary_allocation.enabled",
|
||||
2: "approval.monthly_settlement.enabled",
|
||||
3: "approval.adjustment.enabled",
|
||||
}
|
||||
|
||||
# 在途状态集合(同一引用对象至多一张在途单)
|
||||
@ -219,6 +221,10 @@ def act(
|
||||
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||||
else APPROVAL_STATUS_APPROVED
|
||||
)
|
||||
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
|
||||
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
|
||||
# 驳回在任何节点都是终态,即时联动。
|
||||
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
|
||||
|
||||
session.add(
|
||||
ApprovalStep(
|
||||
@ -259,12 +265,15 @@ def act(
|
||||
if target is not None:
|
||||
if rejected:
|
||||
settlement_service.mark_rejected(session, target, operator=operator)
|
||||
else:
|
||||
elif final_approved:
|
||||
settlement_service.mark_locked(session, target, operator=operator)
|
||||
else:
|
||||
adj = session.get(AdjustmentOrder, order.ref_id)
|
||||
if adj is not None:
|
||||
adj.status = ADJ_REJECTED if rejected else ADJUSTMENT_STATUS_EFFECTIVE
|
||||
if rejected:
|
||||
adj.status = ADJ_REJECTED
|
||||
elif final_approved:
|
||||
adj.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||||
|
||||
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
|
||||
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
from typing import Any
|
||||
|
||||
@ -78,14 +78,17 @@ def calc_receipt(
|
||||
) -> CalcReceiptOut:
|
||||
"""单笔回款核算 / 重算(recalc_reason 非 None 时为重算模式)。
|
||||
|
||||
防重前两层在本函数(FOR UPDATE 锁行 + settlement_status 校验),第三层
|
||||
是落台账 uk(receipt_id, calc_version)。全程一个 audit 事务,出参在
|
||||
commit 前固化。
|
||||
防重前两层在 ``_load_and_guard``(FOR UPDATE 锁行 + settlement_status
|
||||
与月结锁定校验),第三层是落台账 uk(receipt_id, calc_version)。全程
|
||||
一个 audit 事务,出参在 commit 前固化。
|
||||
|
||||
首算模式:回款必须未核算;业务级一次性计提(#25)——已有生效台账行时
|
||||
仅登记进度(repeated=True)。
|
||||
仅登记进度(repeated=True,``_prepare_mode`` 内提前返回)。
|
||||
重算模式:回款必须已核算过;作废旧版本行(is_active=0),按当前参数
|
||||
落 calc_version+1 新行,calc_reason 留痕。
|
||||
|
||||
本函数只做编排:锁行校验 → 模式预处理 → 输入装载 → 逐行折算 →
|
||||
计提组成 → 落库,各段独立私有函数(口径修改只动对应段)。
|
||||
"""
|
||||
is_recalc = recalc_reason is not None
|
||||
action = "重算" if is_recalc else "核算"
|
||||
@ -103,191 +106,321 @@ def calc_receipt(
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
receipt = session.get(Receipt, receipt_id, with_for_update=True)
|
||||
if receipt is None:
|
||||
raise BadRequestError("回款不存在")
|
||||
if is_recalc:
|
||||
if receipt.settlement_status == SETTLEMENT_PENDING:
|
||||
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
|
||||
elif receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
|
||||
receipt, business = _load_and_guard(session, receipt_id, is_recalc=is_recalc)
|
||||
repeated_out = _prepare_mode(session, receipt, business, is_recalc=is_recalc)
|
||||
if repeated_out is not None:
|
||||
return repeated_out
|
||||
|
||||
business = session.get(Business, receipt.business_id, with_for_update=True)
|
||||
if business is None:
|
||||
raise BadRequestError("回款关联的业务不存在")
|
||||
if business.owner_personnel_id is None:
|
||||
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||
shipment = _load_shipment(session, receipt)
|
||||
person = _load_person(session, business)
|
||||
snapshot = resolve_snapshot(session, receipt.receipt_date)
|
||||
items = _load_items(session, business)
|
||||
|
||||
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算
|
||||
if is_recalc:
|
||||
old = performance_ledger_repository.get_active_by_receipt(session, receipt_id)
|
||||
if old is not None:
|
||||
performance_ledger_repository.mark_inactive(session, old)
|
||||
|
||||
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
||||
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
|
||||
repeated = False
|
||||
if not is_recalc:
|
||||
repeated = performance_ledger_repository.has_active_for_business(
|
||||
session, business.id, calc_type=CALC_TYPE_RECEIPT
|
||||
)
|
||||
if repeated:
|
||||
_mark_settled(session, receipt)
|
||||
row = performance_ledger_repository.get_active_by_receipt_of_business(
|
||||
session, business.id
|
||||
)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
|
||||
session.commit()
|
||||
logger.info(
|
||||
"回款登记进度(业务已核算)| receipt=%s | business=%s", receipt_id, business.id
|
||||
)
|
||||
return out
|
||||
|
||||
if receipt.shipment_id is None:
|
||||
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
|
||||
shipment = session.get(Shipment, receipt.shipment_id)
|
||||
if shipment is None or shipment.ship_date is None:
|
||||
raise BadRequestError("发货批次缺少发货日期,无法核算")
|
||||
|
||||
person = personnel_service.ensure_active(
|
||||
session, business.owner_personnel_id, label="核算归属"
|
||||
conversion = _convert_items(snapshot, person, items)
|
||||
amounts = _compose_amounts(
|
||||
session, snapshot, person, business, receipt, shipment, conversion
|
||||
)
|
||||
if person.business_role_id is None:
|
||||
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
|
||||
|
||||
receipt_date = receipt.receipt_date
|
||||
snapshot = resolve_snapshot(session, receipt_date)
|
||||
items = business_repository.list_items(session, business.id)
|
||||
if not items:
|
||||
raise BadRequestError("业务无产品明细行,无法核算")
|
||||
|
||||
# 逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)
|
||||
total_revenue = Decimal("0")
|
||||
total_profit = Decimal("0")
|
||||
converted_total = Decimal("0")
|
||||
missing_cost = False
|
||||
line_details: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
row_revenue = item.sale_amount
|
||||
if item.cost_amount is None:
|
||||
missing_cost = True
|
||||
row_profit = Decimal("0")
|
||||
else:
|
||||
row_profit = _row_profit(row_revenue, item.cost_amount)
|
||||
total_revenue += row_revenue
|
||||
total_profit += row_profit
|
||||
threshold = parse_fraction(
|
||||
snapshot.get(
|
||||
"product.threshold",
|
||||
role_code=role_code_of(person),
|
||||
product_category=item.product_category,
|
||||
)
|
||||
)
|
||||
row_converted = converted_profit(
|
||||
row_revenue, profit_rate(row_profit, row_revenue), threshold
|
||||
)
|
||||
converted_total += row_converted
|
||||
line_details.append(
|
||||
{
|
||||
"item_id": item.id,
|
||||
"product_category": item.product_category,
|
||||
"revenue": str(row_revenue),
|
||||
"profit": str(row_profit),
|
||||
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
|
||||
# 快照/台账口径需与列一致)
|
||||
"converted": str(row_converted.quantize(Decimal("0.01"))),
|
||||
}
|
||||
)
|
||||
|
||||
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
|
||||
days = payment_days(shipment.ship_date, receipt_date)
|
||||
decision = _personal_rate(session, snapshot, person, business, days)
|
||||
|
||||
if decision.base_override is not None:
|
||||
converted = decision.base_override # 信息员:基数=本人销售额
|
||||
else:
|
||||
converted = converted_total
|
||||
|
||||
performance = (
|
||||
Decimal("0")
|
||||
if decision.manager_pending
|
||||
else performance_amount(converted, decision.rate)
|
||||
)
|
||||
barter_applied = receipt.is_barter == 1
|
||||
base_amount = performance
|
||||
if barter_applied:
|
||||
discount = parse_fraction(
|
||||
snapshot.get("barter.discount", role_code=role_code_of(person))
|
||||
)
|
||||
base_amount = apply_barter(performance, discount)
|
||||
|
||||
received_total = business.total_received_amount or Decimal("0")
|
||||
start_ratio = parse_fraction(
|
||||
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
|
||||
)
|
||||
start_ok = start_payment_ok(received_total, total_revenue, start_ratio)
|
||||
weight_r = clamp_weight_r(received_total, total_revenue)
|
||||
weight = payment_weight(weight_r)
|
||||
payable = param_service.to_decimal(Fraction(base_amount) * weight)
|
||||
|
||||
settlement_month = receipt_date.strftime("%Y-%m")
|
||||
snapshot_json: dict[str, Any] = {
|
||||
"params_as_of": str(receipt_date),
|
||||
"line_details": line_details,
|
||||
"start_payment_ok": start_ok,
|
||||
"rate_note": decision.rate_note,
|
||||
"revenue_total": str(total_revenue),
|
||||
"profit_total": str(total_profit),
|
||||
}
|
||||
if decision.manager_pending:
|
||||
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
|
||||
|
||||
def q2(value: Decimal | None) -> Decimal | None:
|
||||
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。"""
|
||||
return value.quantize(Decimal("0.01")) if value is not None else None
|
||||
|
||||
row = performance_ledger_repository.add(
|
||||
row = _persist_ledger(
|
||||
session,
|
||||
PerformanceLedger(
|
||||
receipt_id=receipt.id,
|
||||
calc_version=performance_ledger_repository.next_version(session, receipt.id),
|
||||
calc_type=CALC_TYPE_RECEIPT,
|
||||
business_id=business.id,
|
||||
business_no=business.business_no,
|
||||
personnel_id=person.id,
|
||||
personnel_name=person.personnel_name,
|
||||
role_code=role_code_of(person),
|
||||
customer_type=_customer_type(session, business),
|
||||
product_category=_category_label(items),
|
||||
ship_date=shipment.ship_date,
|
||||
receipt_date=receipt_date,
|
||||
payment_days=days,
|
||||
rate_text=str(decision.rate),
|
||||
converted_profit=q2(converted),
|
||||
performance_amount=q2(performance),
|
||||
barter_applied=1 if barter_applied else 0,
|
||||
weight_r=weight_r,
|
||||
payment_weight=param_service.to_decimal(weight),
|
||||
payable_amount=q2(payable),
|
||||
settlement_month=settlement_month,
|
||||
is_probation=1 if person.is_probation == 1 else 0,
|
||||
missing_cost=1 if missing_cost else 0,
|
||||
snapshot_json=snapshot_json,
|
||||
calc_reason=recalc_reason,
|
||||
),
|
||||
receipt=receipt,
|
||||
business=business,
|
||||
person=person,
|
||||
shipment=shipment,
|
||||
items=items,
|
||||
conversion=conversion,
|
||||
amounts=amounts,
|
||||
recalc_reason=recalc_reason,
|
||||
is_recalc=is_recalc,
|
||||
)
|
||||
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
|
||||
# 会 rowcount=0 误报并发撞车)
|
||||
if not is_recalc:
|
||||
_mark_settled(session, receipt)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(row), repeated=False)
|
||||
session.commit()
|
||||
logger.info("核算完成 | receipt=%s | ledger=%s | payable=%s", receipt_id, row.id, payable)
|
||||
logger.info(
|
||||
"核算完成 | receipt=%s | ledger=%s | payable=%s",
|
||||
receipt_id,
|
||||
row.id,
|
||||
amounts.payable,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _load_and_guard(
|
||||
session: Session, receipt_id: int, *, is_recalc: bool
|
||||
) -> tuple[Receipt, Business]:
|
||||
"""锁行 + 模式校验 + 月结锁定拦截 + 业务归属校验(核算/重算共用)。"""
|
||||
receipt = session.get(Receipt, receipt_id, with_for_update=True)
|
||||
if receipt is None:
|
||||
raise BadRequestError("回款不存在")
|
||||
if is_recalc:
|
||||
if receipt.settlement_status == SETTLEMENT_PENDING:
|
||||
raise BadRequestError("该回款尚未核算过(无旧版本可重算,走首次核算)")
|
||||
elif receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("该回款已核算,禁止重复核算(重算请走重算入口)")
|
||||
|
||||
# 月结锁定拦截:已审定月份的回款不得再核算/重算——金额已固化,
|
||||
# 变动唯一通道是调整单(否则重算会绕开审批改写已锁定月份的应付)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||
|
||||
business = session.get(Business, receipt.business_id, with_for_update=True)
|
||||
if business is None:
|
||||
raise BadRequestError("回款关联的业务不存在")
|
||||
if business.owner_personnel_id is None:
|
||||
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||
return receipt, business
|
||||
|
||||
|
||||
def _prepare_mode(
|
||||
session: Session, receipt: Receipt, business: Business, *, is_recalc: bool
|
||||
) -> CalcReceiptOut | None:
|
||||
"""模式预处理:重算作废旧版本;首算判业务级一次性计提。
|
||||
|
||||
返回非 None = 本笔仅登记进度(repeated=True 的出参,已 commit),
|
||||
调用方直接返回。
|
||||
"""
|
||||
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
|
||||
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
|
||||
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
|
||||
# 两行生效行导致双重计提 → 一律 400
|
||||
if is_recalc:
|
||||
old = performance_ledger_repository.get_active_by_receipt(session, receipt.id)
|
||||
if old is None:
|
||||
raise BadRequestError("该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算")
|
||||
performance_ledger_repository.mark_inactive(session, old)
|
||||
return None
|
||||
|
||||
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
||||
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
|
||||
if not performance_ledger_repository.has_active_for_business(
|
||||
session, business.id, calc_type=CALC_TYPE_RECEIPT
|
||||
):
|
||||
return None
|
||||
_mark_settled(session, receipt)
|
||||
row = performance_ledger_repository.get_active_by_receipt_of_business(session, business.id)
|
||||
session.flush()
|
||||
out = CalcReceiptOut(ledger=_to_out(row), repeated=True)
|
||||
session.commit()
|
||||
logger.info("回款登记进度(业务已核算)| receipt=%s | business=%s", receipt.id, business.id)
|
||||
return out
|
||||
|
||||
|
||||
def _load_shipment(session: Session, receipt: Receipt) -> Shipment:
|
||||
"""核销基准:发货批次存在且带发货日(回款天数公式的输入)。"""
|
||||
if receipt.shipment_id is None:
|
||||
raise BadRequestError("回款未指定发货批次,无法确定发货日期(先指定批次再核算)")
|
||||
shipment = session.get(Shipment, receipt.shipment_id)
|
||||
if shipment is None or shipment.ship_date is None:
|
||||
raise BadRequestError("发货批次缺少发货日期,无法核算")
|
||||
return shipment
|
||||
|
||||
|
||||
def _load_person(session: Session, business: Business):
|
||||
"""核算归属:在职 + 绑定岗位角色(计提公式锚点)。"""
|
||||
person = personnel_service.ensure_active(session, business.owner_personnel_id, label="核算归属")
|
||||
if person.business_role_id is None:
|
||||
raise BadRequestError("归属人员未绑定岗位角色,无法确定计提公式")
|
||||
return person
|
||||
|
||||
|
||||
def _load_items(session: Session, business: Business) -> list[BusinessItem]:
|
||||
"""业务产品明细行(逐行折算的输入;空明细无法核算)。"""
|
||||
items = business_repository.list_items(session, business.id)
|
||||
if not items:
|
||||
raise BadRequestError("业务无产品明细行,无法核算")
|
||||
return items
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Conversion:
|
||||
"""逐行折算结果(行级明细入台账快照,汇总值驱动计提与起付判定)。"""
|
||||
|
||||
total_revenue: Decimal
|
||||
total_profit: Decimal
|
||||
converted_total: Decimal
|
||||
missing_cost: bool
|
||||
line_details: list[dict[str, Any]]
|
||||
|
||||
|
||||
def _convert_items(snapshot, person, items: list[BusinessItem]) -> _Conversion:
|
||||
"""逐行折算(行级 product_category 阈值;成本缺失行毛利按 0 计并标记)。"""
|
||||
total_revenue = Decimal("0")
|
||||
total_profit = Decimal("0")
|
||||
converted_total = Decimal("0")
|
||||
missing_cost = False
|
||||
line_details: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
row_revenue = item.sale_amount
|
||||
if item.cost_amount is None:
|
||||
missing_cost = True
|
||||
row_profit = Decimal("0")
|
||||
else:
|
||||
row_profit = _row_profit(row_revenue, item.cost_amount)
|
||||
total_revenue += row_revenue
|
||||
total_profit += row_profit
|
||||
threshold = parse_fraction(
|
||||
snapshot.get(
|
||||
"product.threshold",
|
||||
role_code=role_code_of(person),
|
||||
product_category=item.product_category,
|
||||
)
|
||||
)
|
||||
row_converted = converted_profit(
|
||||
row_revenue, profit_rate(row_profit, row_revenue), threshold
|
||||
)
|
||||
converted_total += row_converted
|
||||
line_details.append(
|
||||
{
|
||||
"item_id": item.id,
|
||||
"product_category": item.product_category,
|
||||
"revenue": str(row_revenue),
|
||||
"profit": str(row_profit),
|
||||
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
|
||||
# 快照/台账口径需与列一致)
|
||||
"converted": str(_q2(row_converted)),
|
||||
}
|
||||
)
|
||||
return _Conversion(total_revenue, total_profit, converted_total, missing_cost, line_details)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Amounts:
|
||||
"""计提组成结果(比例分派 → 绩效 → 抵顶 → 支付权重 → 应付)。"""
|
||||
|
||||
days: int
|
||||
decision: _RateDecision
|
||||
converted: Decimal
|
||||
performance: Decimal
|
||||
barter_applied: bool
|
||||
weight_r: int
|
||||
weight: Fraction
|
||||
payable: Decimal
|
||||
start_ok: bool
|
||||
|
||||
|
||||
def _compose_amounts(
|
||||
session: Session,
|
||||
snapshot,
|
||||
person,
|
||||
business: Business,
|
||||
receipt: Receipt,
|
||||
shipment: Shipment,
|
||||
conversion: _Conversion,
|
||||
) -> _Amounts:
|
||||
"""按比例分派与支付权重组成应付(与 param_service.preview 同公式路径)。"""
|
||||
# 天数只依赖发货/回款日期,先于比例分派算出(业务员递减公式的输入)
|
||||
days = payment_days(shipment.ship_date, receipt.receipt_date)
|
||||
decision = _personal_rate(session, snapshot, person, business, days)
|
||||
|
||||
if decision.base_override is not None:
|
||||
converted = decision.base_override # 信息员:基数=本人销售额
|
||||
else:
|
||||
converted = conversion.converted_total
|
||||
|
||||
performance = (
|
||||
Decimal("0") if decision.manager_pending else performance_amount(converted, decision.rate)
|
||||
)
|
||||
barter_applied = receipt.is_barter == 1
|
||||
base_amount = performance
|
||||
if barter_applied:
|
||||
discount = parse_fraction(snapshot.get("barter.discount", role_code=role_code_of(person)))
|
||||
base_amount = apply_barter(performance, discount)
|
||||
|
||||
received_total = business.total_received_amount or Decimal("0")
|
||||
start_ratio = parse_fraction(
|
||||
snapshot.get("payment.start.ratio", role_code=role_code_of(person))
|
||||
)
|
||||
start_ok = start_payment_ok(received_total, conversion.total_revenue, start_ratio)
|
||||
weight_r = clamp_weight_r(received_total, conversion.total_revenue)
|
||||
weight = payment_weight(weight_r)
|
||||
payable = param_service.to_decimal(Fraction(base_amount) * weight)
|
||||
return _Amounts(
|
||||
days=days,
|
||||
decision=decision,
|
||||
converted=converted,
|
||||
performance=performance,
|
||||
barter_applied=barter_applied,
|
||||
weight_r=weight_r,
|
||||
weight=weight,
|
||||
payable=payable,
|
||||
start_ok=start_ok,
|
||||
)
|
||||
|
||||
|
||||
def _persist_ledger(
|
||||
session: Session,
|
||||
*,
|
||||
receipt: Receipt,
|
||||
business: Business,
|
||||
person,
|
||||
shipment: Shipment,
|
||||
items: list[BusinessItem],
|
||||
conversion: _Conversion,
|
||||
amounts: _Amounts,
|
||||
recalc_reason: str | None,
|
||||
is_recalc: bool,
|
||||
) -> PerformanceLedger:
|
||||
"""落台账行(版本号自增;金额两位 HALF_UP)+ 首算 CAS 置已核算。"""
|
||||
receipt_date = receipt.receipt_date
|
||||
snapshot_json: dict[str, Any] = {
|
||||
"params_as_of": str(receipt_date),
|
||||
"line_details": conversion.line_details,
|
||||
"start_payment_ok": amounts.start_ok,
|
||||
"rate_note": amounts.decision.rate_note,
|
||||
"revenue_total": str(conversion.total_revenue),
|
||||
"profit_total": str(conversion.total_profit),
|
||||
}
|
||||
if amounts.decision.manager_pending:
|
||||
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
|
||||
|
||||
row = performance_ledger_repository.add(
|
||||
session,
|
||||
PerformanceLedger(
|
||||
receipt_id=receipt.id,
|
||||
calc_version=performance_ledger_repository.next_version(session, receipt.id),
|
||||
calc_type=CALC_TYPE_RECEIPT,
|
||||
business_id=business.id,
|
||||
business_no=business.business_no,
|
||||
personnel_id=person.id,
|
||||
personnel_name=person.personnel_name,
|
||||
role_code=role_code_of(person),
|
||||
customer_type=_customer_type(session, business),
|
||||
product_category=_category_label(items),
|
||||
ship_date=shipment.ship_date,
|
||||
receipt_date=receipt_date,
|
||||
payment_days=amounts.days,
|
||||
rate_text=str(amounts.decision.rate),
|
||||
converted_profit=_q2(amounts.converted),
|
||||
performance_amount=_q2(amounts.performance),
|
||||
barter_applied=1 if amounts.barter_applied else 0,
|
||||
weight_r=amounts.weight_r,
|
||||
payment_weight=param_service.to_decimal(amounts.weight),
|
||||
payable_amount=_q2(amounts.payable),
|
||||
settlement_month=receipt_date.strftime("%Y-%m"),
|
||||
is_probation=1 if person.is_probation == 1 else 0,
|
||||
missing_cost=1 if conversion.missing_cost else 0,
|
||||
snapshot_json=snapshot_json,
|
||||
calc_reason=recalc_reason,
|
||||
),
|
||||
)
|
||||
# CAS 仅首算执行:重算模式状态本就是已核算(UPDATE ... WHERE status=0
|
||||
# 会 rowcount=0 误报并发撞车)
|
||||
if not is_recalc:
|
||||
_mark_settled(session, receipt)
|
||||
return row
|
||||
|
||||
|
||||
def _q2(value: Decimal | None) -> Decimal | None:
|
||||
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
|
||||
|
||||
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家舍入),
|
||||
x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _personal_rate(
|
||||
session: Session,
|
||||
snapshot,
|
||||
@ -324,7 +457,9 @@ def _personal_rate(
|
||||
start = parse_fraction(snapshot.get("salesperson.rate.start"))
|
||||
slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
|
||||
floor = parse_fraction(snapshot.get("salesperson.rate.floor"))
|
||||
boundary = int(snapshot.get("salesperson.days.boundary"))
|
||||
# int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500;
|
||||
# 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损)
|
||||
boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary")))
|
||||
rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
|
||||
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
|
||||
return _RateDecision(rate, note, None, False)
|
||||
@ -393,7 +528,7 @@ def recalc_receipt(
|
||||
"""重算(授权动作,管理员/财务):作废旧版本行、按当前参数重算新版本。
|
||||
|
||||
薄委托 calc_receipt(recalc_reason=...)——折算体只有一份;所属月已审定
|
||||
的拦截在月结(T5)接入后补入本函数。
|
||||
的拦截在 calc_receipt 内(核算与重算同口径)。
|
||||
"""
|
||||
if not reason or not reason.strip():
|
||||
raise BadRequestError("重算必须填写原因")
|
||||
|
||||
@ -41,6 +41,10 @@ def salesperson_rate(
|
||||
≤分界天数按「起始 − 天数×斜率」线性递减;超过分界天数固定取下限
|
||||
(对照表 #4:360 天=6‰,361 天起固定 4‰)。
|
||||
|
||||
首日取整(澄清 #14 业务答复 2026-10-19):第 1 天**不递减**、直接取
|
||||
起始值(需规图示「第 1 天 10‰」覆盖公式的 9.9889‰)——第 2 天起按公式
|
||||
递减,360 天=6‰ 锚点与 361 天=4‰ 分界均不受影响。
|
||||
|
||||
先款后货的回款(payment_days 为负)钳制为 0 天:负天数线性外插会算出
|
||||
高于起始值的比例(多算绩效),钳到 0 天即按起始比例计提;payment_days
|
||||
的出参仍照实输出负值(回款登记照常入库,录入异常由台账留痕暴露)。
|
||||
@ -48,6 +52,8 @@ def salesperson_rate(
|
||||
days = max(days, 0)
|
||||
if days > boundary_days:
|
||||
return floor / 1000
|
||||
if days <= 1:
|
||||
return start / 1000
|
||||
return (start - days * slope) / 1000
|
||||
|
||||
|
||||
|
||||
@ -291,6 +291,12 @@ def update_receipt(
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止修改")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "修改")
|
||||
# 月结锁定拦截:原月与新月都不得落入已锁定月份(改到账日=跨月搬数,
|
||||
# 只查新月会让「从锁定月改到未锁定月」绕过拦截)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||
settlement_service.ensure_month_not_locked(session, payload.receipt_date.strftime("%Y-%m"))
|
||||
# 校验返回的 (shipment_no, ship_date) 即新批次 DTO 所需 —— shipment_id
|
||||
# 已被赋为 payload 新值,与重查 get_with_refs 同源,省一次三表 join
|
||||
shipment_no, ship_date = _ensure_shipment_of_business(
|
||||
@ -342,6 +348,10 @@ def delete_receipt(
|
||||
if receipt.settlement_status == SETTLEMENT_DONE:
|
||||
raise BadRequestError("已核算回款禁止删除")
|
||||
business = _ensure_business_writable(session, receipt.business_id, "删除")
|
||||
# 月结锁定拦截:已锁定月份的回款是月结快照的源数据,不得删除
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, receipt.receipt_date.strftime("%Y-%m"))
|
||||
|
||||
batch_no = receipt.receipt_batch_no
|
||||
receipt_repository.delete(session, receipt)
|
||||
|
||||
@ -9,7 +9,11 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from calendar import monthrange
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from fractions import Fraction
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@ -25,14 +29,17 @@ from app.models import (
|
||||
User,
|
||||
)
|
||||
from app.models.approval import APPROVAL_TYPE_MONTHLY_SETTLEMENT
|
||||
from app.models.performance_ledger import CALC_TYPE_MANAGER_AGGREGATE
|
||||
from app.models.performance_ledger import (
|
||||
CALC_TYPE_MANAGER_AGGREGATE,
|
||||
CALC_TYPE_RECEIPT,
|
||||
)
|
||||
from app.models.settlement import (
|
||||
SETTLEMENT_STATUS_APPROVED,
|
||||
SETTLEMENT_STATUS_DRAFT,
|
||||
SETTLEMENT_STATUS_PENDING,
|
||||
SETTLEMENT_STATUS_REJECTED,
|
||||
)
|
||||
from app.repositories import settlement_repository
|
||||
from app.repositories import performance_ledger_repository, settlement_repository
|
||||
from app.schemas.settlement import (
|
||||
SettlementDetailOut,
|
||||
SettlementOut,
|
||||
@ -59,8 +66,11 @@ def generate(
|
||||
"""生成月度结算快照(草稿):按确认回款口径取有效台账行,按人汇总。
|
||||
|
||||
- 同月已有月结行(任意状态)时 400——一行一月,重生成走「驳回/草稿态
|
||||
重新生成」(先清 items 重算头表)
|
||||
- 经理聚合(calc_type=2)在生成时计算(#12 暂口径:基数不含经理个人户)
|
||||
重新生成」(_release_previous_round 解绑回填后清明细重算)
|
||||
- 经理聚合(calc_type=2)在生成时计算并落库(#12 参数化见
|
||||
``_build_manager_aggregates``)
|
||||
|
||||
本函数只做编排,各段独立私有函数(口径修改只动对应段)。
|
||||
"""
|
||||
with audit_service.audit(
|
||||
session,
|
||||
@ -74,101 +84,152 @@ def generate(
|
||||
meta=meta,
|
||||
):
|
||||
existing = settlement_repository.get_by_month(session, month)
|
||||
if existing is not None:
|
||||
if existing.status == SETTLEMENT_STATUS_APPROVED:
|
||||
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
|
||||
if existing.status == SETTLEMENT_STATUS_PENDING:
|
||||
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
|
||||
# 草稿/已驳回:允许重新生成(清明细重算)
|
||||
_guard_regeneration(existing, month)
|
||||
_release_previous_round(session, existing, month)
|
||||
|
||||
ledger_rows = _confirm_scope(session, month)
|
||||
if not ledger_rows:
|
||||
raise BadRequestError(f"{month} 无可结算的核算记录(先在台账完成核算)")
|
||||
|
||||
# 经理团队聚合(#12 策略点:基数不含经理个人户业绩)
|
||||
aggregate_rows = _build_manager_aggregates(session, ledger_rows, month)
|
||||
|
||||
settlement = settlement_repository.get_by_month(session, month)
|
||||
is_new = settlement is None
|
||||
if is_new:
|
||||
settlement = MonthlySettlement(
|
||||
settlement_month=month,
|
||||
submitter_id=operator.id,
|
||||
submitter_name=operator.username,
|
||||
status=SETTLEMENT_STATUS_DRAFT,
|
||||
)
|
||||
settlement_repository.add(session, settlement)
|
||||
else:
|
||||
settlement.submitter_id = operator.id
|
||||
settlement.submitter_name = operator.username
|
||||
settlement.status = SETTLEMENT_STATUS_DRAFT
|
||||
|
||||
# 按人汇总(台账行:personnel+calc_type 维度)
|
||||
by_person: dict[tuple[int, int], dict] = {}
|
||||
for row in ledger_rows + aggregate_rows:
|
||||
key = (row.personnel_id, row.calc_type)
|
||||
bucket = by_person.setdefault(
|
||||
key,
|
||||
{
|
||||
"personnel_id": row.personnel_id,
|
||||
"personnel_name": row.personnel_name,
|
||||
"role_code": row.role_code,
|
||||
"calc_type": row.calc_type,
|
||||
"performance_sum": Decimal("0"),
|
||||
"payable_sum": Decimal("0"),
|
||||
"ledger_ids": [],
|
||||
"is_probation": row.is_probation,
|
||||
},
|
||||
)
|
||||
bucket["performance_sum"] += row.performance_amount or Decimal("0")
|
||||
bucket["payable_sum"] += row.payable_amount or Decimal("0")
|
||||
bucket["ledger_ids"].append(row.id)
|
||||
|
||||
items: list[SettlementItem] = []
|
||||
for (_, _), bucket in sorted(by_person.items()):
|
||||
person = session.get(Personnel, bucket["personnel_id"])
|
||||
basic = person.salary_standard if person else None
|
||||
mode = _salary_mode_of(session, bucket["role_code"])
|
||||
net = (
|
||||
monthly_salary(bucket["payable_sum"], basic, mode)
|
||||
if person and person.is_probation != 1
|
||||
else None
|
||||
)
|
||||
note = None
|
||||
if person and person.is_probation == 1:
|
||||
note = (
|
||||
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session)} 元部分"
|
||||
"年底一次性结算"
|
||||
)
|
||||
items.append(
|
||||
SettlementItem(
|
||||
personnel_id=bucket["personnel_id"],
|
||||
personnel_name=bucket["personnel_name"],
|
||||
role_code=bucket["role_code"],
|
||||
calc_type=bucket["calc_type"],
|
||||
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01")),
|
||||
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01")),
|
||||
basic_amount=basic,
|
||||
salary_mode=mode,
|
||||
net_amount=net.quantize(Decimal("0.01")) if net is not None else None,
|
||||
is_probation=bucket["is_probation"],
|
||||
probation_note=note,
|
||||
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
|
||||
)
|
||||
)
|
||||
settlement = _upsert_draft(session, existing, operator, month)
|
||||
items, ledger_ids = _build_items(session, ledger_rows, aggregate_rows, month)
|
||||
|
||||
settlement_repository.replace_items(session, settlement.id, items)
|
||||
settlement.total_performance = sum(
|
||||
(i.performance_sum for i in items), Decimal("0")
|
||||
).quantize(Decimal("0.01"))
|
||||
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
|
||||
Decimal("0.01")
|
||||
)
|
||||
settlement.person_count = len({i.personnel_id for i in items})
|
||||
_apply_totals(settlement, items)
|
||||
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
||||
# 入结回填:台账行 settlement_id 是「未入结」防重判据(_confirm_scope),
|
||||
# 不回填会让重生成把已固化行反复纳入;聚合行此刻已落库、id 可用
|
||||
settlement_repository.update_ledger_settlement_id(session, ledger_ids, settlement.id)
|
||||
|
||||
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已生成的月结
|
||||
# 记成失败审计,与库中事实相反)
|
||||
session.flush()
|
||||
detail = _detail_of(session, settlement)
|
||||
session.commit()
|
||||
logger.info("月结生成 | month=%s | items=%s", month, len(items))
|
||||
return _detail_of(session, settlement)
|
||||
return detail
|
||||
|
||||
|
||||
def _guard_regeneration(existing: MonthlySettlement | None, month: str) -> None:
|
||||
"""重生成准入:已审定 / 待审批不可重生成(草稿、已驳回放行)。"""
|
||||
if existing is None:
|
||||
return
|
||||
if existing.status == SETTLEMENT_STATUS_APPROVED:
|
||||
raise BadRequestError(f"{month} 已审定锁定,不可重新生成(调整走调整单)")
|
||||
if existing.status == SETTLEMENT_STATUS_PENDING:
|
||||
raise BadRequestError(f"{month} 有待审批的月结,不可重新生成")
|
||||
|
||||
|
||||
def _release_previous_round(
|
||||
session: Session, existing: MonthlySettlement | None, month: str
|
||||
) -> None:
|
||||
"""释放上一轮固化标记(必须早于 _confirm_scope)。
|
||||
|
||||
台账行 settlement_id 回填后,「未入结」过滤会把它们永久排除在候选集外;
|
||||
旧聚合行也要作废,否则本轮重建的聚合行与旧行重复计入。
|
||||
"""
|
||||
if existing is None:
|
||||
return
|
||||
settlement_repository.clear_ledger_settlement_id(session, existing.id)
|
||||
performance_ledger_repository.deactivate_month_aggregates(session, month)
|
||||
|
||||
|
||||
def _upsert_draft(
|
||||
session: Session, existing: MonthlySettlement | None, operator: User, month: str
|
||||
) -> MonthlySettlement:
|
||||
"""新建或重置草稿头表(重生成时刷新提交人与状态)。"""
|
||||
if existing is None:
|
||||
settlement = MonthlySettlement(
|
||||
settlement_month=month,
|
||||
submitter_id=operator.id,
|
||||
submitter_name=operator.username,
|
||||
status=SETTLEMENT_STATUS_DRAFT,
|
||||
)
|
||||
return settlement_repository.add(session, settlement)
|
||||
existing.submitter_id = operator.id
|
||||
existing.submitter_name = operator.username
|
||||
existing.status = SETTLEMENT_STATUS_DRAFT
|
||||
return existing
|
||||
|
||||
|
||||
def _build_items(
|
||||
session: Session, ledger_rows: list, aggregate_rows: list, month: str
|
||||
) -> tuple[list[SettlementItem], list[int]]:
|
||||
"""按人汇总生成明细行,返回 (明细, 本次入结的台账行 id 列表)。"""
|
||||
by_person = _bucket_rows(ledger_rows + aggregate_rows)
|
||||
items = [_item_of(session, bucket, month) for _, bucket in sorted(by_person.items())]
|
||||
ledger_ids = [lid for bucket in by_person.values() for lid in bucket["ledger_ids"]]
|
||||
return items, ledger_ids
|
||||
|
||||
|
||||
def _bucket_rows(rows: list) -> dict[tuple[int, int], dict]:
|
||||
"""台账行按 (人员, 核算类型) 维度分桶(冗余人读字段取首行)。"""
|
||||
by_person: dict[tuple[int, int], dict] = {}
|
||||
for row in rows:
|
||||
key = (row.personnel_id, row.calc_type)
|
||||
bucket = by_person.setdefault(
|
||||
key,
|
||||
{
|
||||
"personnel_id": row.personnel_id,
|
||||
"personnel_name": row.personnel_name,
|
||||
"role_code": row.role_code,
|
||||
"calc_type": row.calc_type,
|
||||
"performance_sum": Decimal("0"),
|
||||
"payable_sum": Decimal("0"),
|
||||
"ledger_ids": [],
|
||||
"is_probation": row.is_probation,
|
||||
},
|
||||
)
|
||||
bucket["performance_sum"] += row.performance_amount or Decimal("0")
|
||||
bucket["payable_sum"] += row.payable_amount or Decimal("0")
|
||||
bucket["ledger_ids"].append(row.id)
|
||||
return by_person
|
||||
|
||||
|
||||
def _item_of(session: Session, bucket: dict, month: str) -> SettlementItem:
|
||||
"""单条结算明细:月工资三口径净额、试用期说明与台账行引用列表。"""
|
||||
person = session.get(Personnel, bucket["personnel_id"])
|
||||
basic = person.salary_standard if person else None
|
||||
mode = _salary_mode_of(session, bucket["role_code"], month)
|
||||
net = (
|
||||
monthly_salary(bucket["payable_sum"], basic, mode)
|
||||
if person and person.is_probation != 1
|
||||
else None
|
||||
)
|
||||
note = None
|
||||
if person and person.is_probation == 1:
|
||||
note = (
|
||||
f"试用期绩效照算不发放;年累计超 {_probation_threshold(session, month)} 元部分"
|
||||
"年底一次性结算"
|
||||
)
|
||||
return SettlementItem(
|
||||
personnel_id=bucket["personnel_id"],
|
||||
personnel_name=bucket["personnel_name"],
|
||||
role_code=bucket["role_code"],
|
||||
calc_type=bucket["calc_type"],
|
||||
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
basic_amount=basic,
|
||||
salary_mode=mode,
|
||||
net_amount=(
|
||||
net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) if net is not None else None
|
||||
),
|
||||
is_probation=bucket["is_probation"],
|
||||
probation_note=note,
|
||||
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
|
||||
)
|
||||
|
||||
|
||||
def _apply_totals(settlement: MonthlySettlement, items: list[SettlementItem]) -> None:
|
||||
"""头表三合计固化(明细已两位小数,合计再 HALF_UP 收口)。"""
|
||||
settlement.total_performance = sum((i.performance_sum for i in items), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
settlement.person_count = len({i.personnel_id for i in items})
|
||||
|
||||
|
||||
def submit(
|
||||
@ -206,13 +267,16 @@ def submit(
|
||||
settlement.approval_order_no = order.order_no
|
||||
if order.status == SETTLEMENT_STATUS_APPROVED:
|
||||
mark_locked(session, settlement, operator=operator)
|
||||
session.commit()
|
||||
logger.info("月结自动通过并锁定 | %s", settlement.settlement_month)
|
||||
return _detail_of(session, settlement)
|
||||
else:
|
||||
settlement.status = SETTLEMENT_STATUS_PENDING
|
||||
|
||||
settlement.status = SETTLEMENT_STATUS_PENDING
|
||||
# 出参在 commit 前固化(audit 约定:commit 后查询失败会把已提交的
|
||||
# 月结记成失败审计,与库中事实相反)
|
||||
session.flush()
|
||||
detail = _detail_of(session, settlement)
|
||||
session.commit()
|
||||
return _detail_of(session, settlement)
|
||||
return detail
|
||||
|
||||
|
||||
def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
||||
@ -225,8 +289,14 @@ def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: Us
|
||||
|
||||
|
||||
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
||||
"""审批驳回钩子:回草稿(可修改后重提)。"""
|
||||
"""审批驳回钩子:回草稿(可修改后重提)。
|
||||
|
||||
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
|
||||
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
|
||||
"""
|
||||
settlement.status = SETTLEMENT_STATUS_DRAFT
|
||||
settlement.locked_at = None
|
||||
settlement.locked_by = None
|
||||
|
||||
|
||||
def ensure_month_not_locked(session: Session, month: str) -> None:
|
||||
@ -261,7 +331,11 @@ def list_page(session: Session, query: SettlementQuery):
|
||||
|
||||
|
||||
def _confirm_scope(session: Session, month: str) -> list:
|
||||
"""确认回款口径(#8 策略点):本月有效台账行(未入结)。"""
|
||||
"""确认回款口径(#8 策略点):本月**单笔回款**的有效台账行(未入结)。
|
||||
|
||||
calc_type 必须限定单笔(1):聚合行(2)也落同月台账、也会被回填
|
||||
settlement_id,不排除会在重生成时与新聚合行重复计入。
|
||||
"""
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
return list(
|
||||
@ -269,6 +343,7 @@ def _confirm_scope(session: Session, month: str) -> list:
|
||||
select(PerformanceLedger)
|
||||
.where(
|
||||
PerformanceLedger.settlement_month == month,
|
||||
PerformanceLedger.calc_type == CALC_TYPE_RECEIPT,
|
||||
PerformanceLedger.is_active == 1,
|
||||
PerformanceLedger.settlement_id.is_(None),
|
||||
)
|
||||
@ -277,46 +352,152 @@ def _confirm_scope(session: Session, month: str) -> list:
|
||||
)
|
||||
|
||||
|
||||
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
|
||||
"""按经理生成团队聚合行(calc_type=2)。
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ShareParams:
|
||||
"""经理分成取参与参数原文(同一次生成共用一份,随月月末时点化)。"""
|
||||
|
||||
#12 策略点:聚合基数**不含**经理个人户业绩(customer_type=
|
||||
manager_personal 的行按经理个人户单笔处理,已落台账但此处不入基数)。
|
||||
经理 = 本月台账行里 role_code=sales_manager 且 customer_type=personal
|
||||
的人员(本人带领的业务员经 manager_id 关联)。
|
||||
month: str
|
||||
param_date: date
|
||||
team_text: str
|
||||
company_text: str
|
||||
team_pct: Fraction
|
||||
company_pct: Fraction
|
||||
include_manager_personal: bool
|
||||
|
||||
|
||||
def _manager_share_params(session: Session, month: str) -> _ShareParams:
|
||||
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
|
||||
|
||||
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
|
||||
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
|
||||
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
|
||||
"""
|
||||
from app.models import PerformanceLedger
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
param_date = _month_end(month)
|
||||
snapshot = resolve_snapshot(session, param_date)
|
||||
team_text = snapshot.get("manager.share.team")
|
||||
company_text = snapshot.get("manager.share.company")
|
||||
return _ShareParams(
|
||||
month=month,
|
||||
param_date=param_date,
|
||||
team_text=team_text,
|
||||
company_text=company_text,
|
||||
team_pct=parse_fraction(team_text),
|
||||
company_pct=parse_fraction(company_text),
|
||||
include_manager_personal=snapshot.get("manager.personal.included") == "1",
|
||||
)
|
||||
|
||||
|
||||
def _add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
|
||||
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
|
||||
bucket = managers.setdefault(
|
||||
manager_id,
|
||||
{
|
||||
"performance": Decimal("0"),
|
||||
"business_id": row.business_id,
|
||||
"business_no": row.business_no,
|
||||
"names": set(),
|
||||
},
|
||||
)
|
||||
bucket["performance"] += row.performance_amount or Decimal("0")
|
||||
bucket["names"].add(row.personnel_name)
|
||||
|
||||
|
||||
def _collect_team_buckets(
|
||||
session: Session, ledger_rows: list, include_manager_personal: bool
|
||||
) -> dict[int, dict]:
|
||||
"""按经理采集团队业绩。
|
||||
|
||||
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
|
||||
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
|
||||
按上溯会被跳过漏计)。
|
||||
"""
|
||||
managers: dict[int, dict] = {}
|
||||
for row in ledger_rows:
|
||||
if row.customer_type == "manager_personal":
|
||||
continue # 经理个人户业绩不入聚合基数(#12 暂口径)
|
||||
# 找到该行业务归属人员的经理(manager_id)
|
||||
if include_manager_personal:
|
||||
_add_team_performance(managers, row.personnel_id, row)
|
||||
continue # 默认不计入聚合基数(#12 参数化)
|
||||
person = session.get(Personnel, row.personnel_id)
|
||||
manager_id = person.manager_id if person else None
|
||||
if manager_id is None:
|
||||
continue
|
||||
bucket = managers.setdefault(
|
||||
manager_id,
|
||||
{
|
||||
"performance": Decimal("0"),
|
||||
"business_id": row.business_id,
|
||||
"business_no": row.business_no,
|
||||
"names": set(),
|
||||
if manager_id is not None:
|
||||
_add_team_performance(managers, manager_id, row)
|
||||
return managers
|
||||
|
||||
|
||||
def _aggregate_row(
|
||||
session: Session, manager, bucket: dict, company_base: Decimal, share: _ShareParams
|
||||
):
|
||||
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
amount = manager_team_share(
|
||||
bucket["performance"],
|
||||
company_base,
|
||||
team_pct=share.team_pct,
|
||||
company_pct=share.company_pct,
|
||||
)
|
||||
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
||||
return performance_ledger_repository.add(
|
||||
session,
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_version=1, # 聚合行无回款维度,版本恒 1(uk 含 NULL 不去重)
|
||||
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
|
||||
business_id=bucket["business_id"],
|
||||
business_no=bucket["business_no"],
|
||||
personnel_id=manager.id,
|
||||
personnel_name=manager.personnel_name,
|
||||
role_code="sales_manager",
|
||||
customer_type="personal",
|
||||
product_category="mixed",
|
||||
receipt_date=share.param_date,
|
||||
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
|
||||
rate_text=f"聚合:{share.team_text}%+{share.company_text}%",
|
||||
converted_profit=None,
|
||||
performance_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
barter_applied=0,
|
||||
payable_amount=amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||
settlement_month=share.month,
|
||||
is_probation=1 if manager.is_probation == 1 else 0,
|
||||
snapshot_json={
|
||||
"team_base": str(bucket["performance"]),
|
||||
"company_base": str(company_base),
|
||||
"members": member_names,
|
||||
"params_as_of": str(share.param_date),
|
||||
"manager_personal_included": 1 if share.include_manager_personal else 0,
|
||||
"note": (
|
||||
f"#12 参数化:基数{'含' if share.include_manager_personal else '不含'}"
|
||||
"经理个人户业绩;9.8 方案公式"
|
||||
),
|
||||
},
|
||||
)
|
||||
bucket["performance"] += row.performance_amount or Decimal("0")
|
||||
bucket["names"].add(row.personnel_name)
|
||||
),
|
||||
)
|
||||
|
||||
from datetime import date
|
||||
|
||||
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
|
||||
"""按经理生成团队聚合行(calc_type=2)并落库。
|
||||
|
||||
#12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数
|
||||
``manager.personal.included`` 决定(0=不计入默认,1=计入);
|
||||
经理 = 台账行归属人员经 manager_id 关联的 sales_manager 岗人员。
|
||||
"""
|
||||
from app.repositories import business_role_repository
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
share = _manager_share_params(session, month)
|
||||
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
|
||||
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);
|
||||
# 口径逐行固定,循环外算一次
|
||||
company_base = sum(
|
||||
(
|
||||
r.performance_amount or Decimal("0")
|
||||
for r in ledger_rows
|
||||
if share.include_manager_personal or r.customer_type != "manager_personal"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
aggregates: list = []
|
||||
snapshot = resolve_snapshot(session, date.today())
|
||||
team_pct = parse_fraction(snapshot.get("manager.share.team"))
|
||||
company_pct = parse_fraction(snapshot.get("manager.share.company"))
|
||||
for manager_id, bucket in managers.items():
|
||||
manager = session.get(Personnel, manager_id)
|
||||
if manager is None:
|
||||
@ -328,67 +509,33 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
||||
)
|
||||
if role is None or role.role_code != "sales_manager":
|
||||
continue # 非营销经理的上级不参与聚合
|
||||
# 全公司基数:全部非经理个人户的有效行
|
||||
company_base = sum(
|
||||
(
|
||||
r.performance_amount or Decimal("0")
|
||||
for r in ledger_rows
|
||||
if r.customer_type != "manager_personal"
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
share = manager_team_share(
|
||||
bucket["performance"],
|
||||
company_base,
|
||||
team_pct=team_pct * 100,
|
||||
company_pct=company_pct * 100,
|
||||
)
|
||||
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
||||
aggregates.append(
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_type=CALC_TYPE_MANAGER_AGGREGATE,
|
||||
business_id=bucket["business_id"],
|
||||
business_no=bucket["business_no"],
|
||||
personnel_id=manager_id,
|
||||
personnel_name=manager.personnel_name,
|
||||
role_code="sales_manager",
|
||||
customer_type="personal",
|
||||
product_category="mixed",
|
||||
receipt_date=date.today(),
|
||||
rate_text="聚合:12.5%+5%",
|
||||
converted_profit=None,
|
||||
performance_amount=share.quantize(Decimal("0.01")),
|
||||
barter_applied=0,
|
||||
payable_amount=share.quantize(Decimal("0.01")),
|
||||
settlement_month=month,
|
||||
is_probation=1 if manager.is_probation == 1 else 0,
|
||||
snapshot_json={
|
||||
"team_base": str(bucket["performance"]),
|
||||
"company_base": str(company_base),
|
||||
"members": member_names,
|
||||
"note": "#12 暂口径:基数不含经理个人户业绩;9.8 方案公式",
|
||||
},
|
||||
)
|
||||
)
|
||||
aggregates.append(_aggregate_row(session, manager, bucket, company_base, share))
|
||||
return aggregates
|
||||
|
||||
|
||||
def _salary_mode_of(session: Session, role_code: str) -> str | None:
|
||||
from datetime import date
|
||||
def _month_end(month: str) -> date:
|
||||
"""YYYY-MM → 所属月月末(月结取参锚点)。
|
||||
|
||||
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
|
||||
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
|
||||
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
||||
更细的业务日期,取该月最后可确定的生效时点。
|
||||
"""
|
||||
year, mon = int(month[:4]), int(month[5:7])
|
||||
return date(year, mon, monthrange(year, mon)[1])
|
||||
|
||||
|
||||
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
|
||||
from app.services.param_service import resolve_snapshot
|
||||
|
||||
snapshot = resolve_snapshot(session, date.today())
|
||||
snapshot = resolve_snapshot(session, _month_end(month))
|
||||
return snapshot.get("salary.basic.mode", role_code=role_code) or None
|
||||
|
||||
|
||||
def _probation_threshold(session: Session) -> Decimal:
|
||||
from datetime import date
|
||||
|
||||
def _probation_threshold(session: Session, month: str) -> Decimal:
|
||||
from app.services.param_service import parse_fraction, resolve_snapshot
|
||||
|
||||
snapshot = resolve_snapshot(session, date.today())
|
||||
snapshot = resolve_snapshot(session, _month_end(month))
|
||||
from app.services.formula import to_decimal
|
||||
|
||||
return to_decimal(parse_fraction(snapshot.get("probation.annual.threshold")))
|
||||
@ -412,19 +559,11 @@ def create_adjustment(
|
||||
):
|
||||
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
|
||||
from app.core.constants import NO_PREFIX_ADJUSTMENT
|
||||
from app.models import AdjustmentOrder, ApprovalStep
|
||||
from app.models.approval import (
|
||||
APPROVAL_ACTION_AUTO_APPROVE,
|
||||
APPROVAL_NODE_FINANCE,
|
||||
APPROVAL_NODE_LEADER,
|
||||
APPROVAL_TYPE_ADJUSTMENT,
|
||||
)
|
||||
from app.models.settlement import (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||
ADJUSTMENT_STATUS_PENDING,
|
||||
)
|
||||
from app.models import AdjustmentOrder
|
||||
from app.services.no_generator import next_no
|
||||
|
||||
amount = _adjustment_amount(payload)
|
||||
|
||||
person = session.get(Personnel, payload.personnel_id)
|
||||
if person is None:
|
||||
raise BadRequestError("调整人员不存在")
|
||||
@ -451,7 +590,7 @@ def create_adjustment(
|
||||
personnel_id=payload.personnel_id,
|
||||
personnel_name=person.personnel_name,
|
||||
direction=payload.direction,
|
||||
amount=payload.amount,
|
||||
amount=amount,
|
||||
reason=payload.reason.strip(),
|
||||
submitter_id=operator.id,
|
||||
submitter_name=operator.username,
|
||||
@ -459,40 +598,68 @@ def create_adjustment(
|
||||
session.add(order)
|
||||
session.flush()
|
||||
|
||||
if approval_service.is_enabled(session, APPROVAL_TYPE_ADJUSTMENT):
|
||||
order.status = ADJUSTMENT_STATUS_PENDING
|
||||
session.flush()
|
||||
approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
||||
ref_id=order.id,
|
||||
refer_no=order.order_no,
|
||||
submitter=operator,
|
||||
)
|
||||
else:
|
||||
order.status = ADJUSTMENT_STATUS_EFFECTIVE
|
||||
session.add_all(
|
||||
[
|
||||
ApprovalStep(
|
||||
order_id=order.id,
|
||||
node=node,
|
||||
action=APPROVAL_ACTION_AUTO_APPROVE,
|
||||
operator_name="系统",
|
||||
)
|
||||
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
|
||||
]
|
||||
)
|
||||
session.flush()
|
||||
_wire_adjustment_approval(session, order, operator)
|
||||
out = _adjustment_out(order) # 出参在 commit 前固化(audit 约定)
|
||||
session.commit()
|
||||
logger.info("调整单创建 | order=%s | status=%s", order.order_no, order.status)
|
||||
return {
|
||||
"id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"status": order.status,
|
||||
"personnel_name": order.personnel_name,
|
||||
"amount": str(order.amount),
|
||||
"direction": order.direction,
|
||||
}
|
||||
logger.info("调整单创建 | order=%s | status=%s", out["order_no"], out["status"])
|
||||
return out
|
||||
|
||||
|
||||
def _adjustment_amount(payload) -> Decimal:
|
||||
"""金额恒正校验(方向表达补发/扣减)。
|
||||
|
||||
列注释即「恒正」约定,负数会让方向语义反转、审批/报表按 direction
|
||||
汇总出错;格式已由 schema 正则拦截,此处兜底转换 + 语义判定。
|
||||
"""
|
||||
from decimal import InvalidOperation
|
||||
|
||||
try:
|
||||
amount = Decimal(payload.amount)
|
||||
except InvalidOperation as exc:
|
||||
raise BadRequestError("调整金额格式非法(非负两位小数)") from exc
|
||||
if amount <= 0:
|
||||
raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)")
|
||||
return amount
|
||||
|
||||
|
||||
def _wire_adjustment_approval(session: Session, order, operator: User) -> None:
|
||||
"""开关分流与留痕统一交给 submit_order(关=自动通过留痕;开=待财务复核)。
|
||||
|
||||
**不得手工插 ApprovalStep**——``approval_steps.order_id`` 外键指向
|
||||
approval_orders(不是 adjustment_orders),用调整单 id 落步必然外键
|
||||
失败(历史缺陷:关开关时新建调整单不可用)。
|
||||
"""
|
||||
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
|
||||
from app.models.settlement import (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||
ADJUSTMENT_STATUS_PENDING,
|
||||
)
|
||||
|
||||
approval_order = approval_service.submit_order(
|
||||
session,
|
||||
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
||||
ref_id=order.id,
|
||||
refer_no=order.order_no,
|
||||
submitter=operator,
|
||||
)
|
||||
order.status = (
|
||||
ADJUSTMENT_STATUS_EFFECTIVE
|
||||
if approval_order.status == APPROVAL_STATUS_APPROVED
|
||||
else ADJUSTMENT_STATUS_PENDING
|
||||
)
|
||||
session.flush()
|
||||
|
||||
|
||||
def _adjustment_out(order) -> dict:
|
||||
"""调整单出参(commit 前固化;expire_on_commit 关闭下也不依赖后置读取)。"""
|
||||
return {
|
||||
"id": order.id,
|
||||
"order_no": order.order_no,
|
||||
"status": order.status,
|
||||
"personnel_name": order.personnel_name,
|
||||
"amount": str(order.amount),
|
||||
"direction": order.direction,
|
||||
}
|
||||
|
||||
|
||||
def list_adjustments(session: Session) -> list[dict]:
|
||||
|
||||
@ -237,6 +237,12 @@ def create_shipment(
|
||||
raise NotFoundError("业务不存在")
|
||||
if business.status != BUSINESS_STATUS_ACTIVE:
|
||||
raise BadRequestError("业务已完成或已终止,禁止新发货")
|
||||
# 月结锁定拦截:发货日所在月已审定 → 源数据不可改(调整走调整单)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(
|
||||
session, payload.ship_date.strftime("%Y-%m")
|
||||
)
|
||||
|
||||
shipment = Shipment(
|
||||
shipment_no=shipment_no,
|
||||
@ -309,6 +315,11 @@ def update_shipment(
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
business = _ensure_business_writable(session, shipment.business_id, "编辑发货单")
|
||||
# 月结锁定拦截:原发货日与新月都不得落入已锁定月份(改日期=跨月搬数)
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||||
settlement_service.ensure_month_not_locked(session, payload.ship_date.strftime("%Y-%m"))
|
||||
|
||||
shipment.ship_date = payload.ship_date
|
||||
shipment.freight_amount = _amount_or_zero(payload.freight_amount)
|
||||
@ -357,6 +368,10 @@ def delete_shipment(
|
||||
if shipment is None:
|
||||
raise NotFoundError("发货单不存在")
|
||||
business = _ensure_business_writable(session, shipment.business_id, "删除发货单")
|
||||
# 月结锁定拦截:已锁定月份的发货单是月结快照的源数据,不得删除
|
||||
from app.services import settlement_service
|
||||
|
||||
settlement_service.ensure_month_not_locked(session, shipment.ship_date.strftime("%Y-%m"))
|
||||
if shipment_repository.count_receipt_refs(session, shipment_id):
|
||||
raise BadRequestError("已被回款批次指定,请先解除回款的批次指定")
|
||||
|
||||
|
||||
@ -137,7 +137,12 @@ def save_logo(
|
||||
|
||||
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
|
||||
if old_ext and old_ext != ext:
|
||||
_cleanup_old_logo(old_ext)
|
||||
try:
|
||||
_cleanup_old_logo(old_ext)
|
||||
except OSError:
|
||||
# 清理失败不影响本次更新(库与新文件均已生效);若异常外抛,
|
||||
# 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反
|
||||
logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True)
|
||||
logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
|
||||
return to_out(setting)
|
||||
|
||||
|
||||
@ -153,6 +153,8 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-17 | M3-core 定点 | 32 | ✅ 通过 | **④ 核算引擎+月结完成**(calc 13 / settlements 6 / formula 扩 12 / schemas 等)。金标准复算到分;重算版本链;月结锁定与审批联动 |
|
||||
| 2026-10-17 | 全量回归 | 701+N | ✅ 全部通过 | **M3-core 收尾回归**(T1-T6:V7 四表+核算+月结+调整单;前端台账/月结两页 order17/18)。e2e 补 calc_settlement_flow 待下批 |
|
||||
| 2026-10-16 | 全量回归 + 7 个 e2e | 697 | ✅ 697 passed(5:10);e2e 七脚本全过 | **M3 增量批次完成**(+72 例:settings 11 / 分配 schema 13+集成 9 / 审批 11 / registry 与 schema_sync 断言扩充等)。e2e:登录 8 + 系统管理 13 + 基础数据 12 + 业务 16 + 参数 7 步回归全过;**新增 settings_flow 4 步 / secondary_allocation_flow 6 步**(首跑即抓出 public 缓存与 logo 清理两处缺陷,见下表) |
|
||||
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
|
||||
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
@ -190,6 +192,19 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 2026-10-16 | **logo 上传同扩展名覆盖时先写后删** | `save_logo` 在写入新文件**之后**清理旧扩展名——同扩展名重传时把刚落盘的新文件一并删掉,回读 404(首次单跑通过、集成连跑即暴露,依赖「库里已有同扩展名旧记录」的前置状态) | 清理旧扩展名移到写新文件**之前**且仅异扩展名执行;集成连跑两遍幂等锁定 |
|
||||
| 2026-10-16 | **public 端点 300s 响应缓存让登录页拿旧公司名** | 公司名保存后退出登录,登录页命中浏览器缓存副本仍显示旧品牌名(设置页「保存后生效」承诺失效)——**由 settings_flow e2e 首跑暴露**(新标签页直接打开正常、退出回跳的登录页异常) | 登录页改用带时间戳的原生 `fetch` + `cache: no-store` 绕过磁盘缓存(axios 对响应 max-age 无能为力);手动解包统一信封 |
|
||||
| 2026-10-16 | V6 迁移 downgrade 保留 autogenerate 的逐个 `drop_index` 且漏删行级种子 | 外键唯一支撑索引单独删除报 MySQL 1553 回滚中断;roles/param_definitions/param_values 的行级种子(表由 V1/V3 建)drop_table 不会带走——重放 upgrade 时唯一键冲突 | downgrade 只保留 `drop_table`(V1/V3 先例)+ 三条 DELETE 反向清理行级种子;downgrade→upgrade 往返实测通过 |
|
||||
| 2026-10-19 | **经理团队聚合分成放大 100 倍**(settlement_service) | 参数按百分数存储(12.5=12.5%),`parse_fraction` 后调用方又 ×100、`manager_team_share` 内部再 ÷100——净效应「绩效×12.5」;单元测试直传 Fraction 未覆盖调用方换算使缺陷存活 | 去掉调用方 ×100;`rate_text` 改用实际参数原文(原硬编码默认值失真);集成用例两档金标准(6.84/9.78 vs 错误值 684.43)锁定 |
|
||||
| 2026-10-19 | **审批联动不判终审**(approval_service.act) | 财务节点通过(1→2)即 mark_locked 月结/调整单置生效——领导驳回后 locked_at/locked_by 残留、两级审批形同虚设(锁定后源数据写路径立即被拦) | 生效类联动收敛到 `final_approved`(终态才触发);驳回任意节点即时;mark_rejected 补清锁定字段;用例断言财务通过后仍 status=2 且无锁定信息 |
|
||||
| 2026-10-19 | **调整单创建完全不可用**(两处叠加) | ① `ENABLE_CODE_BY_TYPE` 漏登记 type=3 → is_enabled 恒 400「未知的审批对象类型」;② 修复①后暴露:自动生效分支手工插 `ApprovalStep(order_id=调整单id)`,而该外键指向 approval_orders,必然 errno 1452 | 补登记 `approval.adjustment.enabled`;自动通过留痕统一交 `submit_order`(不再手工插步);新建调整单集成用例锁定 |
|
||||
| 2026-10-19 | **重算绕过业务级一次性计提防线** | 「进度登记」回款(#25 第二笔及以后)无独立台账行,重算会凭空落一行生效行——同业务两行生效行、绩效按两倍计入工资 | 无旧版本行一律 400「进度登记无可重算」;用例锁定「重算后同业务仍只有一行生效行」 |
|
||||
| 2026-10-19 | **前端重算传错 ID**(台账页) | 把台账行主键当 receipt_id 传给重算接口——会作废并重算**无关回款**的台账(若目标回款已核算),或报错无法重算目标行 | 改传 `row.receipt_id`(聚合行 receipt_id 空值防御);`LedgerRow` 本含该字段 |
|
||||
| 2026-10-19 | **quantize 缺省银行家舍入**(9 处金额固化) | docstring 声称 HALF_UP,实际未传 rounding 走 ROUND_HALF_EVEN——x.xx5 舍成偶数尾数,与业务四舍五入口径差 1 分(台账固化金额比应发少 1 分) | 9 处显式 `rounding=ROUND_HALF_UP`;`_q2` 提为模块级并单测锁定(2.345→2.35) |
|
||||
| 2026-10-19 | **月结锁定拦截只覆盖 1/4 写路径** | 仅新增回款被拦;回款编辑(可跨月搬数)/删除、发货增/改/删、核算与重算全部放行——锁定月份快照与源数据脱钩且无留痕 | 五处写路径补齐 `ensure_month_not_locked`(编辑按原月+新月双查);拦截先于「被引用/已核算」判定;三用例锁定 |
|
||||
| 2026-10-19 | **台账 settlement_id 回填从未发生**(死代码) | `update_ledger_settlement_id` 全库零调用——「未入结」判据恒真,重生成会把已固化行反复纳入(或补上回填却不先解绑导致漏算整月) | generate 回填 + 重生成前解绑(新增 `clear_ledger_settlement_id`);用例断言回填与重生成金额不翻倍 |
|
||||
| 2026-10-19 | **经理聚合行从不落库** | `_build_manager_aggregates` 只构造内存对象参与 items 汇总——台账表永远无 calc_type=2 行,报表下钻(business_id 锚点)落空 | 聚合行经 repository 落库并回填 settlement_id;重生成先作废旧聚合行(`deactivate_month_aggregates`);`_confirm_scope` 限定 calc_type=1 防聚合行混入单笔池 |
|
||||
| 2026-10-19 | **月结取参用生成日**(date.today) | 补生成历史月份时经理分成/月工资口径/试用期阈值按生成日参数计算——同一月份两次生成金额不同,违背「参数按生效日期时点化」红线 | 新增 `_month_end`(所属月月末)为聚合与月工资口径取参锚点;单测 5 例 + 集成用例(10-25 预约变更影响 10 月聚合) |
|
||||
| 2026-10-19 | **迁移双轨三处漂移** | ① V6 就地修改加入 uk 致旧库补不上(V8 也未补)→ V9 条件式补齐(幂等裸 SQL,临时库验证两分支);② V7 留存 SQL 约束/索引名单数(`fk_performance_ledger_*`)与 alembic op.f/init-dev 漂移;③ V8 迁移 revision 缺类型注解,`test_schema_sync` 的 head 推断**静默漏检 V8**、init-dev 版本号落后未被发现 | V9 迁移 + V7 SQL 名修正 + V8 注解修正 + init-dev 版本推进;schema 同步测试恢复全量断言 |
|
||||
| 2026-10-19 | 运维脚本两处(restore_drill/health_check) | ① 行数核对把查询失败回显 `'?'` 当数据比较(`'?'=='?'` 判 ✓)且 TABLES 缺 V7 四表等 16 张——备份漏表时演练全绿误报「可恢复」;② `redis-cli -a "$REDIS_PASSWORD"` 密码进宿主进程命令行 | TABLES 补全 32 张业务表 + `'?'` 一律判 ✗;Redis 探活改容器内环境变量展开(compose 补 REDIS_PASSWORD),实测 PONG |
|
||||
| 2026-10-19 | 调整单金额无校验 / INT 参数原文 `int()` / logo 清理裸抛 | ① amount 负数反转补发-扣减语义、'abc' 直落库 500;② `int('360.0')` 使整月核算 500(INT 参数合法入库原文);③ 旧 logo 清理 OSError 裸抛把成功更新记成失败审计并 500 | schema 正则 + 服务层 Decimal>0;`int(parse_fraction(...))`;清理包 try 仅 warning;各补回归用例 |
|
||||
|
||||
## 五、待补用例
|
||||
|
||||
@ -198,7 +213,7 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||
| M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 |
|
||||
| 绩效工资核算 | 未开始 | **被业务澄清阻塞**(经理个人户基数、含税/退货冲减等 5 项口径待答复);答复后按同粒度另出计划 |
|
||||
| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 |
|
||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||
|
||||
> 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。
|
||||
|
||||
@ -129,11 +129,12 @@ def switched_on(client: TestClient, admin: dict) -> None:
|
||||
|
||||
|
||||
class TestSwitchOff:
|
||||
"""开关关闭(种子默认):提单即自动通过。"""
|
||||
"""开关关闭(用例显式设置,不依赖种子默认值):提单即自动通过。"""
|
||||
|
||||
def test_创建带方案_自动通过全节点留痕(
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
_set_switch(client, admin["headers"], "0") # V8 起种子默认开启
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_plan(client, admin["headers"], p["id"])
|
||||
|
||||
|
||||
@ -357,6 +357,38 @@ class TestGuards:
|
||||
resp = _calc(client, ops["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_分界天数参数小数原文_核算不500(self, client: TestClient, admin: dict) -> None:
|
||||
"""守 int(参数原文) 缺陷:INT 参数入库允许 '360.0'(Fraction 分母为 1),
|
||||
直接 int(str) 抛 ValueError 会让整月核算 500。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "用例:INT 参数小数原文",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "salesperson.days.boundary",
|
||||
"dimension_value": "salesperson",
|
||||
"value": "360.0",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
_biz, receipt = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
resp = _calc(client, admin["headers"], receipt["id"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
# 分界取整无损:20 天 < 360 天 → 同一递减公式
|
||||
assert resp.json()["data"]["ledger"]["rate_text"] == "11/1125"
|
||||
|
||||
def test_台账列表与筛选(self, client: TestClient, admin: dict) -> None:
|
||||
biz, receipt = _create_full_chain(
|
||||
client,
|
||||
@ -459,6 +491,53 @@ class TestRecalc:
|
||||
assert resp.status_code == 400
|
||||
assert "尚未核算" in resp.json()["message"]
|
||||
|
||||
def test_进度登记回款重算400_不得凭空落行(self, client: TestClient, admin: dict) -> None:
|
||||
"""业务级一次性计提的第二笔回款无台账行;重算若落新行会双重计提。"""
|
||||
biz, first = _create_full_chain(
|
||||
client,
|
||||
admin["headers"],
|
||||
items=_GOLD_ITEMS,
|
||||
ship_date="2026-10-01",
|
||||
receipt_date="2026-10-20",
|
||||
amount="20000.00",
|
||||
)
|
||||
assert _calc(client, admin["headers"], first["id"]).status_code == 200
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-25",
|
||||
"amount": "100.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
second = resp.json()["data"]
|
||||
assert _calc(client, admin["headers"], second["id"]).json()["data"]["repeated"] is True
|
||||
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{second['id']}/recalc",
|
||||
json={"reason": "尝试重算进度登记"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "进度登记" in resp.json()["message"]
|
||||
|
||||
# 同业务仍只有一行生效台账(未凭空新增)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.business_id == biz["id"],
|
||||
PerformanceLedger.is_active == 1,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1
|
||||
|
||||
def test_重算_普通用户与营销后勤403(
|
||||
self, client: TestClient, normal: dict, make_user, auth_headers
|
||||
) -> None:
|
||||
|
||||
@ -69,6 +69,26 @@ def _create_with_allocations(client: TestClient, headers: dict, allocations: lis
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _set_switch(client: TestClient, headers: dict, value: str) -> None:
|
||||
"""显式设置二次分配审批开关(V8 起默认开启,用例不得依赖种子默认值)。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"用例设置二次分配审批开关={value}",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "approval.secondary_allocation.enabled",
|
||||
"dimension_value": "",
|
||||
"value": value,
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _detail(client: TestClient, headers: dict, business_id: int) -> dict:
|
||||
resp = client.get(f"{_BUSINESSES}/{business_id}", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
@ -239,6 +259,8 @@ class TestDeleteProtection:
|
||||
assert detail["secondary_allocations"][0]["personnel_name"] == p["personnel_name"]
|
||||
|
||||
def test_删除业务后可删人员(self, client: TestClient, admin: dict) -> None:
|
||||
# 显式关开关:开启时审批单在途、删业务被 400 拦截(产品行为,另有用例覆盖)
|
||||
_set_switch(client, admin["headers"], "0")
|
||||
p = _person(client, admin["headers"])
|
||||
data = _create_with_allocations(
|
||||
client, admin["headers"], [{"personnel_id": p["id"], "ratio": "50.00"}]
|
||||
|
||||
@ -10,13 +10,15 @@
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import MonthlySettlement
|
||||
from app.models import MonthlySettlement, PerformanceLedger
|
||||
from tests.integration.factories import business_payload, customer
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
@ -39,30 +41,38 @@ _GOLD_ITEMS = [
|
||||
]
|
||||
|
||||
|
||||
def _person(client: TestClient, headers: dict) -> dict:
|
||||
def _role_id(role_code: str) -> int:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import BUSINESS_ROLE_SALESPERSON
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import BusinessRole
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
role_id = int(
|
||||
return int(
|
||||
session.execute(
|
||||
select(BusinessRole.id).where(
|
||||
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||
BusinessRole.role_code == role_code,
|
||||
BusinessRole.status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
def _person(
|
||||
client: TestClient,
|
||||
headers: dict,
|
||||
*,
|
||||
role_code: str = "salesperson",
|
||||
manager_id: int | None = None,
|
||||
) -> dict:
|
||||
resp = client.post(
|
||||
_PERSONNEL,
|
||||
json={
|
||||
"personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}",
|
||||
"business_role_id": role_id,
|
||||
"business_role_id": _role_id(role_code),
|
||||
"salary_standard": "3000.00",
|
||||
**({"manager_id": manager_id} if manager_id is not None else {}),
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
@ -70,6 +80,20 @@ def _person(client: TestClient, headers: dict) -> dict:
|
||||
return resp.json()["data"]
|
||||
|
||||
|
||||
def _set_switch(client: TestClient, headers: dict, code: str, value: str) -> None:
|
||||
"""显式设置审批开关(V8 起审批开关默认开启,用例不得依赖种子默认值)。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"用例设置 {code}={value}",
|
||||
"items": [{"param_code": code, "dimension_value": "", "value": value}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _calc_one(client: TestClient, headers: dict) -> dict:
|
||||
"""完整造一笔已核算回款(回款日 2026-10-20 → 归月 2026-10)。"""
|
||||
cust = customer(client, headers)
|
||||
@ -145,6 +169,15 @@ class TestGenerate:
|
||||
select(MonthlySettlement).where(MonthlySettlement.settlement_month == "2026-10")
|
||||
).scalar_one()
|
||||
assert settled.person_count >= 1
|
||||
backfilled = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(PerformanceLedger.settlement_id == settled.id)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert backfilled, "台账行 settlement_id 未回填(「未入结」判据失效)"
|
||||
assert all(r.settlement_month == "2026-10" for r in backfilled)
|
||||
|
||||
def test_无核算记录生成400(self, client: TestClient, admin: dict) -> None:
|
||||
resp = client.post(
|
||||
@ -161,7 +194,8 @@ class TestGenerate:
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
# 提交(开关默认关)→ 自动通过锁定
|
||||
# 显式关开关 → 提交即自动通过锁定(用例不依赖种子默认值)
|
||||
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
|
||||
"id"
|
||||
]
|
||||
@ -172,8 +206,7 @@ class TestGenerate:
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
# 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改
|
||||
assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"])
|
||||
assert "已审定锁定" in resp.json()["message"]
|
||||
|
||||
|
||||
class TestSubmitAndLock:
|
||||
@ -183,6 +216,8 @@ class TestSubmitAndLock:
|
||||
self, client: TestClient, admin: dict, finance: dict
|
||||
) -> None:
|
||||
_calc_one(client, admin["headers"])
|
||||
# 显式关开关(V8 起种子默认开启,用例不得依赖默认值)
|
||||
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
@ -322,6 +357,13 @@ class TestSwitchOn:
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# 财务节点通过只是流转到待领导——不得提前锁定月结(否则领导驳回后
|
||||
# 锁定字段残留、两级审批形同虚设)
|
||||
mid = client.get(f"{_SETTLEMENTS}/{sid}", headers=admin["headers"]).json()["data"]
|
||||
assert mid["status"] == 2, "财务通过后应仍为待审批,不得提前锁定"
|
||||
assert mid["locked_at"] is None
|
||||
assert mid["locked_by"] is None
|
||||
|
||||
# 领导通过 → 锁定
|
||||
resp = client.post(
|
||||
f"/api/v1/system/approvals/{order['id']}/action",
|
||||
@ -333,3 +375,377 @@ class TestSwitchOn:
|
||||
assert final["status"] == 3
|
||||
assert final["locked_at"] is not None
|
||||
assert final["locked_by"] == leader["username"] # 审批通过人=领导账号
|
||||
|
||||
|
||||
class TestManagerAggregate:
|
||||
"""经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。"""
|
||||
|
||||
def _scene(self, client: TestClient, headers: dict) -> tuple[dict, dict]:
|
||||
"""经理(带一名业务员)→ 业务 → 发货 → 已核算回款,返回 (经理, 回款)。"""
|
||||
manager = _person(client, headers, role_code="sales_manager")
|
||||
sales = _person(client, headers, manager_id=manager["id"])
|
||||
|
||||
cust = customer(client, headers)
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=business_payload(
|
||||
cust["id"],
|
||||
owner_personnel_id=sales["id"],
|
||||
items=[dict(_GOLD_ITEMS[0], product_name=f"t_聚合_{uuid.uuid4().hex[:6]}")],
|
||||
),
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
biz = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-10-01",
|
||||
"items": [{"product_name": "t_发", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-20",
|
||||
"amount": "20000.00",
|
||||
"shipment_id": resp.json()["data"]["id"],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
receipt = resp.json()["data"]
|
||||
assert (
|
||||
client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers).status_code
|
||||
== 200
|
||||
)
|
||||
return manager, receipt
|
||||
|
||||
def test_聚合金额按实际比例计入且落库回填(self, client: TestClient, admin: dict) -> None:
|
||||
manager, _receipt = self._scene(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
items = out["items"]
|
||||
manager_items = [i for i in items if i["calc_type"] == 2]
|
||||
assert len(manager_items) == 1
|
||||
# 业务员绩效 39.11;经理 = 39.11×12.5% + 39.11×5% = 6.84425 → 6.84
|
||||
# (若调用方把百分数再 ×100,此处会是 684.43)
|
||||
assert manager_items[0]["personnel_id"] == manager["id"]
|
||||
assert manager_items[0]["performance_sum"] == "6.84"
|
||||
assert manager_items[0]["payable_sum"] == "6.84"
|
||||
|
||||
# 聚合行必须落台账(报告下钻的数据源)且被回填 settlement_id
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
rows = (
|
||||
session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == "2026-10",
|
||||
PerformanceLedger.calc_type == 2,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 1, "聚合行必须落库(修复前只在内存参与汇总)"
|
||||
assert rows[0].is_active == 1
|
||||
assert rows[0].receipt_id is None
|
||||
assert rows[0].settlement_id == out["id"], "聚合行未回填 settlement_id"
|
||||
|
||||
def test_草稿态重生成_聚合不重复计入(self, client: TestClient, admin: dict) -> None:
|
||||
self._scene(client, admin["headers"])
|
||||
first = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
).json()["data"]
|
||||
second = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert second.status_code == 200, second.text
|
||||
out = second.json()["data"]
|
||||
# 重生成金额不翻倍(聚合行不做废旧行会 45.95→52.79)也不漏行
|
||||
assert out["total_payable"] == first["total_payable"] == "45.95"
|
||||
assert len([i for i in out["items"] if i["calc_type"] == 2]) == 1
|
||||
assert len([i for i in out["items"] if i["calc_type"] == 1]) == 1
|
||||
|
||||
def test_月末取参_预约变更影响本月聚合(self, client: TestClient, admin: dict) -> None:
|
||||
"""取参锚点=所属月月末:10-25 生效的预约变更应影响 2026-10 月结。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 2,
|
||||
"effective_date": "2026-10-25",
|
||||
"remark": "用例:月末取参锚点",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "manager.share.team",
|
||||
"dimension_value": "",
|
||||
"value": "20",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
self._scene(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
manager_items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
|
||||
# 39.11×20% + 39.11×5% = 9.7775 → 9.78(按生成日取参会得 6.84)
|
||||
assert manager_items[0]["performance_sum"] == "9.78"
|
||||
|
||||
# 台账快照固化月末取参日与实际比例原文
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
row = session.execute(
|
||||
select(PerformanceLedger).where(
|
||||
PerformanceLedger.settlement_month == "2026-10",
|
||||
PerformanceLedger.calc_type == 2,
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.snapshot_json["params_as_of"] == "2026-10-31"
|
||||
assert row.rate_text == "聚合:20%+5%"
|
||||
assert str(row.receipt_date) == "2026-10-31"
|
||||
|
||||
def test_聚合基数开关_经理个人户计入可配置(self, client: TestClient, admin: dict) -> None:
|
||||
"""#12 答复(2026-10-19)参数化:manager.personal.included=1 时个人户业绩并入基数。
|
||||
|
||||
直插一行经理个人户台账(绩效 100,模拟该类业务经单笔侧计提后的
|
||||
结果)——默认 0 时聚合 6.84 不变;开关 1 后基数 39.11+100=139.11。
|
||||
"""
|
||||
manager, _receipt = self._scene(client, admin["headers"])
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.add(
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_version=1,
|
||||
calc_type=1,
|
||||
business_id=0,
|
||||
business_no="t_个人户",
|
||||
personnel_id=manager["id"],
|
||||
personnel_name=manager["personnel_name"],
|
||||
role_code="sales_manager",
|
||||
customer_type="manager_personal",
|
||||
product_category="mixed",
|
||||
receipt_date=date(2026, 10, 15),
|
||||
rate_text="0",
|
||||
performance_amount=Decimal("100.00"),
|
||||
barter_applied=0,
|
||||
payable_amount=Decimal("0"),
|
||||
settlement_month="2026-10",
|
||||
is_probation=0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def _manager_share() -> str:
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
items = [i for i in resp.json()["data"]["items"] if i["calc_type"] == 2]
|
||||
assert len(items) == 1
|
||||
return items[0]["performance_sum"]
|
||||
|
||||
# 默认(开关 0=不计入):基数 39.11 → 6.84(与既有金标准一致)
|
||||
assert _manager_share() == "6.84"
|
||||
|
||||
# 开关 1(立即生效,月末 2026-10-31 取参命中):139.11×12.5% + ×5% = 24.34425
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": "用例:#12 开关计入",
|
||||
"items": [
|
||||
{
|
||||
"param_code": "manager.personal.included",
|
||||
"dimension_value": "",
|
||||
"value": "1",
|
||||
}
|
||||
],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _manager_share() == "24.34"
|
||||
|
||||
|
||||
class TestAdjustment:
|
||||
"""调整单:已锁定月份补发/扣减的唯一通道。"""
|
||||
|
||||
def test_新建调整单_开关关闭即生效(self, client: TestClient, admin: dict) -> None:
|
||||
# 显式关开关(用例不依赖种子默认值)
|
||||
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
||||
person = _person(client, admin["headers"])
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/adjustments",
|
||||
json={
|
||||
"settlement_month": "2026-10",
|
||||
"personnel_id": person["id"],
|
||||
"direction": 1,
|
||||
"amount": "500.00",
|
||||
"reason": "补发差额",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
# 守 ENABLE_CODE_BY_TYPE 漏登记 type=3:修复前此处恒 400「未知的审批对象类型」
|
||||
assert resp.status_code == 200, resp.text
|
||||
out = resp.json()["data"]
|
||||
assert out["status"] == 2 # 已生效
|
||||
|
||||
def test_金额恒正校验(self, client: TestClient, admin: dict) -> None:
|
||||
"""守金额无校验缺陷:负数会反转补发/扣减语义,非数值直落库 500。"""
|
||||
_set_switch(client, admin["headers"], "approval.adjustment.enabled", "0")
|
||||
person = _person(client, admin["headers"])
|
||||
base = {
|
||||
"settlement_month": "2026-10",
|
||||
"personnel_id": person["id"],
|
||||
"direction": 1,
|
||||
"reason": "金额校验用例",
|
||||
}
|
||||
for bad in ("-500.00", "0", "0.00", "abc", "1e3"):
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/adjustments",
|
||||
json={**base, "amount": bad},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, f"amount={bad!r} 应 400:{resp.text}"
|
||||
|
||||
|
||||
class TestLockInterception:
|
||||
"""锁定后源数据写路径全量拦截(回款编辑/删除、发货增改删、核算/重算)。"""
|
||||
|
||||
def _lock_scene(self, client: TestClient, admin: dict) -> tuple[dict, dict, dict, dict]:
|
||||
"""已核算+未核算各一笔回款、两张 10 月发货单,然后锁定 2026-10。
|
||||
|
||||
返回 (月结, 业务, 未核算回款, 无回款引用的发货单)。
|
||||
"""
|
||||
biz = _calc_one(client, admin["headers"])
|
||||
resp = client.post(
|
||||
_RECEIPTS,
|
||||
json={"business_id": biz["id"], "receipt_date": "2026-10-22", "amount": "100.00"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
unsettled = resp.json()["data"]
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"ship_date": "2026-10-05",
|
||||
"items": [{"product_name": "t_发2", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
free_shipment = resp.json()["data"]
|
||||
|
||||
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement = resp.json()["data"]
|
||||
resp = client.post(f"{_SETTLEMENTS}/{settlement['id']}/submit", headers=admin["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["data"]["status"] == 3
|
||||
return settlement, biz, unsettled, free_shipment
|
||||
|
||||
def test_锁定后_未核算回款禁改禁删(self, client: TestClient, admin: dict) -> None:
|
||||
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
|
||||
resp = client.put(
|
||||
f"{_RECEIPTS}/{unsettled['id']}",
|
||||
json={
|
||||
"business_id": biz["id"],
|
||||
"receipt_date": "2026-10-23",
|
||||
"amount": "88.00",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
resp = client.delete(f"{_RECEIPTS}/{unsettled['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
def test_锁定后_发货增改删400_非锁定月放行(self, client: TestClient, admin: dict) -> None:
|
||||
_settlement, biz, _unsettled, free_shipment = self._lock_scene(client, admin)
|
||||
items = [{"product_name": "t_发x", "quantity": "1", "sale_price": "1.00"}]
|
||||
|
||||
# 新增:落锁定月 400
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={"business_id": biz["id"], "ship_date": "2026-10-08", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
# 新增:非锁定月放行(拦截不误伤其它月份)
|
||||
resp = client.post(
|
||||
_SHIPMENTS,
|
||||
json={"business_id": biz["id"], "ship_date": "2026-11-02", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
nov = resp.json()["data"]
|
||||
|
||||
# 编辑:把非锁定月的发货单改入锁定月 400
|
||||
resp = client.put(
|
||||
f"{_SHIPMENTS}/{nov['id']}",
|
||||
json={"ship_date": "2026-10-15", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
# 编辑:原月即锁定月 400
|
||||
resp = client.put(
|
||||
f"{_SHIPMENTS}/{free_shipment['id']}",
|
||||
json={"ship_date": "2026-10-06", "items": items},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
# 删除:锁定月的发货单 400(拦截先于「被回款引用」判定)
|
||||
resp = client.delete(f"{_SHIPMENTS}/{free_shipment['id']}", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
def test_锁定后_首算与重算400(self, client: TestClient, admin: dict) -> None:
|
||||
_settlement, biz, unsettled, _ship = self._lock_scene(client, admin)
|
||||
from app.models import Receipt
|
||||
|
||||
with get_session_factory()() as session:
|
||||
settled_id = int(
|
||||
session.execute(
|
||||
select(Receipt.id).where(
|
||||
Receipt.business_id == biz["id"],
|
||||
Receipt.settlement_status == 1,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"{_CALC}/receipts/{settled_id}/recalc",
|
||||
json={"reason": "锁定月重算"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
28
backend/tests/unit/test_calc_helpers.py
Normal file
28
backend/tests/unit/test_calc_helpers.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""核算落库辅助纯函数测试。
|
||||
|
||||
守的是:金额两位固化的舍入模式 —— quantize 缺省是上下文 ROUND_HALF_EVEN
|
||||
(银行家舍入),x.xx5 会舍成偶数尾数,与业务四舍五入口径差 1 分
|
||||
(docstring 声称 HALF_UP,必须用测试锁死实际行为)。
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from app.services.calc_service import _q2
|
||||
|
||||
|
||||
class TestQ2:
|
||||
"""金额两位小数(HALF_UP)。"""
|
||||
|
||||
def test_半进位一律入(self) -> None:
|
||||
# 缺省 ROUND_HALF_EVEN 会舍成 2.34(4 是偶数)
|
||||
assert _q2(Decimal("2.345")) == Decimal("2.35")
|
||||
|
||||
def test_负数半进位远离零(self) -> None:
|
||||
assert _q2(Decimal("-2.345")) == Decimal("-2.35")
|
||||
|
||||
def test_普通进位不变(self) -> None:
|
||||
assert _q2(Decimal("39.111111")) == Decimal("39.11")
|
||||
assert _q2(Decimal("39.115000")) == Decimal("39.12")
|
||||
|
||||
def test_None直通(self) -> None:
|
||||
assert _q2(None) is None
|
||||
@ -61,9 +61,14 @@ class TestSalespersonRate:
|
||||
# 公式外推为 5.989‰,业务确认「360 为分界线,360 以后按千分之四」
|
||||
assert _sp_rate(361) == Fraction(4, 1000)
|
||||
|
||||
def test_第1天按公式为899比90000_非取整为10(self) -> None:
|
||||
# 需规图示「第 1 天对应 10‰」为上限参考;公式值 = (10 − 4/360)/1000 = 899/90000
|
||||
assert _sp_rate(1) == Fraction(899, 90000)
|
||||
def test_首日取整10千分_图示口径裁决(self) -> None:
|
||||
# 需规图示「第 1 天 10‰」2026-10-19 裁决覆盖公式(公式值 9.9889‰ 作废):
|
||||
# 首日不递减、直接取起始值;第 2 天起才进入公式段
|
||||
assert _sp_rate(1) == Fraction(10, 1000)
|
||||
|
||||
def test_第2天起进入公式递减段(self) -> None:
|
||||
# 10‰ − 2×4/360‰ = 9.9778‰(首日取整裁决只影响第 1 天,第 2 天起不变)
|
||||
assert _sp_rate(2) == Fraction(10, 1000) - Fraction(2 * 4, 360 * 1000)
|
||||
|
||||
def test_第180天线性段中点千分之8(self) -> None:
|
||||
assert _sp_rate(180) == Fraction(8, 1000)
|
||||
|
||||
29
backend/tests/unit/test_settlement_helpers.py
Normal file
29
backend/tests/unit/test_settlement_helpers.py
Normal 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)
|
||||
@ -874,4 +874,4 @@ CREATE TABLE alembic_version (
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('3bca400421c6');
|
||||
INSERT INTO alembic_version (version_num) VALUES ('ba0217f5091b');
|
||||
|
||||
@ -93,19 +93,19 @@ CREATE TABLE performance_ledgers (
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_performance_ledgers PRIMARY KEY (id),
|
||||
CONSTRAINT fk_performance_ledger_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledger_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledger_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT uk_performance_ledger_receipt_id_calc_version UNIQUE (receipt_id, calc_version)
|
||||
CONSTRAINT fk_performance_ledgers_personnel_id FOREIGN KEY(personnel_id) REFERENCES personnel (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledgers_receipt_id FOREIGN KEY(receipt_id) REFERENCES receipts (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_performance_ledgers_settlement_id FOREIGN KEY(settlement_id) REFERENCES monthly_settlements (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT uk_performance_ledgers_receipt_id_calc_version UNIQUE (receipt_id, calc_version)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='绩效核算台账表(append-only 版本行;重算插新版本旧行仅翻 is_active)' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_performance_ledger_business_id ON performance_ledgers (business_id);
|
||||
CREATE INDEX idx_performance_ledgers_business_id ON performance_ledgers (business_id);
|
||||
|
||||
CREATE INDEX idx_performance_ledger_month_active ON performance_ledgers (settlement_month, is_active);
|
||||
CREATE INDEX idx_performance_ledgers_month_active ON performance_ledgers (settlement_month, is_active);
|
||||
|
||||
CREATE INDEX idx_performance_ledger_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
|
||||
CREATE INDEX idx_performance_ledgers_month_personnel_type ON performance_ledgers (settlement_month, personnel_id, calc_type);
|
||||
|
||||
CREATE INDEX idx_performance_ledger_personnel_id ON performance_ledgers (personnel_id);
|
||||
CREATE INDEX idx_performance_ledgers_personnel_id ON performance_ledgers (personnel_id);
|
||||
|
||||
ALTER TABLE approval_orders MODIFY order_type TINYINT NOT NULL COMMENT '审批对象类型:1二次分配方案 2月度结算 3调整单';
|
||||
|
||||
|
||||
13
db/migration/V9__add_secondary_allocations_uk.sql
Normal file
13
db/migration/V9__add_secondary_allocations_uk.sql
Normal 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';
|
||||
@ -38,6 +38,8 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
# 健康检查脚本在容器内展开(密码不进宿主进程命令行,模式同 mysql)
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
# appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
|
||||
@ -3,13 +3,14 @@
|
||||
> 汇总当前所有「待执行 / 待答复 / 待恢复」事项,供项目例会与对外跟催使用。
|
||||
> 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。
|
||||
> 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。
|
||||
> **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。
|
||||
|
||||
## 一、立即待办(需项目内处理)
|
||||
|
||||
| # | 事项 | 状态 | 下一步动作 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | **推送 feat/m3-increments(27 个提交)** | ⛔ 被远端阻塞 | 43.140.215.244:7040 的 Git 服务应用层无响应(ping 通、TCP 通、HTTP 不回包),需**服务器管理员重启 Git 服务**;恢复后执行 `git push -u origin feat/m3-increments`。本地提交完整无丢失 |
|
||||
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;远端恢复后与 #1 一并处理(PR 评审或本地合并) |
|
||||
| 1 | **推送 feat/m3-increments** | 🔄 远端已恢复 | 2026-10-19 实测 `git ls-remote` 连通;远端已推进至 35fe37f(V8/权限/增量备份),**本地领先 8 提交**(code-review 修复 4 提交 + #14 首日口径 + 相关测试/文档)——推送前与并行会话对账,避免重复推送 |
|
||||
| 2 | **M2 分支合并 main** | 未开始 | feat/base-business-data(M2 全部 + code-review 32 条修复)尚未合入 main;与 #1 一并处理(PR 评审或本地合并) |
|
||||
|
||||
## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)
|
||||
|
||||
@ -22,23 +23,23 @@
|
||||
| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 未答复前只做方案录入,不实现分摊(已按此交付) | ④ 分摊计算 | ❌ 未答复 |
|
||||
| 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 |
|
||||
| 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 |
|
||||
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定 |
|
||||
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认 |
|
||||
| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊实现(88 批次)进行中 |
|
||||
| 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** |
|
||||
| 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 |
|
||||
| 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 |
|
||||
| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ⚠️ 模糊:「做参数可调」,初始值未给 |
|
||||
| 12 | **经理个人户业绩是否计入其 12.5% 基数?(无默认,必须答复)** | —— | ④ 台账表结构设计 | ✅ **已落实为参数(2026-10-19)**:答复「做参数可调、初始值未给」→ 按默认 0=不计入落地(`manager.personal.included`),可手工调 1=计入 |
|
||||
|
||||
### 中优先级(模块实现期内答复即可,9 项)
|
||||
|
||||
| 清单编号 | 问题 | 答复状态(2026-10-16) |
|
||||
| --- | --- | --- |
|
||||
| 5 | 二次分配审批开关默认值? | 🔁 **与默认不同:默认开启**,业务可关闭 |
|
||||
| 7 | 月度结算审批链默认开还是关? | ✅ 默认开启,可手动关闭 |
|
||||
| 5 | 二次分配审批开关默认值? | ✅ 已落实(2026-10-19,V8):默认开启 + 当日生效行;调整单/二次分配/月结三类审批开关均已可读可调 |
|
||||
| 7 | 月度结算审批链默认开还是关? | ✅ 已落实(2026-10-19,V8):默认开启;财务节点通过仅流转,终审(领导)通过才锁定月结 |
|
||||
| 10 | 「风险预警」包含哪些情形与阈值? | ✅ 方向确认:参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步 |
|
||||
| 11 | 「待办事项」范围? | ✅ 确认:待审批+同步失败+回款未指定批次起步 |
|
||||
| 13 | 支付环节 5 项默认值顺手确认(起付 60%/R×R/预付 10‰/抵顶 80%/月工资口径) | ✅ 确认按默认执行 |
|
||||
| 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL |
|
||||
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | 🔁 **与默认不同:按人员档案逐人维护**(用 `personnel.salary_standard`,撤 `probation.salary.basic` 参数) |
|
||||
| 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` |
|
||||
| 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 |
|
||||
| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 |
|
||||
|
||||
@ -46,16 +47,17 @@
|
||||
|
||||
| 清单编号 | 问题 | 答复状态(2026-10-16) |
|
||||
| --- | --- | --- |
|
||||
| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | 🔁 **与默认不同:首日整 10‰**。实现口径待追问:A 仅首日覆盖(保「360 天=6‰」锚点,推荐)/ B 整体平移(破坏锚点) |
|
||||
| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 查看可、修改仅管理员 |
|
||||
| 14 | 需规图示「第 1 天 10‰」与公式不一致,是否认可按公式? | ✅ 已落实(2026-10-19,按 A 口径):首日整 10‰ 特判、次日起按公式(保「360 天=6‰」锚点);B 口径(整体平移)追问保留 |
|
||||
| 17 | 计提参数等敏感配置普通用户可否查看? | ✅ 已落实(2026-10-19):查看放开登录用户、修改仅管理员 |
|
||||
| 18 | 企业户 02-17 之前的业务是否按 2‰ 回溯? | ✅ 确认按回溯 |
|
||||
| 19 | 备份保留周期与恢复目标? | 🔁 **新增要求:每日全量 + 有更新时增量补充**(现三脚本仅全量) |
|
||||
| 19 | 备份保留周期与恢复目标? | ✅ 已落实(2026-10-19):全量 + binlog 增量归档补位 |
|
||||
| 21 | 退货/红字是否冲减绩效? | ✅ 当期冲减(未结算月),已结算走调整单 |
|
||||
| 23 | 回款按到账金额还是扣手续费净额? | ✅ 按到账金额 |
|
||||
| 24 | 「首期不生成负绩效」与裁决「负绩效不归零」冲突,以哪个为准? | ✅ 维持裁决:负数照算不归零、月结支持扣补(业务补注:按当前算法首期不会出现负绩效) |
|
||||
|
||||
## 三、待 IT / T+ 侧答复(18 项,见《T+对接数据需求清单》v1.3「待IT确认事项」表)
|
||||
|
||||
> **2026-10-19 清单已升 v1.4**(`docs/T+对接数据需求清单_v1.4_2026-10-19.xlsx`,七节:人员/产品/业务单据/发货单/回款单/客户档案/同步完整性与补充字段)——本节口径待收编到 v1.4 后再对外;待 IT 逐项确认的状态以最新版文件为准。
|
||||
> **2026-10-16 业务方已对「口径与用途说明」修订,已收编清单升 v1.3**:#18 回款↔发货批次定稿**人工指定**(T+ 核销关系仅作辅助,`shipment_id` 保持「同步不覆盖」,v1.2.1「T+ 主源」修订作废,M2 回款页现状不动);「确认回款」两处 #8 开放引用收敛;特殊业务标记待确认降为低优。
|
||||
> **新增追问 2 条**(随下一轮回执发业务方):① 「负责营销人员」口径被改为「人员编码**以姓名**为唯一识别」,与「编码是业绩归属唯一锚点(防重名错钱)」矛盾,疑笔误;② 数据清单回款「关联订单号」仍写「由 T+ 收款分配关系指定,人工辅助」,与不覆盖表「人工指定」主次相反,疑漏改。
|
||||
|
||||
@ -81,14 +83,15 @@
|
||||
|
||||
| 里程碑 | 内容 | 前置条件 |
|
||||
| --- | --- | --- |
|
||||
| M3 剩余 | ④ 核算引擎 + 月结/调整单/负数工资 + ⑦ 月结审批对象接入 | #12(基数参数初始值)、#20(含税口径)、#1–#3(二次分摊)落定后按同粒度另出计划;#14 首日 10‰ 口径(A/B)确认后随本批实现 |
|
||||
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 交付后 |
|
||||
| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;**余**:二次分摊实现(#1–#3 答复空白)、业务答复回填批次收口(88 会话) | #20(含税口径)、#1–#3 答复后按同粒度推进 |
|
||||
| M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 |
|
||||
| M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 |
|
||||
| code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 |
|
||||
| 杂项收口(低优) | 超长函数 17 处收敛、散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
|
||||
| 杂项收口(低优) | 超长函数收敛:calc_receipt 与 settlement_service(生成/聚合/调整单)已拆(2026-10-19);剩 preview/change_ownership/create_receipt/update_receipt 等;散参数 66 处封装、useDropdownOptions 余两页 | 触碰时顺手 |
|
||||
|
||||
## 五、文档待办(低优)
|
||||
|
||||
- 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准
|
||||
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.3**(收编业务方口径修订,生成脚本 build_tplus_xlsx.py 同步对齐)
|
||||
- 新口径待落实(业务答复与默认不同的 4 项):#5 审批开关默认开启(核对 M3 参数种子)、#14 首日整 10‰(④ 实现,A/B 口径待追问)、#16 试用期工资迁人员档案、#19 备份补增量能力
|
||||
- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4)
|
||||
- ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)**
|
||||
- **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可
|
||||
|
||||
@ -40,19 +40,23 @@ const recalcTarget = ref<LedgerRow | null>(null)
|
||||
const recalcReason = ref('')
|
||||
|
||||
function openRecalc(row: LedgerRow): void {
|
||||
// 聚合行(calc_type=2)receipt_id 为 NULL,无回款可重算(按钮已限量,防御)
|
||||
if (row.receipt_id == null) return
|
||||
recalcTarget.value = row
|
||||
recalcReason.value = ''
|
||||
recalcVisible.value = true
|
||||
}
|
||||
|
||||
async function submitRecalc(): Promise<void> {
|
||||
if (!recalcTarget.value) return
|
||||
const row = recalcTarget.value
|
||||
if (!row || row.receipt_id == null) return
|
||||
if (!recalcReason.value.trim()) {
|
||||
ElMessage.warning('重算必须填写原因')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await calcApi.recalcReceipt(recalcTarget.value.id, recalcReason.value.trim())
|
||||
// 重算接口按回款 ID 定位(台账行主键 ≠ 回款 ID:传 row.id 会重算到错误回款)
|
||||
await calcApi.recalcReceipt(row.receipt_id, recalcReason.value.trim())
|
||||
ElMessage.success('重算完成(新版本已生效)')
|
||||
recalcVisible.value = false
|
||||
await load()
|
||||
@ -75,9 +79,16 @@ async function submitRecalc(): Promise<void> {
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本">
|
||||
<el-select v-model="filters.is_active" style="width: 130px">
|
||||
<!-- 可清空:清空=不加 is_active 过滤(axios 自动剔除 undefined),
|
||||
即「全部版本」;0 精确匹配历史行,不能用来表达「含历史」 -->
|
||||
<el-select
|
||||
v-model="filters.is_active"
|
||||
placeholder="全部版本"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
>
|
||||
<el-option label="仅当前生效" :value="1" />
|
||||
<el-option label="含历史版本" :value="0" />
|
||||
<el-option label="仅历史版本" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
|
||||
@ -67,9 +67,10 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Redis 存活(密码经宿主 docker/.env,不经命令行明文参数)
|
||||
# 4. Redis 存活(密码经容器内环境变量展开,不出现在宿主进程命令行;
|
||||
# 写法同 backup_full.sh 的 MYSQL_ROOT_PASSWORD)
|
||||
if [[ -n "${REDIS_PASSWORD:-}" ]]; then
|
||||
if docker exec ys-redis-dev redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping 2>/dev/null | grep -q PONG; then
|
||||
if docker exec ys-redis-dev sh -c 'redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping' 2>/dev/null | grep -q PONG; then
|
||||
pass "Redis 响应 PONG"
|
||||
else
|
||||
warn "Redis ping 无响应(检查 REDIS_PASSWORD 或容器状态)"
|
||||
|
||||
@ -61,8 +61,16 @@ docker exec "$CONTAINER" sh -c \
|
||||
gunzip -c "$LATEST" | docker exec -i "$CONTAINER" sh -c \
|
||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$DRILL_DB\""
|
||||
|
||||
# 3. 行数核对:关键业务表
|
||||
TABLES=(users roles user_roles param_definitions param_values businesses business_items personnel products customers shipments shipment_items receipts approval_orders approval_steps system_settings)
|
||||
# 3. 行数核对:**全部业务表**(新增表必须同步补入本清单——备份漏表时
|
||||
# 演练要能发现;alembic_version 不列,行数恒 1 无核对价值)
|
||||
TABLES=(
|
||||
adjustment_orders approval_orders approval_steps business_items business_roles
|
||||
business_secondary_allocations business_status_logs businesses customer_rating_grades
|
||||
customers login_logs monthly_settlements operation_logs ownership_change_logs
|
||||
param_change_batches param_definitions param_values performance_ledgers permissions
|
||||
personnel personnel_plans personnel_regions products receipts role_permissions roles
|
||||
settlement_items shipment_items shipments system_settings user_roles users
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "======== 行数对比(源库 vs 演练库)========"
|
||||
@ -73,7 +81,12 @@ for T in "${TABLES[@]}"; do
|
||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $MYSQL_DATABASE.$T;\" 2>/dev/null" || echo "?")
|
||||
DST=$(docker exec "$CONTAINER" sh -c \
|
||||
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $DRILL_DB.$T;\" 2>/dev/null" || echo "?")
|
||||
if [[ "$SRC" == "$DST" ]]; then
|
||||
if [[ "$SRC" == "?" || "$DST" == "?" ]]; then
|
||||
# 查询失败(表缺失/权限/客户端故障)不可与「数据一致」混淆:?
|
||||
# 与 ? 相等会误报可恢复——备份真的漏表时演练必须红
|
||||
MARK="✗"
|
||||
ALL_OK=0
|
||||
elif [[ "$SRC" == "$DST" ]]; then
|
||||
MARK="✓"
|
||||
else
|
||||
MARK="✗"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user