fix(核算): 对齐抵顶口径并完善界面细节
回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
This commit is contained in:
1 parent
35fea3802f
commit
b3de35c2ec
53 files changed
+1381
-362
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 的表集合不一致 —— 表结构变更时需同时维护 "
|
||||
|
||||
Reference in new issue
Block a user