561 lines
27 KiB
Python
561 lines
27 KiB
Python
"""业务接入以源单据为边界;核算资料缺失不得阻断建档。"""
|
|
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
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,
|
|
PayrollInput,
|
|
Personnel,
|
|
Product,
|
|
Receipt,
|
|
Shipment,
|
|
ShipmentItem,
|
|
TPlusBusinessDocument,
|
|
TPlusBusinessSyncConfig,
|
|
TPlusSyncBatch,
|
|
TPlusWorkRun,
|
|
User,
|
|
)
|
|
from app.repositories import customer_repository, product_repository
|
|
from app.schemas.business_source import (
|
|
SourceMaterialsIn,
|
|
)
|
|
from app.schemas.category_batch import (
|
|
CustomerCategoryBatch,
|
|
ProductCategoryBatch,
|
|
)
|
|
from app.schemas.customer import CustomerQuery
|
|
from app.schemas.payroll import PayrollSetupIn
|
|
from app.schemas.product import ProductQuery
|
|
from app.services import (
|
|
business_service,
|
|
business_source,
|
|
category_batch,
|
|
payroll_months,
|
|
receipt_confirmation,
|
|
)
|
|
from app.services.tplus import business_sync, business_sync_control
|
|
from app.services.tplus.snapshots import source_manifest
|
|
from app.services.tplus.workbench import WorkbenchError, digest, execute_next
|
|
from tests.unit.test_tplus_workbench import 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)
|