旧应收已经由期初余额承接,启用前发货再次进入分母, 会使新业务足额回款仍未达到起付门槛。 将销售额日期范围与 FIFO 的启用边界保持一致, 补充普通历史日期及二月末的两项回归用例。
522 lines
17 KiB
Python
522 lines
17 KiB
Python
"""正式链路使用独立内存库,验证金额、防重、生成锁与历史归属。"""
|
|
|
|
import copy
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from types import SimpleNamespace
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from sqlalchemy import func, select
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.exceptions import BadRequestError, ForbiddenError
|
|
from app.models import (
|
|
Business,
|
|
BusinessRole,
|
|
Customer,
|
|
PayrollAllocation,
|
|
PayrollInput,
|
|
PayrollMonth,
|
|
PerformanceLedger,
|
|
Personnel,
|
|
Product,
|
|
Receipt,
|
|
Shipment,
|
|
ShipmentItem,
|
|
TPlusSourceLink,
|
|
TPlusWorkRun,
|
|
User,
|
|
)
|
|
from app.services import manager_share, payroll_calculation, payroll_months
|
|
from app.services.payroll_access import can_write_payroll
|
|
from app.services.tplus.business_import import execute_business
|
|
from app.services.tplus.workbench import digest
|
|
from tests.unit.test_tplus_workbench import apply, fact, prepare_calc, preview
|
|
from tests.unit.test_tplus_workbench import work_session as work_session
|
|
|
|
|
|
@pytest.fixture
|
|
def payroll_case(work_session, monkeypatch):
|
|
s = work_session
|
|
user = User(username="test_maker", password="test_hash_only", status=1)
|
|
s.add(user)
|
|
role = BusinessRole(
|
|
role_name="经理",
|
|
role_code="sales_manager",
|
|
calc_template="SALES_MANAGER",
|
|
status=1,
|
|
sort_order=2,
|
|
)
|
|
s.add(role)
|
|
s.flush()
|
|
manager = Personnel(
|
|
personnel_name="测试经理", business_role_id=role.id, status=1, source_no="90001"
|
|
)
|
|
s.add(manager)
|
|
s.commit()
|
|
monkeypatch.setattr(get_settings(), "payroll_maker_user_id", user.id)
|
|
prepare_calc(s)
|
|
apply(s, preview(s, ["personnel"]))
|
|
product = Product(
|
|
product_name="测试产品",
|
|
spec_model="测试规格",
|
|
unit="件",
|
|
product_category="municipal_water",
|
|
status=1,
|
|
)
|
|
customer = Customer(company_name="测试客户", owner_name="负责人", customer_type="personal")
|
|
s.add_all([product, customer])
|
|
s.flush()
|
|
s.add_all(
|
|
[
|
|
TPlusSourceLink(
|
|
source_database="test_source",
|
|
entity_type="products",
|
|
source_id="9",
|
|
target_id=product.id,
|
|
),
|
|
TPlusSourceLink(
|
|
source_database="test_source",
|
|
entity_type="customers",
|
|
source_id="8",
|
|
target_id=customer.id,
|
|
),
|
|
]
|
|
)
|
|
s.commit()
|
|
s.add(
|
|
TPlusSourceLink(
|
|
source_database="test_source",
|
|
entity_type="personnel",
|
|
source_id="90001",
|
|
target_id=manager.id,
|
|
)
|
|
)
|
|
s.commit()
|
|
fact(
|
|
s,
|
|
"PERSONNEL",
|
|
"90001",
|
|
{
|
|
"code": "90001",
|
|
"name": manager.personnel_name,
|
|
"role_code": "sales_manager",
|
|
"status": 1,
|
|
},
|
|
)
|
|
fact(
|
|
s,
|
|
"MANAGER",
|
|
"manager-biz",
|
|
{"business_key": "biz", "manager_id": "90001", "effective_date": "2026-03-01"},
|
|
)
|
|
from app.models import TPlusFact
|
|
|
|
guidance = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE"))
|
|
guidance.data_json = {**guidance.data_json, "is_finalized": True}
|
|
s.commit()
|
|
return s, user, manager
|
|
|
|
|
|
def business_work(s, user, kind, previous=None):
|
|
run = TPlusWorkRun(
|
|
run_no=uuid4().hex,
|
|
request_key=str(uuid4()),
|
|
source_database="test_source",
|
|
requested_by=user.id,
|
|
kind=kind,
|
|
status="RUNNING",
|
|
input_json={"preview_no": previous.run_no}
|
|
if previous
|
|
else {"month": "2026-03", "group_keys": ["g"]},
|
|
)
|
|
s.add(run)
|
|
s.flush()
|
|
execute_business(s, run)
|
|
s.commit()
|
|
return run
|
|
|
|
|
|
def imported(case):
|
|
s, user, _manager = case
|
|
pre = business_work(s, user, "BUSINESS_PREVIEW")
|
|
assert pre.status == "SUCCEEDED", pre.summary_json
|
|
assert pre.summary_json["counts"] == {"CREATE": 1}
|
|
run = business_work(s, user, "BUSINESS_APPLY", pre)
|
|
assert run.status == "SUCCEEDED", run.summary_json
|
|
return s.scalar(select(Business))
|
|
|
|
|
|
def test_指定账号而非管理员或岗位放行(payroll_case):
|
|
_, user, _ = payroll_case
|
|
assert can_write_payroll(user)
|
|
assert not can_write_payroll(SimpleNamespace(id=user.id + 1, status=1))
|
|
assert not can_write_payroll(None)
|
|
|
|
|
|
def test_正式导入防重与来源可追溯(payroll_case):
|
|
s, user, _ = payroll_case
|
|
business = imported(payroll_case)
|
|
assert s.scalar(select(func.count()).select_from(Receipt)) == 1
|
|
assert s.scalar(select(func.count()).select_from(ShipmentItem)) == 1
|
|
again = business_work(s, user, "BUSINESS_PREVIEW")
|
|
assert again.summary_json["counts"] == {"SKIP": 1}
|
|
business_work(s, user, "BUSINESS_APPLY", again)
|
|
assert s.scalar(select(func.count()).select_from(Business)) == 1
|
|
record = payroll_calculation.latest_input(s, business.id)
|
|
assert record.data_json["source_facts"]
|
|
|
|
|
|
def test_正式FIFO台账金额与逐段过程守恒(payroll_case):
|
|
s, user, manager = payroll_case
|
|
business = imported(payroll_case)
|
|
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
|
assert result["performance"] == "0.20"
|
|
assert result["payable"] == "0.20"
|
|
ledger = s.get(PerformanceLedger, result["ledger_ids"][0])
|
|
assert ledger.snapshot_json["manager_id"] == manager.id
|
|
assert s.scalar(select(PayrollAllocation)).amount == Decimal("100.00")
|
|
with pytest.raises(BadRequestError, match="已有核算"):
|
|
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
|
|
|
|
|
def test_输入变化使旧预览失效(payroll_case):
|
|
s, user, _ = payroll_case
|
|
pre = business_work(s, user, "BUSINESS_PREVIEW")
|
|
product = s.scalar(select(Product))
|
|
product.product_category = "rail_transit"
|
|
s.commit()
|
|
with pytest.raises(ValueError, match="PREVIEW_STALE"):
|
|
business_work(s, user, "BUSINESS_APPLY", pre)
|
|
s.rollback()
|
|
assert s.scalar(select(func.count()).select_from(Business)) == 0
|
|
|
|
|
|
def test_经理汇总读取固化业务分管(payroll_case):
|
|
s, user, manager = payroll_case
|
|
business = imported(payroll_case)
|
|
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
|
rows = list(s.scalars(select(PerformanceLedger)))
|
|
owner = s.get(Personnel, rows[0].personnel_id)
|
|
owner.manager_id = None
|
|
s.commit()
|
|
buckets = manager_share.collect_team_buckets(s, rows, False)
|
|
assert buckets[manager.id]["performance"] == Decimal("0.20")
|
|
|
|
|
|
def test_生成标记独立于审批状态(payroll_case):
|
|
s, user, _ = payroll_case
|
|
control = payroll_months.lock_month(s, "2026-03", user)
|
|
from datetime import datetime
|
|
|
|
control.generated_at = datetime(2026, 3, 31)
|
|
s.commit()
|
|
with pytest.raises(BadRequestError, match="已锁定"):
|
|
payroll_months.ensure_allocation_editable(s, "2026-03", user)
|
|
s.rollback()
|
|
assert payroll_months.ensure_allocation_editable(s, "2026-04", user).generated_at is None
|
|
|
|
|
|
def test_生成失败不留下月份锁(payroll_case):
|
|
s, user, _ = payroll_case
|
|
from app.services.settlement_service import generate
|
|
|
|
with pytest.raises(BadRequestError, match="无可结算"):
|
|
generate(s, "2026-03", operator=user)
|
|
assert s.scalar(select(PayrollMonth)) is None
|
|
|
|
|
|
def test_非制作人无法生成工资(payroll_case):
|
|
s, user, _ = payroll_case
|
|
with pytest.raises(ForbiddenError):
|
|
payroll_months.lock_month(s, "2026-03", SimpleNamespace(id=user.id + 1, status=1))
|
|
|
|
|
|
def replace_input(s, record, values):
|
|
newer = PayrollInput(
|
|
business_id=record.business_id,
|
|
version=record.version + 1,
|
|
source_database=record.source_database,
|
|
group_key=record.group_key,
|
|
fingerprint=digest(values),
|
|
data_json=values,
|
|
requested_by=record.requested_by,
|
|
)
|
|
s.add(newer)
|
|
s.commit()
|
|
return newer
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"amount,performance,payable,start",
|
|
[("50", "0.10", "0.00", False), ("60", "0.12", "0.04", True), ("100", "0.20", "0.20", True)],
|
|
)
|
|
def test_起付百分之六十与平方权重(payroll_case, amount, performance, payable, start):
|
|
s, user, _ = payroll_case
|
|
business = imported(payroll_case)
|
|
record = payroll_calculation.latest_input(s, business.id)
|
|
data = copy.deepcopy(record.data_json)
|
|
data["receipts"][0]["amount"] = amount
|
|
receipt = s.scalar(select(Receipt))
|
|
receipt.amount = Decimal(amount)
|
|
replace_input(s, record, data)
|
|
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
|
assert (result["performance"], result["payable"], result["start_payment_ok"]) == (
|
|
performance,
|
|
payable,
|
|
start,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("legacy_date", [date(2026, 2, 10), date(2026, 2, 28)])
|
|
def test_历史发货不重复计入新业务起付分母(payroll_case, legacy_date):
|
|
"""旧应收先冲抵,启用前发货不得再次压低新业务的回款比例。"""
|
|
s, user, _ = payroll_case
|
|
business = imported(payroll_case)
|
|
record = payroll_calculation.latest_input(s, business.id)
|
|
data = copy.deepcopy(record.data_json)
|
|
legacy = Shipment(
|
|
business_id=business.id,
|
|
shipment_no="SH-LEGACY",
|
|
ship_date=legacy_date,
|
|
total_amount=Decimal("100"),
|
|
freight_amount=Decimal("0"),
|
|
)
|
|
s.add(legacy)
|
|
s.flush()
|
|
original = s.get(ShipmentItem, data["maps"]["shipment_items"][data["shipments"][0]["line_id"]])
|
|
item = ShipmentItem(
|
|
shipment_id=legacy.id,
|
|
product_id=original.product_id,
|
|
product_name=original.product_name,
|
|
spec_model=original.spec_model,
|
|
unit=original.unit,
|
|
quantity=Decimal("2"),
|
|
sale_price=Decimal("50"),
|
|
amount=Decimal("100"),
|
|
)
|
|
s.add(item)
|
|
s.flush()
|
|
data["shipments"].append(
|
|
{
|
|
**data["shipments"][0],
|
|
"source_id": "99",
|
|
"line_id": "99",
|
|
"date": legacy_date.isoformat(),
|
|
}
|
|
)
|
|
data["maps"]["shipments"]["99"] = legacy.id
|
|
data["maps"]["shipment_items"]["99"] = item.id
|
|
data["group"]["opening_balance"] = "100"
|
|
data["receipts"][0]["amount"] = "200"
|
|
s.get(Receipt, data["maps"]["receipts"][data["receipts"][0]["source_id"]]).amount = Decimal(
|
|
"200"
|
|
)
|
|
replace_input(s, record, data)
|
|
|
|
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
|
|
|
assert result["start_payment_ok"] is True
|
|
assert result["performance"] == result["payable"] == "0.20"
|
|
allocations = list(s.scalars(select(PayrollAllocation)))
|
|
assert sum(row.amount for row in allocations) == Decimal("100.00")
|
|
assert all(row.data_json["shipment_date"] == "2026-03-10" for row in allocations)
|
|
|
|
|
|
def test_跨月续算仅计应付差额且离职未发给接手人(payroll_case):
|
|
s, user, _manager = payroll_case
|
|
business = imported(payroll_case)
|
|
record = payroll_calculation.latest_input(s, business.id)
|
|
data = copy.deepcopy(record.data_json)
|
|
receipt = s.scalar(select(Receipt))
|
|
receipt.amount = Decimal("80")
|
|
data["receipts"][0]["amount"] = "80"
|
|
record = replace_input(s, record, data)
|
|
march = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
|
assert march["payable"] == "0.10"
|
|
old = s.get(PerformanceLedger, march["ledger_ids"][0])
|
|
new_person = Personnel(
|
|
personnel_name="接手业务员",
|
|
business_role_id=s.get(Personnel, old.personnel_id).business_role_id,
|
|
status=1,
|
|
)
|
|
s.add(new_person)
|
|
s.flush()
|
|
second = Receipt(
|
|
business_id=business.id,
|
|
receipt_batch_no="HK-NEXT",
|
|
receipt_date=date(2026, 4, 10),
|
|
amount=Decimal("20"),
|
|
is_barter=0,
|
|
settlement_status=0,
|
|
)
|
|
s.add(second)
|
|
s.flush()
|
|
data = copy.deepcopy(record.data_json)
|
|
data["receipts"].append(
|
|
{
|
|
"source_id": "11",
|
|
"source_no": "HK-NEXT",
|
|
"date": "2026-04-10",
|
|
"group_key": "g",
|
|
"amount": "20",
|
|
"voucher_confirmed": True,
|
|
}
|
|
)
|
|
data["maps"]["receipts"]["11"] = second.id
|
|
data["maps"]["personnel"]["next-person"] = new_person.id
|
|
data["facts"]["personnel"]["next-person"] = {"role_code": "salesperson", "status": 1}
|
|
data["facts"]["handovers"] = [
|
|
{
|
|
"mode": "RESIGNATION",
|
|
"business_key": "biz",
|
|
"from_personnel_id": "00043",
|
|
"to_personnel_id": "next-person",
|
|
"effective_date": "2026-04-01",
|
|
}
|
|
]
|
|
replace_input(s, record, data)
|
|
april = payroll_calculation.calculate(s, business.id, "2026-04", operator=user)
|
|
assert april["payable"] == "0.10"
|
|
assert old.payable_amount == Decimal("0.10") and old.personnel_id != new_person.id
|
|
assert all(
|
|
s.get(PerformanceLedger, lid).personnel_id == new_person.id for lid in april["ledger_ids"]
|
|
)
|
|
assert sum(s.scalars(select(PerformanceLedger.payable_amount))) == Decimal("0.20")
|
|
rebuilt = payroll_calculation.calculate(
|
|
s, business.id, "2026-03", operator=user, reason="复核分次回款"
|
|
)
|
|
assert rebuilt["recalculated_months"] == ["2026-03", "2026-04"]
|
|
active = list(s.scalars(select(PerformanceLedger).where(PerformanceLedger.is_active == 1)))
|
|
assert sum(r.payable_amount for r in active) == Decimal("0.20")
|
|
assert all(r.calc_reason == "复核分次回款" for r in active)
|
|
|
|
|
|
def test_已核算源金额变化不静默改工资(payroll_case):
|
|
s, user, _ = payroll_case
|
|
business = imported(payroll_case)
|
|
receipt = s.scalar(select(Receipt))
|
|
receipt.amount = Decimal("99")
|
|
s.commit()
|
|
with pytest.raises(BadRequestError, match="不一致"):
|
|
payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
|
assert s.scalar(select(func.count()).select_from(PerformanceLedger)) == 0
|
|
|
|
|
|
def test_同客户不同合同合并平账_一款对应多发货(payroll_case):
|
|
from tests.unit.test_tplus_workbench import source
|
|
|
|
s, user, _ = payroll_case
|
|
source(
|
|
s,
|
|
"shipments",
|
|
[
|
|
{
|
|
"id": 20,
|
|
"code": "SH-ONE",
|
|
"idcustomer": 8,
|
|
"voucherdate": "2026-03-10",
|
|
"voucherState": 189,
|
|
},
|
|
{
|
|
"id": 22,
|
|
"code": "SH-TWO",
|
|
"idcustomer": 8,
|
|
"voucherdate": "2026-03-11",
|
|
"voucherState": 189,
|
|
},
|
|
],
|
|
)
|
|
source(
|
|
s,
|
|
"shipment_items",
|
|
[
|
|
{
|
|
"id": 21,
|
|
"source_parent_id": 20,
|
|
"idinventory": 9,
|
|
"taxAmount": "100",
|
|
"quantity": "2",
|
|
},
|
|
{
|
|
"id": 23,
|
|
"source_parent_id": 22,
|
|
"idinventory": 9,
|
|
"taxAmount": "100",
|
|
"quantity": "2",
|
|
},
|
|
],
|
|
)
|
|
source(
|
|
s,
|
|
"receipts",
|
|
[
|
|
{
|
|
"id": 10,
|
|
"code": "RC-ONE",
|
|
"idpartner": 8,
|
|
"voucherDate": "2026-03-12",
|
|
"isReceiveFlag": 1,
|
|
"voucherstate": 189,
|
|
"amount": "200",
|
|
}
|
|
],
|
|
)
|
|
fact(
|
|
s,
|
|
"SHIPMENT",
|
|
"23",
|
|
{
|
|
"sales_amount_excludes_freight": True,
|
|
"business_key": "second-contract",
|
|
"personnel_id": "00043",
|
|
},
|
|
)
|
|
fact(
|
|
s,
|
|
"MANAGER",
|
|
"second-manager",
|
|
{"business_key": "second-contract", "manager_id": "90001", "effective_date": "2026-03-01"},
|
|
)
|
|
business = imported(payroll_case)
|
|
assert s.scalar(select(func.count()).select_from(Business)) == 1
|
|
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
|
assert len(result["ledger_ids"]) == 2
|
|
assert sum(s.scalars(select(PayrollAllocation.amount))) == Decimal("200.00")
|
|
record = payroll_calculation.latest_input(s, business.id)
|
|
assert record.data_json["group"]["shipment_ids"] == ["20", "22"]
|
|
|
|
|
|
def test_封存发货月指导成本不随后续回款改变(payroll_case):
|
|
from app.models import TPlusFact
|
|
from app.schemas.tplus_workbench import FactIn
|
|
from app.services.tplus import workbench
|
|
|
|
s, user, _ = payroll_case
|
|
cost = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GUIDANCE"))
|
|
with pytest.raises(BadRequestError, match="已封存"):
|
|
workbench.save_fact(
|
|
s,
|
|
FactIn(
|
|
kind="GUIDANCE",
|
|
fact_key=cost.fact_key,
|
|
expected_version=cost.version,
|
|
evidence="试图修改已封存月份",
|
|
data={**cost.data_json, "amount": "30"},
|
|
),
|
|
user.id,
|
|
)
|
|
|
|
|
|
def test_同客户旧项目期初不一致必须先合并事实(payroll_case):
|
|
from app.models import TPlusFact
|
|
|
|
s, user, _ = payroll_case
|
|
group = s.scalar(select(TPlusFact).where(TPlusFact.kind == "GROUP")).data_json
|
|
fact(s, "GROUP", "old-contract-group", {**group, "opening_balance": "10"})
|
|
run = business_work(s, user, "BUSINESS_PREVIEW")
|
|
assert run.status == "PARTIAL"
|
|
assert run.summary_json["counts"] == {"PENDING": 1}
|
|
assert s.scalar(select(func.count()).select_from(Business)) == 0
|