feat(核算)!: 添加 T+ 正式核算与采集托管
补齐源快照、资料预演、正式导入及工资核算的依赖链路。 新增版本化输入、逐段 FIFO、生成锁和结构化当月调整, 并提供默认关闭的周期采集、有限重试及 Windows 托管。 原始客户资料、私有配置及首页改动不纳入本次提交。 BREAKING CHANGE: 升级前需应用新增迁移,并通过 PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。 旧单批次核算默认关闭;历史兼容需显式启用 PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
1 parent
1b074576fe
commit
108bf87225
154 files changed
+9515
-645
No files matched your search
@@ -43,7 +43,7 @@ from app.models import (
|
||||
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("legacy_calculation")]
|
||||
|
||||
_BUSINESSES = "/api/v1/business/businesses"
|
||||
_PERSONNEL = "/api/v1/base/personnel"
|
||||
@@ -199,8 +199,8 @@ class TestCreate:
|
||||
"""创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端
|
||||
传值,列表检索与 M3 取数都会错乱。"""
|
||||
|
||||
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
first = _create(client, headers, customer["id"])
|
||||
second = _create(client, headers, customer["id"])
|
||||
@@ -218,18 +218,18 @@ class TestCreate:
|
||||
assert first["status"] == BUSINESS_STATUS_ACTIVE
|
||||
assert first["customer_name"] == customer["company_name"]
|
||||
|
||||
def test_空明细_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_空明细_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES, json=_payload(customer["id"], items=[]), headers=normal["headers"]
|
||||
_BUSINESSES, json=_payload(customer["id"], items=[]), headers=writer["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, normal: dict) -> None:
|
||||
def test_行金额乘积显式两位固化_回显与库值一致(self, client: TestClient, writer: dict) -> None:
|
||||
"""数量(2位)×单价(4位) 可产 6 位小数:显式 HALF_UP 固化,与 MySQL 的
|
||||
DECIMAL(14,2) 舍入方向一致 —— 直写不 quantize 会被库端静默舍入,
|
||||
创建响应(读内存值)与复查(读库值)前后显示不一致。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -250,43 +250,43 @@ class TestCreate:
|
||||
).scalar_one()
|
||||
assert db_amount == Decimal("5.17")
|
||||
|
||||
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_明细行数量为0_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], items=[_item(quantity="0")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "数量" in resp.json()["message"], resp.text
|
||||
|
||||
def test_非法产品类别_400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_非法产品类别_400(self, client: TestClient, writer: dict) -> None:
|
||||
"""类别是折算阈值的行级锚点:脏类别取不到阈值参数。"""
|
||||
customer = _customer(client, normal["headers"])
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], items=[_item(product_category="water")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "产品类别不合法" in resp.json()["message"], resp.text
|
||||
|
||||
def test_销售单价非数字_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_销售单价非数字_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], items=[_item(sale_price="abc")]),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "须为有效数字" in resp.json()["message"], resp.text
|
||||
|
||||
def test_销售额由服务层固化_入参sale_amount不生效(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由
|
||||
数量×单价计算,否则前端传值直接改业务销售额。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
item = _item(quantity="3", sale_price="12.34", sale_amount="88888.00")
|
||||
data = _create(client, headers, customer["id"], items=[item])
|
||||
@@ -295,18 +295,18 @@ class TestCreate:
|
||||
assert Decimal(detail["items"][0]["sale_amount"]) == Decimal("37.02")
|
||||
assert Decimal(detail["total_sale_amount"]) == Decimal("37.02")
|
||||
|
||||
def test_特殊业务缺说明_400(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_特殊业务缺说明_400(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], is_special=1),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "特殊业务必须填写说明" in resp.json()["message"], resp.text
|
||||
|
||||
def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_特殊业务有说明创建成功_回显说明(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算")
|
||||
assert data["is_special"] == 1
|
||||
@@ -317,8 +317,8 @@ class TestEdit:
|
||||
"""编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 /
|
||||
数据库 SUM)不一致就说明重算有旁路。"""
|
||||
|
||||
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -365,10 +365,10 @@ class TestEdit:
|
||||
assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00")
|
||||
|
||||
def test_编辑端点塞owner不生效_归属唯一走ownership(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
"""归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
someone = _personnel(client, headers)
|
||||
@@ -387,10 +387,10 @@ class TestEdit:
|
||||
]
|
||||
assert logs["total"] == 0, "编辑端点改归属却无留痕"
|
||||
|
||||
def test_终态业务编辑_400且明细未动(self, client: TestClient, normal: dict) -> None:
|
||||
def test_终态业务编辑_400且明细未动(self, client: TestClient, writer: dict) -> None:
|
||||
"""终态门槛与发货/回款写准入同口径:明细/汇总是 M3 核算基线,终态
|
||||
业务禁改 —— 父单据若放行,发货/回款服务「禁写终态」的防线就被绕过。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
_terminate(client, headers, data["id"])
|
||||
@@ -412,10 +412,10 @@ class TestEdit:
|
||||
assert len(detail["items"]) == 1
|
||||
assert Decimal(detail["items"][0]["quantity"]) == Decimal("100")
|
||||
|
||||
def test_二次分配方案合计超100_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
def test_二次分配方案合计超100_保存400(self, client: TestClient, writer: dict) -> None:
|
||||
"""口径(澄清 #1/#2 答复 2026-10-19):分配绩效、余额归合同负责人——
|
||||
超 100% 会把负责人份额算成负数,保存侧先拦(核算侧另有防御)。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
@@ -435,8 +435,8 @@ class TestEdit:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "超过 100%" in resp.json()["message"]
|
||||
|
||||
def test_二次分配参与人含负责人_保存400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_二次分配参与人含负责人_保存400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
@@ -452,8 +452,8 @@ class TestEdit:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "含合同负责营销人员" in resp.json()["message"]
|
||||
|
||||
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_二次分配方案合法比例_保存成功且回显(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
part = _personnel(client, headers)
|
||||
@@ -479,8 +479,8 @@ class TestEdit:
|
||||
class TestStatus:
|
||||
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||
|
||||
def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_终止留痕_终态禁再变更(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
|
||||
@@ -514,14 +514,14 @@ class TestStatus:
|
||||
assert (rows[0]["old_status"], rows[0]["new_status"]) == (1, 1)
|
||||
assert (rows[1]["old_status"], rows[1]["new_status"]) == (1, 3)
|
||||
assert rows[1]["change_reason"] == "客户取消订单"
|
||||
assert rows[1]["operator_name"] == normal["username"]
|
||||
assert rows[1]["operator_name"] == writer["username"]
|
||||
|
||||
|
||||
class TestOwnership:
|
||||
"""归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。"""
|
||||
|
||||
def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属变更_主表更新与留痕同生(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
old_owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"])
|
||||
@@ -555,7 +555,7 @@ class TestOwnership:
|
||||
assert row["old_owner_name"] == old_owner["personnel_name"]
|
||||
assert row["new_owner_name"] == new_owner["personnel_name"]
|
||||
assert row["change_reason"] == "人员调动"
|
||||
assert row["operator_name"] == normal["username"]
|
||||
assert row["operator_name"] == writer["username"]
|
||||
|
||||
# owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属)
|
||||
factory = get_session_factory()
|
||||
@@ -567,8 +567,8 @@ class TestOwnership:
|
||||
f"owner_type 应为业务归属(2):{log.owner_type}"
|
||||
)
|
||||
|
||||
def test_归属未变化_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属未变化_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||
@@ -580,8 +580,8 @@ class TestOwnership:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "归属未变化" in resp.json()["message"], resp.text
|
||||
|
||||
def test_创建时负责人员已离职_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_创建时负责人员已离职_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
quit_owner = _personnel(client, headers)
|
||||
off = client.put(
|
||||
f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers
|
||||
@@ -596,8 +596,8 @@ class TestOwnership:
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "负责人员不存在或已离职" in resp.json()["message"], resp.text
|
||||
|
||||
def test_归属端点负责人员不存在_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_归属端点负责人员不存在_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
resp = client.put(
|
||||
@@ -613,8 +613,8 @@ class TestCustomerProtect:
|
||||
"""客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据
|
||||
语义断裂 —— 服务层检查是唯一防线。"""
|
||||
|
||||
def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_有发货或回款时换客户_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
other = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
@@ -658,8 +658,8 @@ class TestCustomerProtect:
|
||||
assert blocked_receipt.status_code == 400, blocked_receipt.text
|
||||
assert "已有发货或回款记录" in blocked_receipt.json()["message"], blocked_receipt.text
|
||||
|
||||
def test_有发货或回款禁删_400(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_有发货或回款禁删_400(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(client, headers, customer["id"])
|
||||
|
||||
@@ -693,8 +693,8 @@ class TestCustomerProtect:
|
||||
assert blocked_receipt.status_code == 400, blocked_receipt.text
|
||||
assert "请改为已终止而非删除" in blocked_receipt.json()["message"], blocked_receipt.text
|
||||
|
||||
def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_空业务删除成功_连带清理明细与留痕(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
owner = _personnel(client, headers)
|
||||
new_owner = _personnel(client, headers)
|
||||
@@ -745,10 +745,10 @@ class TestGrossProfit:
|
||||
"""毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、
|
||||
折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。"""
|
||||
|
||||
def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, normal: dict) -> None:
|
||||
def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, writer: dict) -> None:
|
||||
"""销售 10000 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% →
|
||||
折算 10000 ×(997/1000 − 30/100) = 6970。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -778,10 +778,10 @@ class TestGrossProfit:
|
||||
assert cats[0]["profit_rate"] == "997/1000"
|
||||
assert Decimal(cats[0]["converted_profit"]) == Decimal("6970")
|
||||
|
||||
def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, normal: dict) -> None:
|
||||
def test_毛利润_销售额0_全0不抛错_防除零(self, client: TestClient, writer: dict) -> None:
|
||||
"""单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常,
|
||||
否则录入期(补价前)打开毛利润视图就是 500。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -805,9 +805,9 @@ class TestGrossProfit:
|
||||
assert Decimal(cats[0]["converted_profit"]) == Decimal("0")
|
||||
|
||||
def test_毛利润_成本缺失行_派生为空不计入类别汇总(
|
||||
self, client: TestClient, normal: dict
|
||||
self, client: TestClient, writer: dict
|
||||
) -> None:
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -854,11 +854,11 @@ class TestGrossProfit:
|
||||
assert Decimal(cats[0]["converted_profit"]) == Decimal("3400")
|
||||
|
||||
def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值(
|
||||
self, client: TestClient, normal: dict, threshold_batch
|
||||
self, client: TestClient, writer: dict, threshold_batch
|
||||
) -> None:
|
||||
"""改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」
|
||||
靠 asOf 回溯参数快照,M3 台账才是法定固化点。"""
|
||||
headers = normal["headers"]
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
data = _create(
|
||||
client,
|
||||
@@ -893,13 +893,13 @@ class TestGrossProfit:
|
||||
class TestAudit:
|
||||
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||
|
||||
def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||
headers = normal["headers"]
|
||||
def test_新增业务成功留审计(self, client: TestClient, writer: dict) -> None:
|
||||
headers = writer["headers"]
|
||||
customer = _customer(client, headers)
|
||||
_create(client, headers, customer["id"])
|
||||
logs = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE
|
||||
]
|
||||
assert logs, "新增业务未留操作日志"
|
||||
@@ -907,17 +907,17 @@ class TestAudit:
|
||||
assert latest.success == 1
|
||||
assert "创建业务" in latest.summary
|
||||
|
||||
def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||
customer = _customer(client, normal["headers"])
|
||||
def test_归属人员不存在失败留审计(self, client: TestClient, writer: dict) -> None:
|
||||
customer = _customer(client, writer["headers"])
|
||||
resp = client.post(
|
||||
_BUSINESSES,
|
||||
json=_payload(customer["id"], owner_personnel_id=999999999),
|
||||
headers=normal["headers"],
|
||||
headers=writer["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
failed = [
|
||||
log
|
||||
for log in _logs_of(normal["username"])
|
||||
for log in _logs_of(writer["username"])
|
||||
if log.module == MODULE_BUSINESS and log.success == 0
|
||||
]
|
||||
assert failed, "失败操作未留痕"
|
||||
|
||||
Reference in new issue
Block a user