feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
This commit is contained in:
1 parent
3efe72c344
commit
1e53792ad6
151 files changed
+8842
-401
No files matched your search
@@ -0,0 +1,31 @@
|
||||
"""业务列表按照核算准备结果筛选时保持准确分页。"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.schemas.business import BusinessOut, BusinessQuery
|
||||
from app.services import business_service, business_source
|
||||
|
||||
|
||||
def test_准备原因筛选跨批次分页并按实际状态计数(monkeypatch):
|
||||
rows = [(SimpleNamespace(id=i, integration_json={"source_database": "test"}),)
|
||||
for i in range(105, 0, -1)]
|
||||
|
||||
def scan(_session, _query, before_id, limit=100):
|
||||
return [row for row in rows if before_id is None or row[0].id < before_id][:limit]
|
||||
|
||||
def summaries(_session, businesses, *, snapshot_cache=None):
|
||||
return {row.id: {"status": "PENDING" if row.id % 2 else "READY",
|
||||
"reasons": ([{"code": "GUIDANCE_PENDING", "message": "指导成本待补"}]
|
||||
if row.id % 2 else [])} for row in businesses}
|
||||
|
||||
monkeypatch.setattr(business_service.business_repository, "scan_businesses", scan)
|
||||
monkeypatch.setattr(business_source, "readiness_summaries", summaries)
|
||||
monkeypatch.setattr(business_service, "_to_out", lambda row: BusinessOut(
|
||||
id=row[0].id, business_no=str(row[0].id), customer_id=1))
|
||||
|
||||
result = business_service.list_businesses(None, BusinessQuery(
|
||||
pageNum=3, pageSize=10, readiness_reason="GUIDANCE_PENDING", pending_only=True))
|
||||
|
||||
assert result.total == 53
|
||||
assert [row.id for row in result.records] == list(range(65, 45, -2))
|
||||
assert all(row.readiness_status == "PENDING" for row in result.records)
|
||||
@@ -141,16 +141,37 @@ def business_work(s, user, kind, previous=None):
|
||||
return run
|
||||
|
||||
|
||||
def imported(case):
|
||||
def confirm_test_receipts(s, user):
|
||||
"""金额回归的明确前置:经独立确认服务确认测试回款,不关闭默认门槛。"""
|
||||
from app.schemas.receipt import ReceiptConfirmIn
|
||||
from app.services import receipt_confirmation, receipt_service
|
||||
|
||||
for row in list(s.scalars(select(Receipt))):
|
||||
receipt_service.confirm_receipt(s, row.id, ReceiptConfirmIn(
|
||||
expected_revision=receipt_confirmation.revision(row),
|
||||
), operator=user)
|
||||
|
||||
|
||||
def imported(case, *, confirmed=True):
|
||||
s, user, _manager = case
|
||||
pre = business_work(s, user, "BUSINESS_PREVIEW")
|
||||
assert pre.status == "SUCCEEDED", pre.summary_json
|
||||
assert pre.status == "SUCCEEDED", pre.snapshot_json["plan"]
|
||||
assert pre.summary_json["counts"] == {"CREATE": 1}
|
||||
run = business_work(s, user, "BUSINESS_APPLY", pre)
|
||||
assert run.status == "SUCCEEDED", run.summary_json
|
||||
if confirmed:
|
||||
confirm_test_receipts(s, user)
|
||||
return s.scalar(select(Business))
|
||||
|
||||
|
||||
def test_正式导入不自动确认_保存资料也不能代替确认(payroll_case):
|
||||
s, user, _ = payroll_case
|
||||
business = imported(payroll_case, confirmed=False)
|
||||
assert s.scalar(select(Receipt)).confirmation_status == 0
|
||||
with pytest.raises(BadRequestError, match="人工确认"):
|
||||
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
||||
|
||||
|
||||
def test_工资角色可写且未授权对象不能冒用身份(payroll_case):
|
||||
_, user, _ = payroll_case
|
||||
assert can_write_payroll(user)
|
||||
@@ -248,6 +269,7 @@ def replace_input(s, record, values):
|
||||
)
|
||||
s.add(newer)
|
||||
s.commit()
|
||||
confirm_test_receipts(s, s.get(User, newer.requested_by))
|
||||
return newer
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""历史任职不能被当前岗位覆盖,预演、台账与工资分项必须使用同一依据。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import Customer, PerformanceLedger, Product, TPlusFact
|
||||
from app.schemas.tplus_workbench import PersonFact
|
||||
from app.services import payroll_calculation, settlement_service
|
||||
from tests.unit.test_formal_payroll import imported
|
||||
from tests.unit.test_formal_payroll import payroll_case as payroll_case
|
||||
from tests.unit.test_tplus_fifo import inputs, run
|
||||
from tests.unit.test_tplus_workbench import apply, fact, preview, source
|
||||
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||
|
||||
|
||||
def periods():
|
||||
return [{"start_month": "2026-03", "end_month": "2026-07", "role_code": "sales_manager"}]
|
||||
|
||||
|
||||
def test_按月份保存已确认岗位而不补造转岗日():
|
||||
person = PersonFact(
|
||||
code="010110",
|
||||
name="历史岗位样本",
|
||||
role_code="salesperson",
|
||||
status=1,
|
||||
role_history=periods(),
|
||||
)
|
||||
assert person.model_dump()["role_history"] == periods()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"history",
|
||||
[
|
||||
[{"start_month": "2026-13", "end_month": "2026-07", "role_code": "sales_manager"}],
|
||||
[{"start_month": "2026-08", "end_month": "2026-07", "role_code": "sales_manager"}],
|
||||
[
|
||||
*periods(),
|
||||
{"start_month": "2026-07", "end_month": "2026-09", "role_code": "salesperson"},
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_无效或重叠历史岗位拒绝入库(history):
|
||||
with pytest.raises(ValidationError):
|
||||
PersonFact(
|
||||
code="010110",
|
||||
name="历史岗位样本",
|
||||
role_code="salesperson",
|
||||
status=1,
|
||||
role_history=history,
|
||||
)
|
||||
|
||||
|
||||
def test_七月经理本人业务在八月转岗后仍排除():
|
||||
group, ship, cash, facts, params = inputs()
|
||||
ship["date"], cash["date"] = "2026-07-16", "2026-08-16"
|
||||
facts["personnel"]["001"].update(role_history=periods())
|
||||
params["2026-08-16"] = params.pop("2026-03-10")
|
||||
params["august_confirmed"] = True
|
||||
line = run(group, [ship], [cash], facts, params, date(2026, 8, 31))["lines"][0]
|
||||
assert line["performance"] == "0.00"
|
||||
assert line["excluded_manager_own"]
|
||||
assert line["original_role_code"] == "sales_manager"
|
||||
assert line["role_code"] == "salesperson"
|
||||
|
||||
|
||||
def test_八月新业务按业务员且不沿用七月经理岗位():
|
||||
group, ship, cash, facts, params = inputs()
|
||||
ship["date"] = cash["date"] = "2026-08-16"
|
||||
facts["personnel"]["001"].update(role_history=periods())
|
||||
params["2026-08-16"] = params.pop("2026-03-10")
|
||||
params["august_confirmed"] = True
|
||||
line = run(group, [ship], [cash], facts, params, date(2026, 8, 31))["lines"][0]
|
||||
assert line["performance"] == "0.20" and not line["excluded_manager_own"]
|
||||
|
||||
|
||||
def test_历史经理贯通正式导入台账及月结岗位(payroll_case):
|
||||
session, operator, _ = payroll_case
|
||||
fact = session.scalar(
|
||||
select(TPlusFact).where(TPlusFact.kind == "PERSONNEL", TPlusFact.fact_key == "00043")
|
||||
)
|
||||
fact.data_json = {**fact.data_json, "role_history": periods()}
|
||||
session.commit()
|
||||
business = imported(payroll_case)
|
||||
fact.data_json = {**fact.data_json, "role_history": []}
|
||||
session.commit()
|
||||
result = payroll_calculation.calculate(session, business.id, "2026-03", operator=operator)
|
||||
assert result["performance"] == result["payable"] == "0.00"
|
||||
row = session.scalar(select(PerformanceLedger).where(PerformanceLedger.calc_type == 1))
|
||||
assert row.role_code == "sales_manager"
|
||||
assert row.snapshot_json["manager_own_excluded"]
|
||||
settlement = settlement_service.generate(session, "2026-03", operator=operator)
|
||||
item = next(item for item in settlement.items if item.personnel_id == row.personnel_id)
|
||||
assert item.role_code == "sales_manager"
|
||||
|
||||
|
||||
def test_源客户允许负责人未知且不冒用营销人员姓名(work_session):
|
||||
session = work_session
|
||||
source(session, "customers", [{"id": 9647, "name": "真实样本客户", "partnerType": 211}])
|
||||
fact(session, "CUSTOMER", "9647", {"customer_type": "enterprise"})
|
||||
imported_run = apply(session, preview(session, ["customers"]))
|
||||
assert imported_run.summary_json["counts"] == {"CREATE": 1}
|
||||
customer = session.scalar(select(Customer))
|
||||
assert customer.owner_name == "" and customer.customer_type == "enterprise"
|
||||
|
||||
|
||||
def test_带真实发货参照价格的产品可预览并正式导入(work_session):
|
||||
session = work_session
|
||||
source(session, "products", [{"id": 9, "name": "样本产品", "unit_name": "根"}])
|
||||
source(
|
||||
session,
|
||||
"shipments",
|
||||
[{"id": 20, "code": "SA-SAMPLE", "voucherdate": "2026-07-16", "voucherState": 189}],
|
||||
)
|
||||
source(
|
||||
session,
|
||||
"shipment_items",
|
||||
[
|
||||
{
|
||||
"id": 21,
|
||||
"source_parent_id": 20,
|
||||
"idinventory": 9,
|
||||
"taxAmount": "3850.00000000000000",
|
||||
"quantity": "28.00000000000000",
|
||||
"taxPrice": "137.50000000000000",
|
||||
}
|
||||
],
|
||||
)
|
||||
fact(session, "PRODUCT", "9", {"category": "municipal_water", "unit": "根"})
|
||||
imported_run = apply(session, preview(session, ["products"]))
|
||||
assert imported_run.summary_json["counts"] == {"CREATE": 1}
|
||||
product = session.scalar(select(Product))
|
||||
assert product.stock_quantity == Decimal("28")
|
||||
assert product.source_trade_json["price"] == "137.5000"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""手工业务再次准备时保留交接及已封存成本。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessItem,
|
||||
BusinessRole,
|
||||
Customer,
|
||||
PayrollInput,
|
||||
Personnel,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
User,
|
||||
)
|
||||
from app.schemas.payroll import PayrollSetupIn
|
||||
from app.services import payroll_inputs, payroll_months
|
||||
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||
|
||||
|
||||
def test_手工业务再次保存保留交接并拒绝指导单价微小变更(work_session, monkeypatch):
|
||||
session = work_session
|
||||
sales = session.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
|
||||
manager_role = BusinessRole(role_name="经理", role_code="sales_manager", calc_template="SALES_MANAGER", sort_order=2)
|
||||
customer = Customer(company_name="手工客户", owner_name="负责人", customer_type="personal")
|
||||
session.add_all([manager_role, customer])
|
||||
session.flush()
|
||||
owner = Personnel(personnel_name="业务员", business_role_id=sales.id)
|
||||
receiver = Personnel(personnel_name="接手人员", business_role_id=sales.id)
|
||||
manager = Personnel(personnel_name="经理", business_role_id=manager_role.id)
|
||||
session.add_all([owner, receiver, manager])
|
||||
session.flush()
|
||||
business = Business(business_no="MANUAL-01", customer_id=customer.id)
|
||||
session.add(business)
|
||||
session.flush()
|
||||
session.add(BusinessItem(business_id=business.id, product_name="管材", spec_model="",
|
||||
product_category="municipal_water", quantity=Decimal("5"), sale_amount=Decimal("500")))
|
||||
shipment = Shipment(business_id=business.id, shipment_no="SH-MANUAL", ship_date=date(2026, 3, 10))
|
||||
session.add(shipment)
|
||||
session.flush()
|
||||
item = ShipmentItem(shipment_id=shipment.id, product_name="管材", spec_model="",
|
||||
quantity=Decimal("5"), amount=Decimal("500"))
|
||||
session.add(item)
|
||||
session.commit()
|
||||
monkeypatch.setattr(payroll_months, "ensure_allocation_editable", lambda *args: None)
|
||||
payload = {"month": "2026-03", "evidence": "已核实", "opening_balance": "0", "history_confirmed": True,
|
||||
"guidance_finalized": True, "shipment_ownerships": [{"shipment_item_id": item.id,
|
||||
"personnel_id": owner.id, "manager_id": manager.id, "unit_cost": "1"}],
|
||||
"handovers": [{"business_key": business.business_no, "mode": "RESIGNATION",
|
||||
"from_personnel_id": str(owner.id), "to_personnel_id": str(receiver.id), "effective_date": "2026-03-20"}]}
|
||||
payroll_inputs.setup(session, business.id, PayrollSetupIn(**payload), session.get(User, 1))
|
||||
retry = {**payload, "expected_version": 1}
|
||||
retry.pop("handovers")
|
||||
payroll_inputs.setup(session, business.id, PayrollSetupIn(**retry), session.get(User, 1))
|
||||
latest = session.scalar(select(PayrollInput).order_by(PayrollInput.version.desc()))
|
||||
assert latest.data_json["facts"]["handovers"][0]["to_personnel_id"] == str(receiver.id)
|
||||
changed = {**retry, "expected_version": 2,
|
||||
"shipment_ownerships": [{**retry["shipment_ownerships"][0], "unit_cost": "1.0001"}]}
|
||||
with pytest.raises(BadRequestError, match="指导成本已封存"):
|
||||
payroll_inputs.setup(session, business.id, PayrollSetupIn(**changed), session.get(User, 1))
|
||||
equivalent = {**changed, "shipment_ownerships": [{**changed["shipment_ownerships"][0], "unit_cost": "1.0000"}]}
|
||||
payroll_inputs.setup(session, business.id, PayrollSetupIn(**equivalent), session.get(User, 1))
|
||||
payroll_inputs.setup(session, business.id, PayrollSetupIn(**{**equivalent, "expected_version": 3, "handovers": []}),
|
||||
session.get(User, 1))
|
||||
latest = session.scalar(select(PayrollInput).order_by(PayrollInput.version.desc()))
|
||||
assert latest.data_json["facts"]["handovers"] == []
|
||||
@@ -51,6 +51,8 @@ EXPECTED_TABLES = {
|
||||
"monthly_settlements",
|
||||
"settlement_items",
|
||||
"adjustment_orders",
|
||||
"tplus_business_documents",
|
||||
"tplus_business_sync_configs",
|
||||
"tplus_sync_batches",
|
||||
"tplus_sync_errors",
|
||||
"tplus_sync_records",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""在显式指定的临时 MySQL 库中验证工资月份行锁。"""
|
||||
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||
from datetime import datetime
|
||||
from threading import Event
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PayrollMonth
|
||||
from app.services.payroll_months import lock_month, lock_month_row
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.environ.get("PAYROLL_LOCK_TEST_URL"), reason="需要独立的临时 MySQL 测试库")
|
||||
def test_同步等待工资事务提交并读取最新生成状态(monkeypatch):
|
||||
from app.services import payroll_months
|
||||
|
||||
engine = create_engine(os.environ["PAYROLL_LOCK_TEST_URL"])
|
||||
PayrollMonth.__table__.create(engine)
|
||||
monkeypatch.setattr(payroll_months, "ensure_payroll_writer", lambda _operator: None)
|
||||
actor = SimpleNamespace(id=1, username="test")
|
||||
started = Event()
|
||||
|
||||
def check_sync():
|
||||
with Session(engine) as session:
|
||||
started.set()
|
||||
control = lock_month_row(session, "2026-03", actor)
|
||||
return control.generated_at
|
||||
|
||||
try:
|
||||
with Session(engine) as generation, ThreadPoolExecutor(max_workers=1) as pool:
|
||||
control = lock_month(generation, "2026-03", actor)
|
||||
result = pool.submit(check_sync)
|
||||
assert started.wait(5)
|
||||
with pytest.raises(TimeoutError):
|
||||
result.result(timeout=0.25)
|
||||
control.generated_at = datetime(2026, 3, 31)
|
||||
generation.commit()
|
||||
assert result.result(timeout=10) == datetime(2026, 3, 31)
|
||||
|
||||
started.clear()
|
||||
|
||||
def generate():
|
||||
with Session(engine) as session:
|
||||
started.set()
|
||||
control = lock_month(session, "2026-04", actor)
|
||||
control.generated_at = datetime(2026, 4, 30)
|
||||
session.commit()
|
||||
|
||||
with Session(engine) as sync, ThreadPoolExecutor(max_workers=1) as pool:
|
||||
lock_month_row(sync, "2026-04", actor)
|
||||
result = pool.submit(generate)
|
||||
assert started.wait(5)
|
||||
with pytest.raises(TimeoutError):
|
||||
result.result(timeout=0.25)
|
||||
sync.commit()
|
||||
result.result(timeout=10)
|
||||
finally:
|
||||
engine.dispose()
|
||||
@@ -23,30 +23,35 @@ from app.services.param_registry import (
|
||||
# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12;
|
||||
# V10 增工作台预警规则三项 #10)
|
||||
EXPECTED_CODES = {
|
||||
"coefficient.personal.first", "coefficient.personal.start", "coefficient.personal.slope",
|
||||
"coefficient.personal.floor", "coefficient.personal.boundary",
|
||||
"salesperson.rate.start",
|
||||
"salesperson.rate.slope",
|
||||
"salesperson.rate.floor",
|
||||
"salesperson.days.boundary",
|
||||
"manager.share.team",
|
||||
"manager.share.company",
|
||||
"approval.adjustment.enabled",
|
||||
"approval.monthly_settlement.enabled",
|
||||
"approval.secondary_allocation.enabled",
|
||||
"barter.discount",
|
||||
"coefficient.personal.boundary",
|
||||
"coefficient.personal.first",
|
||||
"coefficient.personal.floor",
|
||||
"coefficient.personal.slope",
|
||||
"coefficient.personal.start",
|
||||
"enterprise.rate",
|
||||
"info.rate",
|
||||
"prepay.rate",
|
||||
"barter.discount",
|
||||
"manager.personal.included",
|
||||
"manager.share.company",
|
||||
"manager.share.team",
|
||||
"payment.start.ratio",
|
||||
"payment.weight.mode",
|
||||
"product.threshold",
|
||||
"salary.basic.mode",
|
||||
"prepay.rate",
|
||||
"probation.annual.threshold",
|
||||
"approval.secondary_allocation.enabled",
|
||||
"approval.monthly_settlement.enabled",
|
||||
"approval.adjustment.enabled",
|
||||
"manager.personal.included",
|
||||
"warning.enabled",
|
||||
"warning.receipt.overdue.days",
|
||||
"product.threshold",
|
||||
"receipt.confirmation.enabled",
|
||||
"salary.basic.mode",
|
||||
"salesperson.days.boundary",
|
||||
"salesperson.rate.floor",
|
||||
"salesperson.rate.slope",
|
||||
"salesperson.rate.start",
|
||||
"warning.approval.timeout.days",
|
||||
"warning.enabled",
|
||||
"warning.receipt.overdue.days"
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +92,7 @@ class TestRegistry:
|
||||
"""注册表元数据完整性(与迁移种子同源)。"""
|
||||
|
||||
def test_注册表共27项(self) -> None:
|
||||
assert len(PARAM_REGISTRY) == 27
|
||||
assert len(PARAM_REGISTRY) == 28
|
||||
|
||||
def test_编码集合与迁移种子一致(self) -> None:
|
||||
assert set(PARAM_REGISTRY) == EXPECTED_CODES
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""核算资料参照必须能完整遍历大于单页的发货和人员。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessRole,
|
||||
Personnel,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
TPlusFact,
|
||||
TPlusSourceLink,
|
||||
User,
|
||||
)
|
||||
from app.services import authorization_metadata, authorization_service
|
||||
from tests.unit.test_tplus_business_sync import dataset, sync
|
||||
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||
|
||||
|
||||
def test_核算参照按页返回超过二百条的发货与人员(work_session, monkeypatch):
|
||||
session = work_session
|
||||
dataset(session, shipping=True)
|
||||
sync(session)
|
||||
business = session.scalar(select(Business))
|
||||
role = session.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
|
||||
for number in range(205):
|
||||
shipment = Shipment(business_id=business.id, shipment_no=f"P{number:04}",
|
||||
ship_date=date(2026, 3, 1), total_amount=Decimal("1"))
|
||||
session.add(shipment)
|
||||
session.flush()
|
||||
session.add(ShipmentItem(shipment_id=shipment.id, product_name="测试产品", spec_model="",
|
||||
quantity=Decimal("1"), amount=Decimal("1")))
|
||||
session.add(Personnel(personnel_name=f"测试人员{number:04}", business_role_id=role.id))
|
||||
session.flush()
|
||||
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
|
||||
first = authorization_metadata.payroll_options(session, session.get(User, 1), business.id, page_num=1, page_size=200)
|
||||
second = authorization_metadata.payroll_options(session, session.get(User, 1), business.id, page_num=2, page_size=200)
|
||||
assert first["business_key"] == second["business_key"] == "customer:8"
|
||||
assert first["shipments_total"] == second["shipments_total"] == 206
|
||||
assert first["personnel_total"] == second["personnel_total"] == 205
|
||||
assert (len(first["shipments"]), len(second["shipments"])) == (200, 6)
|
||||
assert (len(first["personnel"]), len(second["personnel"])) == (200, 5)
|
||||
assert len({row["id"] for row in first["shipments"] + second["shipments"]}) == 206
|
||||
|
||||
|
||||
def test_来源人员只按已确认身份关联预填且未匹配保持空值(work_session, monkeypatch):
|
||||
session = work_session
|
||||
dataset(session, shipping=True)
|
||||
sync(session)
|
||||
business = session.scalar(select(Business))
|
||||
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
|
||||
before = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)
|
||||
assert before["shipments"][0]["source_personnel_name"] == "来源员工"
|
||||
assert before["shipments"][0]["matched_personnel_id"] is None
|
||||
role = session.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
|
||||
person = Personnel(personnel_name="来源员工", business_role_id=role.id)
|
||||
session.add(person)
|
||||
session.flush()
|
||||
# 相同姓名本身不是身份关联。
|
||||
unmatched = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)
|
||||
assert unmatched["shipments"][0]["matched_personnel_id"] is None
|
||||
other_source = TPlusSourceLink(source_database="other_source", entity_type="personnel", source_id="1", target_id=person.id)
|
||||
session.add(other_source)
|
||||
session.flush()
|
||||
assert authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0]["matched_personnel_id"] is None
|
||||
session.delete(other_source)
|
||||
session.flush()
|
||||
session.add(TPlusSourceLink(source_database="test_source", entity_type="personnel", source_id="1", target_id=person.id))
|
||||
session.flush()
|
||||
matched = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)
|
||||
assert matched["shipments"][0]["matched_personnel_id"] == person.id
|
||||
|
||||
|
||||
def test_核算参照复用同来源已封存的单位指导成本(work_session, monkeypatch):
|
||||
session = work_session
|
||||
dataset(session, shipping=True)
|
||||
sync(session)
|
||||
business = session.scalar(select(Business))
|
||||
item = session.scalar(select(ShipmentItem))
|
||||
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
|
||||
initial = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0]
|
||||
assert initial["guidance_status"] == "MISSING" and initial["guidance_unit_cost"] is None
|
||||
data = {"product_id": "9", "month": "2026-03", "basis": "UNIT",
|
||||
"amount": "2.1234", "unit": item.unit, "is_finalized": True}
|
||||
session.add(TPlusFact(source_database="test_source", kind="GUIDANCE", fact_key="price9-03",
|
||||
version=1, data_json=data, evidence="历史记录", requested_by=1))
|
||||
session.flush()
|
||||
ready = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0]
|
||||
assert (ready["guidance_status"], ready["guidance_unit_cost"]) == ("FINALIZED_UNIT", "2.1234")
|
||||
session.add(TPlusFact(source_database="test_source", kind="GUIDANCE", fact_key="duplicate9-03",
|
||||
version=1, data_json=data, evidence="历史重复记录", requested_by=1))
|
||||
session.flush()
|
||||
conflict = authorization_metadata.payroll_options(session, session.get(User, 1), business.id)["shipments"][0]
|
||||
assert conflict["guidance_status"] == "AMBIGUOUS" and conflict["guidance_unit_cost"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("page_num,page_size", [(0, 200), (1, 0), (1, 201)])
|
||||
def test_核算参照拒绝无效页码与页大小(work_session, monkeypatch, page_num, page_size):
|
||||
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
|
||||
with pytest.raises(ValueError):
|
||||
authorization_metadata.payroll_options(work_session, work_session.get(User, 1), 1,
|
||||
page_num=page_num, page_size=page_size)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""人工确认绑定被核对内容,不能由核算状态、凭证或同步状态推导。"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models import Receipt
|
||||
from app.services import receipt_confirmation
|
||||
from app.services.param_service import ParamSnapshot
|
||||
|
||||
|
||||
def receipt():
|
||||
return Receipt(id=1, business_id=2, receipt_date=date(2026, 3, 10), amount=Decimal("100"), is_barter=0)
|
||||
|
||||
|
||||
def test_人工确认默认开启且与工资审批独立(monkeypatch):
|
||||
monkeypatch.setattr(receipt_confirmation, "resolve_snapshot", lambda *args: ParamSnapshot.from_rows(date.today(), []))
|
||||
assert receipt_confirmation.enabled(None)
|
||||
|
||||
|
||||
def test_已核算及同步来源不能替代确认():
|
||||
row = receipt()
|
||||
row.settlement_status = 1
|
||||
row.t_plus_id = "source1"
|
||||
row.sync_batch_no = "SYNC-SUCCEEDED"
|
||||
assert not receipt_confirmation.is_confirmed(row)
|
||||
|
||||
|
||||
def test_关键内容变化使确认失效而备注不影响():
|
||||
row = receipt()
|
||||
row.confirmation_status = 1
|
||||
row.confirmed_by = "test_operator"
|
||||
row.confirmed_at = datetime.now()
|
||||
row.confirmed_revision = receipt_confirmation.revision(row)
|
||||
assert receipt_confirmation.is_confirmed(row)
|
||||
row.remark = "只改备注"
|
||||
assert receipt_confirmation.is_confirmed(row)
|
||||
row.amount = Decimal("101")
|
||||
assert not receipt_confirmation.is_confirmed(row)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""V29 能识别先于迁移版本登记的兼容字段。"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.dialects.mysql import DATETIME, TINYINT, VARCHAR
|
||||
|
||||
|
||||
def migration(monkeypatch, columns):
|
||||
path = Path(__file__).resolve().parents[2] / "alembic/versions/29b0c1d2e3f4_receipt_manual_confirmation.py"
|
||||
spec = importlib.util.spec_from_file_location("receipt_confirmation_migration", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
statements = []
|
||||
bind = SimpleNamespace(execute=lambda sql: statements.append(str(sql)))
|
||||
monkeypatch.setattr(module, "op", SimpleNamespace(get_bind=lambda: bind))
|
||||
monkeypatch.setattr(module, "inspect", lambda _: SimpleNamespace(get_columns=lambda table: columns))
|
||||
return module, statements
|
||||
|
||||
|
||||
def existing_columns():
|
||||
return [
|
||||
{"name": "confirmation_status", "type": TINYINT(), "nullable": False, "default": "'0'"},
|
||||
{"name": "confirmed_by", "type": VARCHAR(64, collation="utf8mb4_general_ci"), "nullable": True},
|
||||
{"name": "confirmed_at", "type": DATETIME(), "nullable": True},
|
||||
{"name": "confirmed_revision", "type": VARCHAR(64), "nullable": True},
|
||||
]
|
||||
|
||||
|
||||
def test_兼容已有字段且仍执行权限和参数初始化(monkeypatch):
|
||||
module, statements = migration(monkeypatch, existing_columns())
|
||||
module.upgrade()
|
||||
assert len(statements) == 4
|
||||
assert all(not statement.startswith("ALTER TABLE") for statement in statements)
|
||||
assert any("receipt.confirmation.enabled" in statement for statement in statements)
|
||||
|
||||
|
||||
def test_已有字段不完整时拒绝迁移(monkeypatch):
|
||||
module, statements = migration(monkeypatch, existing_columns()[:1])
|
||||
with pytest.raises(RuntimeError, match="字段不完整"):
|
||||
module.upgrade()
|
||||
assert not statements
|
||||
@@ -0,0 +1,31 @@
|
||||
"""来源单价确认接口的权限边界。"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.v1.business.businesses import confirm_source_price
|
||||
from app.core.exceptions import ForbiddenError
|
||||
from app.schemas.business_source import SourcePriceConfirmIn
|
||||
from app.services import authorization_service, business_source
|
||||
from app.services.authorization_policy import permission_for
|
||||
|
||||
|
||||
def test_来源单价确认路由要求业务更新权限():
|
||||
assert permission_for("business.businesses", "confirm_source_price", "PUT") == "business:businesses:update"
|
||||
|
||||
|
||||
def test_无核算准备权限不能确认来源单价(monkeypatch):
|
||||
session, actor, meta = Mock(), Mock(), Mock()
|
||||
confirm = Mock()
|
||||
|
||||
def deny(current_session, current_actor, permission):
|
||||
assert (current_session, current_actor, permission) == (session, actor, "payroll:prepare")
|
||||
raise ForbiddenError("无权核算准备")
|
||||
|
||||
monkeypatch.setattr(authorization_service, "require_permission", deny)
|
||||
monkeypatch.setattr(business_source, "confirm_missing_source_price", confirm)
|
||||
payload = SourcePriceConfirmIn(revision="0" * 64, evidence="核对原单")
|
||||
with pytest.raises(ForbiddenError):
|
||||
confirm_source_price(session, actor, 1, 1, payload, meta)
|
||||
confirm.assert_not_called()
|
||||
@@ -0,0 +1,573 @@
|
||||
"""业务接入以源单据为边界;核算资料缺失不得阻断建档。"""
|
||||
|
||||
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
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.exceptions import BadRequestError, ForbiddenError
|
||||
from app.core.response import PageQuery
|
||||
from app.models import (
|
||||
Business,
|
||||
BusinessItem,
|
||||
BusinessRole,
|
||||
Customer,
|
||||
OwnershipChangeLog,
|
||||
PayrollInput,
|
||||
Personnel,
|
||||
Product,
|
||||
Receipt,
|
||||
Shipment,
|
||||
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.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 work_session as work_session
|
||||
|
||||
|
||||
def dataset(s, *, shipping=False, cash=False):
|
||||
entities = {
|
||||
"customers": [{"id": 8, "code": "C8", "name": "自动客户", "partnerType": 228}],
|
||||
"products": [{"id": 9, "code": "P9", "name": "管材", "specification": "DN300", "unit_name": "根"}],
|
||||
"personnel": [{"id": 1, "code": "00043", "name": "来源员工", "isSalesman": 1}],
|
||||
"businesses": [{"id": 11, "code": "SO-11", "idcustomer": 8, "idclerk": 1, "voucherdate": "2026-03-12", "voucherState": 189}],
|
||||
"business_items": [{"id": 12, "source_parent_id": 11, "idinventory": 9, "quantity": "10", "taxPrice": "100", "taxAmount": "1000"}],
|
||||
"shipments": [{"id": 21, "code": "SH-21", "idcustomer": 8, "idclerk": 1, "voucherdate": "2026-03-13", "voucherState": 189}] if shipping else [],
|
||||
"shipment_items": [{"id": 22, "source_parent_id": 21, "idinventory": 9, "saleOrderId": 11, "saleOrderDetailId": 12, "quantity": "5", "taxPrice": "100", "taxAmount": "500"}] if shipping else [],
|
||||
"receipts": [{"id": 31, "code": "RC-31", "idpartner": 8, "idperson": 1, "isReceiveFlag": 1, "amount": "200", "voucherDate": "2026-03-14", "voucherstate": 189, "accounting_voucher_count": 1, "valid_accounting_voucher_count": 1, "accounting_voucher_id": 501}] if cash else [],
|
||||
"receipt_items": [],
|
||||
}
|
||||
for entity, rows in entities.items():
|
||||
source(s, entity, rows)
|
||||
|
||||
|
||||
def sync(s):
|
||||
run = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source", requested_by=1,
|
||||
kind="BUSINESS_SYNC", status="RUNNING", input_json={"start_date": "2026-03-01"})
|
||||
s.add(run)
|
||||
business_sync.execute_sync(s, run)
|
||||
s.commit()
|
||||
return run
|
||||
|
||||
|
||||
def test_自动接入按实际发布快照去重(work_session, monkeypatch):
|
||||
session = work_session
|
||||
dataset(session)
|
||||
monkeypatch.setattr(business_sync_control, "history_source_database", lambda _session: "test_source")
|
||||
config = TPlusBusinessSyncConfig(source_database="test_source", enabled=1,
|
||||
start_date=date(2026, 3, 1), version=1, updated_by=1)
|
||||
session.add(config)
|
||||
session.commit()
|
||||
manifest = list(source_manifest(session, "test_source").items())
|
||||
expected_key = str(uuid5(NAMESPACE_URL, digest(["test_source", 1, manifest, "business-sync-v1"])))
|
||||
first = business_sync_control.schedule(session)
|
||||
assert session.scalar(select(TPlusWorkRun.request_key)) == expected_key
|
||||
config.last_error = "BUSINESS_SYNC_SCHEDULE_FAILED"
|
||||
session.commit()
|
||||
# 历史成功批次可能声明实体,但没有发布该实体;它不能改变实际来源版本。
|
||||
session.add(TPlusSyncBatch(batch_no=uuid4().hex[:24], source_database="test_source",
|
||||
sync_mode="FULL", status="SUCCEEDED", entity_types_json=["customers"],
|
||||
audit_result_json={"observed_entities": []}))
|
||||
session.commit()
|
||||
second = business_sync_control.schedule(session)
|
||||
session.expire_all()
|
||||
assert config.last_error is None
|
||||
assert second["run_no"] == first["run_no"]
|
||||
assert session.scalar(select(func.count()).select_from(TPlusWorkRun)) == 1
|
||||
run = session.scalar(select(TPlusWorkRun))
|
||||
run.status, config.last_error = "FAILED", "TASK_AUTHORIZATION_REVOKED"
|
||||
session.commit()
|
||||
assert business_sync_control.schedule(session)["status"] == "FAILED"
|
||||
session.expire_all()
|
||||
assert config.last_error == "TASK_AUTHORIZATION_REVOKED"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_owner", [1, 2])
|
||||
def test_排队后撤权仅提示对应授权人的自动接入配置(work_session, monkeypatch, config_owner):
|
||||
from app.services import authorization_service
|
||||
|
||||
session = work_session
|
||||
if config_owner == 2:
|
||||
session.add(User(id=2, username="other_operator", password="test_hash_only", status=1))
|
||||
config = TPlusBusinessSyncConfig(source_database="test_source", enabled=1,
|
||||
start_date=date(2026, 3, 1), version=1, updated_by=config_owner)
|
||||
run = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source",
|
||||
requested_by=1, kind="BUSINESS_SYNC", status="QUEUED", input_json={})
|
||||
session.add_all([config, run])
|
||||
session.commit()
|
||||
|
||||
def denied(*_args):
|
||||
raise ForbiddenError()
|
||||
|
||||
monkeypatch.setattr(authorization_service, "require_permission", denied)
|
||||
assert execute_next(session) == run.run_no
|
||||
session.refresh(run)
|
||||
session.refresh(config)
|
||||
assert (run.status, run.error_code) == ("FAILED", "TASK_AUTHORIZATION_REVOKED")
|
||||
assert config.last_error == ("TASK_AUTHORIZATION_REVOKED" if config_owner == 1 else None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_owner", [1, 2])
|
||||
def test_重试成功仅清除对应授权人的接入错误(work_session, monkeypatch, config_owner):
|
||||
session = work_session
|
||||
if config_owner == 2:
|
||||
session.add(User(id=2, username="other_operator", password="test_hash_only", status=1))
|
||||
config = TPlusBusinessSyncConfig(source_database="test_source", enabled=1,
|
||||
start_date=date(2026, 3, 1), version=1, updated_by=config_owner,
|
||||
last_error="TASK_AUTHORIZATION_REVOKED")
|
||||
run = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source",
|
||||
requested_by=1, kind="BUSINESS_SYNC", status="QUEUED", input_json={})
|
||||
session.add_all([config, run])
|
||||
session.commit()
|
||||
|
||||
def success(_session, task):
|
||||
task.status = "SUCCEEDED"
|
||||
|
||||
monkeypatch.setattr(business_sync, "execute_sync", success)
|
||||
assert execute_next(session) == run.run_no
|
||||
session.refresh(config)
|
||||
assert config.last_error == (None if config_owner == 1 else "TASK_AUTHORIZATION_REVOKED")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("value", "scale"), [("999999999999.99", 2), ("9999999999.9999", 4)])
|
||||
def test_目标精度范围内的来源数值可接入(value, scale):
|
||||
assert business_sync.decimal_value(value, scale) == Decimal(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("value", "scale"), [("1000000000000", 2), ("10000000000", 4), ("1.001", 2)])
|
||||
def test_来源数值超出目标范围或精度时拒绝(value, scale):
|
||||
with pytest.raises(WorkbenchError, match="SOURCE_VALUE_INVALID"):
|
||||
business_sync.decimal_value(value, scale)
|
||||
|
||||
|
||||
def test_订单明细合计溢出时标记待核对(work_session):
|
||||
s = work_session
|
||||
dataset(s)
|
||||
source(s, "business_items", [
|
||||
{"id": 12, "source_parent_id": 11, "idinventory": 9, "quantity": "1", "taxAmount": "600000000000"},
|
||||
{"id": 13, "source_parent_id": 11, "idinventory": 9, "quantity": "1", "taxAmount": "600000000000"},
|
||||
])
|
||||
run = sync(s)
|
||||
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "businesses"))
|
||||
assert doc.status == "PENDING"
|
||||
assert doc.reason == "SOURCE_VALUE_INVALID"
|
||||
assert run.summary_json["counts"]["PENDING"] == 1
|
||||
|
||||
|
||||
def test_只有订单且分类指导价均缺失仍自动建业务(work_session):
|
||||
s = work_session
|
||||
dataset(s)
|
||||
run = sync(s)
|
||||
business = s.scalar(select(Business))
|
||||
assert business is not None and business.business_date == date(2026, 3, 12)
|
||||
assert s.scalar(select(Customer)).customer_type is None
|
||||
assert s.scalar(select(Product)).product_category is None
|
||||
assert s.scalar(select(BusinessItem)).product_category is None
|
||||
assert business.total_sale_amount == 1000
|
||||
assert business.total_freight_amount is None
|
||||
assert business.owner_personnel_id is None
|
||||
assert s.scalar(select(func.count()).select_from(PayrollInput)) == 0
|
||||
assert run.status == "SUCCEEDED"
|
||||
sync(s)
|
||||
assert business.total_freight_amount is None
|
||||
business_service._recalc_totals(s, business)
|
||||
assert business.total_cost_amount is None
|
||||
assert business.total_freight_amount is None
|
||||
assert business.total_sale_amount == 1000
|
||||
|
||||
|
||||
def test_已生成工资的月份不接入新来源单据(work_session):
|
||||
from datetime import datetime
|
||||
|
||||
from app.models import PayrollMonth
|
||||
|
||||
session = work_session
|
||||
dataset(session, cash=True)
|
||||
session.add(PayrollMonth(month="2026-03", maker_id=1, maker_name="test_operator",
|
||||
generated_at=datetime(2026, 3, 31)))
|
||||
session.commit()
|
||||
run = sync(session)
|
||||
assert session.scalar(select(func.count()).select_from(Receipt)) == 0
|
||||
documents = list(session.scalars(select(TPlusBusinessDocument)))
|
||||
assert any(doc.reason == "PAYROLL_MONTH_LOCKED" for doc in documents)
|
||||
assert run.status == "PARTIAL"
|
||||
|
||||
|
||||
def test_运费仅在所有发货均确认后汇总(work_session):
|
||||
s = work_session
|
||||
dataset(s, shipping=True)
|
||||
sync(s)
|
||||
business = s.scalar(select(Business))
|
||||
shipment = s.scalar(select(Shipment))
|
||||
assert business.total_freight_amount is None
|
||||
shipment.freight_amount = Decimal("0.00")
|
||||
s.flush()
|
||||
business_sync._refresh_business_totals(s, business)
|
||||
assert business.total_freight_amount == Decimal("0.00")
|
||||
|
||||
|
||||
def test_来源订单与发货数量按产品单位分别汇总(work_session):
|
||||
s = work_session
|
||||
dataset(s, shipping=True, cash=True)
|
||||
sync(s)
|
||||
business = s.scalar(select(Business))
|
||||
rows = business_source.source_quantities(s, business.id, PageQuery()).records
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["product_name"] == "管材" and rows[0]["unit"] == "根"
|
||||
assert Decimal(rows[0]["order_quantity"]) == Decimal("10")
|
||||
assert Decimal(rows[0]["shipment_quantity"]) == Decimal("5")
|
||||
assert business.total_sale_amount == Decimal("1000")
|
||||
assert business.total_received_amount == Decimal("200")
|
||||
sync(s)
|
||||
assert business_source.source_quantities(s, business.id, PageQuery()).records == rows
|
||||
|
||||
|
||||
def test_来源数量按不同产品单位分组并分页(work_session):
|
||||
s = work_session
|
||||
dataset(s, shipping=True)
|
||||
source(s, "products", [
|
||||
{"id": 9, "code": "P9", "name": "管材", "specification": "DN300", "unit_name": "根"},
|
||||
{"id": 10, "code": "P10", "name": "钢板", "specification": "Q235", "unit_name": "吨"},
|
||||
])
|
||||
source(s, "business_items", [
|
||||
{"id": 12, "source_parent_id": 11, "idinventory": 9, "quantity": "10", "taxAmount": "1000"},
|
||||
{"id": 13, "source_parent_id": 11, "idinventory": 10, "quantity": "3", "taxAmount": "300"},
|
||||
])
|
||||
source(s, "shipment_items", [
|
||||
{"id": 22, "source_parent_id": 21, "idinventory": 9, "saleOrderDetailId": 12,
|
||||
"quantity": "5", "taxAmount": "500"},
|
||||
{"id": 23, "source_parent_id": 21, "idinventory": 10, "saleOrderDetailId": 13,
|
||||
"quantity": "2", "taxAmount": "200"},
|
||||
])
|
||||
sync(s)
|
||||
business = s.scalar(select(Business))
|
||||
first = business_source.source_quantities(s, business.id, PageQuery(page_size=1, page_num=1))
|
||||
second = business_source.source_quantities(s, business.id, PageQuery(page_size=1, page_num=2))
|
||||
assert first.total == second.total == 2
|
||||
rows = {row["unit"]: row for row in first.records + second.records}
|
||||
assert (Decimal(rows["根"]["order_quantity"]), Decimal(rows["根"]["shipment_quantity"])) == (10, 5)
|
||||
assert (Decimal(rows["吨"]["order_quantity"]), Decimal(rows["吨"]["shipment_quantity"])) == (3, 2)
|
||||
|
||||
|
||||
def test_仅待核对单据的业务不填零成本运费且重试修复旧汇总(work_session):
|
||||
s = work_session
|
||||
dataset(s)
|
||||
source(s, "businesses", [{"id": 11, "code": "SO-11", "idcustomer": 8,
|
||||
"voucherdate": "2026-03-12", "voucherState": 0}])
|
||||
run = sync(s)
|
||||
business = s.scalar(select(Business))
|
||||
assert run.status == "PARTIAL"
|
||||
assert business.total_cost_amount is None and business.total_freight_amount is None
|
||||
business.total_cost_amount = Decimal("0.00")
|
||||
business.total_freight_amount = Decimal("0.00")
|
||||
s.commit()
|
||||
sync(s)
|
||||
assert business.total_cost_amount is None and business.total_freight_amount is None
|
||||
|
||||
|
||||
def test_待核对回款来源客户变化时不静默归入原客户(work_session):
|
||||
s = work_session
|
||||
dataset(s, cash=True)
|
||||
source(s, "customers", [
|
||||
{"id": 8, "code": "C8", "name": "自动客户", "partnerType": 228},
|
||||
{"id": 10, "code": "C10", "name": "其他客户", "partnerType": 228},
|
||||
])
|
||||
receipt = {"id": 31, "code": "RC-31", "idpartner": 8, "idperson": 1,
|
||||
"isReceiveFlag": 1, "amount": "-200", "voucherDate": "2026-03-14", "voucherstate": 189}
|
||||
source(s, "receipts", [receipt])
|
||||
sync(s)
|
||||
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "receipts"))
|
||||
original_business_id = doc.business_id
|
||||
assert doc.reason == "SOURCE_VALUE_INVALID" and original_business_id is not None
|
||||
source(s, "receipts", [{**receipt, "idpartner": 10}])
|
||||
sync(s)
|
||||
assert (doc.status, doc.reason, doc.business_id) == (
|
||||
"CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED", original_business_id)
|
||||
assert s.scalar(select(func.count()).select_from(Receipt)) == 0
|
||||
displayed = business_source.documents(s, original_business_id, PageQuery(), entity="receipts").records[0]
|
||||
assert displayed["changes"] == [{"field": "customer_id", "label": "客户标识", "before": "8", "after": "10"}]
|
||||
source(s, "receipts", [{**receipt, "idpartner": 10, "amount": "200"}])
|
||||
sync(s)
|
||||
assert (doc.status, doc.reason, doc.business_id) == (
|
||||
"CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED", original_business_id)
|
||||
assert s.scalar(select(func.count()).select_from(Receipt)) == 0
|
||||
source(s, "receipts", [{**receipt, "amount": "200"}])
|
||||
sync(s)
|
||||
assert doc.status == "APPLIED" and doc.business_id == original_business_id
|
||||
assert s.scalar(select(func.count()).select_from(Receipt)) == 1
|
||||
|
||||
|
||||
def test_待核对回款同客户更新来源日期及人员(work_session):
|
||||
s = work_session
|
||||
dataset(s, cash=True)
|
||||
receipt = {"id": 31, "code": "RC-31", "idpartner": 8, "idperson": 1,
|
||||
"isReceiveFlag": 1, "amount": "-200", "voucherDate": "2026-03-14", "voucherstate": 189}
|
||||
source(s, "receipts", [receipt])
|
||||
sync(s)
|
||||
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "receipts"))
|
||||
source(s, "receipts", [{**receipt, "idperson": 2, "voucherDate": "2026-03-15"}])
|
||||
sync(s)
|
||||
assert doc.reason == "SOURCE_VALUE_INVALID"
|
||||
assert doc.data_json["source_date"] == "2026-03-15"
|
||||
assert doc.data_json["source_personnel_id"] == "2"
|
||||
assert s.scalar(select(func.count()).select_from(Receipt)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("entity", ["businesses", "shipments"])
|
||||
def test_待核对订单或发货客户变化需核对(work_session, entity):
|
||||
s = work_session
|
||||
dataset(s, shipping=entity == "shipments")
|
||||
source(s, "customers", [
|
||||
{"id": 8, "code": "C8", "name": "自动客户"},
|
||||
{"id": 10, "code": "C10", "name": "其他客户"},
|
||||
])
|
||||
raw = {"id": 11 if entity == "businesses" else 21, "idcustomer": 8,
|
||||
"voucherdate": "2026-03-12", "voucherState": 0}
|
||||
source(s, entity, [raw])
|
||||
sync(s)
|
||||
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == entity))
|
||||
original_business_id = doc.business_id
|
||||
assert doc.reason == "SOURCE_DOCUMENT_INACTIVE" and original_business_id is not None
|
||||
source(s, entity, [{**raw, "idcustomer": 10, "voucherState": 189}])
|
||||
sync(s)
|
||||
assert (doc.status, doc.reason, doc.business_id, doc.target_id) == (
|
||||
"CONFLICT", "PENDING_SOURCE_CUSTOMER_CHANGED", original_business_id, None)
|
||||
source(s, entity, [{**raw, "voucherState": 189}])
|
||||
sync(s)
|
||||
assert doc.status == "APPLIED" and doc.business_id == original_business_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mismatch", ["product", "order"])
|
||||
def test_发货关联订单的产品或父单不一致时不静默归并(work_session, mismatch):
|
||||
s = work_session
|
||||
dataset(s, shipping=True)
|
||||
if mismatch == "product":
|
||||
source(s, "products", [
|
||||
{"id": 9, "code": "P9", "name": "管材", "specification": "DN300", "unit_name": "根"},
|
||||
{"id": 10, "code": "P10", "name": "其他管材", "specification": "DN200", "unit_name": "根"},
|
||||
])
|
||||
source(s, "shipment_items", [{
|
||||
"id": 22, "source_parent_id": 21,
|
||||
"idinventory": 10 if mismatch == "product" else 9,
|
||||
"saleOrderId": 99 if mismatch == "order" else 11,
|
||||
"saleOrderDetailId": 12, "quantity": "5", "taxPrice": "100", "taxAmount": "500",
|
||||
}])
|
||||
sync(s)
|
||||
business = s.scalar(select(Business))
|
||||
assert s.scalar(select(func.count()).select_from(BusinessItem)) == 2
|
||||
assert business.total_sale_amount is None
|
||||
assert business.integration_json["sales_relation_pending"] is True
|
||||
shipment = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "shipments"))
|
||||
assert (shipment.status, shipment.reason) == ("CONFLICT", "ORDER_SHIPMENT_MISMATCH")
|
||||
sync(s)
|
||||
assert shipment.status == "CONFLICT"
|
||||
assert shipment.reason == "ORDER_SHIPMENT_MISMATCH"
|
||||
|
||||
|
||||
def test_已有错误关联的发货重跑时标记冲突且保留正式数据(work_session, monkeypatch):
|
||||
s = work_session
|
||||
dataset(s, shipping=True)
|
||||
source(s, "shipment_items", [{
|
||||
"id": 22, "source_parent_id": 21, "idinventory": 9,
|
||||
"saleOrderId": 99, "saleOrderDetailId": 12,
|
||||
"quantity": "5", "taxPrice": "100", "taxAmount": "500",
|
||||
}])
|
||||
original = business_sync._shipment_order_line
|
||||
def old_match(ctx, business_id, child):
|
||||
doc = ctx.session.scalar(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.entity_type == "business_items",
|
||||
TPlusBusinessDocument.source_id == "12",
|
||||
))
|
||||
return doc, False
|
||||
monkeypatch.setattr(business_sync, "_shipment_order_line", old_match)
|
||||
sync(s)
|
||||
shipment = s.scalar(select(Shipment))
|
||||
shipment_id = shipment.id
|
||||
monkeypatch.setattr(business_sync, "_shipment_order_line", original)
|
||||
sync(s)
|
||||
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "shipments"))
|
||||
assert (doc.status, doc.reason) == ("CONFLICT", "ORDER_SHIPMENT_MISMATCH")
|
||||
assert business_source.sales_relation_pending(s, doc.business_id)
|
||||
assert s.get(Shipment, shipment_id) is not None
|
||||
assert s.scalar(select(func.count()).select_from(PayrollInput)) == 0
|
||||
|
||||
|
||||
def test_基础数据可筛选待分类与待归属(work_session):
|
||||
s = work_session
|
||||
dataset(s)
|
||||
sync(s)
|
||||
role = s.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
|
||||
person = Personnel(personnel_name="已归属人员", business_role_id=role.id)
|
||||
s.add(person)
|
||||
s.flush()
|
||||
s.add(Customer(company_name="已分类客户", owner_name="", customer_type="personal", owner_personnel_id=person.id))
|
||||
s.add(Product(product_name="已分类产品", spec_model="", product_category="municipal_water", status=1))
|
||||
s.flush()
|
||||
customers = CustomerQuery(category_missing=True, owner_missing=True)
|
||||
products = ProductQuery(category_missing=True)
|
||||
assert customer_repository.count_customers(s, customers) == 1
|
||||
assert customer_repository.list_customers(s, customers)[0][0].company_name == "自动客户"
|
||||
assert product_repository.count_products(s, products) == 1
|
||||
assert product_repository.list_products(s, products)[0].product_name == "管材"
|
||||
assert customer_repository.count_customers(s, CustomerQuery(customer_type="personal", category_missing=True)) == 0
|
||||
assert product_repository.count_products(s, ProductQuery(product_category="municipal_water", category_missing=True)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frozen", [False, True])
|
||||
def test_批量补分类仅补空值并保护冻结明细(work_session, frozen):
|
||||
s = work_session
|
||||
dataset(s)
|
||||
sync(s)
|
||||
product = s.scalar(select(Product))
|
||||
customer = s.scalar(select(Customer))
|
||||
item = s.scalar(select(BusinessItem))
|
||||
existing = Product(product_name="已有分类", spec_model="", product_category="rail_transit", status=1)
|
||||
s.add(existing)
|
||||
if frozen:
|
||||
s.add(PayrollInput(business_id=item.business_id, version=1, source_database="test_source",
|
||||
group_key="frozen", fingerprint="f" * 64, data_json={}, requested_by=1))
|
||||
s.commit()
|
||||
actor = s.get(User, 1)
|
||||
with pytest.raises(BadRequestError, match="分类已变化"):
|
||||
category_batch.fill_missing(s, Product, ProductCategoryBatch(ids=[product.id, existing.id], category="municipal_water"), actor)
|
||||
assert s.get(Product, product.id).product_category is None
|
||||
result = category_batch.fill_missing(s, Product, ProductCategoryBatch(ids=[product.id], category="municipal_water"), actor)
|
||||
assert result["updated"] == 1
|
||||
s.refresh(item)
|
||||
assert item.product_category == (None if frozen else "municipal_water")
|
||||
category_batch.fill_missing(s, Customer, CustomerCategoryBatch(ids=[customer.id], category="enterprise"), actor)
|
||||
assert s.get(Customer, customer.id).customer_type == "enterprise"
|
||||
with pytest.raises(BadRequestError, match="分类已变化"):
|
||||
category_batch.fill_missing(s, Customer, CustomerCategoryBatch(ids=[customer.id], category="personal"), actor)
|
||||
assert s.get(Customer, customer.id).customer_type == "enterprise"
|
||||
|
||||
|
||||
def test_业务详情补产品分类同步其他未封存业务(work_session, monkeypatch):
|
||||
from app.services import authorization_service
|
||||
|
||||
s = work_session
|
||||
dataset(s)
|
||||
sync(s)
|
||||
first = s.scalar(select(Business))
|
||||
product = s.scalar(select(Product))
|
||||
others = [Business(business_no=f"OTHER-{n}", customer_id=first.customer_id) for n in range(2)]
|
||||
s.add_all(others)
|
||||
s.flush()
|
||||
items = [BusinessItem(business_id=b.id, product_id=product.id, product_name="管材",
|
||||
quantity=1, sale_amount=100) for b in others]
|
||||
s.add_all(items)
|
||||
s.add(PayrollInput(business_id=others[1].id, version=1, source_database="test_source",
|
||||
group_key="frozen", fingerprint="f" * 64, data_json={}, requested_by=1))
|
||||
s.commit()
|
||||
monkeypatch.setattr(authorization_service, "require_permission", lambda *args: None)
|
||||
payload = SourceMaterialsIn(products=[{"product_id": product.id, "category": "municipal_water"}])
|
||||
business_source.save_materials(s, first.id, payload, s.get(User, 1))
|
||||
for item in items:
|
||||
s.refresh(item)
|
||||
assert items[0].product_category == "municipal_water"
|
||||
assert items[1].product_category is None
|
||||
|
||||
|
||||
def test_可平账回款仅按有效封存口径展示(work_session):
|
||||
s = work_session
|
||||
dataset(s, cash=True)
|
||||
sync(s)
|
||||
business = s.scalar(select(Business))
|
||||
receipt = s.scalar(select(Receipt))
|
||||
assert business_source.readiness(s, business.id)["eligible_receipts_snapshot"] is None
|
||||
data = {"input_schema": "TPLUS_SYNC_V1", "shipments": [],
|
||||
"receipts": [{"amount": "200.00", "voucher_confirmed": True, "exclusion": None}],
|
||||
"maps": {"shipment_items": {}, "receipts": {str(receipt.id): receipt.id}}}
|
||||
snapshot = PayrollInput(business_id=business.id, version=1, source_database="test_source",
|
||||
group_key="frozen", fingerprint="f" * 64, data_json=data, requested_by=1)
|
||||
s.add(snapshot)
|
||||
s.commit()
|
||||
assert Decimal(business_source.readiness(s, business.id)["eligible_receipts_snapshot"]) == 200
|
||||
snapshot.data_json = {**data, "receipts": [{"amount": "200.00", "voucher_confirmed": False,
|
||||
"exclusion": "NOT_GENERATED_VOUCHER"}]}
|
||||
s.commit()
|
||||
assert Decimal(business_source.readiness(s, business.id)["eligible_receipts_snapshot"]) == 0
|
||||
doc = s.scalar(select(TPlusBusinessDocument).where(TPlusBusinessDocument.entity_type == "receipts"))
|
||||
doc.status = "CONFLICT"
|
||||
s.commit()
|
||||
assert business_source.readiness(s, business.id)["eligible_receipts_snapshot"] is None
|
||||
|
||||
|
||||
def test_来源核算准备只要求截止月份回款已确认(work_session, monkeypatch):
|
||||
from datetime import datetime
|
||||
|
||||
s = work_session
|
||||
dataset(s, shipping=True, cash=True)
|
||||
cash = {"idpartner": 8, "idperson": 1, "isReceiveFlag": 1, "amount": "200",
|
||||
"voucherstate": 189, "accounting_voucher_count": 1,
|
||||
"valid_accounting_voucher_count": 1, "accounting_voucher_id": 501}
|
||||
source(s, "receipts", [{**cash, "id": 31, "voucherDate": "2026-03-31"},
|
||||
{**cash, "id": 32, "voucherDate": "2026-04-01"}])
|
||||
sync(s)
|
||||
business = s.scalar(select(Business))
|
||||
role = s.scalar(select(BusinessRole).where(BusinessRole.role_code == "salesperson"))
|
||||
person = Personnel(personnel_name="经办人", business_role_id=role.id)
|
||||
s.add(person)
|
||||
s.flush()
|
||||
item = s.scalar(select(ShipmentItem))
|
||||
payload = PayrollSetupIn(month="2026-03", evidence="已核实", opening_balance="0",
|
||||
history_confirmed=True, guidance_finalized=True,
|
||||
shipment_ownerships=[{"shipment_item_id": item.id, "personnel_id": person.id,
|
||||
"unit_cost": "1", "freight_amount": "0"}])
|
||||
actor = s.get(User, 1)
|
||||
s.commit()
|
||||
monkeypatch.setattr(payroll_months, "ensure_allocation_editable", lambda *args: None)
|
||||
monkeypatch.setattr(business_source, "readiness", lambda *args: {"reasons": []})
|
||||
receipts = list(s.scalars(select(Receipt).order_by(Receipt.receipt_date)))
|
||||
with pytest.raises(BadRequestError, match="人工确认"):
|
||||
business_source.prepare(s, business.id, payload, actor)
|
||||
past = s.get(Receipt, receipts[0].id)
|
||||
past.confirmation_status = 1
|
||||
past.confirmed_by = "reviewer"
|
||||
past.confirmed_at = datetime.now()
|
||||
past.confirmed_revision = receipt_confirmation.revision(past)
|
||||
s.commit()
|
||||
result = business_source.prepare(s, business.id, payload, actor)
|
||||
snapshot = s.get(PayrollInput, result["input_id"])
|
||||
assert len(snapshot.data_json["receipts"]) == 2
|
||||
with pytest.raises(BadRequestError, match="人工确认"):
|
||||
business_source.prepare(s, business.id, payload.model_copy(update={"month": "2026-04", "expected_version": 1}), actor)
|
||||
@@ -70,3 +70,25 @@ def test_连接文件不接受sa或应用配置覆盖(tmp_path):
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
resolve_tplus_settings(settings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("environment", "allowed", "accepted"),
|
||||
[("dev", False, False), ("prod", True, False), ("dev", True, True)],
|
||||
)
|
||||
def test_sa仅允许显式启用的开发环境(environment, allowed, accepted):
|
||||
settings = Settings(
|
||||
_env_file=None,
|
||||
app_secret_key="x" * 32,
|
||||
app_env=environment,
|
||||
t_plus_enabled=True,
|
||||
t_plus_user="sa",
|
||||
t_plus_database="test_source",
|
||||
t_plus_odbc_driver="test_driver",
|
||||
t_plus_allow_dev_sa=allowed,
|
||||
)
|
||||
if accepted:
|
||||
assert resolve_tplus_settings(settings).t_plus_user == "sa"
|
||||
else:
|
||||
with pytest.raises(RuntimeError, match="不能使用 sa"):
|
||||
resolve_tplus_settings(settings)
|
||||
@@ -243,9 +243,23 @@ def test_离职交接转给新人员_在职交接保留发货人():
|
||||
f["handovers"] = [handover]
|
||||
assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "002"
|
||||
handover["mode"] = "ACTIVE"
|
||||
handover["shipping_ownership_confirmed"] = True
|
||||
assert run(g, [s], [c], f, p)["lines"][0]["personnel_id"] == "001"
|
||||
|
||||
|
||||
def test_在职交接未确认发货归属时不生成绩效():
|
||||
g, s, c, f, p = inputs()
|
||||
handover = {"business_key": "business1", "mode": "ACTIVE", "effective_date": "2026-03-05",
|
||||
"from_personnel_id": "001", "to_personnel_id": "002", "shipping_ownership_confirmed": False}
|
||||
f["handovers"] = [handover]
|
||||
result = run(g, [s], [c], f, p)
|
||||
assert result["status"] == "PARTIAL"
|
||||
assert "HANDOVER_SHIPMENTS_PENDING" in result["lines"][0]["reasons"]
|
||||
assert result["lines"][0]["performance"] is None
|
||||
handover["business_key"] = "another-business"
|
||||
assert run(g, [s], [c], f, p)["status"] == "SUCCEEDED"
|
||||
|
||||
|
||||
def test_输入金额拒绝浮点与无穷():
|
||||
for value in [1.1, "NaN", "Infinity"]:
|
||||
with pytest.raises(ValueError):
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""月度指导价检查必须读取同账套的历史封存发货。"""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import (
|
||||
Business,
|
||||
Customer,
|
||||
PayrollInput,
|
||||
Product,
|
||||
Shipment,
|
||||
ShipmentItem,
|
||||
TPlusBusinessDocument,
|
||||
TPlusSourceLink,
|
||||
User,
|
||||
)
|
||||
from app.schemas.business_source import GuidanceConfirmIn
|
||||
from app.services import business_source
|
||||
from app.services.tplus.guidance import frozen_guidance_shipments
|
||||
from tests.unit.test_tplus_business_sync import dataset, sync
|
||||
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||
|
||||
|
||||
def test_登记指导成本校验已应用的发货单版本(work_session, monkeypatch):
|
||||
session = work_session
|
||||
dataset(session, shipping=True)
|
||||
sync(session)
|
||||
business = session.scalar(select(Business))
|
||||
item = session.scalar(select(ShipmentItem))
|
||||
actor = session.get(User, 1)
|
||||
monkeypatch.setattr(business_source, "source_snapshot_ready", lambda *_: True)
|
||||
payload = GuidanceConfirmIn(shipment_item_id=item.id, amount="2.1234", evidence="核对指导成本")
|
||||
|
||||
created = business_source.confirm_guidance(session, business.id, payload, actor)
|
||||
assert created["status"] == "CREATED"
|
||||
assert business_source.confirm_guidance(session, business.id, payload, actor)["status"] == "REUSED"
|
||||
parent = session.scalar(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.entity_type == "shipments"
|
||||
))
|
||||
line = session.scalar(select(TPlusBusinessDocument).where(
|
||||
TPlusBusinessDocument.entity_type == "shipment_items"
|
||||
))
|
||||
parent.applied_record_id = line.applied_record_id
|
||||
session.commit()
|
||||
with pytest.raises(BadRequestError, match="来源单据已变化"):
|
||||
business_source.confirm_guidance(session, business.id, payload, actor)
|
||||
|
||||
|
||||
def test_只返回来源产品与历史月份匹配的已封存发货(work_session):
|
||||
session = work_session
|
||||
customer = Customer(company_name="测试客户", owner_name="", customer_type="personal")
|
||||
product = Product(product_name="测试产品", spec_model="", unit="根", product_category="municipal_water")
|
||||
session.add_all([customer, product])
|
||||
session.flush()
|
||||
session.add(TPlusSourceLink(source_database="test_source", entity_type="products", source_id="9", target_id=product.id))
|
||||
business = Business(business_no="GUIDE-HISTORY", customer_id=customer.id)
|
||||
session.add(business)
|
||||
session.flush()
|
||||
shipment = Shipment(business_id=business.id, shipment_no="GUIDE-SHIP", ship_date=date(2026, 4, 1))
|
||||
session.add(shipment)
|
||||
session.flush()
|
||||
item = ShipmentItem(shipment_id=shipment.id, product_id=product.id, product_name="测试产品", spec_model="",
|
||||
unit="根", quantity=Decimal("2"), amount=Decimal("100"))
|
||||
session.add(item)
|
||||
session.flush()
|
||||
record = {"line_id": "source-line-999", "product_id": "9", "date": "2026-03-10", "unit": "根", "unit_cost": "10"}
|
||||
session.add_all([
|
||||
PayrollInput(business_id=business.id, version=1, source_database="test_source", group_key="g",
|
||||
fingerprint="a" * 64, data_json={"shipments": [record]}, requested_by=1),
|
||||
PayrollInput(business_id=business.id, version=2, source_database="test_source", group_key="g",
|
||||
fingerprint="b" * 64, data_json={"shipments": [{**record, "unit_cost": "11"}]}, requested_by=1),
|
||||
])
|
||||
session.flush()
|
||||
rows = list(frozen_guidance_shipments(session, "test_source", "9", "2026-03"))
|
||||
assert [row["unit_cost"] for row in rows] == ["10", "11"]
|
||||
assert list(frozen_guidance_shipments(session, "test_source", "9", "2026-04")) == []
|
||||
assert list(frozen_guidance_shipments(session, "other_source", "9", "2026-03")) == []
|
||||
assert list(frozen_guidance_shipments(session, "test_source", "10", "2026-03")) == []
|
||||
session.delete(item)
|
||||
session.flush()
|
||||
assert len(list(frozen_guidance_shipments(session, "test_source", "9", "2026-03"))) == 2
|
||||
broken = session.get(PayrollInput, 1)
|
||||
broken.data_json = {"shipments": [{**record, "product_id": None}]}
|
||||
session.flush()
|
||||
with pytest.raises(BadRequestError, match="缺少来源产品身份"):
|
||||
list(frozen_guidance_shipments(session, "test_source", "9", "2026-03"))
|
||||
broken = session.get(PayrollInput, 1)
|
||||
broken.data_json = {"shipments": [{"date": "2026-03-10", "unit_cost": "10"}]}
|
||||
session.flush()
|
||||
with pytest.raises(BadRequestError, match="已封存发货数据异常"):
|
||||
list(frozen_guidance_shipments(session, "test_source", "9", "2026-03"))
|
||||
@@ -0,0 +1,103 @@
|
||||
"""指导成本解析只认已登记事实:多条候选不猜、单位与封存状态必须显式匹配。"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.services.tplus.guidance import check_unit_guidance_conflicts, resolve_guidance
|
||||
|
||||
|
||||
class Fact:
|
||||
"""最小事实替身:resolve_guidance 只读取 kind 与 data_json。"""
|
||||
|
||||
def __init__(self, data_json):
|
||||
self.kind = "GUIDANCE"
|
||||
self.data_json = data_json
|
||||
|
||||
|
||||
def facts_of(*rows):
|
||||
"""按 (kind, fact_key) 组装成 latest_guidance_facts 的返回结构。"""
|
||||
return {("GUIDANCE", str(index)): Fact(row) for index, row in enumerate(rows)}
|
||||
|
||||
|
||||
def final_row(**overrides):
|
||||
row = {
|
||||
"product_id": 9,
|
||||
"month": "2026-03",
|
||||
"basis": "UNIT",
|
||||
"unit": "根",
|
||||
"is_finalized": True,
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
def test_没有匹配事实时返回缺失():
|
||||
state, guidance = resolve_guidance(facts_of(), 9, "line-1", "2026-03", "根")
|
||||
assert state == "MISSING"
|
||||
assert guidance is None
|
||||
|
||||
|
||||
def test_多条候选时不选择其中一条():
|
||||
rows = facts_of(final_row(), final_row(shipment_line_id="line-1"))
|
||||
state, guidance = resolve_guidance(rows, 9, "line-1", "2026-03", "根")
|
||||
assert state == "AMBIGUOUS"
|
||||
assert guidance is None
|
||||
|
||||
|
||||
def test_唯一候选且单位一致时返回已封存事实():
|
||||
row = final_row()
|
||||
state, guidance = resolve_guidance(facts_of(row), 9, "line-1", "2026-03", "根")
|
||||
assert state == "FINALIZED"
|
||||
assert guidance is row
|
||||
|
||||
|
||||
def test_单位不一致时不采用该事实():
|
||||
state, guidance = resolve_guidance(facts_of(final_row(unit="吨")), 9, "line-1", "2026-03", "根")
|
||||
assert state == "UNIT_MISMATCH"
|
||||
assert guidance is None
|
||||
|
||||
|
||||
def test_未封存事实不能作为指导价依据():
|
||||
state, guidance = resolve_guidance(facts_of(final_row(is_finalized=False)), 9, "line-1", "2026-03", "根")
|
||||
assert state == "UNFINALIZED"
|
||||
assert guidance is None
|
||||
|
||||
|
||||
def test_按明细行登记的指导价只对该行生效():
|
||||
rows = facts_of(final_row(basis="LINE", shipment_line_id="line-1"))
|
||||
assert resolve_guidance(rows, 9, "line-1", "2026-03", "根")[0] == "FINALIZED"
|
||||
assert resolve_guidance(rows, 9, "line-2", "2026-03", "根")[0] == "MISSING"
|
||||
|
||||
|
||||
def test_同一产品同月已有事实时拒绝重复登记():
|
||||
with pytest.raises(BadRequestError, match="重叠"):
|
||||
check_unit_guidance_conflicts(facts_of(final_row()), 9, "2026-03", "根", "10", [])
|
||||
|
||||
|
||||
def test_历史封存明细的成本或单位不一致时拒绝():
|
||||
with pytest.raises(BadRequestError, match="不一致"):
|
||||
check_unit_guidance_conflicts(
|
||||
facts_of(), 9, "2026-03", "根", "10",
|
||||
[{"unit_cost": "11", "unit": "根"}],
|
||||
)
|
||||
with pytest.raises(BadRequestError, match="不一致"):
|
||||
check_unit_guidance_conflicts(
|
||||
facts_of(), 9, "2026-03", "根", "10",
|
||||
[{"unit_cost": "10", "unit": "吨"}],
|
||||
)
|
||||
|
||||
|
||||
def test_事实为空且历史一致时放行():
|
||||
check_unit_guidance_conflicts(
|
||||
facts_of(), 9, "2026-03", "根", Decimal("10"),
|
||||
[{"unit_cost": "10", "unit": "根"}, {"unit_cost": "10.00", "unit": "根"}],
|
||||
)
|
||||
|
||||
|
||||
def test_金额非法时拒绝():
|
||||
for bad in ("abc", "-1", "nan"):
|
||||
with pytest.raises(BadRequestError, match="不一致"):
|
||||
check_unit_guidance_conflicts(facts_of(), 9, "2026-03", "根", bad, [])
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""运行控制以实际心跳呈现状态,并保护并发变更。"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Event
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.core.exceptions import ConflictError, ForbiddenError
|
||||
from app.models import OperationLog, TPlusRuntimeControl, User
|
||||
from app.schemas.tplus import TPlusRuntimeUpdate
|
||||
from app.services.tplus import runtime_control, supervisor, task_service
|
||||
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||
|
||||
|
||||
def test_启动请求不冒充运行且冲突受保护(work_session):
|
||||
session = work_session
|
||||
session.add(TPlusRuntimeControl(id=1))
|
||||
session.commit()
|
||||
actor = task_service.TaskActor(session.get(User, 1))
|
||||
payload = TPlusRuntimeUpdate(desired_state="RUNNING", expected_version=0)
|
||||
status = runtime_control.save(session, payload, actor)
|
||||
assert status["desired_state"] == "RUNNING"
|
||||
assert status["actual_state"] == "OFFLINE"
|
||||
assert status["version"] == 1
|
||||
assert session.scalar(select(OperationLog).where(OperationLog.action == "修改读取服务状态"))
|
||||
with pytest.raises(ConflictError):
|
||||
runtime_control.save(session, payload, actor)
|
||||
assert supervisor.may_claim(session)
|
||||
session.rollback()
|
||||
runtime_control.save(session, TPlusRuntimeUpdate(desired_state="STOPPED", expected_version=1), actor)
|
||||
assert not supervisor.may_claim(session)
|
||||
|
||||
|
||||
def test_过期心跳显示离线且权限拒绝(work_session, monkeypatch):
|
||||
session = work_session
|
||||
now = datetime(2026, 11, 5, 10)
|
||||
session.add(TPlusRuntimeControl(id=1, desired_state="RUNNING", actual_state="RUNNING",
|
||||
worker_phase="RUNNING", heartbeat_at=now - timedelta(seconds=91)))
|
||||
session.commit()
|
||||
assert runtime_control.overview(session, now=now)["actual_state"] == "OFFLINE"
|
||||
def denied(*_args):
|
||||
raise ForbiddenError()
|
||||
monkeypatch.setattr(runtime_control, "require_permission", denied)
|
||||
with pytest.raises(ForbiddenError):
|
||||
runtime_control.save(session, TPlusRuntimeUpdate(desired_state="STOPPED", expected_version=0),
|
||||
task_service.TaskActor(session.get(User, 1)))
|
||||
assert session.get(TPlusRuntimeControl, 1).desired_state == "RUNNING"
|
||||
|
||||
|
||||
def test_健康检查拒绝外部地址():
|
||||
from app.core.config import Settings
|
||||
|
||||
settings = Settings(_env_file=None, app_secret_key="x" * 32,
|
||||
t_plus_supervisor_health_url="http://example.com/api/health")
|
||||
assert supervisor.health_error(settings) == "后端健康检查地址必须是本机 HTTP 地址"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [b'[]', b'null', b'{"data":null}', b'{"data":[]}', b'{"data":"bad"}'])
|
||||
def test_健康检查结构异常时返回未就绪(monkeypatch, body):
|
||||
from app.core.config import Settings
|
||||
|
||||
settings = Settings(_env_file=None, app_secret_key="x" * 32)
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value.read.return_value = body
|
||||
monkeypatch.setattr(supervisor, "urlopen", lambda *_args, **_kwargs: response)
|
||||
assert supervisor.health_error(settings) == "系统或依赖尚未就绪"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("connected", [True, False])
|
||||
def test_健康检查实际探测来源连接且不泄露错误(monkeypatch, connected):
|
||||
from app.core.config import Settings
|
||||
|
||||
settings = Settings(_env_file=None, app_secret_key="x" * 32, t_plus_enabled=True,
|
||||
t_plus_database="source", t_plus_user="reader",
|
||||
t_plus_odbc_driver="test-driver")
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value.read.return_value = b'{"data":{"app":true,"db":true,"redis":true}}'
|
||||
monkeypatch.setattr(supervisor, "urlopen", lambda *_args, **_kwargs: response)
|
||||
import pyodbc
|
||||
monkeypatch.setattr(pyodbc, "drivers", lambda: ["test-driver"])
|
||||
engine = MagicMock()
|
||||
if not connected:
|
||||
engine.connect.side_effect = SQLAlchemyError("secret-connection-string")
|
||||
monkeypatch.setattr(supervisor, "create_engine", lambda *_args, **_kwargs: engine)
|
||||
|
||||
result = supervisor.health_error(settings)
|
||||
assert result is None if connected else result == "T+ 数据库连接不可用,请检查连接配置"
|
||||
if connected:
|
||||
connection = engine.connect.return_value.__enter__.return_value
|
||||
assert connection.connection.driver_connection.timeout == settings.t_plus_query_timeout
|
||||
connection.exec_driver_sql.assert_called_once_with("SELECT 1")
|
||||
engine.dispose.assert_called_once()
|
||||
|
||||
|
||||
def test_来源不可用时托管器不排队或领取任务(monkeypatch):
|
||||
connection = MagicMock()
|
||||
connection.dialect.name = "mysql"
|
||||
connection.execute.return_value.scalar.return_value = 1
|
||||
factory = MagicMock()
|
||||
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
|
||||
session = factory.return_value.__enter__.return_value
|
||||
session.scalar.return_value.desired_state = "RUNNING"
|
||||
stopped = Event()
|
||||
|
||||
def unavailable(_settings):
|
||||
stopped.set()
|
||||
return "T+ 数据库连接不可用,请检查连接配置"
|
||||
|
||||
maintenance, worker = MagicMock(), MagicMock()
|
||||
monkeypatch.setattr(supervisor, "health_error", unavailable)
|
||||
monkeypatch.setattr(supervisor, "tick", maintenance)
|
||||
monkeypatch.setattr(supervisor, "run_once", worker)
|
||||
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
|
||||
maintenance.assert_not_called()
|
||||
worker.assert_not_called()
|
||||
|
||||
|
||||
def test_托管检查异常记录类型但不泄露内容(caplog):
|
||||
connection = MagicMock()
|
||||
connection.dialect.name = "mysql"
|
||||
connection.execute.side_effect = [MagicMock(scalar=lambda: 1), RuntimeError("secret-value"),
|
||||
MagicMock(scalar=lambda: 1)]
|
||||
factory = MagicMock()
|
||||
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
|
||||
assert supervisor.run(factory, MagicMock()) == 1
|
||||
assert "RuntimeError" in caplog.text
|
||||
assert "secret-value" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("release", [None, 0])
|
||||
def test_托管锁释放失败时使连接失效(release):
|
||||
connection = MagicMock()
|
||||
connection.dialect.name = "mysql"
|
||||
connection.execute.side_effect = [MagicMock(scalar=lambda: 1),
|
||||
MagicMock(scalar=lambda: release)]
|
||||
factory = MagicMock()
|
||||
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
|
||||
stopped = Event()
|
||||
stopped.set()
|
||||
|
||||
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
|
||||
connection.invalidate.assert_called_once()
|
||||
connection.close.assert_called_once()
|
||||
|
||||
|
||||
def test_停止状态写入失败仍释放托管锁():
|
||||
connection = MagicMock()
|
||||
connection.dialect.name = "mysql"
|
||||
connection.execute.side_effect = [MagicMock(scalar=lambda: 1), MagicMock(scalar=lambda: 1)]
|
||||
factory = MagicMock()
|
||||
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
|
||||
factory.return_value.__enter__.side_effect = RuntimeError("数据库暂不可用")
|
||||
stopped = Event()
|
||||
stopped.set()
|
||||
|
||||
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
|
||||
assert connection.execute.call_count == 2
|
||||
connection.invalidate.assert_not_called()
|
||||
connection.close.assert_called_once()
|
||||
|
||||
|
||||
def test_释放托管锁抛错时使连接失效():
|
||||
connection = MagicMock()
|
||||
connection.dialect.name = "mysql"
|
||||
connection.execute.side_effect = [MagicMock(scalar=lambda: 1), RuntimeError("连接异常")]
|
||||
factory = MagicMock()
|
||||
factory.kw = {"bind": MagicMock(connect=lambda: connection)}
|
||||
stopped = Event()
|
||||
stopped.set()
|
||||
|
||||
assert supervisor.run(factory, MagicMock(), stop_event=stopped) == 0
|
||||
connection.invalidate.assert_called_once()
|
||||
connection.close.assert_called_once()
|
||||
@@ -3,11 +3,13 @@
|
||||
from contextlib import nullcontext
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import DefaultClause, Integer, MetaData, create_engine, func, select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import (
|
||||
@@ -22,6 +24,7 @@ from app.models import (
|
||||
TPlusSourceLink,
|
||||
TPlusSyncBatch,
|
||||
TPlusSyncRecord,
|
||||
TPlusWorkLine,
|
||||
TPlusWorkRun,
|
||||
)
|
||||
from app.schemas.tplus_workbench import FactIn, WorkCreateIn, WorkQuery
|
||||
@@ -163,6 +166,55 @@ def prepare_person(session):
|
||||
)
|
||||
|
||||
|
||||
def test_旧基础明细从原快照补名称和工号且不改历史(work_session, monkeypatch):
|
||||
monkeypatch.setattr(workbench, "history_source_database", lambda s: "test_source")
|
||||
s = work_session
|
||||
source(s, "personnel", [{"id": 1, "code": "00001", "name": "旧快照人员", "mobilePhoneNo": "PRIVATE"}])
|
||||
run = preview(s, ["personnel"])
|
||||
line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no))
|
||||
original = {k: v for k, v in line.data_json.items() if k not in ("source_name", "source_no")}
|
||||
line.data_json = original
|
||||
s.commit()
|
||||
# 后续采集改名,历史任务仍应使用当时的记录版本。
|
||||
source(s, "personnel", [{"id": 1, "code": "00001", "name": "后来改名"}])
|
||||
result = workbench.list_lines(s, run.run_no, WorkQuery()).records[0]
|
||||
assert result["data"]["source_name"] == "旧快照人员"
|
||||
assert result["data"]["source_no"] == "00001"
|
||||
assert "mobilePhoneNo" not in result["data"]
|
||||
assert "PRIVATE" not in str(result)
|
||||
assert line.data_json == original
|
||||
report = "".join(workbench.report_csv(s, run.run_no))
|
||||
assert "旧快照人员" in report and "00001" in report
|
||||
assert "后来改名" not in report
|
||||
|
||||
|
||||
def test_名单缺失人员显示任务冻结的姓名工号(work_session, monkeypatch):
|
||||
monkeypatch.setattr(workbench, "history_source_database", lambda s: "test_source")
|
||||
s = work_session
|
||||
prepare_person(s)
|
||||
fact(s, "PERSONNEL", "09999", {"code": "09999", "name": "名单待核对人员", "role_code": "salesperson", "status": 1})
|
||||
run = preview(s, ["personnel"])
|
||||
result = workbench.list_lines(s, run.run_no, WorkQuery(action="PENDING")).records[0]
|
||||
assert result["reason"] == "ROSTER_SOURCE_MISSING"
|
||||
assert result["data"]["source_name"] == "名单待核对人员"
|
||||
assert result["data"]["source_no"] == "09999"
|
||||
|
||||
|
||||
def test_基础明细不使用不匹配实体的快照补名称(work_session, monkeypatch):
|
||||
monkeypatch.setattr(workbench, "history_source_database", lambda s: "test_source")
|
||||
s = work_session
|
||||
source(s, "products", [{"id": 1, "code": "P1", "name": "产品私有名称", "unit_name": "根"}])
|
||||
record = s.scalar(select(TPlusSyncRecord).where(TPlusSyncRecord.entity_type == "products"))
|
||||
source(s, "personnel", [{"id": 1, "code": "00001", "name": "人员"}])
|
||||
run = preview(s, ["personnel"])
|
||||
line = s.scalar(select(TPlusWorkLine).where(TPlusWorkLine.run_no == run.run_no))
|
||||
line.data_json = {"record_id": record.id, "values": {}}
|
||||
s.commit()
|
||||
result = workbench.list_lines(s, run.run_no, WorkQuery()).records[0]
|
||||
assert not result["data"].get("source_name")
|
||||
assert "产品私有名称" not in "".join(workbench.report_csv(s, run.run_no))
|
||||
|
||||
|
||||
def test_关闭源读取仍可查询历史资料和任务(work_session, monkeypatch):
|
||||
from app.core.config import Settings
|
||||
from app.services.tplus import task_service
|
||||
@@ -447,6 +499,55 @@ def test_工作请求幂等且不同意图不复用(work_session):
|
||||
workbench.queue_work(s, payload.model_copy(update={"entity_types": ["customers"]}), 1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("race", ["same", "different", "unrelated"])
|
||||
def test_工作区排队完整性错误只核对一次既有任务(monkeypatch, race):
|
||||
from app.core.exceptions import BadRequestError, ConflictError
|
||||
from app.services import authorization_service
|
||||
|
||||
payload = WorkCreateIn(kind="MASTER_PREVIEW", request_key=uuid4(), entity_types=["personnel"])
|
||||
found = SimpleNamespace(input_json=payload.model_dump(mode="json", exclude={"request_key"}),
|
||||
source_database="test_source", requested_by=2 if race == "different" else 1)
|
||||
results = iter([None, found if race != "unrelated" else None])
|
||||
rollbacks = []
|
||||
|
||||
class Session:
|
||||
def get(self, *_args): return object()
|
||||
def scalar(self, _query): return next(results)
|
||||
def begin_nested(self): return nullcontext()
|
||||
def add(self, _row): pass
|
||||
def flush(self): raise IntegrityError("insert", {}, Exception())
|
||||
def rollback(self): rollbacks.append(True)
|
||||
|
||||
monkeypatch.setattr(authorization_service, "require_permission", lambda *_args: None)
|
||||
monkeypatch.setattr(workbench, "_work_source_database", lambda _session: "test_source")
|
||||
monkeypatch.setattr(workbench, "work_out", lambda row: row)
|
||||
if race == "same":
|
||||
assert workbench.queue_work(Session(), payload, 1) is found
|
||||
else:
|
||||
with pytest.raises(BadRequestError if race == "different" else ConflictError):
|
||||
workbench.queue_work(Session(), payload, 1)
|
||||
assert rollbacks == [True]
|
||||
|
||||
|
||||
def test_旧业务预览不可排队执行且历史排队任务不写业务(work_session):
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.models import Business, PayrollInput
|
||||
|
||||
s = work_session
|
||||
payload = WorkCreateIn(kind="BUSINESS_APPLY", request_key=uuid4(), preview_no="old-preview")
|
||||
with pytest.raises(BadRequestError, match="旧规则业务预览仅供核对"):
|
||||
workbench.queue_work(s, payload, 1)
|
||||
legacy = TPlusWorkRun(run_no=uuid4().hex, request_key=str(uuid4()), source_database="test_source",
|
||||
requested_by=1, kind="BUSINESS_APPLY", status="QUEUED", input_json={"preview_no": "old-preview"})
|
||||
s.add(legacy)
|
||||
s.commit()
|
||||
assert workbench.execute_next(s) == legacy.run_no
|
||||
s.refresh(legacy)
|
||||
assert (legacy.status, legacy.error_code) == ("FAILED", "LEGACY_BUSINESS_APPLY_DISABLED")
|
||||
assert s.scalar(select(func.count()).select_from(Business)) == 0
|
||||
assert s.scalar(select(func.count()).select_from(PayrollInput)) == 0
|
||||
|
||||
|
||||
def test_工作区接口拒绝未登录请求(client):
|
||||
for url in (
|
||||
"/api/v1/system/tplus/facts",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""采集与业务接入分别记录结果。"""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.exceptions import BadRequestError, ConflictError
|
||||
from app.services.tplus import business_sync_control, worker
|
||||
|
||||
|
||||
def test_自动接入排队失败不改变成功采集状态(monkeypatch):
|
||||
batch = SimpleNamespace(requested_by=None, source_database="test_source", sync_mode="FULL", status="RUNNING")
|
||||
session = SimpleNamespace(rollback=lambda: None)
|
||||
failures = []
|
||||
schedule_failures = []
|
||||
monkeypatch.setattr(worker.task_service, "get_settings", lambda: object())
|
||||
monkeypatch.setattr(worker, "resolve_tplus_settings", lambda _: SimpleNamespace(t_plus_database="test_source"))
|
||||
monkeypatch.setattr(worker.task_service, "fail_batch", lambda *args: failures.append(args))
|
||||
|
||||
class SyncService:
|
||||
def __init__(self, source, target):
|
||||
pass
|
||||
|
||||
def execute_claimed(self, task):
|
||||
task.status = "SUCCEEDED"
|
||||
|
||||
def fail_schedule(session):
|
||||
raise RuntimeError("排队失败")
|
||||
|
||||
monkeypatch.setattr(worker, "TPlusSyncService", SyncService)
|
||||
monkeypatch.setattr(business_sync_control, "schedule", fail_schedule)
|
||||
monkeypatch.setattr(business_sync_control, "record_schedule_failure", lambda session, source: schedule_failures.append(source))
|
||||
worker._execute(session, batch, lambda: nullcontext(object()))
|
||||
assert batch.status == "SUCCEEDED"
|
||||
assert failures == []
|
||||
assert schedule_failures == ["test_source"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("race", ["same", "different", "unrelated"])
|
||||
def test_业务接入排队完整性错误只核对一次既有任务(monkeypatch, race):
|
||||
found = SimpleNamespace(kind="BUSINESS_SYNC", requested_by=1, source_database="test_source",
|
||||
input_json={"start_date": "2026-03-01"})
|
||||
if race == "different":
|
||||
found.requested_by = 2
|
||||
results = iter([None, found if race != "unrelated" else None])
|
||||
rollbacks = []
|
||||
|
||||
class Session:
|
||||
def get(self, *_args):
|
||||
return object()
|
||||
|
||||
def scalar(self, _query):
|
||||
return next(results)
|
||||
|
||||
def begin_nested(self):
|
||||
return nullcontext()
|
||||
|
||||
def add(self, _row):
|
||||
pass
|
||||
|
||||
def flush(self):
|
||||
raise IntegrityError("insert", {}, Exception())
|
||||
|
||||
def rollback(self):
|
||||
rollbacks.append(True)
|
||||
|
||||
monkeypatch.setattr(business_sync_control, "require_permission", lambda *_args: None)
|
||||
monkeypatch.setattr(business_sync_control, "history_source_database", lambda _session: "test_source")
|
||||
monkeypatch.setattr(business_sync_control, "work_out", lambda row: row)
|
||||
args = (Session(), 1, date(2026, 3, 1), "same-key")
|
||||
if race == "same":
|
||||
assert business_sync_control.queue(*args) is found
|
||||
else:
|
||||
with pytest.raises(BadRequestError if race == "different" else ConflictError):
|
||||
business_sync_control.queue(*args)
|
||||
assert rollbacks == [True]
|
||||
Reference in new issue
Block a user