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

+91
View File
@@ -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)
+6
View File
@@ -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")
+1
View File
@@ -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="核对")
+2 -15
View File
@@ -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