fix(核算): 对齐抵顶口径并完善界面细节
回款确认时间收敛到秒级;抵顶回款不再自动折算并保留标记。补齐公式模板角色标签与提示、指导成本登记入口及基础布局、分页、设置等响应式细节。同步忽略本地排查产物。
This commit is contained in:
1 parent
35fea3802f
commit
b3de35c2ec
53 files changed
+1381
-362
No files matched your search
@@ -393,3 +393,15 @@ uv run pytest tests/integration -q # 仅集成
|
||||
- 模板定义迁移至 `calc_template_definitions`(V28 种子 5 个内置模板);骨架 `FORMULAS` 受控,核算/预览按骨架分派,岗位模板校验改为数据库定义优先、旧库注册表兜底。
|
||||
- 更新 `test_param_registry` 模板断言:内置模板参数绑定覆盖骨架角色且参数编码已注册。单元 24 项通过;`test_calc_helpers`、`test_param_snapshot`、`test_formal_payroll` 合计 63 项通过;Ruff 与前端类型/构建通过。
|
||||
- 岗位相关 MySQL 集成需独立测试库环境(`PAYROLL_TEST_DATABASE`),本轮未执行;新增模板 CRUD 集成用例待补。
|
||||
|
||||
|
||||
## 更正单与备份恢复(2026-11-03)
|
||||
|
||||
105 项定点单元、37 项隔离 MySQL 集成通过。新用例覆盖正负成对核对、回款转原业务、来源防重/原子拒绝/同号实体隔离、来源变化拦截、普通抵顶不打折、备份篡改/断链/服务器更换/无更新不归档。独立 Docker 全量+增量重放逐值核对通过;说明及证据见 `docs/更正单与备份恢复交付_2026-11-03.md`。
|
||||
|
||||
|
||||
## 真实样本闭环与历史岗位(2026-11-03)
|
||||
|
||||
新增 `tests/unit/test_historical_roles.py`,覆盖按月任职区间、重叠拒绝、跨月转岗、经理本人业务排除、正式台账与月结冻结岗位、客户负责人暂缺、带实际成交参照的产品导入。完整单元 961 项、隔离 MySQL 专项 19 项通过;辽宁兴晟达真实样本完成正式导入至月结、四张报表及导出,30 个不同检查点通过。证据与边界见 `docs/真实样本业务闭环验收_2026-11-03.md`。
|
||||
|
||||
定点复验:`python -m pytest tests/unit/test_historical_roles.py -q`。真实样本只证明本组经理本人排除场景,不能据 0 元结果认定人员整月工资为零。
|
||||
@@ -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 的表集合不一致 —— 表结构变更时需同时维护 "
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.services.payroll_access import can_write_payroll
|
||||
from app.services.tplus.business_import import execute_business
|
||||
from app.services.tplus.workbench import digest
|
||||
from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview
|
||||
from tests.unit.test_tplus_workbench import source as publish_source
|
||||
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||
|
||||
|
||||
@@ -192,6 +193,96 @@ def test_正式导入防重与来源可追溯(payroll_case):
|
||||
assert record.data_json["source_facts"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def correction_case(payroll_case, monkeypatch):
|
||||
from contextlib import nullcontext
|
||||
|
||||
from app.models import TPlusSyncRecord
|
||||
from app.schemas.tplus_workbench import CorrectionPairIn
|
||||
from app.services.tplus import corrections
|
||||
from app.services.tplus.snapshots import source_records
|
||||
|
||||
s, user, _ = payroll_case
|
||||
original = source_records(s, "test_source", "shipments")[0].payload_json
|
||||
original_line = source_records(s, "test_source", "shipment_items")[0].payload_json
|
||||
# 收款ID与负更正发货ID相同,必须由实体前缀区分。
|
||||
publish_source(s, "shipments", [original,
|
||||
{**original, "id": 30, "code": "A-NEG"},
|
||||
{**original, "id": 40, "code": "B-POS", "idcustomer": 88}])
|
||||
publish_source(s, "shipment_items", [original_line,
|
||||
{**original_line, "id": 31, "source_parent_id": 30, "taxAmount": "-100", "quantity": "-2"},
|
||||
{**original_line, "id": 41, "source_parent_id": 40}])
|
||||
publish_source(s, "receipts", [{"id": 30, "code": "B-CASH", "idpartner": 88,
|
||||
"voucherDate": "2026-03-10", "isReceiveFlag": 1, "voucherstate": 189, "amount": "100",
|
||||
"accounting_voucher_count": 1, "valid_accounting_voucher_count": 1,
|
||||
"accounting_voucher_id": 123, "accounting_voucher_code": "TEST-GL"}])
|
||||
monkeypatch.setattr(corrections, "sync_lock", lambda db: nullcontext())
|
||||
payload = CorrectionPairIn(negative_shipment_id="30", positive_shipment_id="40",
|
||||
receipt_ids=["30"], target_group_key="g", evidence="隔离转客户例单核对")
|
||||
before = s.scalar(select(func.count()).select_from(TPlusSyncRecord))
|
||||
return s, user, corrections, payload, before
|
||||
|
||||
|
||||
def test_更正对贯通正式导入与FIFO_保留日期金额且重复不增加(payroll_case, correction_case):
|
||||
from app.models import TPlusFact, TPlusSyncRecord
|
||||
|
||||
s, user, service, payload, before = correction_case
|
||||
first = service.save_pair(s, payload, user.id)
|
||||
again = service.save_pair(s, payload, user.id)
|
||||
assert first["pair_key"] == again["pair_key"]
|
||||
assert s.scalar(select(func.count()).select_from(TPlusFact).where(TPlusFact.kind == "CORRECTION")) == 3
|
||||
business = imported(payroll_case)
|
||||
assert business.total_sale_amount == Decimal("100")
|
||||
cash = s.scalar(select(Receipt))
|
||||
assert cash.source_no == "B-CASH" and cash.receipt_date == date(2026, 3, 10)
|
||||
assert cash.amount == Decimal("100") and cash.business_id == business.id
|
||||
assert s.scalar(select(func.count()).select_from(Shipment)) == 1
|
||||
assert s.scalar(select(func.count()).select_from(TPlusSyncRecord)) == before
|
||||
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
||||
assert result["performance"] == "0.20" and result["payable"] == "0.20"
|
||||
repeated = business_work(s, user, "BUSINESS_PREVIEW")
|
||||
assert repeated.summary_json["counts"] == {"SKIP": 1}
|
||||
with pytest.raises(BadRequestError):
|
||||
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["wrong_sign", "wrong_group", "wrong_receipt", "missing_source"])
|
||||
def test_无效更正对原子拒绝不留下半边事实(correction_case, change):
|
||||
from app.models import TPlusFact
|
||||
|
||||
s, user, service, payload, _ = correction_case
|
||||
updates = {"wrong_sign": {"negative_shipment_id": "40", "positive_shipment_id": "30"},
|
||||
"wrong_group": {"target_group_key": "unknown"},
|
||||
"wrong_receipt": {"receipt_ids": ["999"]},
|
||||
"missing_source": {"positive_shipment_id": "999"}}
|
||||
with pytest.raises(BadRequestError):
|
||||
service.save_pair(s, payload.model_copy(update=updates[change]), user.id)
|
||||
assert s.scalar(select(func.count()).select_from(TPlusFact).where(TPlusFact.kind == "CORRECTION")) == 0
|
||||
|
||||
|
||||
def test_更正对后来源变化阻止静默排除(correction_case):
|
||||
from app.services.tplus.snapshots import source_records
|
||||
from app.services.tplus.workbench import latest_facts
|
||||
|
||||
s, user, service, payload, _ = correction_case
|
||||
service.save_pair(s, payload, user.id)
|
||||
rows = [r.payload_json for r in source_records(s, "test_source", "shipments")]
|
||||
publish_source(s, "shipments", [{**r, "code": "CHANGED"} if r["id"] == 30 else r for r in rows])
|
||||
record = source_records(s, "test_source", "shipments", ids=["30"])[0]
|
||||
with pytest.raises(ValueError, match="CORRECTION_RECONFIRM_REQUIRED"):
|
||||
service.verified_correction(s, "test_source", latest_facts(s, "test_source"), "SHIPMENT", record)
|
||||
|
||||
|
||||
def test_抵顶预览显示未自动折算且不读取折扣参数():
|
||||
from app.services.param_service import _preview_barter
|
||||
|
||||
def forbidden(_):
|
||||
raise AssertionError("线下抵顶不得读取自动折扣参数")
|
||||
result = _preview_barter(SimpleNamespace(payload=SimpleNamespace(barter=True), take=forbidden), Decimal("100"))
|
||||
assert result.base_amount == Decimal("100")
|
||||
assert result.applied is False and result.after_barter is None
|
||||
|
||||
|
||||
def test_正式FIFO台账金额与逐段过程守恒(payroll_case):
|
||||
s, user, manager = payroll_case
|
||||
business = imported(payroll_case)
|
||||
|
||||
@@ -113,6 +113,12 @@ class TestConvertedProfit:
|
||||
def test_毛利润额为零_折算为零_不除零(self) -> None:
|
||||
assert converted_profit(Decimal("0"), Fraction(0), Fraction(30)) == Decimal("0")
|
||||
|
||||
def test_有销售但零毛利润_折算及普通绩效均为零(self) -> None:
|
||||
rate = profit_rate(Decimal("100") - Decimal("100"), Decimal("100"))
|
||||
base = converted_profit(Decimal("100"), rate, Fraction(30))
|
||||
assert base == Decimal("0")
|
||||
assert performance_amount(base, Fraction(1, 100)) == Decimal("0")
|
||||
|
||||
def test_毛利润率低于阈值_折算为负_不归零(self) -> None:
|
||||
# 100 × (1/10 − 3/10) = -20(业务确认负值正常计算)
|
||||
assert converted_profit(Decimal("100"), Fraction(1, 10), Fraction(30)) == Decimal("-20")
|
||||
|
||||
@@ -17,6 +17,7 @@ EXPECTED_TABLES = {
|
||||
"authorization_settings", "user_permission_overrides",
|
||||
"tplus_automation_states",
|
||||
"tplus_automation_configs",
|
||||
"tplus_runtime_controls",
|
||||
"payroll_months", "payroll_inputs", "payroll_allocations",
|
||||
"tplus_source_heads", "tplus_facts", "tplus_source_links", "tplus_work_runs", "tplus_work_lines",
|
||||
"users",
|
||||
|
||||
@@ -207,6 +207,12 @@ class TestExcel模板:
|
||||
text = _sheet_text(_build("business-stats", rows))
|
||||
assert "已完成" in text
|
||||
|
||||
def test_业务销售额未知时导出明细和合计不显示零(self):
|
||||
rows = [{"business_no": "YW001", "status": 1, "total_sale_amount": None}]
|
||||
workbook = load_workbook(io.BytesIO(_build("business-stats", rows)))
|
||||
values = [cell.value for row in workbook.active.iter_rows() for cell in row]
|
||||
assert values.count("待核对") >= 2
|
||||
|
||||
|
||||
def _member(
|
||||
personnel_id: int,
|
||||
|
||||
@@ -4,9 +4,9 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1.business.businesses import confirm_source_price
|
||||
from app.api.v1.business.businesses import confirm_guidance, confirm_source_price
|
||||
from app.core.exceptions import ForbiddenError
|
||||
from app.schemas.business_source import SourcePriceConfirmIn
|
||||
from app.schemas.business_source import GuidanceConfirmIn, SourcePriceConfirmIn
|
||||
from app.services import authorization_service, business_source
|
||||
from app.services.authorization_policy import permission_for
|
||||
|
||||
@@ -29,3 +29,46 @@ def test_无核算准备权限不能确认来源单价(monkeypatch):
|
||||
with pytest.raises(ForbiddenError):
|
||||
confirm_source_price(session, actor, 1, 1, payload, meta)
|
||||
confirm.assert_not_called()
|
||||
|
||||
|
||||
def test_指导成本登记必须同时具备核算准备和事实维护权限(monkeypatch):
|
||||
session, actor, meta = Mock(), Mock(), Mock()
|
||||
save = Mock(return_value={"status": "CREATED"})
|
||||
permissions = []
|
||||
|
||||
def allow(current_session, current_actor, permission):
|
||||
assert (current_session, current_actor) == (session, actor)
|
||||
permissions.append(permission)
|
||||
|
||||
monkeypatch.setattr(authorization_service, "require_permission", allow)
|
||||
monkeypatch.setattr(business_source, "confirm_guidance", save)
|
||||
payload = GuidanceConfirmIn(shipment_item_id=1, amount="2.1234", evidence="核对月度指导成本")
|
||||
|
||||
result = confirm_guidance(session, actor, 1, payload, meta)
|
||||
|
||||
assert result.data == {"status": "CREATED"}
|
||||
assert permissions == ["payroll:prepare", "tplus:facts"]
|
||||
save.assert_called_once_with(session, 1, payload, actor, meta)
|
||||
|
||||
|
||||
def test_缺事实维护权限不能登记指导成本(monkeypatch):
|
||||
session, actor, meta = Mock(), Mock(), Mock()
|
||||
save = Mock()
|
||||
|
||||
def deny(current_session, current_actor, permission):
|
||||
if permission == "tplus:facts":
|
||||
raise ForbiddenError("无权维护事实")
|
||||
|
||||
monkeypatch.setattr(authorization_service, "require_permission", deny)
|
||||
monkeypatch.setattr(business_source, "confirm_guidance", save)
|
||||
payload = GuidanceConfirmIn(shipment_item_id=1, amount="2", evidence="核对")
|
||||
|
||||
with pytest.raises(ForbiddenError):
|
||||
confirm_guidance(session, actor, 1, payload, meta)
|
||||
save.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("amount", ["abc", "-1", "1.12345", "NaN"])
|
||||
def test_指导成本入参拒绝非法精度和值(amount):
|
||||
with pytest.raises(ValueError):
|
||||
GuidanceConfirmIn(shipment_item_id=1, amount=amount, evidence="核对")
|
||||
@@ -1,10 +1,7 @@
|
||||
"""业务接入以源单据为边界;核算资料缺失不得阻断建档。"""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from types import SimpleNamespace
|
||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||
|
||||
import pytest
|
||||
@@ -17,7 +14,6 @@ from app.models import (
|
||||
BusinessItem,
|
||||
BusinessRole,
|
||||
Customer,
|
||||
OwnershipChangeLog,
|
||||
PayrollInput,
|
||||
Personnel,
|
||||
Product,
|
||||
@@ -26,41 +22,32 @@ from app.models import (
|
||||
ShipmentItem,
|
||||
TPlusBusinessDocument,
|
||||
TPlusBusinessSyncConfig,
|
||||
TPlusSourceHead,
|
||||
TPlusSyncBatch,
|
||||
TPlusWorkRun,
|
||||
User,
|
||||
)
|
||||
from app.repositories import customer_repository, product_repository
|
||||
from app.schemas.business_source import (
|
||||
SalesRelationConfirmIn,
|
||||
SalesRelationReleaseIn,
|
||||
SourceMaterialsIn,
|
||||
SourcePriceConfirmIn,
|
||||
)
|
||||
from app.schemas.category_batch import (
|
||||
CustomerCategoryBatch,
|
||||
CustomerOwnershipBatch,
|
||||
ProductCategoryBatch,
|
||||
)
|
||||
from app.schemas.customer import CustomerQuery
|
||||
from app.schemas.payroll import PayrollSetupIn
|
||||
from app.schemas.product import ProductQuery
|
||||
from app.schemas.report import BusinessStatsQuery
|
||||
from app.services import (
|
||||
business_service,
|
||||
business_source,
|
||||
category_batch,
|
||||
customer_service,
|
||||
payroll_months,
|
||||
receipt_confirmation,
|
||||
report_service,
|
||||
)
|
||||
from app.services.business_source import ensure_source_editable
|
||||
from app.services.tplus import business_sync, business_sync_control, sales_relations
|
||||
from app.services.tplus import business_sync, business_sync_control
|
||||
from app.services.tplus.snapshots import source_manifest
|
||||
from app.services.tplus.workbench import WorkbenchError, digest, execute_next
|
||||
from tests.unit.test_tplus_workbench import fact, source
|
||||
from tests.unit.test_tplus_workbench import source
|
||||
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user