fix(核算): 对齐抵顶口径并完善界面细节

回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
This commit is contained in:
liuwenxin committed 2026-11-06 16:27:45 +08:00
1 parent 35fea3802f
commit b3de35c2ec
53 files changed
+1381 -362

No files matched your search

+3 -1
View File
@@ -390,9 +390,11 @@ def tplus_tasks(monkeypatch):
@pytest.fixture
def legacy_calculation(monkeypatch):
"""明确启用旧引擎兼容回归;生产和正式工资测试默认关闭。"""
"""旧引擎回归使用关闭确认开关的口径;确认门槛由独立用例覆盖。"""
from app.core.config import get_settings
from app.services import receipt_confirmation
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", True)
monkeypatch.setattr(receipt_confirmation, "enabled", lambda _session: False)
@pytest.fixture
+3 -4
View File
@@ -307,11 +307,10 @@ class TestGuards:
resp = _calc(client, admin["headers"], receipt["id"])
assert resp.status_code == 200, resp.text
ledger = resp.json()["data"]["ledger"]
# 成本缺失 → 毛利按 0 → 折算 = 销售额×(0−30%) = **−3000**(保守少算
# 方向:缺成本的行宁少算不多发),missing_cost=1
# 旧引擎缺成本标记保留,零毛利按现行公式特例折算为零。
assert ledger["missing_cost"] == 1
assert float(ledger["converted_profit"]) == -3000.0
assert float(ledger["performance_amount"]) == pytest.approx(-29.33, abs=0.01)
assert float(ledger["converted_profit"]) == 0.0
assert float(ledger["performance_amount"]) == 0.0
def test_负绩效照算不归零(self, client: TestClient, admin: dict) -> None:
items = [
@@ -30,8 +30,9 @@ def post(client, path, headers, data):
@pytest.fixture
def formal_chain(client, admin):
def unconfirmed_formal_chain(client, admin, request):
headers = admin["headers"]
unit_cost = getattr(request, "param", {}).get("unit_cost", "25")
with get_session_factory()() as s:
roles = {r.role_code: r.id for r in s.scalars(select(BusinessRole))}
persons = []
@@ -56,7 +57,7 @@ def formal_chain(client, admin):
"product_category": "municipal_water",
"quantity": "2",
"sale_price": "50",
"guided_price": "25",
"guided_price": unit_cost,
}
business = post(
client,
@@ -103,7 +104,7 @@ def formal_chain(client, admin):
"shipment_item_id": detail["items"][0]["id"],
"personnel_id": owner["id"],
"manager_id": manager["id"],
"unit_cost": "25",
"unit_cost": unit_cost,
}
],
}
@@ -111,6 +112,15 @@ def formal_chain(client, admin):
return business, receipt, setup, owner, manager
@pytest.fixture
def formal_chain(client, admin, unconfirmed_formal_chain):
business, receipt, setup, owner, manager = unconfirmed_formal_chain
confirmed = post(client, f"/api/v1/business/receipts/{receipt['id']}/confirm", admin["headers"], {
"expected_revision": receipt["confirmation_revision"],
})
return business, confirmed, setup, owner, manager
def test_正式核算生成工资锁定及报表一致(client, admin, formal_chain):
business, _, setup, owner, manager = formal_chain
headers = admin["headers"]
@@ -321,3 +331,30 @@ def test_无正式资料时默认拒绝旧核算(client, admin, formal_chain):
f"/api/v1/calc/receipts/{empty_receipt['id']}/calc", headers=admin["headers"]
)
assert response.status_code == 400 and "正式核算" in response.json()["message"]
def test_抵顶标记不阻断正式平账且不自动打八折(client, admin, formal_chain):
from app.models import Receipt
business, cash, setup, _, _ = formal_chain
with get_session_factory()() as session:
receipt = session.get(Receipt, cash["id"])
receipt.is_barter = 1
session.commit()
post(client, f"{BASE}/businesses/{business['id']}/input", admin["headers"],
{**setup, "expected_version": 1})
rejected = client.post(f"{BASE}/businesses/{business['id']}/calculate", headers=admin["headers"],
json={"month": "2026-03"})
assert rejected.status_code == 400 and "人工确认" in rejected.json()["message"]
current = client.get(f"/api/v1/business/receipts/{cash['id']}", headers=admin["headers"])
assert current.status_code == 200, current.text
post(client, f"/api/v1/business/receipts/{cash['id']}/confirm", admin["headers"], {
"expected_revision": current.json()["data"]["confirmation_revision"],
})
result = post(client, f"{BASE}/businesses/{business['id']}/calculate", admin["headers"],
{"month": "2026-03"})
assert result["performance"] == "0.20" and result["payable"] == "0.20"
with get_session_factory()() as session:
assert session.get(Receipt, cash["id"]).is_barter == 1
assert all(session.get(PerformanceLedger, ledger_id).barter_applied == 0
for ledger_id in result["ledger_ids"])
@@ -87,10 +87,11 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of:
class TestCatalog:
"""参数目录:种子完整性与时点化回溯。"""
def test_目录含27项及产品系数组(self, client: TestClient, admin: dict) -> None:
"""V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。"""
def test_目录含28项及产品系数组(self, client: TestClient, admin: dict) -> None:
"""目录包含回款人工确认开关和既有参数。"""
items = client.get(_PARAMS, headers=admin["headers"]).json()["data"]
assert len(items) == 27
assert len(items) == 28
assert any(item["param_code"] == "receipt.confirmation.enabled" for item in items)
groups = {item["param_group"] for item in items}
assert groups == {
"业务员个人户",
@@ -99,6 +100,7 @@ class TestCatalog:
"企业户",
"信息员",
"支付环节",
"回款确认",
"产品阈值",
"月工资口径",
"审批开关",
@@ -355,7 +357,7 @@ class TestPreview:
"salesperson.rate.floor",
"salesperson.days.boundary",
"product.threshold",
"barter.discount",
# 2026-11-03 口径:抵顶线下核算,预览不再读取 barter.discount(参数保留作回退位)。
"payment.start.ratio",
"probation.annual.threshold",
]
@@ -495,7 +497,7 @@ class TestPermission:
session.commit()
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 27
assert len(resp.json()["data"]) == 28
def test_未登录访问目录_403(self, client: TestClient) -> None:
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
@@ -10,6 +10,8 @@ from app.core.db import get_session_factory
from app.models import OperationLog, PayrollInput, PerformanceLedger, Receipt
from tests.integration.test_formal_payroll import (
post,
)
from tests.integration.test_formal_payroll import (
unconfirmed_formal_chain as unconfirmed_formal_chain,
)
@@ -128,7 +130,7 @@ def test_正式核算零毛利特例但低阈值仍为负(client, admin, unconfi
@pytest.mark.parametrize("role,profit,converted,performance", [
("salesperson", "0", "0", "0"),
("salesperson", "10", "-20", "-0.20"),
("info_officer", "0", "0", "0.10"),
("info_officer", "0", "100", "0.10"),
])
def test_试算零毛利与信息员基数保持独立(client, admin, role, profit, converted, performance):
data = post(client, "/api/v1/system/params/preview", admin["headers"], {
@@ -12,6 +12,9 @@ from app.core.config import get_settings
from app.core.db import get_session_factory
from app.models import ParamChangeBatch, ProbationPayment
from tests.integration.test_formal_payroll import formal_chain as formal_chain
from tests.integration.test_formal_payroll import (
unconfirmed_formal_chain as unconfirmed_formal_chain,
)
pytestmark = pytest.mark.integration
+26 -5
View File
@@ -14,6 +14,7 @@
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
"""
import ast
import re
from pathlib import Path
@@ -24,7 +25,7 @@ MIGRATION_DIR = PROJECT_ROOT / "db" / "migration"
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
ALEMBIC_VERSIONS_DIR = PROJECT_ROOT / "backend" / "alembic" / "versions"
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+(?:IF NOT EXISTS\s+)?`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL | re.IGNORECASE)
_COLUMN_LINE = re.compile(r"^[ \t]+`?(\w+)`?[ \t]", re.MULTILINE)
# 这些开头的是约束定义,不是列定义
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
@@ -32,6 +33,7 @@ _NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "IN
# MODIFY 只改类型不改列名,对列集合无影响,无需处理
_ALTER_ADD = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+ADD(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
_ALTER_DROP = re.compile(r"ALTER TABLE\s+`?(\w+)`?\s+DROP(?:\s+COLUMN)?\s+`?(\w+)`?", re.IGNORECASE)
_RENAME_TABLE = re.compile(r"RENAME TABLE\s+`?(\w+)`?\s+TO\s+`?(\w+)`?", re.IGNORECASE)
def _parse_tables(sql: str) -> dict[str, set[str]]:
@@ -67,6 +69,8 @@ def _migration_final_state(paths: list[Path]) -> dict[str, set[str]]:
merged.setdefault(table, set()).add(column)
for table, column in _ALTER_DROP.findall(sql):
merged.setdefault(table, set()).discard(column)
for old, new in _RENAME_TABLE.findall(sql):
merged[new] = merged.pop(old)
return merged
@@ -83,8 +87,15 @@ def _alembic_head() -> str:
text = path.read_text(encoding="utf-8")
if found := re.search(r"^revision(?:\s*:\s*str)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
revisions.add(found.group(1))
if found := re.search(r"^down_revision(?:\s*:.*?)?\s*=\s*['\"](\w+)['\"]", text, re.MULTILINE):
referenced.add(found.group(1))
for node in ast.parse(text).body:
if isinstance(node, (ast.Assign, ast.AnnAssign)):
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
if any(isinstance(target, ast.Name) and target.id == "down_revision" for target in targets):
value = ast.literal_eval(node.value)
if isinstance(value, str):
referenced.add(value)
elif isinstance(value, tuple):
referenced.update(value)
heads = revisions - referenced
assert len(heads) == 1, f"迁移链存在分叉或缺失,heads={sorted(heads)}"
@@ -94,14 +105,14 @@ def _alembic_head() -> str:
@pytest.fixture(scope="module")
def migration_tables() -> dict[str, set[str]]:
"""按文件序重放 db/migration/ 下全部 V*.sql,取迁移链终态。"""
files = sorted(MIGRATION_DIR.glob("V*.sql"))
files = sorted(MIGRATION_DIR.glob("V*.sql"), key=lambda path: int(re.match(r"V(\d+)", path.name).group(1)))
assert files, f"未找到迁移脚本:{MIGRATION_DIR}"
return _migration_final_state(files)
@pytest.fixture(scope="module")
def init_dev_tables() -> dict[str, set[str]]:
return _parse_tables(INIT_DEV_FILE.read_text(encoding="utf-8"))
return _migration_final_state([INIT_DEV_FILE])
class TestSchemaSync:
@@ -111,6 +122,16 @@ class TestSchemaSync:
assert list(MIGRATION_DIR.glob("V*.sql")), f"缺少迁移脚本:{MIGRATION_DIR}"
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
def test_迁移解析支持建表后增加列与重命名(self, tmp_path: Path) -> None:
migration = tmp_path / "V1__example.sql"
migration.write_text(
"CREATE TABLE old_name (\n id BIGINT\n);\n"
"ALTER TABLE old_name ADD extra VARCHAR(8);\n"
"RENAME TABLE old_name TO new_name;\n",
encoding="utf-8",
)
assert _migration_final_state([migration]) == {"new_name": {"id", "extra"}}
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
assert set(init_dev_tables) == set(migration_tables), (
"两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 "