fix(m3): code-review P0 六项——算错钱与不可用
- 经理聚合分成放大 100 倍:参数按百分数存储(12.5=12.5%),parse_fraction 后调用方又 ×100、函数内部再 ÷100,净效应「绩效×12.5」;去掉调用方 ×100,rate_text 改用实际参数原文(原硬编码 12.5%+5% 参数调整后失真) - 审批联动只认终审:财务节点通过(1→2)不再提前 mark_locked/调整单生效, 否则领导驳回后 locked_at/locked_by 残留、两级审批形同虚设;驳回在任何 节点即时联动;mark_rejected 补清锁定字段 - 调整单新建不可用:ENABLE_CODE_BY_TYPE 未登记 type=3 → is_enabled 恒 400; 补登记 approval.adjustment.enabled。另修复叠加缺陷——开关关闭分支手工 ApprovalStep(order_id=调整单id),而该外键指向 approval_orders,必然 errno 1452;统一改走 submit_order(自动通过留痕由它负责) - 重算双重计提:业务级一次性计提的「进度登记」回款无生效台账行,重算会 凭空落新行;无旧行一律 400 - 前端重算传错 id:台账行主键当 receipt_id 传给重算接口,会作废无关回款 的台账;改传 row.receipt_id(聚合行防御) - quantize 显式 ROUND_HALF_UP:9 处金额 quantize 缺省银行家舍入, x.xx5 与四舍五入口径差 1 分(docstring 却声称 HALF_UP) 测试:显式控制审批开关(V8 起种子默认开启,用例不再依赖默认值); 新增回归 4 例(经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前 锁定),test_calc+test_settlements 22 passed Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
35fe37fb0c
commit
cb4f38dfd9
@ -64,10 +64,12 @@ from app.services.no_generator import next_no
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 开关参数编码(与 param_registry 种子同源)
|
# 开关参数编码(与 param_registry 种子同源;新增审批对象类型必须在此登记,
|
||||||
|
# 否则 is_enabled 走 BadRequestError 兜底、对应业务接口整体不可用)
|
||||||
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
ENABLE_CODE_BY_TYPE: dict[int, str] = {
|
||||||
1: "approval.secondary_allocation.enabled",
|
1: "approval.secondary_allocation.enabled",
|
||||||
2: "approval.monthly_settlement.enabled",
|
2: "approval.monthly_settlement.enabled",
|
||||||
|
3: "approval.adjustment.enabled",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 在途状态集合(同一引用对象至多一张在途单)
|
# 在途状态集合(同一引用对象至多一张在途单)
|
||||||
@ -219,6 +221,10 @@ def act(
|
|||||||
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
if order.status == APPROVAL_STATUS_PENDING_FINANCE
|
||||||
else APPROVAL_STATUS_APPROVED
|
else APPROVAL_STATUS_APPROVED
|
||||||
)
|
)
|
||||||
|
# 生效类联动只认**终审**:财务节点通过(1→2)只是流转,不得提前
|
||||||
|
# 锁定月结/生效调整单——否则领导驳回后锁定字段残留、审批流形同虚设。
|
||||||
|
# 驳回在任何节点都是终态,即时联动。
|
||||||
|
final_approved = (not rejected) and order.status == APPROVAL_STATUS_APPROVED
|
||||||
|
|
||||||
session.add(
|
session.add(
|
||||||
ApprovalStep(
|
ApprovalStep(
|
||||||
@ -259,12 +265,15 @@ def act(
|
|||||||
if target is not None:
|
if target is not None:
|
||||||
if rejected:
|
if rejected:
|
||||||
settlement_service.mark_rejected(session, target, operator=operator)
|
settlement_service.mark_rejected(session, target, operator=operator)
|
||||||
else:
|
elif final_approved:
|
||||||
settlement_service.mark_locked(session, target, operator=operator)
|
settlement_service.mark_locked(session, target, operator=operator)
|
||||||
else:
|
else:
|
||||||
adj = session.get(AdjustmentOrder, order.ref_id)
|
adj = session.get(AdjustmentOrder, order.ref_id)
|
||||||
if adj is not None:
|
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 后再碰
|
# 出参(含 steps)在 **commit 之前** 固化:audit 禁止 commit 后再碰
|
||||||
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
|
# ORM/查询——commit 后的任何 SELECT 失败都会让「已通过的审批」被记
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from decimal import Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -118,11 +118,17 @@ def calc_receipt(
|
|||||||
if business.owner_personnel_id is None:
|
if business.owner_personnel_id is None:
|
||||||
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
raise BadRequestError("业务未指定负责营销人员,无法核算(先在业务归属中指定)")
|
||||||
|
|
||||||
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算
|
# 重算:先作废旧版本行(is_active=0),折算体随后按当前参数重算。
|
||||||
|
# 无生效行 = 该回款是「进度登记」(业务级一次性计提 #25 的第二笔及
|
||||||
|
# 以后)——本无独立计提,继续重算会凭空落一行生效行,同业务出现
|
||||||
|
# 两行生效行导致双重计提 → 一律 400
|
||||||
if is_recalc:
|
if is_recalc:
|
||||||
old = performance_ledger_repository.get_active_by_receipt(session, receipt_id)
|
old = performance_ledger_repository.get_active_by_receipt(session, receipt_id)
|
||||||
if old is not None:
|
if old is None:
|
||||||
performance_ledger_repository.mark_inactive(session, old)
|
raise BadRequestError(
|
||||||
|
"该回款为进度登记(业务已按一次性计提核算),无独立台账行可重算"
|
||||||
|
)
|
||||||
|
performance_ledger_repository.mark_inactive(session, old)
|
||||||
|
|
||||||
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
# 核算粒度 #25(一期口径,仅首算模式):业务级一次性计提——已有生效
|
||||||
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
|
# 台账行时,本笔回款仅置已核算状态(支付进度),不重复计提
|
||||||
@ -195,8 +201,11 @@ def calc_receipt(
|
|||||||
"revenue": str(row_revenue),
|
"revenue": str(row_revenue),
|
||||||
"profit": str(row_profit),
|
"profit": str(row_profit),
|
||||||
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
|
# 行级折算同样固化为两位(to_decimal 对整数值无小数位,
|
||||||
# 快照/台账口径需与列一致)
|
# 快照/台账口径需与列一致;显式 HALF_UP——Python 默认
|
||||||
"converted": str(row_converted.quantize(Decimal("0.01"))),
|
# 银行家舍入会让 x.xx5 少 1 分)
|
||||||
|
"converted": str(
|
||||||
|
row_converted.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -244,8 +253,14 @@ def calc_receipt(
|
|||||||
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
|
snapshot_json["strategy_note"] = _NOTE_MANAGER_PENDING
|
||||||
|
|
||||||
def q2(value: Decimal | None) -> Decimal | None:
|
def q2(value: Decimal | None) -> Decimal | None:
|
||||||
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。"""
|
"""金额统一两位小数(HALF_UP):内存/DB/序列化三方一致。
|
||||||
return value.quantize(Decimal("0.01")) if value is not None else None
|
|
||||||
|
显式传 rounding——quantize 缺省是上下文 ROUND_HALF_EVEN(银行家
|
||||||
|
舍入),x.xx5 会舍成偶数值,与业务四舍五入口径差 1 分。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
row = performance_ledger_repository.add(
|
row = performance_ledger_repository.add(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from decimal import Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@ -146,11 +146,19 @@ def generate(
|
|||||||
personnel_name=bucket["personnel_name"],
|
personnel_name=bucket["personnel_name"],
|
||||||
role_code=bucket["role_code"],
|
role_code=bucket["role_code"],
|
||||||
calc_type=bucket["calc_type"],
|
calc_type=bucket["calc_type"],
|
||||||
performance_sum=bucket["performance_sum"].quantize(Decimal("0.01")),
|
performance_sum=bucket["performance_sum"].quantize(
|
||||||
payable_sum=bucket["payable_sum"].quantize(Decimal("0.01")),
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
|
),
|
||||||
|
payable_sum=bucket["payable_sum"].quantize(
|
||||||
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
|
),
|
||||||
basic_amount=basic,
|
basic_amount=basic,
|
||||||
salary_mode=mode,
|
salary_mode=mode,
|
||||||
net_amount=net.quantize(Decimal("0.01")) if net is not None else None,
|
net_amount=(
|
||||||
|
net.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
|
if net is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
is_probation=bucket["is_probation"],
|
is_probation=bucket["is_probation"],
|
||||||
probation_note=note,
|
probation_note=note,
|
||||||
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
|
snapshot_json={"ledger_ids": bucket["ledger_ids"]},
|
||||||
@ -160,9 +168,9 @@ def generate(
|
|||||||
settlement_repository.replace_items(session, settlement.id, items)
|
settlement_repository.replace_items(session, settlement.id, items)
|
||||||
settlement.total_performance = sum(
|
settlement.total_performance = sum(
|
||||||
(i.performance_sum for i in items), Decimal("0")
|
(i.performance_sum for i in items), Decimal("0")
|
||||||
).quantize(Decimal("0.01"))
|
).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
|
settlement.total_payable = sum((i.payable_sum for i in items), Decimal("0")).quantize(
|
||||||
Decimal("0.01")
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
)
|
)
|
||||||
settlement.person_count = len({i.personnel_id for i in items})
|
settlement.person_count = len({i.personnel_id for i in items})
|
||||||
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
settlement.snapshot_json = {"note": _SETTLEMENT_NOTE}
|
||||||
@ -225,8 +233,14 @@ def mark_locked(session: Session, settlement: MonthlySettlement, *, operator: Us
|
|||||||
|
|
||||||
|
|
||||||
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
def mark_rejected(session: Session, settlement: MonthlySettlement, *, operator: User) -> None:
|
||||||
"""审批驳回钩子:回草稿(可修改后重提)。"""
|
"""审批驳回钩子:回草稿(可修改后重提)。
|
||||||
|
|
||||||
|
锁定信息一并清空:草稿态残留 locked_at/locked_by 会让详情页展示
|
||||||
|
「已锁定」的假象(历史缺陷路径下驳回前可能已被提前锁定)。
|
||||||
|
"""
|
||||||
settlement.status = SETTLEMENT_STATUS_DRAFT
|
settlement.status = SETTLEMENT_STATUS_DRAFT
|
||||||
|
settlement.locked_at = None
|
||||||
|
settlement.locked_by = None
|
||||||
|
|
||||||
|
|
||||||
def ensure_month_not_locked(session: Session, month: str) -> None:
|
def ensure_month_not_locked(session: Session, month: str) -> None:
|
||||||
@ -315,8 +329,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
|||||||
|
|
||||||
aggregates: list = []
|
aggregates: list = []
|
||||||
snapshot = resolve_snapshot(session, date.today())
|
snapshot = resolve_snapshot(session, date.today())
|
||||||
team_pct = parse_fraction(snapshot.get("manager.share.team"))
|
# 参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
|
||||||
company_pct = parse_fraction(snapshot.get("manager.share.company"))
|
# manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)
|
||||||
|
team_text = snapshot.get("manager.share.team")
|
||||||
|
company_text = snapshot.get("manager.share.company")
|
||||||
|
team_pct = parse_fraction(team_text)
|
||||||
|
company_pct = parse_fraction(company_text)
|
||||||
for manager_id, bucket in managers.items():
|
for manager_id, bucket in managers.items():
|
||||||
manager = session.get(Personnel, manager_id)
|
manager = session.get(Personnel, manager_id)
|
||||||
if manager is None:
|
if manager is None:
|
||||||
@ -340,8 +358,8 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
|||||||
share = manager_team_share(
|
share = manager_team_share(
|
||||||
bucket["performance"],
|
bucket["performance"],
|
||||||
company_base,
|
company_base,
|
||||||
team_pct=team_pct * 100,
|
team_pct=team_pct,
|
||||||
company_pct=company_pct * 100,
|
company_pct=company_pct,
|
||||||
)
|
)
|
||||||
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
member_names = "、".join(sorted(bucket["names"])) or "(无成员)"
|
||||||
aggregates.append(
|
aggregates.append(
|
||||||
@ -356,11 +374,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
|
|||||||
customer_type="personal",
|
customer_type="personal",
|
||||||
product_category="mixed",
|
product_category="mixed",
|
||||||
receipt_date=date.today(),
|
receipt_date=date.today(),
|
||||||
rate_text="聚合:12.5%+5%",
|
# 比例原文取实际参数(勿硬编码默认值——参数调整后台账说明会失真)
|
||||||
|
rate_text=f"聚合:{team_text}%+{company_text}%",
|
||||||
converted_profit=None,
|
converted_profit=None,
|
||||||
performance_amount=share.quantize(Decimal("0.01")),
|
performance_amount=share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||||
barter_applied=0,
|
barter_applied=0,
|
||||||
payable_amount=share.quantize(Decimal("0.01")),
|
payable_amount=share.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP),
|
||||||
settlement_month=month,
|
settlement_month=month,
|
||||||
is_probation=1 if manager.is_probation == 1 else 0,
|
is_probation=1 if manager.is_probation == 1 else 0,
|
||||||
snapshot_json={
|
snapshot_json={
|
||||||
@ -412,13 +431,8 @@ def create_adjustment(
|
|||||||
):
|
):
|
||||||
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
|
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
|
||||||
from app.core.constants import NO_PREFIX_ADJUSTMENT
|
from app.core.constants import NO_PREFIX_ADJUSTMENT
|
||||||
from app.models import AdjustmentOrder, ApprovalStep
|
from app.models import AdjustmentOrder
|
||||||
from app.models.approval import (
|
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT
|
||||||
APPROVAL_ACTION_AUTO_APPROVE,
|
|
||||||
APPROVAL_NODE_FINANCE,
|
|
||||||
APPROVAL_NODE_LEADER,
|
|
||||||
APPROVAL_TYPE_ADJUSTMENT,
|
|
||||||
)
|
|
||||||
from app.models.settlement import (
|
from app.models.settlement import (
|
||||||
ADJUSTMENT_STATUS_EFFECTIVE,
|
ADJUSTMENT_STATUS_EFFECTIVE,
|
||||||
ADJUSTMENT_STATUS_PENDING,
|
ADJUSTMENT_STATUS_PENDING,
|
||||||
@ -459,30 +473,23 @@ def create_adjustment(
|
|||||||
session.add(order)
|
session.add(order)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
if approval_service.is_enabled(session, APPROVAL_TYPE_ADJUSTMENT):
|
# 开关分流与留痕统一交给 submit_order:开关关闭时它自动通过并写两条
|
||||||
order.status = ADJUSTMENT_STATUS_PENDING
|
# 「系统」留痕。**不得手工插 ApprovalStep** —— approval_steps.order_id
|
||||||
session.flush()
|
# 外键指向 approval_orders(不是 adjustment_orders),用调整单 id 落步
|
||||||
approval_service.submit_order(
|
# 必然外键失败(历史缺陷:关开关时新建调整单不可用)
|
||||||
session,
|
approval_order = approval_service.submit_order(
|
||||||
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
session,
|
||||||
ref_id=order.id,
|
order_type=APPROVAL_TYPE_ADJUSTMENT,
|
||||||
refer_no=order.order_no,
|
ref_id=order.id,
|
||||||
submitter=operator,
|
refer_no=order.order_no,
|
||||||
)
|
submitter=operator,
|
||||||
else:
|
)
|
||||||
order.status = ADJUSTMENT_STATUS_EFFECTIVE
|
order.status = (
|
||||||
session.add_all(
|
ADJUSTMENT_STATUS_EFFECTIVE
|
||||||
[
|
if approval_order.status == APPROVAL_STATUS_APPROVED
|
||||||
ApprovalStep(
|
else ADJUSTMENT_STATUS_PENDING
|
||||||
order_id=order.id,
|
)
|
||||||
node=node,
|
session.flush()
|
||||||
action=APPROVAL_ACTION_AUTO_APPROVE,
|
|
||||||
operator_name="系统",
|
|
||||||
)
|
|
||||||
for node in (APPROVAL_NODE_FINANCE, APPROVAL_NODE_LEADER)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
session.flush()
|
|
||||||
session.commit()
|
session.commit()
|
||||||
logger.info("调整单创建 | order=%s | status=%s", order.order_no, order.status)
|
logger.info("调整单创建 | order=%s | status=%s", order.order_no, order.status)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -459,6 +459,53 @@ class TestRecalc:
|
|||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
assert "尚未核算" in resp.json()["message"]
|
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(
|
def test_重算_普通用户与营销后勤403(
|
||||||
self, client: TestClient, normal: dict, make_user, auth_headers
|
self, client: TestClient, normal: dict, make_user, auth_headers
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@ -39,30 +39,38 @@ _GOLD_ITEMS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _person(client: TestClient, headers: dict) -> dict:
|
def _role_id(role_code: str) -> int:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.core.constants import BUSINESS_ROLE_SALESPERSON
|
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.models import BusinessRole
|
from app.models import BusinessRole
|
||||||
|
|
||||||
factory = get_session_factory()
|
factory = get_session_factory()
|
||||||
with factory() as session:
|
with factory() as session:
|
||||||
role_id = int(
|
return int(
|
||||||
session.execute(
|
session.execute(
|
||||||
select(BusinessRole.id).where(
|
select(BusinessRole.id).where(
|
||||||
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
BusinessRole.role_code == role_code,
|
||||||
BusinessRole.status == 1,
|
BusinessRole.status == 1,
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _person(
|
||||||
|
client: TestClient,
|
||||||
|
headers: dict,
|
||||||
|
*,
|
||||||
|
role_code: str = "salesperson",
|
||||||
|
manager_id: int | None = None,
|
||||||
|
) -> dict:
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
_PERSONNEL,
|
_PERSONNEL,
|
||||||
json={
|
json={
|
||||||
"personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}",
|
"personnel_name": f"t_月结_{uuid.uuid4().hex[:8]}",
|
||||||
"business_role_id": role_id,
|
"business_role_id": _role_id(role_code),
|
||||||
"salary_standard": "3000.00",
|
"salary_standard": "3000.00",
|
||||||
|
**({"manager_id": manager_id} if manager_id is not None else {}),
|
||||||
},
|
},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
@ -70,6 +78,20 @@ def _person(client: TestClient, headers: dict) -> dict:
|
|||||||
return resp.json()["data"]
|
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:
|
def _calc_one(client: TestClient, headers: dict) -> dict:
|
||||||
"""完整造一笔已核算回款(回款日 2026-10-20 → 归月 2026-10)。"""
|
"""完整造一笔已核算回款(回款日 2026-10-20 → 归月 2026-10)。"""
|
||||||
cust = customer(client, headers)
|
cust = customer(client, headers)
|
||||||
@ -161,7 +183,8 @@ class TestGenerate:
|
|||||||
).status_code
|
).status_code
|
||||||
== 200
|
== 200
|
||||||
)
|
)
|
||||||
# 提交(开关默认关)→ 自动通过锁定
|
# 显式关开关 → 提交即自动通过锁定(用例不依赖种子默认值)
|
||||||
|
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||||
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
|
sid = client.get(f"{_SETTLEMENTS}", headers=admin["headers"]).json()["data"]["list"][0][
|
||||||
"id"
|
"id"
|
||||||
]
|
]
|
||||||
@ -172,8 +195,7 @@ class TestGenerate:
|
|||||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
# 已核算禁改(M2 防线)先于月结锁定拦截生效——两者均 400 拒改
|
assert "已审定锁定" in resp.json()["message"]
|
||||||
assert ("已核算" in resp.json()["message"]) or ("锁定" in resp.json()["message"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestSubmitAndLock:
|
class TestSubmitAndLock:
|
||||||
@ -183,6 +205,8 @@ class TestSubmitAndLock:
|
|||||||
self, client: TestClient, admin: dict, finance: dict
|
self, client: TestClient, admin: dict, finance: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
_calc_one(client, admin["headers"])
|
_calc_one(client, admin["headers"])
|
||||||
|
# 显式关开关(V8 起种子默认开启,用例不得依赖默认值)
|
||||||
|
_set_switch(client, admin["headers"], "approval.monthly_settlement.enabled", "0")
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||||
)
|
)
|
||||||
@ -322,6 +346,13 @@ class TestSwitchOn:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200, resp.text
|
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(
|
resp = client.post(
|
||||||
f"/api/v1/system/approvals/{order['id']}/action",
|
f"/api/v1/system/approvals/{order['id']}/action",
|
||||||
@ -333,3 +364,89 @@ class TestSwitchOn:
|
|||||||
assert final["status"] == 3
|
assert final["status"] == 3
|
||||||
assert final["locked_at"] is not None
|
assert final["locked_at"] is not None
|
||||||
assert final["locked_by"] == leader["username"] # 审批通过人=领导账号
|
assert final["locked_by"] == leader["username"] # 审批通过人=领导账号
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerAggregate:
|
||||||
|
"""经理团队聚合:比例按参数实际值计算(守「百分数再乘 100」放大缺陷)。"""
|
||||||
|
|
||||||
|
def test_聚合金额按实际比例计入(self, client: TestClient, admin: dict) -> None:
|
||||||
|
manager = _person(client, admin["headers"], role_code="sales_manager")
|
||||||
|
sales = _person(client, admin["headers"], manager_id=manager["id"])
|
||||||
|
|
||||||
|
cust = customer(client, admin["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=admin["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=admin["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=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
receipt = resp.json()["data"]
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"{_CALC}/receipts/{receipt['id']}/calc", headers=admin["headers"]
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"{_SETTLEMENTS}/generate", json={"month": "2026-10"}, headers=admin["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
items = resp.json()["data"]["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"
|
||||||
|
|
||||||
|
|
||||||
|
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 # 已生效
|
||||||
|
|||||||
@ -40,19 +40,23 @@ const recalcTarget = ref<LedgerRow | null>(null)
|
|||||||
const recalcReason = ref('')
|
const recalcReason = ref('')
|
||||||
|
|
||||||
function openRecalc(row: LedgerRow): void {
|
function openRecalc(row: LedgerRow): void {
|
||||||
|
// 聚合行(calc_type=2)receipt_id 为 NULL,无回款可重算(按钮已限量,防御)
|
||||||
|
if (row.receipt_id == null) return
|
||||||
recalcTarget.value = row
|
recalcTarget.value = row
|
||||||
recalcReason.value = ''
|
recalcReason.value = ''
|
||||||
recalcVisible.value = true
|
recalcVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitRecalc(): Promise<void> {
|
async function submitRecalc(): Promise<void> {
|
||||||
if (!recalcTarget.value) return
|
const row = recalcTarget.value
|
||||||
|
if (!row || row.receipt_id == null) return
|
||||||
if (!recalcReason.value.trim()) {
|
if (!recalcReason.value.trim()) {
|
||||||
ElMessage.warning('重算必须填写原因')
|
ElMessage.warning('重算必须填写原因')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
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('重算完成(新版本已生效)')
|
ElMessage.success('重算完成(新版本已生效)')
|
||||||
recalcVisible.value = false
|
recalcVisible.value = false
|
||||||
await load()
|
await load()
|
||||||
@ -75,9 +79,16 @@ async function submitRecalc(): Promise<void> {
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="版本">
|
<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="1" />
|
||||||
<el-option label="含历史版本" :value="0" />
|
<el-option label="仅历史版本" :value="0" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user