"""业务接入以源单据为边界;核算资料缺失不得阻断建档。""" 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)