test(business): 业务数据管理模块集成用例(70 例)与两处缺陷修复
- 覆盖:编号格式与序号递增/状态机终态拦截/三处汇总冗余一致性/跨业务批次 400/已核算禁改禁删/毛利润金标准与防除零/asOf 回溯/评级重叠/审计双记录 - 缺陷修复①:change_status 留痕 old_status 取到赋值后属性(时间线失真 3→3) - 缺陷修复②:freight_amount 漏金额校验(负数/三位小数可落库污染运费合计) - conftest 扩展业务六表清理(编号前缀 + FK 逆序收口)与评级种子基线恢复 - 全量回归 603 passed(单元 381 / 集成 222) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
77e578085a
commit
ca4cc72eea
@ -95,6 +95,12 @@ class ShipmentCreateIn(BaseModel):
|
|||||||
items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行")
|
items: list[ShipmentItemIn] = Field(min_length=1, description="发货明细行,至少 1 行")
|
||||||
remark: str | None = Field(default=None, max_length=255)
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("freight_amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_freight(cls, value: str | None) -> str | None:
|
||||||
|
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
|
||||||
|
return _check_amount(value, "运费金额", AMOUNT_MAX)
|
||||||
|
|
||||||
|
|
||||||
class ShipmentUpdateIn(BaseModel):
|
class ShipmentUpdateIn(BaseModel):
|
||||||
"""编辑发货单(明细全量替换)。
|
"""编辑发货单(明细全量替换)。
|
||||||
@ -112,6 +118,12 @@ class ShipmentUpdateIn(BaseModel):
|
|||||||
)
|
)
|
||||||
remark: str | None = Field(default=None, max_length=255)
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("freight_amount")
|
||||||
|
@classmethod
|
||||||
|
def _check_freight(cls, value: str | None) -> str | None:
|
||||||
|
"""运费同样是金额源数据:复用 _check_amount 拒负数/多小数位/超容量。"""
|
||||||
|
return _check_amount(value, "运费金额", AMOUNT_MAX)
|
||||||
|
|
||||||
|
|
||||||
class ShipmentQuery(PageQuery):
|
class ShipmentQuery(PageQuery):
|
||||||
"""发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
"""发货单列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。
|
||||||
|
|||||||
@ -511,12 +511,15 @@ def change_status(
|
|||||||
if business.status == payload.status:
|
if business.status == payload.status:
|
||||||
raise BadRequestError("状态未变化")
|
raise BadRequestError("状态未变化")
|
||||||
|
|
||||||
|
# 变更前状态须在赋值前固化:ORM 属性赋值后 old_status 就取不到旧值
|
||||||
|
# (留痕会记成 old==new 失真,时间线无法回溯)
|
||||||
|
old_status = business.status
|
||||||
business.status = payload.status
|
business.status = payload.status
|
||||||
business_repository.add_status_log(
|
business_repository.add_status_log(
|
||||||
session,
|
session,
|
||||||
BusinessStatusLog(
|
BusinessStatusLog(
|
||||||
business_id=business_id,
|
business_id=business_id,
|
||||||
old_status=business.status,
|
old_status=old_status,
|
||||||
new_status=payload.status,
|
new_status=payload.status,
|
||||||
change_reason=payload.reason,
|
change_reason=payload.reason,
|
||||||
operator_id=operator.id,
|
operator_id=operator.id,
|
||||||
@ -528,7 +531,7 @@ def change_status(
|
|||||||
logger.info(
|
logger.info(
|
||||||
"业务状态变更 | business_id=%s | %s -> %s",
|
"业务状态变更 | business_id=%s | %s -> %s",
|
||||||
business_id,
|
business_id,
|
||||||
business.status,
|
old_status,
|
||||||
payload.status,
|
payload.status,
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 后端测试
|
# 后端测试
|
||||||
|
|
||||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 301 个用例全绿(单元 223 / 集成 78)。
|
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 603 个用例全绿(单元 381 / 集成 222)。
|
||||||
|
|
||||||
## 一、测试约定
|
## 一、测试约定
|
||||||
|
|
||||||
@ -102,6 +102,17 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
|
|
||||||
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
|
运行:`uv run pytest tests/integration/test_personnel.py tests/integration/test_products.py tests/integration/test_customers.py -q` → **48 passed**(2026-10-13,连跑两次幂等)。
|
||||||
|
|
||||||
|
### 业务数据管理(2026-10-13 完成)
|
||||||
|
|
||||||
|
| 文件 | 守什么 | 用例数 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/integration/test_businesses.py` | 未登录 401;编号 `YW{YYYYMMDD}-{4位}` 与当日序号递增;空明细/数量 0/非法类别/单价非数字 400;**销售额服务层固化**(schema 无 sale_amount 入参,塞了忽略);特殊业务必填说明;编辑全量替换后**三处冗余一致**(明细 SUM == 业务冗余列 == DB SUM);状态机(终止无原因 400 → 留痕 old=1/new=3 → 终态再变更 400);归属变更主表+留痕同生(old/new owner 与 assistant、owner_type=2)、归属未变化 400、创建时归属人员已离职 400;有发货/回款禁换客户禁删;空业务删除连带清理明细/状态留痕/业务归属留痕;毛利润视图金标准(销售 10000/成本 30 → 率 997/1000、市政 30% → 折算 6970;**销售额 0 全 0 防除零**;**成本缺失行**派生为 null 不计类别汇总;**asOf 按业务日期回溯旧阈值**);审计成功/失败双记录 | 25 |
|
||||||
|
| `tests/integration/test_shipments.py` | 未登录 401;编号 `SH{YYYYMMDD}-{4位}`;业务不存在 404;终态业务禁新发货 400;空明细/数量 0/运费负数/运费三位小数 400;**行金额与发货单总额服务层固化**;业务 total_ship/total_freight 回写一致;编辑后两处汇总同步;被回款指定禁删(解除后可删);删除后业务级汇总回退;终态业务发货单禁改禁删;审计双记录 | 15 |
|
||||||
|
| `tests/integration/test_receipts.py` | 未登录 401;批次 `HK{YYYYMMDD}-{4位}`;未指定批次可登记且 payment_days 为 null;**指定批次派生「发货日首日计第 1 天」**(2026-10-05 → 2026-11-02 = 29 天,编辑清指定后回 null);业务不存在 404 / 终态禁新增 400;金额负数/三位小数 400;**跨业务指定批次 400**(「发货批次不属于该业务,无法指定」,编辑路同校验);合计回写与删除回退;**已核算(改库 settlement_status=1)禁改禁删**;settlement_status 筛选 0/1 两路;审计双记录 | 16 |
|
||||||
|
| `tests/integration/test_ratings.py` | 未登录 401;种子四等级全量按 sort_order 升序(A 90-100/B 75-89/C 60-74/D 0-59);区间倒挂/超上限/负下限 → 400「分值区间不合法」;名称唯一 → 400「该等级名称已存在」;**新增与编辑两路重叠 400 点名冲突等级**;**边界相邻不算重叠正例**(删 C 后新增 60-74 / 把 D 编辑为 0-74,74 与 75 相邻放行);编辑改名改区间回显;删除后判定表更新、二次删除 404;审计双记录。种子删改由 conftest 兜底恢复 | 14 |
|
||||||
|
|
||||||
|
运行:`uv run pytest tests/integration/test_{businesses,shipments,receipts,ratings}.py -q` → **70 passed**(2026-10-13,连跑两次幂等);全量回归 `uv run pytest -q` → **603 passed**(单元 381 / 集成 222)。
|
||||||
|
|
||||||
## 三、运行记录
|
## 三、运行记录
|
||||||
|
|
||||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||||
@ -112,6 +123,8 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
|
| 2026-10-10 | 全部 | 282 | ✅ 通过(103.81s) | **系统管理模块(用户/角色/日志)完成**后统一编写该模块套件(新增 91 例:单元 48 / 集成 43)。含操作日志审计机制专项;**e2e 发现并修复 `replace_roles` 唯一键缺陷**(见四、缺陷表);集成用例需容器在跑 |
|
||||||
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
|
| 2026-10-10 | 全部 | 301 | ✅ 通过(87.40s) | **code-review 缺陷修复后全量回归**(新增/改造 19 例)。密码结构化脱敏、时区归一、日志详情两段式各有专项;集成套件因密码哈希模块级预计算提速(63s→42s) |
|
||||||
| 2026-10-13 | 基础数据集成三文件 | 48 | ✅ 通过(27.60s) | **基础数据管理模块完成**后统一编写(人员 20/产品 14/客户 14)。审计上下文内外的校验位置差异为本次新发现的缺陷类别(见下) |
|
| 2026-10-13 | 基础数据集成三文件 | 48 | ✅ 通过(27.60s) | **基础数据管理模块完成**后统一编写(人员 20/产品 14/客户 14)。审计上下文内外的校验位置差异为本次新发现的缺陷类别(见下) |
|
||||||
|
| 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 |
|
||||||
|
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
|
||||||
|
|
||||||
## 四、测试发现并修复的缺陷
|
## 四、测试发现并修复的缺陷
|
||||||
|
|
||||||
@ -132,13 +145,15 @@ uv run pytest tests/integration -q # 仅集成
|
|||||||
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
|
| 2026-10-10 | 时间筛选未做时区归一 | 混用 aware/naive 抛 TypeError 穿透成 **500**;带时区输入被驱动静默丢弃偏移(查询窗口偏 8 小时且无提示) | `LogQueryBase` 加 field_validator:aware → `astimezone()` 本地 naive、naive 原样;补 4 例专项 |
|
||||||
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
|
| 2026-10-10 | 审计时序两处缺陷 | ① 失败路径 rollback 后读 operator 触发额外查询,账号并发被删时 ObjectDeletedError 被兜底吞掉、失败审计静默丢失;② 成功/失败两分支 8 个实参重复展开,且 role/user service 在 commit 之后仍做查询与响应构造——后置查询一旦抛错,「已提交成功」会被记成失败 | operator 标量进入时固化;审计链 DTO 化(`audit` 4 参 / `_safe_write` 2 参 / `add` 2 参);service 的 commit 后查询与响应构造全部前置 |
|
||||||
| 2026-10-13 | 基础数据三 service 的**前置校验在审计上下文之外** | 校验失败(岗位停用/经理自引用/分类不合法等 400)完全不留操作日志,违背「成功与失败都记」的既定原则——与 2026-10-10 审计时序缺陷同源,新写的 service 又犯了(各自独立实现时口径漂移) | personnel/product/customer 三 service 的值域与引用校验统一移入 `audit_service.audit` 上下文内;集成用例锁定(审计成功与失败双记录) |
|
| 2026-10-13 | 基础数据三 service 的**前置校验在审计上下文之外** | 校验失败(岗位停用/经理自引用/分类不合法等 400)完全不留操作日志,违背「成功与失败都记」的既定原则——与 2026-10-10 审计时序缺陷同源,新写的 service 又犯了(各自独立实现时口径漂移) | personnel/product/customer 三 service 的值域与引用校验统一移入 `audit_service.audit` 上下文内;集成用例锁定(审计成功与失败双记录) |
|
||||||
|
| 2026-10-13 | `change_status` 状态留痕的 old_status 取到**赋值后**的 ORM 属性 | 时间线记成 `old==new`(如 1→3 的变更记成 3→3)——「变更前状态」无法回溯,状态流转追溯失效。由集成用例断言 `(old,new)==(1,3)` 抓住 | 赋值前固化 `old_status = business.status` 再流转;应用日志一并修正;回归用例锁定 |
|
||||||
|
| 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 |
|
||||||
|
|
||||||
## 五、待补用例
|
## 五、待补用例
|
||||||
|
|
||||||
| 模块 | 状态 | 说明 |
|
| 模块 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护在业务数据表就位后于 T11 补测 |
|
| 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 |
|
||||||
| 业务数据管理 | 进行中 | 集成用例在 T11 统一编写(28 项裁决见 `docs/问题与解决方案对照表_v2.xlsx`) |
|
| 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 |
|
||||||
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
|
| 绩效工资核算 | 未开始 | 需求已澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率已在 M1 公式层覆盖) |
|
||||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||||
|
|
||||||
|
|||||||
@ -12,9 +12,15 @@
|
|||||||
消失,必须显式清理,否则残留会影响后续用例对留痕条数的断言
|
消失,必须显式清理,否则残留会影响后续用例对留痕条数的断言
|
||||||
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
|
- 操作日志的清理同时按 username 与 user_id —— 账号被删除后其 ``user_id``
|
||||||
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
|
会被 SET NULL,只能靠冗余的 username 追溯,清理逻辑必须覆盖两种状态
|
||||||
|
- M2 业务数据(业务/发货/回款)单据编号由系统生成(YW/SH/HK 前缀 + 当日
|
||||||
|
序号),**没有 t_ 前缀可用**,兜底清理按编号前缀全量清(三类单据表目前
|
||||||
|
只有测试会写);外键均为 RESTRICT,删除顺序不能倒,且必须在客户/人员
|
||||||
|
清理之前(FK 会挡住删客户/删人员);评级判定表则恢复种子基线 ——
|
||||||
|
评级用例会删改种子构造重叠/相邻场景,恢复保证跨文件幂等
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import delete, select, update
|
||||||
@ -23,18 +29,35 @@ from app.core.constants import ROLE_ADMIN, ROLE_USER
|
|||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.core.security import create_access_token, hash_password
|
from app.core.security import create_access_token, hash_password
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
Business,
|
||||||
|
BusinessItem,
|
||||||
BusinessRole,
|
BusinessRole,
|
||||||
|
BusinessStatusLog,
|
||||||
Customer,
|
Customer,
|
||||||
|
CustomerRatingGrade,
|
||||||
OperationLog,
|
OperationLog,
|
||||||
OwnershipChangeLog,
|
OwnershipChangeLog,
|
||||||
Personnel,
|
Personnel,
|
||||||
PersonnelPlan,
|
PersonnelPlan,
|
||||||
PersonnelRegion,
|
PersonnelRegion,
|
||||||
Product,
|
Product,
|
||||||
|
Receipt,
|
||||||
Role,
|
Role,
|
||||||
|
Shipment,
|
||||||
|
ShipmentItem,
|
||||||
User,
|
User,
|
||||||
UserRole,
|
UserRole,
|
||||||
)
|
)
|
||||||
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
|
|
||||||
|
# 评级判定表的种子基线(与 db/migration/V5 一致):判定按 sort_order 升序
|
||||||
|
# 取首个 min_score ≤ 总分的等级,种子铺满 0-100
|
||||||
|
_RATING_SEEDS = [
|
||||||
|
("A", Decimal("90"), Decimal("100"), 1, "优秀"),
|
||||||
|
("B", Decimal("75"), Decimal("89"), 2, "良好"),
|
||||||
|
("C", Decimal("60"), Decimal("74"), 3, "合格"),
|
||||||
|
("D", Decimal("0"), Decimal("59"), 4, "待改进"),
|
||||||
|
]
|
||||||
|
|
||||||
TEST_PASSWORD = "TestPwd@2026"
|
TEST_PASSWORD = "TestPwd@2026"
|
||||||
# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希
|
# 模块级预计算一次:bcrypt(rounds=12)单次约 0.2–0.4 秒,逐用例重复哈希
|
||||||
@ -69,6 +92,44 @@ def _purge_test_data() -> None:
|
|||||||
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
|
session.execute(delete(UserRole).where(UserRole.role_id.in_(role_ids)))
|
||||||
session.execute(delete(Role).where(Role.id.in_(role_ids)))
|
session.execute(delete(Role).where(Role.id.in_(role_ids)))
|
||||||
|
|
||||||
|
# ---- M2 业务数据(T11):业务/发货/回款三类单据 ----
|
||||||
|
# 编号系统生成、无 t_ 前缀可用,按业务编号前缀全量清(三类单据表目前
|
||||||
|
# 只有测试会写)。外键均为 RESTRICT,删除顺序不能倒:回款 → 发货
|
||||||
|
# 明细 → 发货单 → 业务明细 → 状态留痕 → 业务维度归属留痕(无外键,
|
||||||
|
# 须显式清)→ 业务主表;且必须在下方客户/人员清理之前,否则 FK 挡住
|
||||||
|
# 删客户/删人员(businesses.customer_id / .owner_personnel_id RESTRICT)
|
||||||
|
business_ids = list(
|
||||||
|
session.execute(select(Business.id).where(Business.business_no.like("YW%"))).scalars()
|
||||||
|
)
|
||||||
|
shipment_ids = list(
|
||||||
|
session.execute(select(Shipment.id).where(Shipment.shipment_no.like("SH%"))).scalars()
|
||||||
|
)
|
||||||
|
if business_ids:
|
||||||
|
session.execute(delete(Receipt).where(Receipt.business_id.in_(business_ids)))
|
||||||
|
if shipment_ids:
|
||||||
|
session.execute(delete(ShipmentItem).where(ShipmentItem.shipment_id.in_(shipment_ids)))
|
||||||
|
session.execute(delete(Shipment).where(Shipment.id.in_(shipment_ids)))
|
||||||
|
if business_ids:
|
||||||
|
session.execute(delete(BusinessItem).where(BusinessItem.business_id.in_(business_ids)))
|
||||||
|
session.execute(
|
||||||
|
delete(BusinessStatusLog).where(BusinessStatusLog.business_id.in_(business_ids))
|
||||||
|
)
|
||||||
|
session.execute(
|
||||||
|
delete(OwnershipChangeLog).where(
|
||||||
|
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||||
|
OwnershipChangeLog.business_id.in_(business_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.execute(delete(Business).where(Business.id.in_(business_ids)))
|
||||||
|
|
||||||
|
# 评级判定表恢复种子基线:评级用例会删改种子构造重叠/相邻场景,
|
||||||
|
# 兜底恢复保证每个用例开始时都是纯种子(判定表无下游引用,全删全插安全)
|
||||||
|
session.execute(delete(CustomerRatingGrade))
|
||||||
|
session.add_all(
|
||||||
|
CustomerRatingGrade(grade_name=n, min_score=lo, max_score=hi, sort_order=so, remark=rm)
|
||||||
|
for n, lo, hi, so, rm in _RATING_SEEDS
|
||||||
|
)
|
||||||
|
|
||||||
# ---- M2 基础数据:客户/归属留痕/人员/产品/测试岗位 ----
|
# ---- M2 基础数据:客户/归属留痕/人员/产品/测试岗位 ----
|
||||||
# 外键均为 RESTRICT,删除顺序不能倒:留痕无外键先按 customer_id 清,
|
# 外键均为 RESTRICT,删除顺序不能倒:留痕无外键先按 customer_id 清,
|
||||||
# 再删客户(子表)→ 人员(先置空 manager_id 自引用、清子表)→ 产品
|
# 再删客户(子表)→ 人员(先置空 manager_id 自引用、清子表)→ 产品
|
||||||
|
|||||||
811
backend/tests/integration/test_businesses.py
Normal file
811
backend/tests/integration/test_businesses.py
Normal file
@ -0,0 +1,811 @@
|
|||||||
|
"""业务管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是业务单的五处汇总冗余一致性与状态机/归属留痕 —— 明细/发货/回款任何
|
||||||
|
写路径漏算汇总,业务行的 total_* 就会与源数据漂移,M3 起付判定直接读冗余
|
||||||
|
列,漂移就是算错钱;状态留痕的「变更前状态」若取到赋值后的值(old==new
|
||||||
|
失真)或归属变更拆成「主表已换、留痕缺失」,事后追溯无据;编辑端点若能
|
||||||
|
改归属,就绕过留痕产生「改了归属却无留痕」的旁路。毛利润视图为只读派生
|
||||||
|
值,断言锁定金标准口径(销售额 0 防除零、成本缺失行不进类别汇总)。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;业务/
|
||||||
|
发货/回款编号系统生成(YW/SH/HK 前缀),conftest 按编号前缀全量清;
|
||||||
|
删除语义的用例自验 DELETE 端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
ACTION_CREATE,
|
||||||
|
BUSINESS_ROLE_SALESPERSON,
|
||||||
|
MODULE_BUSINESS,
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||||
|
)
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import (
|
||||||
|
Business,
|
||||||
|
BusinessItem,
|
||||||
|
BusinessRole,
|
||||||
|
OperationLog,
|
||||||
|
OwnershipChangeLog,
|
||||||
|
ParamChangeBatch,
|
||||||
|
ParamValue,
|
||||||
|
)
|
||||||
|
from app.models.business import BUSINESS_STATUS_ACTIVE
|
||||||
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
_PARAMS = "/api/v1/system/params"
|
||||||
|
|
||||||
|
|
||||||
|
def _company() -> str:
|
||||||
|
return f"t_公司_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _personnel(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 前缀的在职营销人员(归属变更的被引用方)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
role_id = int(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessRole.id).where(
|
||||||
|
BusinessRole.role_code == BUSINESS_ROLE_SALESPERSON,
|
||||||
|
BusinessRole.status == 1,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
_PERSONNEL,
|
||||||
|
json={
|
||||||
|
"personnel_name": f"t_人员_{uuid.uuid4().hex[:8]}",
|
||||||
|
"business_role_id": role_id,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _customer(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 前缀测试客户(业务的必选引用)。"""
|
||||||
|
resp = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": _company(),
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
"address": "本溪市平山区",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _item(**overrides: object) -> dict:
|
||||||
|
"""业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000,成本 30)。"""
|
||||||
|
body: dict = {
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"product_category": PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
"quantity": "100",
|
||||||
|
"sale_price": "100.00",
|
||||||
|
"cost_amount": "30.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(customer_id: int, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"business_date": str(date.today()),
|
||||||
|
"items": [_item()],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create(client: TestClient, headers: dict, customer_id: int, **overrides: object) -> dict:
|
||||||
|
resp = client.post(_BUSINESSES, json=_payload(customer_id, **overrides), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate(client: TestClient, headers: dict, business_id: int) -> None:
|
||||||
|
"""把业务流转到已终止(终态禁写场景的造数步骤)。"""
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{business_id}/status",
|
||||||
|
json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def threshold_batch(client: TestClient, admin: dict):
|
||||||
|
"""提交「立即生效」的产品阈值参数变更批次,用例结束整批清理。
|
||||||
|
|
||||||
|
走参数接口的真实路径(system params 要求管理员);清理按 batch_id 删
|
||||||
|
条目与批次头(test_param_management.track_batches 同款)。
|
||||||
|
"""
|
||||||
|
created: list[int] = []
|
||||||
|
|
||||||
|
def _factory(dimension_value: str, value: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
f"{_PARAMS}/changes",
|
||||||
|
json={
|
||||||
|
"change_type": 1,
|
||||||
|
"remark": "t_毛利润阈值变更",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"param_code": "product.threshold",
|
||||||
|
"dimension_value": dimension_value,
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=admin["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
batch = resp.json()["data"]
|
||||||
|
created.append(batch["id"])
|
||||||
|
return batch
|
||||||
|
|
||||||
|
yield _factory
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
if created:
|
||||||
|
session.execute(delete(ParamValue).where(ParamValue.batch_id.in_(created)))
|
||||||
|
session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(created)))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。"""
|
||||||
|
|
||||||
|
def test_未登录访问业务列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_BUSINESSES)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreate:
|
||||||
|
"""创建:编号契约、行校验与销售额固化 —— 编号序号错乱或行金额经前端
|
||||||
|
传值,列表检索与 M3 取数都会错乱。"""
|
||||||
|
|
||||||
|
def test_创建业务_编号格式与当日序号递增(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
first = _create(client, headers, customer["id"])
|
||||||
|
second = _create(client, headers, customer["id"])
|
||||||
|
|
||||||
|
# 编号格式:YW{YYYYMMDD}-{4位当日序号}(日期取当日,序号连续 +1)
|
||||||
|
expected_prefix = f"YW{date.today():%Y%m%d}-"
|
||||||
|
for no in (first["business_no"], second["business_no"]):
|
||||||
|
assert re.fullmatch(r"YW\d{8}-\d{4}", no), no
|
||||||
|
assert no.startswith(expected_prefix), (no, expected_prefix)
|
||||||
|
assert int(second["business_no"].rsplit("-", 1)[1]) == (
|
||||||
|
int(first["business_no"].rsplit("-", 1)[1]) + 1
|
||||||
|
), (first["business_no"], second["business_no"])
|
||||||
|
|
||||||
|
# 回包契约:默认执行中、客户名直出
|
||||||
|
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"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES, json=_payload(customer["id"], items=[]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
customer = _customer(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], items=[_item(quantity="0")]),
|
||||||
|
headers=normal["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"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], items=[_item(product_category="water")]),
|
||||||
|
headers=normal["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"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], items=[_item(sale_price="abc")]),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "须为有效数字" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_销售额由服务层固化_入参sale_amount不生效(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
"""schema 无 sale_amount 入参,塞了也必须被忽略 —— 行金额只能由
|
||||||
|
数量×单价计算,否则前端传值直接改业务销售额。"""
|
||||||
|
headers = normal["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])
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
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"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], is_special=1),
|
||||||
|
headers=normal["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"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], is_special=1, special_note="公司参与结算")
|
||||||
|
assert data["is_special"] == 1
|
||||||
|
assert data["special_note"] == "公司参与结算"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdit:
|
||||||
|
"""编辑:全量替换明细并单点重算汇总 —— 三处冗余(行 SUM / 业务冗余列 /
|
||||||
|
数据库 SUM)不一致就说明重算有旁路。"""
|
||||||
|
|
||||||
|
def test_编辑全量替换明细_三处汇总一致(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[
|
||||||
|
_item(quantity="100", sale_price="100.00", cost_amount="30.00"),
|
||||||
|
_item(quantity="50", sale_price="200.00", cost_amount="40.00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
before = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(before["total_sale_amount"]) == Decimal("20000")
|
||||||
|
assert Decimal(before["total_cost_amount"]) == Decimal("70")
|
||||||
|
|
||||||
|
updated = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="200", sale_price="50.00", cost_amount="30.00")],
|
||||||
|
),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
assert Decimal(updated.json()["data"]["total_sale_amount"]) == Decimal("10000")
|
||||||
|
|
||||||
|
detail = client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留"
|
||||||
|
item_sum_sale = sum(Decimal(i["sale_amount"]) for i in detail["items"])
|
||||||
|
item_sum_cost = sum(Decimal(i["cost_amount"]) for i in detail["items"] if i["cost_amount"])
|
||||||
|
assert item_sum_sale == Decimal(detail["total_sale_amount"])
|
||||||
|
assert item_sum_cost == Decimal(detail["total_cost_amount"])
|
||||||
|
|
||||||
|
# 数据库侧再核一次 SUM(接口回包来自内存计算,两处同源才可靠)
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
db_business = session.get(Business, data["id"])
|
||||||
|
db_sale = (
|
||||||
|
session.execute(
|
||||||
|
select(BusinessItem.sale_amount).where(BusinessItem.business_id == data["id"])
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert sum(db_sale) == db_business.total_sale_amount == Decimal("10000.00")
|
||||||
|
|
||||||
|
def test_编辑端点塞owner不生效_归属唯一走ownership(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
"""归属刻意不出编辑端点:否则「改归属无留痕」的旁路破坏 append-only。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
someone = _personnel(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(customer["id"], owner_personnel_id=someone["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["data"]["owner_personnel_id"] == owner["id"], resp.text
|
||||||
|
|
||||||
|
logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert logs["total"] == 0, "编辑端点改归属却无留痕"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatus:
|
||||||
|
"""状态机:终态不可逆是终态单据禁写的前提;留痕从创建首条记起。"""
|
||||||
|
|
||||||
|
def test_终止留痕_终态禁再变更(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"])
|
||||||
|
|
||||||
|
# 终止必填原因(schema 层校验,400)
|
||||||
|
no_reason = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/status", json={"status": 3}, headers=headers
|
||||||
|
)
|
||||||
|
assert no_reason.status_code == 400, no_reason.text
|
||||||
|
assert "终止业务必须填写原因" in no_reason.json()["message"], no_reason.text
|
||||||
|
|
||||||
|
done = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/status",
|
||||||
|
json={"status": 3, "reason": "客户取消订单"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert done.status_code == 200, done.text
|
||||||
|
assert done.json()["data"]["status"] == 3
|
||||||
|
|
||||||
|
# 终态不可再变更
|
||||||
|
again = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/status",
|
||||||
|
json={"status": 2, "reason": "强行翻转"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert again.status_code == 400, again.text
|
||||||
|
assert "不可再变更状态" in again.json()["message"], again.text
|
||||||
|
|
||||||
|
logs = client.get(f"{_BUSINESSES}/{data['id']}/status-logs", headers=headers).json()["data"]
|
||||||
|
assert logs["total"] == 2, logs["total"]
|
||||||
|
rows = logs["list"] # 正序:创建首条在前
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOwnership:
|
||||||
|
"""归属变更:主表更新 + 留痕同事务;两者全相同则属无效操作。"""
|
||||||
|
|
||||||
|
def test_归属变更_主表更新与留痕同生(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
old_owner = _personnel(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], owner_personnel_id=old_owner["id"])
|
||||||
|
|
||||||
|
new_owner = _personnel(client, headers)
|
||||||
|
new_assistant = _personnel(client, headers)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/ownership",
|
||||||
|
json={
|
||||||
|
"new_owner_personnel_id": new_owner["id"],
|
||||||
|
"new_assistant_personnel_id": new_assistant["id"],
|
||||||
|
"reason": "人员调动",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert body["owner_personnel_id"] == new_owner["id"]
|
||||||
|
assert body["owner_personnel_name"] == new_owner["personnel_name"]
|
||||||
|
assert body["assistant_personnel_id"] == new_assistant["id"]
|
||||||
|
|
||||||
|
logs = client.get(f"{_BUSINESSES}/{data['id']}/ownership-logs", headers=headers).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert logs["total"] == 1, "首次归属变更只产生一条留痕"
|
||||||
|
row = logs["list"][0]
|
||||||
|
assert row["old_owner_personnel_id"] == old_owner["id"]
|
||||||
|
assert row["new_owner_personnel_id"] == new_owner["id"]
|
||||||
|
assert row["old_assistant_personnel_id"] is None
|
||||||
|
assert row["new_assistant_personnel_id"] == new_assistant["id"]
|
||||||
|
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"]
|
||||||
|
|
||||||
|
# owner_type 由 DB 固化断言(响应 DTO 不含该列;2=业务归属)
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
log = session.execute(
|
||||||
|
select(OwnershipChangeLog).where(OwnershipChangeLog.business_id == data["id"])
|
||||||
|
).scalar_one()
|
||||||
|
assert log.owner_type == OWNER_TYPE_BUSINESS, (
|
||||||
|
f"owner_type 应为业务归属(2):{log.owner_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_归属未变化_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"], owner_personnel_id=owner["id"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": owner["id"], "reason": "无变化"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
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"]
|
||||||
|
quit_owner = _personnel(client, headers)
|
||||||
|
off = client.put(
|
||||||
|
f"{_PERSONNEL}/{quit_owner['id']}/status", json={"status": 0}, headers=headers
|
||||||
|
)
|
||||||
|
assert off.status_code == 200, off.text
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], owner_personnel_id=quit_owner["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
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"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"])
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": 999999999, "reason": "填错人"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "负责人员不存在或已离职" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomerProtect:
|
||||||
|
"""客户引用保护:发货/回款单据 FK 挂在业务上,换客户或删除会让单据
|
||||||
|
语义断裂 —— 服务层检查是唯一防线。"""
|
||||||
|
|
||||||
|
def test_有发货或回款时换客户_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
other = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"])
|
||||||
|
|
||||||
|
# 有发货记录:禁换
|
||||||
|
ship = client.post(
|
||||||
|
"/api/v1/business/shipments",
|
||||||
|
json={
|
||||||
|
"business_id": data["id"],
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"items": [_item(product_name="t_货", quantity="1", sale_price="10.00")],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert ship.status_code == 200, ship.text
|
||||||
|
blocked_ship = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(other["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert blocked_ship.status_code == 400, blocked_ship.text
|
||||||
|
assert "已有发货或回款记录" in blocked_ship.json()["message"], blocked_ship.text
|
||||||
|
|
||||||
|
# 解除发货后仍有回款:禁换(同一路径 has_docs)
|
||||||
|
client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers)
|
||||||
|
receipt = client.post(
|
||||||
|
"/api/v1/business/receipts",
|
||||||
|
json={
|
||||||
|
"business_id": data["id"],
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "100.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert receipt.status_code == 200, receipt.text
|
||||||
|
blocked_receipt = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}",
|
||||||
|
json=_payload(other["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
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"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(client, headers, customer["id"])
|
||||||
|
|
||||||
|
ship = client.post(
|
||||||
|
"/api/v1/business/shipments",
|
||||||
|
json={
|
||||||
|
"business_id": data["id"],
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"items": [_item(product_name="t_货", quantity="1", sale_price="10.00")],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert ship.status_code == 200, ship.text
|
||||||
|
blocked_ship = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers)
|
||||||
|
assert blocked_ship.status_code == 400, blocked_ship.text
|
||||||
|
assert "请改为已终止而非删除" in blocked_ship.json()["message"], blocked_ship.text
|
||||||
|
|
||||||
|
# 解除发货后仍有回款:禁删(文案同一路径)
|
||||||
|
client.delete(f"/api/v1/business/shipments/{ship.json()['data']['id']}", headers=headers)
|
||||||
|
receipt = client.post(
|
||||||
|
"/api/v1/business/receipts",
|
||||||
|
json={
|
||||||
|
"business_id": data["id"],
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "100.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert receipt.status_code == 200, receipt.text
|
||||||
|
blocked_receipt = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers)
|
||||||
|
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"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
owner = _personnel(client, headers)
|
||||||
|
new_owner = _personnel(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
owner_personnel_id=owner["id"],
|
||||||
|
items=[_item(quantity="1", sale_price="5.00")],
|
||||||
|
)
|
||||||
|
# 先造一次归属留痕,删除时须连带清理(无外键固化表)
|
||||||
|
moved = client.put(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/ownership",
|
||||||
|
json={"new_owner_personnel_id": new_owner["id"], "reason": "调动"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert moved.status_code == 200, moved.text
|
||||||
|
|
||||||
|
deleted = client.delete(f"{_BUSINESSES}/{data['id']}", headers=headers)
|
||||||
|
assert deleted.status_code == 200, deleted.text
|
||||||
|
assert client.get(f"{_BUSINESSES}/{data['id']}", headers=headers).status_code == 404
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
assert session.get(Business, data["id"]) is None
|
||||||
|
assert (
|
||||||
|
session.execute(select(BusinessItem).where(BusinessItem.business_id == data["id"]))
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
# 状态留痕(business_status_logs)与归属留痕(owner_type=2)均无
|
||||||
|
# 外键固化,删除时必须显式清
|
||||||
|
assert (
|
||||||
|
session.execute(
|
||||||
|
select(OwnershipChangeLog).where(
|
||||||
|
OwnershipChangeLog.business_id == data["id"],
|
||||||
|
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.first()
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGrossProfit:
|
||||||
|
"""毛利润数据视图(只读派生):锁定金标准口径 —— 率用精确分数、
|
||||||
|
折算用化简式(率 0 / 销售额 0 防除零)、成本缺失行不进类别汇总。"""
|
||||||
|
|
||||||
|
def test_毛利润基准值_率与折算锁定精确分数(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""销售 10000 / 成本 30 → 利润 9970、率 997/1000、市政阈值 30% →
|
||||||
|
折算 10000 ×(997/1000 − 30/100) = 6970。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")],
|
||||||
|
)
|
||||||
|
resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
view = resp.json()["data"]
|
||||||
|
assert view["as_of"] == str(date.today())
|
||||||
|
|
||||||
|
row = view["rows"][0]
|
||||||
|
assert Decimal(row["sale_amount"]) == Decimal("10000")
|
||||||
|
assert Decimal(row["cost_amount"]) == Decimal("30")
|
||||||
|
assert Decimal(row["profit_amount"]) == Decimal("9970")
|
||||||
|
assert row["profit_rate"] == "997/1000", "率须精确分数串(约分后),不得截断"
|
||||||
|
assert row["threshold"] == "30"
|
||||||
|
assert Decimal(row["converted_profit"]) == Decimal("6970")
|
||||||
|
assert row["missing_reason"] is None
|
||||||
|
assert view["missing_count"] == 0
|
||||||
|
|
||||||
|
cats = view["categories"]
|
||||||
|
assert [c["product_category"] for c in cats] == ["municipal_water"]
|
||||||
|
assert Decimal(cats[0]["sale_amount"]) == Decimal("10000")
|
||||||
|
assert Decimal(cats[0]["profit_amount"]) == Decimal("9970")
|
||||||
|
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:
|
||||||
|
"""单价空 → 行销售额 0:率分母为 0 时必须返回 0 而非抛除零异常,
|
||||||
|
否则录入期(补价前)打开毛利润视图就是 500。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="5", sale_price=None, cost_amount="0.00")],
|
||||||
|
)
|
||||||
|
resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
view = resp.json()["data"]
|
||||||
|
row = view["rows"][0]
|
||||||
|
assert Decimal(row["sale_amount"]) == Decimal("0")
|
||||||
|
assert Decimal(row["profit_amount"]) == Decimal("0")
|
||||||
|
assert row["profit_rate"] == "0"
|
||||||
|
assert Decimal(row["converted_profit"]) == Decimal("0")
|
||||||
|
assert view["missing_count"] == 0
|
||||||
|
|
||||||
|
cats = view["categories"]
|
||||||
|
assert [c["product_category"] for c in cats] == ["municipal_water"]
|
||||||
|
assert Decimal(cats[0]["sale_amount"]) == Decimal("0")
|
||||||
|
assert Decimal(cats[0]["converted_profit"]) == Decimal("0")
|
||||||
|
|
||||||
|
def test_毛利润_成本缺失行_派生为空不计入类别汇总(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[
|
||||||
|
_item(
|
||||||
|
product_name="t_产品_市政",
|
||||||
|
quantity="1",
|
||||||
|
sale_price="5000.00",
|
||||||
|
cost_amount="100.00",
|
||||||
|
),
|
||||||
|
_item(
|
||||||
|
product_name="t_产品_轨交",
|
||||||
|
product_category=PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||||
|
quantity="2",
|
||||||
|
sale_price=None,
|
||||||
|
cost_amount=None,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resp = client.get(f"{_BUSINESSES}/{data['id']}/gross-profit", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
view = resp.json()["data"]
|
||||||
|
assert view["missing_count"] == 1
|
||||||
|
|
||||||
|
rows = view["rows"]
|
||||||
|
assert rows[1]["cost_amount"] is None
|
||||||
|
assert rows[1]["profit_amount"] is None
|
||||||
|
assert rows[1]["profit_rate"] is None
|
||||||
|
assert rows[1]["converted_profit"] is None
|
||||||
|
assert "成本缺失" in (rows[1]["missing_reason"] or ""), rows[1]
|
||||||
|
assert rows[1]["threshold"] == "20"
|
||||||
|
# 可计算行正常派生
|
||||||
|
assert Decimal(rows[0]["profit_amount"]) == Decimal("4900")
|
||||||
|
assert rows[0]["profit_rate"] == "49/50"
|
||||||
|
|
||||||
|
cats = view["categories"]
|
||||||
|
assert [c["product_category"] for c in cats] == ["municipal_water"], "缺失行不得进类别汇总"
|
||||||
|
assert Decimal(cats[0]["sale_amount"]) == Decimal("5000")
|
||||||
|
assert Decimal(cats[0]["cost_amount"]) == Decimal("100")
|
||||||
|
assert Decimal(cats[0]["profit_amount"]) == Decimal("4900")
|
||||||
|
assert cats[0]["profit_rate"] == "49/50"
|
||||||
|
assert Decimal(cats[0]["converted_profit"]) == Decimal("3400")
|
||||||
|
|
||||||
|
def test_毛利润asOf按业务日期取参数时点值_旧日期取旧值(
|
||||||
|
self, client: TestClient, normal: dict, threshold_batch
|
||||||
|
) -> None:
|
||||||
|
"""改参数后按旧业务日期取值必须回溯旧阈值 —— 落库即漂移的「假快照」
|
||||||
|
靠 asOf 回溯参数快照,M3 台账才是法定固化点。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
customer["id"],
|
||||||
|
items=[_item(quantity="1", sale_price="10000.00", cost_amount="30.00")],
|
||||||
|
)
|
||||||
|
threshold_batch("municipal_water", "25") # 立即生效(今日)
|
||||||
|
|
||||||
|
today = client.get(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/gross-profit",
|
||||||
|
params={"as_of": str(date.today())},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert today.status_code == 200, today.text
|
||||||
|
row_today = today.json()["data"]["rows"][0]
|
||||||
|
assert row_today["threshold"] == "25"
|
||||||
|
# 25%:10000 ×(997/1000 − 25/100) = 7470
|
||||||
|
assert Decimal(row_today["converted_profit"]) == Decimal("7470")
|
||||||
|
|
||||||
|
past = client.get(
|
||||||
|
f"{_BUSINESSES}/{data['id']}/gross-profit",
|
||||||
|
params={"as_of": "2026-02-17"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert past.status_code == 200, past.text
|
||||||
|
row_past = past.json()["data"]["rows"][0]
|
||||||
|
assert row_past["threshold"] == "30", "旧业务日期须回溯变更前的阈值"
|
||||||
|
assert Decimal(row_past["converted_profit"]) == Decimal("6970")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增业务成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
_create(client, headers, customer["id"])
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_BUSINESS and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增业务未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert "创建业务" in latest.summary
|
||||||
|
|
||||||
|
def test_归属人员不存在失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
customer = _customer(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_BUSINESSES,
|
||||||
|
json=_payload(customer["id"], owner_personnel_id=999999999),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_BUSINESS and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "负责人员不存在或已离职" in (failed[0].error_message or ""), failed[0]
|
||||||
294
backend/tests/integration/test_ratings.py
Normal file
294
backend/tests/integration/test_ratings.py
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
"""客户评级等级配置接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是评级**判定表**的两条硬约束 —— ① 区间互不重叠:重叠会让同一总分
|
||||||
|
命中多个等级(或两段都跳过),判定表失效,新增与编辑两路都必须校验;
|
||||||
|
② 分值域 0≤min≤max≤100:越界/倒挂的配置直接算错等级。边界相邻不算重叠
|
||||||
|
(前段 max=74、后段 min=75 相邻)是种子铺满 0-100 的铺法,要有正例锁住;
|
||||||
|
「已存在」的名称唯一在先查与 1062 两路都要收敛成 400。
|
||||||
|
|
||||||
|
评级用例会**删改种子**(删除 C、编辑 D 的区间构造相邻/重叠场景),conftest
|
||||||
|
兜底在每个用例后把判定表恢复为种子基线(全删全插,判定表无下游引用),
|
||||||
|
因此种子断言用例(列表全量返回)始终在纯种子上运行。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:测试等级名一律 ``t_`` 前缀;种子(A/B/C/D)的删改由 conftest
|
||||||
|
恢复,删除语义的用例自验 DELETE 端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import ACTION_CREATE, MODULE_RATING
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import OperationLog
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_GRADES = "/api/v1/business/rating-grades"
|
||||||
|
|
||||||
|
# 种子基线(与 V5 迁移一致):分数字符串经 DB round-trip 后一位小数(Numeric(5,1))
|
||||||
|
_SEED = [
|
||||||
|
("A", "90.0", "100.0", 1),
|
||||||
|
("B", "75.0", "89.0", 2),
|
||||||
|
("C", "60.0", "74.0", 3),
|
||||||
|
("D", "0.0", "59.0", 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _tname() -> str:
|
||||||
|
return f"t_等级_{uuid.uuid4().hex[:6]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _gpayload(grade_name: str, min_score: str, max_score: str, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"grade_name": grade_name,
|
||||||
|
"min_score": min_score,
|
||||||
|
"max_score": max_score,
|
||||||
|
"sort_order": 99,
|
||||||
|
"remark": "t_评级测试",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_id(client: TestClient, headers: dict, grade_name: str) -> int:
|
||||||
|
"""从判定表列表取种子等级 id(种子由迁移初始化,主键不可假造)。"""
|
||||||
|
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||||
|
match = next(row for row in rows if row["grade_name"] == grade_name)
|
||||||
|
return match["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_seed(client: TestClient, headers: dict, grade_name: str) -> None:
|
||||||
|
"""删掉种子等级,为测试等级清出分值空隙(种子由 conftest 兜底恢复)。
|
||||||
|
|
||||||
|
种子铺满 0-100 —— 凡要创建/编辑测试等级的用例,必须先清出与既有等级
|
||||||
|
完全分离的空隙,否则重叠约束(设计如此)一律 400。
|
||||||
|
"""
|
||||||
|
resp = client.delete(f"{_GRADES}/{_seed_id(client, headers, grade_name)}", headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _names(client: TestClient, headers: dict) -> list[str]:
|
||||||
|
return [row["grade_name"] for row in client.get(_GRADES, headers=headers).json()["data"]]
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:评级配置属业务操作不要求管理员,但仍是登录后接口。"""
|
||||||
|
|
||||||
|
def test_未登录访问评级等级列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_GRADES)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestList:
|
||||||
|
"""列表直出种子基线:判定表是前端评级计算器的唯一数据源。"""
|
||||||
|
|
||||||
|
def test_种子四等级全量返回_按sort_order升序(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.get(_GRADES, headers=normal["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
rows = resp.json()["data"]
|
||||||
|
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [
|
||||||
|
(name, order) for name, _, _, order in _SEED
|
||||||
|
], rows
|
||||||
|
# 分值红线:str 出参且与种子精确相等(Decimal 比较,不经 float)
|
||||||
|
for row, (_, min_score, max_score, _) in zip(rows, _SEED, strict=True):
|
||||||
|
assert Decimal(row["min_score"]) == Decimal(min_score)
|
||||||
|
assert Decimal(row["max_score"]) == Decimal(max_score)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreate:
|
||||||
|
"""新增:区间合法(0≤min≤max≤100)、互不重叠、名称唯一 —— 三条硬约束
|
||||||
|
任一缺失都会算错等级。"""
|
||||||
|
|
||||||
|
def test_删除种子后新增相邻区间_成功且判定表更新(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
"""种子铺满 0-100,任何新区间都与某条重叠 —— 先删 C(60-74),
|
||||||
|
新增 60-74 与 D(0-59)/B(75-89) **边界相邻不算重叠**(正例)。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||||||
|
resp = client.post(
|
||||||
|
_GRADES, json=_gpayload("t_E", "60", "74", sort_order=5), headers=headers
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
body = resp.json()["data"]
|
||||||
|
assert Decimal(body["min_score"]) == Decimal("60")
|
||||||
|
assert Decimal(body["max_score"]) == Decimal("74")
|
||||||
|
assert body["sort_order"] == 5
|
||||||
|
|
||||||
|
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||||
|
assert _names(client, headers) == ["A", "B", "D", "t_E"], rows
|
||||||
|
|
||||||
|
def test_区间倒挂_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_GRADES, json=_gpayload(_tname(), "80", "10"), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_分值超上限_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
_GRADES, json=_gpayload(_tname(), "95", "150"), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_分值负下限_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_GRADES, json=_gpayload(_tname(), "-5", "10"), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间不合法" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_等级名称已存在_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""先删 D 清出空隙:重叠校验在名称唯一校验之前,不清空隙会先撞
|
||||||
|
「与等级 D 重叠」而走不到名称校验。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
_drop_seed(client, headers, "D")
|
||||||
|
resp = client.post(_GRADES, json=_gpayload("A", "10", "20"), headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "该等级名称已存在" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_新增与种子重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""85-95 与种子 A(90-100) 闭区间相交 → 400 并点名 A,前端据此定位行。"""
|
||||||
|
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间与等级 A 重叠" in resp.json()["message"], resp.text
|
||||||
|
# 失败不落库:判定表仍是 4 条种子
|
||||||
|
assert _names(client, normal["headers"]) == ["A", "B", "C", "D"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdit:
|
||||||
|
"""编辑以修改后的区间对全部既有等级(排除自身)两两相交 —— 编辑路与
|
||||||
|
新增路共用重叠约束,两处都要拦。"""
|
||||||
|
|
||||||
|
def test_编辑与其他等级重叠_400点名冲突等级(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""把 B 编辑为 60-65:与 C(60-74) 相交 → 点名 C;与 D(0-59)/A 均分离。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
b_id = _seed_id(client, headers, "B")
|
||||||
|
resp = client.put(
|
||||||
|
f"{_GRADES}/{b_id}",
|
||||||
|
json=_gpayload("B", "60", "65", sort_order=2),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "分值区间与等级 C 重叠" in resp.json()["message"], resp.text
|
||||||
|
# 失败不落库:种子保持原样
|
||||||
|
assert _names(client, headers) == ["A", "B", "C", "D"]
|
||||||
|
|
||||||
|
def test_编辑相邻区间_成功且判定表更新(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""删除 C 后把 D 编辑为 0-74:与 B(75-89) **边界相邻不算重叠**
|
||||||
|
(74 与 75 相邻),这是种子铺法的正例。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
client.delete(f"{_GRADES}/{_seed_id(client, headers, 'C')}", headers=headers)
|
||||||
|
d_id = _seed_id(client, headers, "D")
|
||||||
|
resp = client.put(
|
||||||
|
f"{_GRADES}/{d_id}",
|
||||||
|
json=_gpayload("D", "0", "74", sort_order=4),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||||
|
assert [(r["grade_name"], r["sort_order"]) for r in rows] == [("A", 1), ("B", 2), ("D", 4)]
|
||||||
|
assert Decimal(rows[2]["max_score"]) == Decimal("74"), rows[2]
|
||||||
|
|
||||||
|
def test_编辑回显改名与分数(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||||
|
grade_name = _tname()
|
||||||
|
created = client.post(
|
||||||
|
_GRADES, json=_gpayload(grade_name, "10", "20", sort_order=50), headers=headers
|
||||||
|
)
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
grade_id = created.json()["data"]["id"]
|
||||||
|
|
||||||
|
new_name = _tname()
|
||||||
|
updated = client.put(
|
||||||
|
f"{_GRADES}/{grade_id}",
|
||||||
|
json=_gpayload(new_name, "15", "25", sort_order=60, remark="t_改"),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
body = updated.json()["data"]
|
||||||
|
assert body["grade_name"] == new_name
|
||||||
|
assert Decimal(body["min_score"]) == Decimal("15")
|
||||||
|
assert Decimal(body["max_score"]) == Decimal("25")
|
||||||
|
assert body["sort_order"] == 60
|
||||||
|
|
||||||
|
rows = client.get(_GRADES, headers=headers).json()["data"]
|
||||||
|
match = next(r for r in rows if r["id"] == grade_id)
|
||||||
|
assert match["grade_name"] == new_name
|
||||||
|
assert Decimal(match["min_score"]) == Decimal("15")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelete:
|
||||||
|
"""删除允许清空(评级是纯计算器、评分记录不落库)—— 删除后判定表
|
||||||
|
立即反映,二次删除 404。"""
|
||||||
|
|
||||||
|
def test_删除等级后判定表更新_二次删除404(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||||
|
grade_name = _tname()
|
||||||
|
created = client.post(
|
||||||
|
_GRADES, json=_gpayload(grade_name, "30.5", "40.5", sort_order=50), headers=headers
|
||||||
|
)
|
||||||
|
assert created.status_code == 200, created.text
|
||||||
|
grade_id = created.json()["data"]["id"]
|
||||||
|
assert grade_name in _names(client, headers)
|
||||||
|
|
||||||
|
assert client.delete(f"{_GRADES}/{grade_id}", headers=headers).status_code == 200
|
||||||
|
assert grade_name not in _names(client, headers)
|
||||||
|
|
||||||
|
again = client.delete(f"{_GRADES}/{grade_id}", headers=headers)
|
||||||
|
assert again.status_code == 404, again.text
|
||||||
|
assert "评级等级不存在" in again.json()["message"], again.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增等级成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
_drop_seed(client, headers, "D") # 清出 0-59 空隙供测试等级落脚
|
||||||
|
grade_name = _tname()
|
||||||
|
resp = client.post(
|
||||||
|
_GRADES,
|
||||||
|
json=_gpayload(grade_name, "30.5", "40.5", sort_order=50),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_RATING and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增评级等级未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert grade_name in latest.summary
|
||||||
|
|
||||||
|
def test_重叠失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_GRADES, json=_gpayload(_tname(), "85", "95"), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_RATING and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "分值区间与等级 A 重叠" in (failed[0].error_message or ""), failed[0]
|
||||||
422
backend/tests/integration/test_receipts.py
Normal file
422
backend/tests/integration/test_receipts.py
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
"""回款管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是回款的数据完整性三道线 —— ① 发货批次同业务强校验:跨业务指定会让
|
||||||
|
回款天数与台账归属漂移;② settlement_status 是 M3 核算防重锚点(需规
|
||||||
|
2.3.3「每笔回款仅核算一次」),已核算禁改禁删缺失会让台账与源数据漂移;
|
||||||
|
③ total_received_amount 回写是 M3 起付判定的直接消费源,删行漏算即漂移。
|
||||||
|
payment_days 为只读派生值,锁定「发货日首日计第 1 天」口径。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;回款批次
|
||||||
|
编号系统生成(HK 前缀),conftest 按编号前缀全量清;「已核算」的造数走
|
||||||
|
直接改库(M2 只写 0、置位是 M3 职责),M3 核算置位语义不绕开。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import ACTION_CREATE, MODULE_RECEIPT
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import OperationLog, Receipt
|
||||||
|
from app.models.receipt import SETTLEMENT_DONE
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_SHIPMENTS = "/api/v1/business/shipments"
|
||||||
|
_RECEIPTS = "/api/v1/business/receipts"
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
|
||||||
|
|
||||||
|
def _customer(client: TestClient, headers: dict) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _bitem(**overrides: object) -> dict:
|
||||||
|
"""业务明细行入参(发货创建的门槛:业务须有明细 ≥1 行)。"""
|
||||||
|
body: dict = {
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"quantity": "100",
|
||||||
|
"sale_price": "100.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _bpayload(customer_id: int, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"business_date": str(date.today()),
|
||||||
|
"items": [_bitem()],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create_business(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 客户并挂一笔默认业务(回款/发货单挂在业务上)。"""
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
resp = client.post(_BUSINESSES, json=_bpayload(customer["id"]), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _sitem(**overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"quantity": "1",
|
||||||
|
"sale_price": "500.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _spayload(business_id: int, ship_date: str, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"business_id": business_id,
|
||||||
|
"ship_date": ship_date,
|
||||||
|
"items": [_sitem()],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create_shipment(client: TestClient, headers: dict, business_id: int, ship_date: str) -> dict:
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(business_id, ship_date), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _rpayload(business_id: int, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"business_id": business_id,
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "1200.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict:
|
||||||
|
resp = client.post(_RECEIPTS, json=_rpayload(business_id, **overrides), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate(client: TestClient, headers: dict, business_id: int) -> None:
|
||||||
|
"""把业务流转到已终止(终态禁写场景的造数步骤)。"""
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{business_id}/status",
|
||||||
|
json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(receipt_id: int) -> None:
|
||||||
|
"""已核算造数:M2 只写 0,置 1 由 M3 核算负责 —— 测试直接改库模拟置位,
|
||||||
|
不绕开「M2 不置位」的实现约定。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
row = session.get(Receipt, receipt_id)
|
||||||
|
assert row is not None, "回款行不存在"
|
||||||
|
row.settlement_status = SETTLEMENT_DONE
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。"""
|
||||||
|
|
||||||
|
def test_未登录访问回款列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_RECEIPTS)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreate:
|
||||||
|
"""创建:HK 批次契约、金额红线、回款天数派生与业务合计回写。"""
|
||||||
|
|
||||||
|
def test_新增回款_批次号格式_未指定批次_无派生(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
|
||||||
|
# 批次号格式:HK{YYYYMMDD}-{4位当日序号}(日期取当日)
|
||||||
|
expected_prefix = f"HK{date.today():%Y%m%d}-"
|
||||||
|
assert re.fullmatch(r"HK\d{8}-\d{4}", data["receipt_batch_no"]), data["receipt_batch_no"]
|
||||||
|
assert data["receipt_batch_no"].startswith(expected_prefix), data["receipt_batch_no"]
|
||||||
|
|
||||||
|
# 未指定批次(shipment_id 空):批次可暂不指定,回款天数无从派生
|
||||||
|
assert data["shipment_id"] is None
|
||||||
|
assert data["shipment_no"] is None
|
||||||
|
assert data["payment_days"] is None
|
||||||
|
# M2 只写 0:置 1 由 M3 核算负责
|
||||||
|
assert data["settlement_status"] == 0
|
||||||
|
assert Decimal(data["amount"]) == Decimal("1200")
|
||||||
|
|
||||||
|
def test_指定批次_回款天数发货日首日计第1天(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""发货 2026-10-05 / 回款 2026-11-02 → 29 天(首日计第 1 天,
|
||||||
|
需规「26 天」示例已作废)。"""
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
shipment_id=shipment["id"],
|
||||||
|
receipt_date="2026-11-02",
|
||||||
|
amount="500.00",
|
||||||
|
)
|
||||||
|
assert data["shipment_id"] == shipment["id"]
|
||||||
|
assert data["shipment_no"] == shipment["shipment_no"]
|
||||||
|
assert data["payment_days"] == 29, data
|
||||||
|
|
||||||
|
def test_新增回款_业务合计回写_删除后回退(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
|
||||||
|
after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(after["total_received_amount"]) == Decimal("1200")
|
||||||
|
|
||||||
|
assert client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers).status_code == 200
|
||||||
|
rollback = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(rollback["total_received_amount"]) == Decimal("0")
|
||||||
|
|
||||||
|
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_RECEIPTS, json=_rpayload(999999999), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
assert "业务不存在" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_终态业务禁新增回款_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
resp = client.post(_RECEIPTS, json=_rpayload(business["id"]), headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "业务已完成或已终止,禁止新增回款" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_金额负数_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""一期禁负数金额(红字/退款冲销需求出现时先补设计再放开)。"""
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS, json=_rpayload(business["id"], amount="-1"), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "不能为负数" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_金额三位小数_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""小数位超限直接拒绝而非静默舍入 —— 舍入即算错钱。"""
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json=_rpayload(business["id"], amount="12.345"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "最多两位小数" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossBusiness:
|
||||||
|
"""发货批次同业务强校验 —— 两类失败(不存在/跨业务)统一文案,防响应
|
||||||
|
差异探测发货单存在性。"""
|
||||||
|
|
||||||
|
def test_跨业务指定发货批次_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business1 = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||||
|
business2 = _create_business(client, headers)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json=_rpayload(business2["id"], shipment_id=shipment["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
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"]
|
||||||
|
business1 = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||||
|
business2 = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business2["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_RECEIPTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"shipment_id": shipment["id"],
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "1200.00",
|
||||||
|
},
|
||||||
|
headers=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"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business["id"], "2026-10-05")
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
assert data["payment_days"] is None
|
||||||
|
|
||||||
|
specified = client.put(
|
||||||
|
f"{_RECEIPTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"shipment_id": shipment["id"],
|
||||||
|
"receipt_date": "2026-11-02",
|
||||||
|
"amount": "300.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert specified.status_code == 200, specified.text
|
||||||
|
body = specified.json()["data"]
|
||||||
|
assert body["payment_days"] == 29, "指定批次后回款天数按发货日首日派生"
|
||||||
|
assert body["shipment_no"] == shipment["shipment_no"]
|
||||||
|
|
||||||
|
cleared = client.put(
|
||||||
|
f"{_RECEIPTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"shipment_id": None,
|
||||||
|
"receipt_date": "2026-11-02",
|
||||||
|
"amount": "300.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert cleared.status_code == 200, cleared.text
|
||||||
|
assert cleared.json()["data"]["payment_days"] is None, "清除指定后派生值回 null"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettled:
|
||||||
|
"""已核算回款禁改禁删(M3 核算防重锚点)—— 台账固化后源数据禁漂移。"""
|
||||||
|
|
||||||
|
def test_已核算禁改_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
_settle(data["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_RECEIPTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"shipment_id": None,
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "9999.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
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"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
_settle(data["id"])
|
||||||
|
|
||||||
|
resp = client.delete(f"{_RECEIPTS}/{data['id']}", headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "已核算回款禁止删除" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilter:
|
||||||
|
"""核算状态筛选(M3 核算消费的入口口径)。"""
|
||||||
|
|
||||||
|
def test_settlement_status筛选_0只回未核算_1只回已核算(
|
||||||
|
self, client: TestClient, normal: dict
|
||||||
|
) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
pending = _create(client, headers, business["id"], amount="500.00")
|
||||||
|
settled = _create(client, headers, business["id"], amount="700.00")
|
||||||
|
_settle(settled["id"])
|
||||||
|
|
||||||
|
resp0 = client.get(
|
||||||
|
_RECEIPTS,
|
||||||
|
params={"business_id": business["id"], "settlement_status": 0},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp0.status_code == 200, resp0.text
|
||||||
|
rows0 = resp0.json()["data"]["list"]
|
||||||
|
assert [r["id"] for r in rows0] == [pending["id"]], rows0
|
||||||
|
assert rows0[0]["settlement_status"] == 0
|
||||||
|
|
||||||
|
resp1 = client.get(
|
||||||
|
_RECEIPTS,
|
||||||
|
params={"business_id": business["id"], "settlement_status": 1},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 200, resp1.text
|
||||||
|
rows1 = resp1.json()["data"]["list"]
|
||||||
|
assert [r["id"] for r in rows1] == [settled["id"]], rows1
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增回款成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_RECEIPT and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增回款未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert "新增回款" in latest.summary
|
||||||
|
assert data["receipt_batch_no"] in latest.summary
|
||||||
|
|
||||||
|
def test_跨业务指定失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business1 = _create_business(client, headers)
|
||||||
|
shipment = _create_shipment(client, headers, business1["id"], str(date.today()))
|
||||||
|
business2 = _create_business(client, headers)
|
||||||
|
resp = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json=_rpayload(business2["id"], shipment_id=shipment["id"]),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_RECEIPT and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "发货批次不属于该业务" in (failed[0].error_message or ""), failed[0]
|
||||||
394
backend/tests/integration/test_shipments.py
Normal file
394
backend/tests/integration/test_shipments.py
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
"""发货管理接口集成测试(需要 MySQL 与 Redis)。
|
||||||
|
|
||||||
|
守的是发货单的行金额固化与双重汇总回写 —— 行金额(amount)与发货单总额
|
||||||
|
(total_amount)若经前端传值,业务级 total_ship_amount 与运费合计就是可
|
||||||
|
被篡改的源数据,M3 核算直接读业务冗余列;终态业务发货单禁写缺失会让核算
|
||||||
|
基线漂移;被回款批次指定的发货单若能直接删,指定它的回款就指向悬空批次,
|
||||||
|
回款天数无从派生。
|
||||||
|
|
||||||
|
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||||
|
|
||||||
|
数据卫生:人员/公司/产品名一律 ``t_`` 前缀由 conftest 兜底清理;发货单
|
||||||
|
编号系统生成(SH 前缀),conftest 按编号前缀全量清;删除语义的用例自验
|
||||||
|
DELETE 端点。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.constants import ACTION_CREATE, MODULE_SHIPMENT
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.models import OperationLog
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_BUSINESSES = "/api/v1/business/businesses"
|
||||||
|
_SHIPMENTS = "/api/v1/business/shipments"
|
||||||
|
_RECEIPTS = "/api/v1/business/receipts"
|
||||||
|
_PERSONNEL = "/api/v1/base/personnel"
|
||||||
|
_CUSTOMERS = "/api/v1/base/customers"
|
||||||
|
|
||||||
|
|
||||||
|
def _customer(client: TestClient, headers: dict) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
_CUSTOMERS,
|
||||||
|
json={
|
||||||
|
"company_name": f"t_公司_{uuid.uuid4().hex[:8]}",
|
||||||
|
"owner_name": "张老板",
|
||||||
|
"customer_type": "personal",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _bitem(**overrides: object) -> dict:
|
||||||
|
"""业务明细行入参(发货创建的门槛:业务须有明细 ≥1 行)。"""
|
||||||
|
body: dict = {
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"product_category": "municipal_water",
|
||||||
|
"quantity": "100",
|
||||||
|
"sale_price": "100.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _bpayload(customer_id: int, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"business_date": str(date.today()),
|
||||||
|
"items": [_bitem()],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create_business(client: TestClient, headers: dict) -> dict:
|
||||||
|
"""建一名 t_ 客户并挂一笔默认业务(业务须先存在,发货单挂在业务上)。"""
|
||||||
|
customer = _customer(client, headers)
|
||||||
|
resp = client.post(_BUSINESSES, json=_bpayload(customer["id"]), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _item(**overrides: object) -> dict:
|
||||||
|
"""发货明细行入参(默认:3 × 300.00 = 900)。"""
|
||||||
|
body: dict = {
|
||||||
|
"product_name": f"t_产品_{uuid.uuid4().hex[:8]}",
|
||||||
|
"quantity": "3",
|
||||||
|
"sale_price": "300.00",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _spayload(business_id: int, **overrides: object) -> dict:
|
||||||
|
body: dict = {
|
||||||
|
"business_id": business_id,
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"items": [_item()],
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _create(client: TestClient, headers: dict, business_id: int, **overrides: object) -> dict:
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(business_id, **overrides), headers=headers)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json()["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate(client: TestClient, headers: dict, business_id: int) -> None:
|
||||||
|
"""把业务流转到已终止(终态禁写场景的造数步骤)。"""
|
||||||
|
resp = client.put(
|
||||||
|
f"{_BUSINESSES}/{business_id}/status",
|
||||||
|
json={"status": 3, "reason": f"t_终止_{uuid.uuid4().hex[:6]}"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_of(username: str) -> list[OperationLog]:
|
||||||
|
"""取某操作人的审计行(按 id 倒序,最新在前)。"""
|
||||||
|
factory = get_session_factory()
|
||||||
|
with factory() as session:
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OperationLog)
|
||||||
|
.where(OperationLog.username == username)
|
||||||
|
.order_by(OperationLog.id.desc())
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
"""鉴权:业务数据属业务操作不要求管理员,但仍是登录后接口。"""
|
||||||
|
|
||||||
|
def test_未登录访问发货单列表_401(self, client: TestClient) -> None:
|
||||||
|
resp = client.get(_SHIPMENTS)
|
||||||
|
assert resp.status_code == 401, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreate:
|
||||||
|
"""创建:SH 编号契约、行校验、行金额固化与双重汇总回写 —— 业务级汇总
|
||||||
|
是 M3 起付判定的直接消费源,错写就是算错钱。"""
|
||||||
|
|
||||||
|
def test_创建发货单_行金额固化与双重汇总回写(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
freight_amount="88.50",
|
||||||
|
items=[
|
||||||
|
_item(quantity="3", sale_price="300.00", amount="999.99"),
|
||||||
|
_item(quantity="2", sale_price="250.00"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 编号格式:SH{YYYYMMDD}-{4位当日序号}(日期取当日)
|
||||||
|
expected_prefix = f"SH{date.today():%Y%m%d}-"
|
||||||
|
assert re.fullmatch(r"SH\d{8}-\d{4}", data["shipment_no"]), data["shipment_no"]
|
||||||
|
assert data["shipment_no"].startswith(expected_prefix), data["shipment_no"]
|
||||||
|
|
||||||
|
# 行金额由服务层按 数量×单价 固化:schema 无 amount 入参,塞了也忽略
|
||||||
|
detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
amounts = [Decimal(i["amount"]) for i in detail["items"]]
|
||||||
|
assert amounts == [Decimal("900"), Decimal("500")], detail["items"]
|
||||||
|
assert Decimal(detail["total_amount"]) == Decimal("1400")
|
||||||
|
assert Decimal(detail["freight_amount"]) == Decimal("88.50")
|
||||||
|
|
||||||
|
# 业务级汇总回写(发货金额 / 运费合计)
|
||||||
|
business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert Decimal(business_detail["total_ship_amount"]) == Decimal("1400")
|
||||||
|
assert Decimal(business_detail["total_freight_amount"]) == Decimal("88.50")
|
||||||
|
|
||||||
|
def test_业务不存在_404(self, client: TestClient, normal: dict) -> None:
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(999999999), headers=normal["headers"])
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
assert "业务不存在" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_终态业务禁新发货_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "业务已完成或已终止,禁止新发货" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_明细为空_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_SHIPMENTS, json=_spayload(business["id"], items=[]), headers=normal["headers"]
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
def test_明细行数量为0_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_SHIPMENTS,
|
||||||
|
json=_spayload(business["id"], items=[_item(quantity="0")]),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "数量" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_运费负数_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
"""运费是金额源数据:负值会让业务运费合计与报表直接错乱。"""
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_SHIPMENTS,
|
||||||
|
json=_spayload(business["id"], freight_amount="-8.00"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "不能为负数" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
def test_运费三位小数_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
business = _create_business(client, normal["headers"])
|
||||||
|
resp = client.post(
|
||||||
|
_SHIPMENTS,
|
||||||
|
json=_spayload(business["id"], freight_amount="8.333"),
|
||||||
|
headers=normal["headers"],
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "最多两位小数" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdit:
|
||||||
|
"""编辑(明细全量替换)与两处汇总同步 —— 业务级汇总漏算即漂移。"""
|
||||||
|
|
||||||
|
def test_编辑发货单_两处汇总同步(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
freight_amount="50.00",
|
||||||
|
items=[_item(quantity="1", sale_price="1000.00")],
|
||||||
|
)
|
||||||
|
before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(before["total_ship_amount"]) == Decimal("1000")
|
||||||
|
|
||||||
|
updated = client.put(
|
||||||
|
f"{_SHIPMENTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"freight_amount": "12.00",
|
||||||
|
"items": [_item(quantity="2", sale_price="150.00")],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200, updated.text
|
||||||
|
assert Decimal(updated.json()["data"]["total_amount"]) == Decimal("300")
|
||||||
|
|
||||||
|
# 业务级两处汇总随编辑同步
|
||||||
|
after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(after["total_ship_amount"]) == Decimal("300")
|
||||||
|
assert Decimal(after["total_freight_amount"]) == Decimal("12")
|
||||||
|
|
||||||
|
detail = client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).json()["data"]
|
||||||
|
assert len(detail["items"]) == 1, "编辑是全量替换,旧行不得残留"
|
||||||
|
assert Decimal(detail["items"][0]["amount"]) == Decimal("300")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelete:
|
||||||
|
"""删除保护与汇总回退:被回款指定禁删;删除后业务级汇总必须回算。"""
|
||||||
|
|
||||||
|
def test_被回款指定禁删_解除后可删_汇总回退(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
freight_amount="20.00",
|
||||||
|
items=[_item(quantity="1", sale_price="500.00")],
|
||||||
|
)
|
||||||
|
receipt = client.post(
|
||||||
|
_RECEIPTS,
|
||||||
|
json={
|
||||||
|
"business_id": business["id"],
|
||||||
|
"shipment_id": data["id"],
|
||||||
|
"receipt_date": str(date.today()),
|
||||||
|
"amount": "500.00",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert receipt.status_code == 200, receipt.text
|
||||||
|
|
||||||
|
blocked = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
|
||||||
|
assert blocked.status_code == 400, blocked.text
|
||||||
|
assert "已被回款批次指定" in blocked.json()["message"], blocked.text
|
||||||
|
|
||||||
|
# 解除回款的批次指定(未核算回款可删)后发货单可删,业务级汇总回退
|
||||||
|
assert (
|
||||||
|
client.delete(
|
||||||
|
f"{_RECEIPTS}/{receipt.json()['data']['id']}", headers=headers
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
|
||||||
|
assert freed.status_code == 200, freed.text
|
||||||
|
business_detail = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()[
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
assert Decimal(business_detail["total_ship_amount"]) == Decimal("0")
|
||||||
|
assert Decimal(business_detail["total_freight_amount"]) == Decimal("0")
|
||||||
|
|
||||||
|
def test_删除未被指定的发货单_业务级汇总回退(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(
|
||||||
|
client,
|
||||||
|
headers,
|
||||||
|
business["id"],
|
||||||
|
freight_amount="100.00",
|
||||||
|
items=[_item(quantity="2", sale_price="1000.00")],
|
||||||
|
)
|
||||||
|
before = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(before["total_ship_amount"]) == Decimal("2000")
|
||||||
|
|
||||||
|
freed = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
|
||||||
|
assert freed.status_code == 200, freed.text
|
||||||
|
assert client.get(f"{_SHIPMENTS}/{data['id']}", headers=headers).status_code == 404
|
||||||
|
after = client.get(f"{_BUSINESSES}/{business['id']}", headers=headers).json()["data"]
|
||||||
|
assert Decimal(after["total_ship_amount"]) == Decimal("0")
|
||||||
|
assert Decimal(after["total_freight_amount"]) == Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTerminal:
|
||||||
|
"""终态业务的发货单禁改禁删 —— 发货的运费/金额是业务级汇总与 M3 核算
|
||||||
|
的源数据,终态后不得变动。"""
|
||||||
|
|
||||||
|
def test_终态业务发货单禁改_400(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
|
||||||
|
resp = client.put(
|
||||||
|
f"{_SHIPMENTS}/{data['id']}",
|
||||||
|
json={
|
||||||
|
"ship_date": str(date.today()),
|
||||||
|
"items": [_item(quantity="1", sale_price="1.00")],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
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"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
|
||||||
|
resp = client.delete(f"{_SHIPMENTS}/{data['id']}", headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "禁止删除发货单" in resp.json()["message"], resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudit:
|
||||||
|
"""写操作(含校验失败的)都在 operation_logs 留痕 —— 审计是验收项。"""
|
||||||
|
|
||||||
|
def test_新增发货单成功留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
data = _create(client, headers, business["id"])
|
||||||
|
logs = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_SHIPMENT and log.action == ACTION_CREATE
|
||||||
|
]
|
||||||
|
assert logs, "新增发货单未留操作日志"
|
||||||
|
latest = logs[0]
|
||||||
|
assert latest.success == 1
|
||||||
|
assert "新增发货单" in latest.summary
|
||||||
|
assert data["shipment_no"] in latest.summary
|
||||||
|
|
||||||
|
def test_终态禁新发货失败留审计(self, client: TestClient, normal: dict) -> None:
|
||||||
|
headers = normal["headers"]
|
||||||
|
business = _create_business(client, headers)
|
||||||
|
_terminate(client, headers, business["id"])
|
||||||
|
resp = client.post(_SHIPMENTS, json=_spayload(business["id"]), headers=headers)
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
failed = [
|
||||||
|
log
|
||||||
|
for log in _logs_of(normal["username"])
|
||||||
|
if log.module == MODULE_SHIPMENT and log.success == 0
|
||||||
|
]
|
||||||
|
assert failed, "失败操作未留痕"
|
||||||
|
assert "禁止新发货" in (failed[0].error_message or ""), failed[0]
|
||||||
Loading…
Reference in New Issue
Block a user