4 次代码提交
作者 SHA1 备注 提交日期
liuwenxin和Claude Code 94c49138bf refactor(e2e): 六个列表页 UI helper 收口 common.py
open_menu 时分组参数化、fill_filter :text-is 精确匹配、messagebox 带
:visible(base_data 实现为准)——两脚本此前各持一份且匹配精度漂移
(has_text 子串 vs :text-is),使用者误以为两者等价;登录/系统管理/
基础数据/业务数据四链路 e2e 复跑全过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 11:01:31 +08:00
liuwenxin和Claude Code f75a9d534e refactor(test): 集成工厂上提共用,submit_change 拆校验段,审计重试断言对齐全局兜底
- tests/integration/factories.py:回款/发货两文件逐字重复的
  _customer/_bitem/_bpayload/_create_business 上提(t_ 前缀与默认行
  口径单点维护);test_audit_retry 的持续冲突断言由 500 更新为
  400「数据完整性冲突」——全局 IntegrityError handler(批 6)收敛
  行为属预期改进,非回归
- param_service.submit_change 拆出 _normalize_items(校验段独立,
  主函数降至 50 行约定内);e2e 六个列表页 helper 收口 common.py
  (base_data 版为准:分组参数化、:text-is 精确匹配),两脚本变薄
- 全量回归 625 passed(基线 603 + 新增 22);e2e 四链路(登录 8/
  系统管理 13/基础数据 13/业务 16)全过
- 测试 README 补 2026-10-15 运行记录与 10 条评审缺陷(预览千分比/
  审计假成功/字典序 MAX/无锁编辑/删产品 500/schema 口径/quantize/
  负天数外插/评级并发/大小写不对称)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 10:58:44 +08:00
liuwenxin和Claude Code 0ef3adec21 refactor(frontend): 金额正则/分页/抽屉状态/行编辑与下拉收口
- 金额形态正则收进 utils/validate.ts 单点导出(此前四页各写,精度
  调整漏改一页即「A 页拦得住 B 页放行后端 400」或历史行无法编辑)
- params 版本/历史两抽屉的手写分页五件套换 usePagedList(白得请求
  序号防竞态——快速翻页旧响应覆盖新页的手写坑不再复现)
- 业务抽屉三份 id 状态收为 drawerRowId computed(单一状态源,消除
  「标题 A 业务、留痕表 B 业务」的失同步面)
- useEditingRow(三页逐字相同的 computed)与 useDropdownOptions
  (取数失败不阻断列表的骨架)收口,映射函数由调用方给出
- e2e 冒烟:基础数据 13 步 + 业务全链路 16 步全过

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 10:08:24 +08:00
liuwenxin和Claude Code 60f6af6494 refactor(repos): 归属留痕共享数据访问,人员在职校验收口单点
- ownership_log_repository:客户/业务两侧逐行同构的 list/count/add
  收口(owner_type 与引用列参数化),双键倒序与被删人员兜底口径
  单点维护;两侧 repository 变薄包装,service 层零改动
- PERSONNEL_STATUS_ACTIVE 单点导出于 models/personnel(对照
  BUSINESS_STATUS_* 先例);业务/客户两侧的「存在且在职」校验收口
  于 personnel_service.ensure_active——此前两份常量+校验仅靠注释
  维持同步,在职判定调整时会两入口口径分叉;校验放 service 层遵守
  「repositories 不抛业务异常」红线

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-15 09:49:53 +08:00
共修改 24 个文件,包含 460 行新增和 455 行删除

没有与搜索条件相匹配的文件

+4
查看文件
@@ -22,6 +22,10 @@ from app.models.base import (
mysql_table_args, mysql_table_args,
) )
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
PERSONNEL_STATUS_ACTIVE = 1
class Personnel(Base, TimestampMixin, TPlusSyncMixin): class Personnel(Base, TimestampMixin, TPlusSyncMixin):
"""营销人员表(需求 2.1.1)。 """营销人员表(需求 2.1.1)。
+16 -28
查看文件
@@ -21,6 +21,7 @@ from app.models import (
Shipment, Shipment,
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import ownership_log_repository
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None: def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
@@ -229,41 +230,28 @@ def count_status_logs(session: Session, business_id: int) -> int:
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None: def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None:
"""追加归属变更留痕(不 commit)。""" """追加归属变更留痕(共享实现,不 commit)。"""
session.add(log) ownership_log_repository.add(session, log)
session.flush()
def list_ownership_logs( def list_ownership_logs(
session: Session, business_id: int, *, offset: int, limit: int session: Session, business_id: int, *, offset: int, limit: int
) -> list[Row]: ) -> list[Row]:
"""某业务的归属变更留痕(join 人员出姓名;人员删除后为 None,留痕固化 ID 兜底)。""" """某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
old_p = aliased(Personnel) return ownership_log_repository.list_by_ref(
new_p = aliased(Personnel) session,
return list( owner_type=OWNER_TYPE_BUSINESS,
session.execute( ref_column=OwnershipChangeLog.business_id,
select(OwnershipChangeLog, old_p.personnel_name, new_p.personnel_name) ref_id=business_id,
.outerjoin(old_p, old_p.id == OwnershipChangeLog.old_owner_personnel_id) offset=offset,
.outerjoin(new_p, new_p.id == OwnershipChangeLog.new_owner_personnel_id) limit=limit,
.where(
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
OwnershipChangeLog.business_id == business_id,
)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
).all()
) )
def count_ownership_logs(session: Session, business_id: int) -> int: def count_ownership_logs(session: Session, business_id: int) -> int:
return int( return ownership_log_repository.count_by_ref(
session.execute( session,
select(func.count()) owner_type=OWNER_TYPE_BUSINESS,
.select_from(OwnershipChangeLog) ref_column=OwnershipChangeLog.business_id,
.where( ref_id=business_id,
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
OwnershipChangeLog.business_id == business_id,
)
).scalar_one()
) )
+17 -31
查看文件
@@ -4,17 +4,13 @@
""" """
from sqlalchemy import ColumnElement, Row, Select, func, or_, select from sqlalchemy import ColumnElement, Row, Select, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session
from app.models import Customer, OwnershipChangeLog, Personnel from app.models import Customer, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories import ownership_log_repository
from app.repositories.query_utils import fuzzy from app.repositories.query_utils import fuzzy
# 归属留痕表对人员 ID 无外键固化:新旧人员姓名靠对 personnel 的两次外联带出,
# 人员被删除后姓名为 null,行仍完整可读
_old_owner = aliased(Personnel)
_new_owner = aliased(Personnel)
# ==================== 读取 ==================== # ==================== 读取 ====================
@@ -102,33 +98,25 @@ def list_ownership_logs(
offset: int, offset: int,
limit: int, limit: int,
) -> list[Row]: ) -> list[Row]:
"""查某客户的归属变更留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。 """查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
return ownership_log_repository.list_by_ref(
按 (create_time, id) 双键倒序:create_time 是秒级精度,同一秒内多条 session,
记录排序不确定,id 兜底保证翻页稳定(与操作日志同一理由)。 owner_type=OWNER_TYPE_CUSTOMER,
""" ref_column=OwnershipChangeLog.customer_id,
stmt = ( ref_id=customer_id,
select(OwnershipChangeLog, _old_owner.personnel_name, _new_owner.personnel_name) offset=offset,
.outerjoin(_old_owner, OwnershipChangeLog.old_owner_personnel_id == _old_owner.id) limit=limit,
.outerjoin(_new_owner, OwnershipChangeLog.new_owner_personnel_id == _new_owner.id)
.where(
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER,
OwnershipChangeLog.customer_id == customer_id,
)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
) )
return list(session.execute(stmt).all())
def count_ownership_logs(session: Session, *, customer_id: int) -> int: def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。""" """统计某客户的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where( return ownership_log_repository.count_by_ref(
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER, session,
OwnershipChangeLog.customer_id == customer_id, owner_type=OWNER_TYPE_CUSTOMER,
ref_column=OwnershipChangeLog.customer_id,
ref_id=customer_id,
) )
return int(session.execute(stmt).scalar_one())
def _base_select() -> Select: def _base_select() -> Select:
@@ -186,7 +174,5 @@ def delete(session: Session, customer: Customer) -> None:
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog: def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。""" """写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。"""
session.add(log) return ownership_log_repository.add(session, log)
session.flush()
return log
@@ -0,0 +1,58 @@
"""归属变更留痕的共享数据访问(客户/业务两维度共用)。
owner_type 与引用列由调用方参数化 —— 此前客户、业务两侧各持一套逐行
同构的 list/count/add,排序口径与 join 兜底的调整须同步两处;收口后
「被删人员留痕仍可读」「翻页稳定双键倒序」等口径单点维护。
"""
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session, aliased
from app.models import OwnershipChangeLog, Personnel
def list_by_ref(
session: Session,
*,
owner_type: int,
ref_column,
ref_id: int,
offset: int,
limit: int,
) -> list[Row]:
"""查某引用对象的归属留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
人员删除后 join 出 None,留痕固化 ID 兜底可读。按 (create_time, id)
双键倒序:create_time 是秒级精度,同一秒内多条排序不确定,id 兜底
保证翻页稳定(与操作日志同一理由)。
"""
old_person = aliased(Personnel)
new_person = aliased(Personnel)
stmt = (
select(OwnershipChangeLog, old_person.personnel_name, new_person.personnel_name)
.outerjoin(old_person, OwnershipChangeLog.old_owner_personnel_id == old_person.id)
.outerjoin(new_person, OwnershipChangeLog.new_owner_personnel_id == new_person.id)
.where(OwnershipChangeLog.owner_type == owner_type, ref_column == ref_id)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
)
return list(session.execute(stmt).all())
def count_by_ref(
session: Session, *, owner_type: int, ref_column, ref_id: int
) -> int:
"""统计某引用对象的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == owner_type,
ref_column == ref_id,
)
return int(session.execute(stmt).scalar_one())
def add(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
session.add(log)
session.flush()
return log
+4 -10
查看文件
@@ -49,7 +49,7 @@ from app.models.business import (
BUSINESS_STATUS_DONE, BUSINESS_STATUS_DONE,
) )
from app.models.customer import OWNER_TYPE_BUSINESS from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository, personnel_repository from app.repositories import business_repository, customer_repository
from app.schemas.business import ( from app.schemas.business import (
BusinessCreateIn, BusinessCreateIn,
BusinessDetailOut, BusinessDetailOut,
@@ -66,7 +66,7 @@ from app.schemas.business import (
GrossProfitRowOut, GrossProfitRowOut,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
from app.services import audit_service, param_service from app.services import audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import converted_profit, profit_rate from app.services.formula import converted_profit, profit_rate
from app.services.no_generator import next_no from app.services.no_generator import next_no
@@ -77,9 +77,6 @@ logger = logging.getLogger(__name__)
# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷 # 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷
_NO_RETRY = 1 _NO_RETRY = 1
# personnel.status 的在职值(与 customer_service 同口径,注释见彼处)
_PERSONNEL_STATUS_ACTIVE = 1
def _to_out(row: tuple) -> BusinessOut: def _to_out(row: tuple) -> BusinessOut:
"""(business, company_name, owner_name, assistant_name) → 响应模型。 """(business, company_name, owner_name, assistant_name) → 响应模型。
@@ -214,11 +211,8 @@ def _generate_no(session: Session) -> str:
def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel: def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel:
"""归属人员须存在且在职(人员侧复用校验文案口径)。""" """归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
person = personnel_repository.get_by_id(session, personnel_id) return personnel_service.ensure_active(session, personnel_id, label=label)
if person is None or person.status != _PERSONNEL_STATUS_ACTIVE:
raise BadRequestError(f"{label}人员不存在或已离职")
return person
def _ensure_customer(session: Session, customer_id: int) -> Customer: def _ensure_customer(session: Session, customer_id: int) -> Customer:
+3 -16
查看文件
@@ -40,15 +40,11 @@ from app.schemas.customer import (
CustomerUpdateIn, CustomerUpdateIn,
OwnershipChangeLogOut, OwnershipChangeLogOut,
) )
from app.services import audit_service from app.services import audit_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta from app.services.audit_service import AuditRecord, RequestMeta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# personnel.status 的在职值(列注释口径:1在职 0离职)。离职人员不可再被
# 客户归属引用;人员侧抽出共享常量后此处替换
_PERSONNEL_STATUS_ACTIVE = 1
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut: def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
"""ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。""" """ORM 对象转响应模型。禁止把 ORM 对象直接当 response_model。"""
@@ -95,17 +91,8 @@ def _ensure_customer_type(customer_type: str) -> None:
def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel: def _ensure_owner_personnel(session: Session, personnel_id: int) -> Personnel:
"""归属人员必须存在且在职,返回人员行。 """归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
return personnel_service.ensure_active(session, personnel_id, label="归属")
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要
向调用方区分的信息(同登录防枚举的思路)。
:raises BadRequestError: 归属人员不存在或已离职
"""
person = personnel_repository.get_by_id(session, personnel_id)
if person is None or person.status != _PERSONNEL_STATUS_ACTIVE:
raise BadRequestError("归属人员不存在或已离职")
return person
def _is_duplicate_entry(exc: IntegrityError) -> bool: def _is_duplicate_entry(exc: IntegrityError) -> bool:
+64 -55
查看文件
@@ -35,6 +35,7 @@ from app.schemas.param import (
ParamCatalogItemOut, ParamCatalogItemOut,
ParamChangeBatchOut, ParamChangeBatchOut,
ParamChangeIn, ParamChangeIn,
ParamChangeItemIn,
ParamDiffOut, ParamDiffOut,
ParamDimensionValueOut, ParamDimensionValueOut,
ParamHistoryOut, ParamHistoryOut,
@@ -188,6 +189,67 @@ def get_catalog(session: Session, *, as_of: date | None = None) -> list[ParamCat
# ==================== 变更与版本管理 ==================== # ==================== 变更与版本管理 ====================
def _normalize_items(
session: Session, items: list[ParamChangeItemIn]
) -> list[tuple[ParamDefinition, str, str]]:
"""全量校验条目并归一为 (定义, 规范维度值, 规范值),任何一项不合法整批拒绝。
定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1);
岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按维度
类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤。
:raises BadRequestError: 参数未定义 / 维度不合法 / 值校验失败 / 同批次重复条目
"""
codes = list({item.param_code for item in items})
definitions = param_repository.list_definitions_by_codes(session, codes)
dimension_values = list({item.dimension_value for item in items if item.dimension_value})
# 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写
# 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会
# 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够
roles = {
code.casefold(): role
for code, role in business_role_repository.list_by_codes(session, dimension_values).items()
}
normalized: list[tuple[ParamDefinition, str, str]] = []
seen: set[tuple[str, str]] = set()
for item in items:
definition = definitions.get(item.param_code)
if definition is None:
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
# ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值:
# 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致
# 取值链静默不命中(按错误参数算钱且无报错)
spec = get_spec(definition.param_code)
dimension = item.dimension_value
if spec.dimension_type == "NONE" and dimension != "":
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
if spec.dimension_type == "BUSINESS_ROLE" and dimension:
role = roles.get(dimension.casefold())
if role is None:
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
)
dimension = role.role_code
if (
spec.dimension_type == "PRODUCT_CATEGORY"
and dimension
and dimension not in PRODUCT_CATEGORIES
):
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
)
value = validate_and_normalize(spec, item.value)
key = (item.param_code, dimension)
if key in seen:
raise BadRequestError(
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
)
seen.add(key)
normalized.append((definition, dimension, value))
return normalized
def submit_change( def submit_change(
session: Session, session: Session,
payload: ParamChangeIn, payload: ParamChangeIn,
@@ -218,61 +280,8 @@ def submit_change(
if payload.change_type == 2 and effective < date.today(): if payload.change_type == 2 and effective < date.today():
raise BadRequestError("指定生效日期不得早于今日") raise BadRequestError("指定生效日期不得早于今日")
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)。 # 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
# 定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1); normalized = _normalize_items(session, payload.items)
# 岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按
# 维度类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤
codes = list({item.param_code for item in payload.items})
definitions = param_repository.list_definitions_by_codes(session, codes)
dimension_values = list(
{item.dimension_value for item in payload.items if item.dimension_value}
)
# 预载字典键归一为 casefold:单条版 get_by_code 走 SQL ci 校对(大小写
# 变形可命中),批量版改字典查找后须自行等价归一,否则变形大小写会
# 被误判「不是已存在的岗位」;岗位编码均为 ascii 小写蛇形,casefold 足够
roles = {
code.casefold(): role
for code, role in business_role_repository.list_by_codes(
session, dimension_values
).items()
}
normalized: list[tuple[ParamDefinition, str, str]] = []
seen: set[tuple[str, str]] = set()
for item in payload.items:
definition = definitions.get(item.param_code)
if definition is None:
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
# ci 校对规则下大小写变形也能命中库,此后一律用库里的规范值:
# 注册表 get_spec 与快照字典均按精确键匹配,原始大小写入库会导致
# 取值链静默不命中(按错误参数算钱且无报错)
spec = get_spec(definition.param_code)
dimension = item.dimension_value
if spec.dimension_type == "NONE" and dimension != "":
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
if spec.dimension_type == "BUSINESS_ROLE" and dimension:
role = roles.get(dimension.casefold())
if role is None:
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位"
)
dimension = role.role_code
if (
spec.dimension_type == "PRODUCT_CATEGORY"
and dimension
and dimension not in PRODUCT_CATEGORIES
):
raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别"
)
value = validate_and_normalize(spec, item.value)
key = (item.param_code, dimension)
if key in seen:
raise BadRequestError(
f"同一批次内重复提交参数 {item.param_code}({dimension or '默认'})"
)
seen.add(key)
normalized.append((definition, dimension, value))
try: try:
batch = param_repository.create_batch( batch = param_repository.create_batch(
+17
查看文件
@@ -30,6 +30,7 @@ from app.core.constants import (
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User from app.models import BusinessRole, Personnel, PersonnelPlan, PersonnelRegion, User
from app.models.personnel import PERSONNEL_STATUS_ACTIVE
from app.repositories import business_role_repository, personnel_repository from app.repositories import business_role_repository, personnel_repository
from app.schemas.personnel import ( from app.schemas.personnel import (
PersonnelCreateIn, PersonnelCreateIn,
@@ -51,6 +52,22 @@ logger = logging.getLogger(__name__)
_DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除" _DELETE_BLOCKED = "存在团队成员、客户归属或业务归属引用,请改为离职而非删除"
def ensure_active(session: Session, personnel_id: int, *, label: str) -> Personnel:
"""归属人员必须存在且在职,返回人员行(业务/客户两侧归属校验共用)。
「不存在」与「已离职」合并为同一文案:人员 ID 是否有效不构成需要向
调用方区分的信息(同登录防枚举的思路);label 落文案(负责/协助/归属)。
收口单点:在职状态值取 models.personnel.PERSONNEL_STATUS_ACTIVE,
此前 business/customer 两 service 各持常量与校验,仅靠注释维持同步。
:raises BadRequestError: {label}人员不存在或已离职
"""
person = personnel_repository.get_by_id(session, personnel_id)
if person is None or person.status != PERSONNEL_STATUS_ACTIVE:
raise BadRequestError(f"{label}人员不存在或已离职")
return person
def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]: def list_personnel(session: Session, query: PersonnelQuery) -> PageResult[PersonnelOut]:
"""分页查询人员(含离职)。""" """分页查询人员(含离职)。"""
rows = personnel_repository.list_personnel( rows = personnel_repository.list_personnel(
+11
查看文件
@@ -126,6 +126,7 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 | | 2026-10-13 | 业务数据集成四文件 | 70 | ✅ 通过(54.73s,连跑两次幂等) | **业务数据管理(后端接口)完成**后统一编写(业务 25/发货 15/回款 16/评级 14)。全量回归 603 例通过 |
| 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 | | 2026-10-13 | 全量回归 + 两个 e2e | 605 | ✅ 605 passed(208s);e2e 基础数据 13 步 + 业务全链路 16 步全过 | **M2 收尾**:删除保护补齐(+2 用例)后最终回归 605;e2e 期间修复三处缺陷(见下表),均已用例锁定 |
| 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 | | 2026-10-13 | 全部 | 603 | ✅ 通过(5:17) | **T11 全量回归**(单元 381 / 集成 222),含本模块 70 例 |
| 2026-10-15 | 全量回归 + e2e | 639 | ✅ 全部通过 | **code-review 32 条修复后全量回归**(新增 34 例:跨位宽 3 / 参数预览与维度 3 / 审计重试 2 / 终态编辑与 quantize 2 / 产品保护 2+6 / 评级锁 1 / formula 钳制 1 等)。e2e 冒烟:登录 8 项 + 系统管理 13 项 + 基础数据 13 步 + 业务全链路 16 步全过(拦截器/正则收口/params 分页改造后复跑) |
## 四、测试发现并修复的缺陷 ## 四、测试发现并修复的缺陷
@@ -150,6 +151,16 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-13 | **客户/人员删除保护漏检业务引用**(T4 占位注释「待业务表就位后补充」在 T7 交付后无人补齐) | 被业务引用的客户、被业务归属(负责/协助)引用的人员删除 → FK RESTRICT 穿透成 **500**。**由 e2e 清理步骤暴露**(集成测试只覆盖了 T2/T4 当时的引用面,业务表就位后引用面扩大未回看) | customer 删除加 `count_business_refs` 检查 + IntegrityError 兜底 400;personnel 删除加 `count_business_roles_refs`(负责/协助合并统计);补 2 个集成用例锁定;文案更新「客户归属或业务归属引用」 | | 2026-10-13 | **客户/人员删除保护漏检业务引用**(T4 占位注释「待业务表就位后补充」在 T7 交付后无人补齐) | 被业务引用的客户、被业务归属(负责/协助)引用的人员删除 → FK RESTRICT 穿透成 **500**。**由 e2e 清理步骤暴露**(集成测试只覆盖了 T2/T4 当时的引用面,业务表就位后引用面扩大未回看) | customer 删除加 `count_business_refs` 检查 + IntegrityError 兜底 400;personnel 删除加 `count_business_roles_refs`(负责/协助合并统计);补 2 个集成用例锁定;文案更新「客户归属或业务归属引用」 |
| 2026-10-13 | **单价列(DECIMAL(14,4))被两位小数校验拒收**(前后端同源) | 编辑任何带精度的历史单价(回显 "5000.0000")被前端 `AMOUNT_PATTERN` 与后端 `_check_amount` 双双拒绝——**创建后单价带小数位的记录永远无法编辑**。**由 e2e「编辑业务」步骤暴露**(创建用整数、编辑回显带 4 位小数,恰好只在这条路径相遇) | 后端 `_check_amount` 加 `scale` 参数(金额/数量 2 位、单价类 4 位);前端业务/发货明细行单价类放行 4 位;集成用例锁定 | | 2026-10-13 | **单价列(DECIMAL(14,4))被两位小数校验拒收**(前后端同源) | 编辑任何带精度的历史单价(回显 "5000.0000")被前端 `AMOUNT_PATTERN` 与后端 `_check_amount` 双双拒绝——**创建后单价带小数位的记录永远无法编辑**。**由 e2e「编辑业务」步骤暴露**(创建用整数、编辑回显带 4 位小数,恰好只在这条路径相遇) | 后端 `_check_amount` 加 `scale` 参数(金额/数量 2 位、单价类 4 位);前端业务/发货明细行单价类放行 4 位;集成用例锁定 |
| 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 | | 2026-10-13 | 发货 `freight_amount` 漏做金额校验 | 负数/三位小数/超容量的运费直接落库(schema 对 quantity/sale_price 都复用了 `_check_amount`,唯独运费没套用),业务运费合计与报表随之错乱 | shipments.py schema 的 Create/Update 加 `field_validator("freight_amount")`(同套口径,文案「运费金额不能为负数 / 最多两位小数」);集成用例锁定 |
| 2026-10-15 | **计算预览把 ‰ 存储值当纯比例相乘**(企业户/信息员分支) | `enterprise.rate`=3(3‰)、`info.rate`=1(1‰)未经 ÷1000 直接乘——绩效金额放大 1000 倍(15000×3=45000)。**既有测试只覆盖业务员路径**(`salesperson_rate` 内部有 ÷1000),两分支零覆盖使缺陷存活;该预览是 M3 核算共用契约 | 两分支显式 `/1000` 并注释声明换算;补企业户/信息员精确数值用例锁定 |
| 2026-10-15 | **编号撞唯一键的重试写在 audit 上下文内 `return _retry_create`**(业务/发货/回款三处同构) | 吞异常后正常退出被记 `success=True`(假成功审计,业务实际已回滚);重入整个 `create_*` 违反「不支持嵌套」约定;`_NO_RETRY=1` 死常量致持续冲突时**无界递归**至 RecursionError | 改为 audit 上下文内 for 循环(上限 `_NO_RETRY`),rollback 后重新锁行与取号;新增集成用例锁定「成功/最终失败各恰好一条日志」 |
| 2026-10-15 | **单号 MAX 检索用 VARCHAR 字典序**(`no_generator`) | 序号破 4 位宽度(9999→10000)后字典序反转("-10000"<"-9999"),MAX 恒取旧值 → seq 恒重算同一号码 → 当日该前缀所有建单持续撞唯一键 | 排序改「长度优先、同级字典序」(同前缀下长度大即数值大);跨位宽/同宽/空表三用例锁定;param 版本号一并复用 |
| 2026-10-15 | **编辑业务全程无锁读 + 无终态门槛** | ① 「有发货禁换客户」走无锁快照读,与发货/回款写路径(FOR UPDATE)不互斥——并发新增发货可旁路保护;② 已完成/已终止业务仍可全量改明细并重算 M3 核算基线(发货/回款服务禁写终态,父单据反而放行) | `get_with_names` 加 `for_update`;update 补终态门槛;前端编辑按钮终态禁用;`has_docs` 单点收口(update/delete 共用) |
| 2026-10-15 | **删除产品无引用保护**(docstring「待 V5 补充」在 V5 就位后未兑现) | 被业务/发货明细引用的产品删除 → FK RESTRICT 穿透成 500(与 2026-10-13 客户/人员删除保护漏检同源——「占位注释交付后无人回看」第二次发生) | `count_refs`(两明细表)+ IntegrityError 兜底 400;**全局注册 IntegrityError handler** 兜底未来遗漏(漏网的引用冲突收敛 400 而非 500) |
| 2026-10-15 | **产品 schema 私写校验不查小数位与容量** | `sale_price` 用 `_parse_non_negative`(非负/有限),5 位小数被 Numeric(14,4) **静默截断**(舍入即算错钱)、超容量 500——与 fba8cfc 修掉的「schema 小数位口径」同源残留 | 复用 `_check_amount`(PRICE_MAX+scale=4 / AMOUNT_MAX);6 个秒级 schema 单测锁定 |
| 2026-10-15 | 行金额乘积(数量2位×单价4位=6位小数)直写 DECIMAL(14,2) | MySQL 静默舍入且回显漂移:创建响应读内存值 "5.166615"、复查读库值 "5.17"——同一行金额前后显示不一致 | 显式 `quantize(HALF_UP)` 固化(与 MySQL 舍入方向一致);回显/库值一致性用例锁定 |
| 2026-10-15 | 先款后货(payment_days 为负)计提比例线性外插 | 比例 = 起始 + \|天数\|×斜率,**高于起始值甚至超 100%**——M3 接入真实回款后直接多算绩效工资(预览被 schema 拦住、回款登记不校验日期先后,缺陷在 M3 前不可见) | `salesperson_rate` 钳制 days≥0(按起始比例计提;payment_days 出参仍照实输出负值,口径经用户 2026-10-15 裁决) |
| 2026-10-15 | 评级区间重叠先查后写无锁、无唯一键兜底 | 两管理员并发提交重叠区间互判不重叠而双双入库——同一总分命中多等级且结果取决于插入顺序 | 判重叠的 `list_all` 加 FOR UPDATE 锁全表行串行化;只读直出不加锁 |
| 2026-10-15 | 岗位维度值大小写校验(ci)与快照匹配(精确)不对称 | API 提交 "SalesPerson" 校验通过入库,快照字典按精确键取值不命中——**静默按错误参数算钱且无报错**;param_code 变形大写则 get_spec 抛 ValueError 成 500 | 校验命中后一律以库里规范值落库(role_code/definition.param_code);批量预载字典键 casefold 归一保持等价命中 |
## 五、待补用例 ## 五、待补用例
+60
查看文件
@@ -0,0 +1,60 @@
"""集成测试共用工厂:客户 → 业务 → 明细的最小造数链。
回款/发货/业务三个测试文件此前各持一份逐字相同的工厂(口径漂移已经
发生:同一 _customer 三份、payload 细节靠复制维持),收口后「t_ 前缀、
默认明细行、业务日期取今日」单点维护;conftest 兜底清理按 t_ 前缀与
YW/SH/HK 编号前缀覆盖本模块全部造数。
"""
import uuid
from datetime import date
from fastapi.testclient import TestClient
_CUSTOMERS = "/api/v1/base/customers"
_BUSINESSES = "/api/v1/business/businesses"
def customer(client: TestClient, headers: dict) -> dict:
"""建一名 t_ 前缀测试客户(业务的必选引用)。"""
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 business_item(**overrides: object) -> dict:
"""业务明细行入参(默认:数量 100 × 单价 100.00 = 销售额 10000)。"""
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 business_payload(customer_id: int, **overrides: object) -> dict:
body: dict = {
"customer_id": customer_id,
"business_date": str(date.today()),
"items": [business_item()],
}
body.update(overrides)
return body
def create_business(client: TestClient, headers: dict) -> dict:
"""建一名 t_ 客户并挂一笔默认业务(发货/回款须先有业务)。"""
cust = customer(client, headers)
resp = client.post(_BUSINESSES, json=business_payload(cust["id"]), headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
@@ -152,8 +152,10 @@ class TestAuditRetry:
second = client.post( second = client.post(
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"] _BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
) )
# 未捕获 IntegrityError 由全局异常兜底收敛 500 # 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)——
assert second.status_code == 500, second.text # 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实
assert second.status_code == 400, second.text
assert "完整性冲突" in second.json()["message"]
# 修复前的症状:return _retry_create 无界递归直至 RecursionError, # 修复前的症状:return _retry_create 无界递归直至 RecursionError,
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志 # 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
assert _log_count(normal["username"], success=False) == base_fail + 1 assert _log_count(normal["username"], success=False) == base_fail + 1
+1 -45
查看文件
@@ -26,57 +26,13 @@ from app.core.constants import ACTION_CREATE, MODULE_RECEIPT
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.models import OperationLog, Receipt from app.models import OperationLog, Receipt
from app.models.receipt import SETTLEMENT_DONE from app.models.receipt import SETTLEMENT_DONE
from tests.integration.factories import create_business as _create_business
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
_BUSINESSES = "/api/v1/business/businesses" _BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments" _SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts" _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: def _sitem(**overrides: object) -> dict:
+1 -45
查看文件
@@ -25,6 +25,7 @@ from sqlalchemy import select
from app.core.constants import ACTION_CREATE, MODULE_SHIPMENT from app.core.constants import ACTION_CREATE, MODULE_SHIPMENT
from app.core.db import get_session_factory from app.core.db import get_session_factory
from app.models import OperationLog from app.models import OperationLog
from tests.integration.factories import create_business as _create_business
pytestmark = pytest.mark.integration pytestmark = pytest.mark.integration
@@ -32,51 +33,6 @@ _BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments" _SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts" _RECEIPTS = "/api/v1/business/receipts"
_PERSONNEL = "/api/v1/base/personnel" _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: def _item(**overrides: object) -> dict:
+7 -59
查看文件
@@ -35,12 +35,19 @@ from playwright.sync_api import Page, sync_playwright
from common import ( from common import (
E2EFailure, E2EFailure,
click_query,
collect_console_errors, collect_console_errors,
confirm_message_box,
current_path, current_path,
expect_toast, expect_toast,
fail, fail,
fill_filter,
login, login,
open_menu,
select_filter_option,
table_rows,
toast_text, toast_text,
visible_dialog,
wait_toast, wait_toast,
) )
@@ -68,65 +75,6 @@ console_errors: list[str] = []
# ==================== 页面操作辅助 ==================== # ==================== 页面操作辅助 ====================
def open_menu(page: Page, title: str, group: str) -> None:
"""点击侧边菜单项,必要时先展开指定分组。
分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。
"""
menu = page.locator(".app-menu")
item = menu.locator(".el-menu-item", has_text=title).first
if not item.is_visible():
menu.locator(".el-sub-menu__title", has_text=group).click()
page.wait_for_timeout(400)
item.click()
page.wait_for_timeout(800)
def fill_filter(page: Page, label: str, value: str) -> None:
"""在**当前可见**的筛选表单中按标签填写输入框。
选择器一律带 `:visible` —— 同页存在多个表单实例(隐藏未销毁)时,
不带可见性过滤会命中隐藏元素。
"""
form_item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
).first
form_item.locator("input").first.fill(value)
def select_filter_option(page: Page, label: str, option: str) -> None:
"""展开可见筛选表单中指定标签的下拉并选择选项(发货/回款页按业务筛选)。"""
item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
)
item.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
page.wait_for_timeout(200)
def click_query(page: Page) -> None:
"""点击可见筛选表单中的查询按钮。"""
page.locator(".filter-form:visible button:has-text('查询')").first.click()
page.wait_for_timeout(800)
def table_rows(page: Page):
"""当前可见的表格行(选择器带 :visible,避开隐藏实例)。"""
return page.locator(".el-table__body .el-table__row:visible")
def visible_dialog(page: Page):
"""当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。"""
return page.locator(".el-dialog:visible").last
def confirm_message_box(page: Page, button_text: str) -> None:
"""点击确认框(ElMessageBox)中的指定按钮。"""
page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click()
page.wait_for_timeout(800)
def confirm_text(page: Page) -> str: def confirm_text(page: Page) -> str:
"""当前确认框的完整文本(业务口径文案断言用),轮询等待出现。""" """当前确认框的完整文本(业务口径文案断言用),轮询等待出现。"""
deadline = time.monotonic() + 5 deadline = time.monotonic() + 5
+63
查看文件
@@ -156,3 +156,66 @@ def login(page: Page, username: str, password: str) -> None:
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
finally: finally:
page.remove_listener("response", on_response) page.remove_listener("response", on_response)
# ==================== 列表页 UI 操作(base_data/system_management 共用) ====================
def open_menu(page: Page, title: str, group: str) -> None:
"""点击侧边菜单项,必要时先展开指定分组。
分组(el-sub-menu)已展开时再次点击会收拢,因此先判断菜单项是否可见。
"""
menu = page.locator(".app-menu")
item = menu.locator(".el-menu-item", has_text=title).first
if not item.is_visible():
menu.locator(".el-sub-menu__title", has_text=group).click()
page.wait_for_timeout(400)
item.click()
page.wait_for_timeout(800)
def fill_filter(page: Page, label: str, value: str) -> None:
"""在**当前可见**的筛选表单中按标签填写输入框。
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」;
选择器带 `:visible` —— 同页存在多个表单实例(v-show 隐藏未销毁)时
会命中隐藏元素。
"""
form_item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
).first
form_item.locator("input").first.fill(value)
def select_filter_option(page: Page, label: str, option: str) -> None:
"""展开可见筛选表单中指定标签的下拉并选择选项。"""
item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
)
item.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
page.wait_for_timeout(200)
def click_query(page: Page) -> None:
"""点击可见筛选表单中的查询按钮。"""
page.locator(".filter-form:visible button:has-text('查询')").first.click()
page.wait_for_timeout(800)
def table_rows(page: Page):
"""当前可见的表格行(选择器带 :visible,避开隐藏实例)。"""
return page.locator(".el-table__body .el-table__row:visible")
def visible_dialog(page: Page):
"""当前可见的对话框(同页可能存在多个 dialog 实例,按可见性取最后一个)。"""
return page.locator(".el-dialog:visible").last
def confirm_message_box(page: Page, button_text: str) -> None:
"""点击确认框(ElMessageBox)中的指定按钮。"""
page.locator(".el-message-box:visible").locator(f"button:has-text('{button_text}')").click()
page.wait_for_timeout(800)
+13 -66
查看文件
@@ -29,6 +29,13 @@ import time
from playwright.sync_api import Page, sync_playwright from playwright.sync_api import Page, sync_playwright
from common import ( from common import (
click_query,
confirm_message_box,
fill_filter,
open_menu,
select_filter_option,
table_rows,
visible_dialog,
BASE_URL, BASE_URL,
E2EFailure, E2EFailure,
collect_console_errors, collect_console_errors,
@@ -56,66 +63,6 @@ console_errors: list[str] = []
# ==================== 页面操作辅助 ==================== # ==================== 页面操作辅助 ====================
def open_menu(page: Page, title: str) -> None:
"""点击侧边菜单项,必要时先展开「系统管理」分组。
分组已展开时再次点击会收拢,因此先判断菜单项是否已经可见。
"""
menu = page.locator(".app-menu")
item = menu.get_by_text(title, exact=True)
if not item.is_visible():
menu.get_by_text("系统管理", exact=True).click()
page.wait_for_timeout(400)
item.click()
page.wait_for_timeout(800)
def fill_filter(page: Page, label: str, value: str) -> None:
"""在**当前可见**的筛选表单中按标签填写输入框。
选择器一律带 `:visible` —— 日志页两个 tab 各有一个表单(非激活 tab
用 v-show 隐藏),不带可见性过滤会命中隐藏表单的元素。
"""
form_item = page.locator(".filter-form:visible .el-form-item", has_text=label).first
form_item.locator("input").first.fill(value)
def select_filter_option(page: Page, label: str, option: str) -> None:
"""展开可见筛选表单中指定标签的下拉并选择选项。
标签用 `:text-is` 精确匹配 —— 子串匹配会让「操作」命中「操作人」。
"""
item = page.locator(
f'.filter-form:visible .el-form-item:has(.el-form-item__label:text-is("{label}"))'
)
item.locator(".el-select").click()
page.wait_for_timeout(300)
page.locator(".el-select-dropdown:visible .el-select-dropdown__item", has_text=option).first.click()
page.wait_for_timeout(200)
def click_query(page: Page) -> None:
"""点击可见筛选表单中的查询按钮。"""
page.locator(".filter-form:visible button:has-text('查询')").first.click()
page.wait_for_timeout(800)
def table_rows(page: Page):
"""当前可见的表格行(日志页非激活 tab 的表格隐藏,需过滤)。"""
return page.locator(".el-table__body .el-table__row:visible")
def confirm_message_box(page: Page, button_text: str) -> None:
"""点击确认框(ElMessageBox)中的指定按钮。"""
page.locator(".el-message-box").locator(f"button:has-text('{button_text}')").click()
page.wait_for_timeout(800)
def visible_dialog(page: Page):
"""当前可见的对话框(同页存在多个 dialog 实例,按可见性取)。"""
return page.locator(".el-dialog:visible").last
def create_user_via_ui( def create_user_via_ui(
page: Page, username: str, real_name: str, expect_success: bool = True page: Page, username: str, real_name: str, expect_success: bool = True
) -> None: ) -> None:
@@ -154,7 +101,7 @@ def find_user_row(page: Page, username: str):
def step_user_create(page: Page, admin_password: str) -> None: def step_user_create(page: Page, admin_password: str) -> None:
"""登录、菜单导航、新增用户与重名负例。""" """登录、菜单导航、新增用户与重名负例。"""
login(page, ADMIN, admin_password) login(page, ADMIN, admin_password)
open_menu(page, "用户管理") open_menu(page, "用户管理", "系统管理")
if current_path(page) != "/system/users": if current_path(page) != "/system/users":
fail("菜单导航", f"未进入用户管理页,当前 {page.url}") fail("菜单导航", f"未进入用户管理页,当前 {page.url}")
print("✓ 1. 菜单导航 经侧边菜单进入用户管理") print("✓ 1. 菜单导航 经侧边菜单进入用户管理")
@@ -217,7 +164,7 @@ def step_user_edit_and_status(page: Page) -> None:
def step_role_management(page: Page) -> None: def step_role_management(page: Page) -> None:
"""角色保护与角色 CRUD。""" """角色保护与角色 CRUD。"""
open_menu(page, "角色管理") open_menu(page, "角色管理", "系统管理")
if current_path(page) != "/system/roles": if current_path(page) != "/system/roles":
fail("菜单导航", f"未进入角色管理页,当前 {page.url}") fail("菜单导航", f"未进入角色管理页,当前 {page.url}")
@@ -259,7 +206,7 @@ def step_role_management(page: Page) -> None:
def step_logs_query(page: Page) -> None: def step_logs_query(page: Page) -> None:
"""操作日志(含详情懒加载与脱敏断言)与登录日志。""" """操作日志(含详情懒加载与脱敏断言)与登录日志。"""
open_menu(page, "日志查询") open_menu(page, "日志查询", "系统管理")
if current_path(page) != "/system/logs": if current_path(page) != "/system/logs":
fail("菜单导航", f"未进入日志查询页,当前 {page.url}") fail("菜单导航", f"未进入日志查询页,当前 {page.url}")
@@ -297,7 +244,7 @@ def step_logs_query(page: Page) -> None:
def step_permission_check(page: Page, admin_password: str) -> None: def step_permission_check(page: Page, admin_password: str) -> None:
"""普通用户菜单隐藏与路由守卫。""" """普通用户菜单隐藏与路由守卫。"""
open_menu(page, "用户管理") open_menu(page, "用户管理", "系统管理")
create_user_via_ui(page, VIEWER_USERNAME, "E2E访客") create_user_via_ui(page, VIEWER_USERNAME, "E2E访客")
page.click("text=退出登录") page.click("text=退出登录")
page.wait_for_url("**/login**", timeout=10_000) page.wait_for_url("**/login**", timeout=10_000)
@@ -322,7 +269,7 @@ def step_permission_check(page: Page, admin_password: str) -> None:
def step_cleanup(page: Page) -> None: def step_cleanup(page: Page) -> None:
"""删除测试用户与角色(用 expect_toast 校验,失败不假绿)。""" """删除测试用户与角色(用 expect_toast 校验,失败不假绿)。"""
open_menu(page, "用户管理") open_menu(page, "用户管理", "系统管理")
for username in (NEW_USERNAME, VIEWER_USERNAME): for username in (NEW_USERNAME, VIEWER_USERNAME):
row = find_user_row(page, username) row = find_user_row(page, username)
if row.count() == 0: if row.count() == 0:
@@ -332,7 +279,7 @@ def step_cleanup(page: Page) -> None:
confirm_message_box(page, "删除") confirm_message_box(page, "删除")
expect_toast(page, "已删除", "数据清理") expect_toast(page, "已删除", "数据清理")
open_menu(page, "角色管理") open_menu(page, "角色管理", "系统管理")
fill_filter(page, "关键词", NEW_ROLE_CODE) fill_filter(page, "关键词", NEW_ROLE_CODE)
click_query(page) click_query(page)
role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first role_row = table_rows(page).filter(has_text=NEW_ROLE_CODE).first
@@ -0,0 +1,24 @@
import { ref, type Ref } from 'vue'
import { handledError } from '@/utils/error'
/**
* 下拉选项的加载状态:取数失败不阻断列表使用(下拉为空 + 拦截器提示)。
*
* 业务/客户/人员等参照下拉在多个页面重复同一「try 取数 → map 成选项 →
* 失败吞掉」骨架,映射函数由调用方给出(各页字段不同),骨架收口单点。
*/
export function useDropdownOptions<T>(fetch: () => Promise<T[]>): {
options: Ref<T[]>
load: () => Promise<void>
} {
const options = ref([]) as Ref<T[]>
async function load(): Promise<void> {
try {
options.value = await fetch()
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
return { options, load }
}
+16
查看文件
@@ -0,0 +1,16 @@
import { computed, type Ref } from 'vue'
/**
* 编辑中的行:editingId 非空时从当前页记录里找出行(回显兜底用)。
*
* 回款/发货/人员三页此前各持一份逐字相同的 computed(行对象上要展示
* 「编辑中」的派生信息,如业务编号、停用岗位回显),收口后口径一处维护。
*/
export function useEditingRow<T extends { id: number }>(
records: Ref<T[]>,
editingId: Ref<number | null>,
) {
return computed(() =>
editingId.value === null ? null : (records.value.find((r) => r.id === editingId.value) ?? null),
)
}
+15
查看文件
@@ -12,6 +12,21 @@ export const PASSWORD_MAX_BYTES = 72
const BYTE_HINT = '(中文一个字符约 3 字节)' const BYTE_HINT = '(中文一个字符约 3 字节)'
/**
* 金额形态正则(与后端 `schemas/business._check_amount` 同口径单点维护)。
*
* 「DECIMAL 列 ↔ 前端放行位数」规则此前在业务/发货/回款/人员四页各写一份
* (businesses 与 shipments 逐字重复,receipts/personnel 又各自内联两位小数
* 正则),后端精度调整时漏改任何一页,就会出现「A 页面拦得住、B 页面放行
* 后端 400」或「带精度历史行永远无法编辑」(fba8cfc 的教训)。
*/
/** 非负数字、最多两位小数(对齐 DECIMAL(14,2):金额/数量/成本/运费/工资) */
export const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
/** 非负数字、最多四位小数(对齐 DECIMAL(14,4):单价/指导价) */
export const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
/** 全零形态(数量「0 或 0.00」须拦:数量 >0 是明细行的硬约束) */
export const ZERO_PATTERN = /^0+(\.0+)?$/
/** 计算 UTF-8 字节数 */ /** 计算 UTF-8 字节数 */
export function passwordByteLength(value: string): number { export function passwordByteLength(value: string): number {
return new TextEncoder().encode(value).length return new TextEncoder().encode(value).length
+4 -4
查看文件
@@ -9,6 +9,8 @@ import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue' import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { useEditingRow } from '@/composables/useEditingRow'
import { AMOUNT_PATTERN } from '@/utils/validate'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -107,9 +109,7 @@ const form = reactive({
}) })
/** 编辑中的行(用于停用岗位/离职经理的回显兜底) */ /** 编辑中的行(用于停用岗位/离职经理的回显兜底) */
const editingRow = computed(() => const editingRow = useEditingRow(records, editingId)
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
/** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */ /** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */
const searchRoleOptions = computed(() => const searchRoleOptions = computed(() =>
@@ -139,7 +139,7 @@ const rules = computed<FormRules>(() => ({
business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }], business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }],
phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }], phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
salary_standard: [ salary_standard: [
{ pattern: /^(\d+)(\.\d{1,2})?$/, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' }, { pattern: AMOUNT_PATTERN, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
], ],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }], remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
})) }))
@@ -12,6 +12,7 @@ import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -101,15 +102,6 @@ function personnelLabel(option: PersonnelOption): string {
// ==================== 新增 / 编辑(含明细行编辑器) ==================== // ==================== 新增 / 编辑(含明细行编辑器) ====================
/** 金额/数量形态:非负数字、最多两位小数(纯形态校验,不涉及 float 运算) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
// 单价类列(sale_price/guided_price)建表为 Numeric(14,4):后端 str 直出
// 会带最多 4 位小数(如 "5000.0000"),前端必须放行到 4 位——否则任何
// 带小数精度的历史记录在编辑时都会被前端永远拦下
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
/** 全零形态:数量用它排除「>0」语义,不走 float 比较 */
const ZERO_PATTERN = /^0+(\.0+)?$/
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */ /** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
@@ -429,8 +421,11 @@ function personnelName(id: number): string {
// ==================== 详情抽屉 ==================== // ==================== 详情抽屉 ====================
const drawerVisible = ref(false) const drawerVisible = ref(false)
const drawerRowId = ref<number | null>(null)
const drawerRow = ref<BusinessItem | null>(null) const drawerRow = ref<BusinessItem | null>(null)
// 单一状态源:drawerRowId 派生自 drawerRow(此前与 ownershipLogsId 三份并存、
// openDrawer 手工同步——新增写入路径漏更新其一就会出现「标题是 A 业务、
// 留痕表拉到 B 业务」且无报错)
const drawerRowId = computed(() => drawerRow.value?.id ?? null)
const drawerTitle = computed(() => const drawerTitle = computed(() =>
drawerRow.value ? `「${drawerRow.value.business_no}」· 业务详情` : '业务详情', drawerRow.value ? `「${drawerRow.value.business_no}」· 业务详情` : '业务详情',
) )
@@ -467,12 +462,10 @@ async function refreshDrawer(id: number): Promise<void> {
function openDrawer(row: BusinessItem): void { function openDrawer(row: BusinessItem): void {
drawerRow.value = row drawerRow.value = row
drawerRowId.value = row.id
drawerVisible.value = true drawerVisible.value = true
detail.value = null detail.value = null
statusLogs.value = [] statusLogs.value = []
gp.value = null gp.value = null
ownershipLogsId.value = row.id
ownershipLogPageNum.value = 1 ownershipLogPageNum.value = 1
void loadDrawerData(row.id) void loadDrawerData(row.id)
void loadOwnershipLogs() void loadOwnershipLogs()
@@ -491,7 +484,7 @@ const {
onSizeChange: onOwnershipLogSizeChange, onSizeChange: onOwnershipLogSizeChange,
} = usePagedList<businessApi.OwnershipLogItem, object>( } = usePagedList<businessApi.OwnershipLogItem, object>(
(params) => { (params) => {
const id = ownershipLogsId.value const id = drawerRowId.value
// 抽屉未指定业务时兜底空数据,避免发出无意义请求 // 抽屉未指定业务时兜底空数据,避免发出无意义请求
return id === null return id === null
? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 }) ? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
@@ -500,8 +493,6 @@ const {
() => ({}), () => ({}),
) )
const ownershipLogsId = ref<number | null>(null)
/** 留痕展示:负责/协助的新旧人员(姓名随联表,ID 固化) */ /** 留痕展示:负责/协助的新旧人员(姓名随联表,ID 固化) */
function ownershipTransition(row: businessApi.OwnershipLogItem): string { function ownershipTransition(row: businessApi.OwnershipLogItem): string {
const oldOwner = row.old_owner_name ?? '(无)' const oldOwner = row.old_owner_name ?? '(无)'
+10 -16
查看文件
@@ -10,6 +10,9 @@ import type { ShipmentItem } from '@/api/shipments'
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { AMOUNT_PATTERN } from '@/utils/validate'
import { useEditingRow } from '@/composables/useEditingRow'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -63,21 +66,17 @@ interface BusinessOption {
name: string name: string
} }
const businessOptions = ref<BusinessOption[]>([]) // 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
async function loadBusinessOptions(): Promise<void> { async () => {
try {
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 }) const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
businessOptions.value = data.list.map((b) => ({ return data.list.map((b) => ({
id: b.id, id: b.id,
no: b.business_no, no: b.business_no,
name: b.customer_name ?? '', name: b.customer_name ?? '',
})) }))
} catch (error) { },
handledError(error) // 下拉为空不阻断列表使用 )
}
}
function businessLabel(option: BusinessOption): string { function businessLabel(option: BusinessOption): string {
return option.name ? `${option.no} · ${option.name}` : option.no return option.name ? `${option.no} · ${option.name}` : option.no
@@ -85,9 +84,6 @@ function businessLabel(option: BusinessOption): string {
// ==================== 新增 / 编辑 ==================== // ==================== 新增 / 编辑 ====================
/** 金额形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */ /** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
@@ -124,9 +120,7 @@ const shipmentOptions = ref<ShipmentItem[]>([])
const shipmentLoading = ref(false) const shipmentLoading = ref(false)
/** 编辑中的回款(回显业务编号/批次语义用) */ /** 编辑中的回款(回显业务编号/批次语义用) */
const editingRow = computed(() => const editingRow = useEditingRow(records, editingId)
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
/** 下拉展示:发货单号(发货日期,总额)—— 与任务约定文案一致 */ /** 下拉展示:发货单号(发货日期,总额)—— 与任务约定文案一致 */
function shipmentLabel(row: ShipmentItem): string { function shipmentLabel(row: ShipmentItem): string {
+10 -20
查看文件
@@ -8,6 +8,9 @@ import type { ShipmentDetail, ShipmentItem } from '@/api/shipments'
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList' import { usePagedList } from '@/composables/usePagedList'
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
import { useEditingRow } from '@/composables/useEditingRow'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -55,22 +58,18 @@ interface BusinessOption {
status: number status: number
} }
const businessOptions = ref<BusinessOption[]>([]) // 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
async function loadBusinessOptions(): Promise<void> { async () => {
try {
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 }) const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
businessOptions.value = data.list.map((b) => ({ return data.list.map((b) => ({
id: b.id, id: b.id,
no: b.business_no, no: b.business_no,
name: b.customer_name ?? '', name: b.customer_name ?? '',
status: b.status, status: b.status,
})) }))
} catch (error) { },
handledError(error) // 下拉为空不阻断列表使用 )
}
}
/** 下拉项展示:业务编号 + 客户名;终态业务加后缀标注(创建发货单会被后端拦) */ /** 下拉项展示:业务编号 + 客户名;终态业务加后缀标注(创建发货单会被后端拦) */
function businessLabel(option: BusinessOption): string { function businessLabel(option: BusinessOption): string {
@@ -80,13 +79,6 @@ function businessLabel(option: BusinessOption): string {
// ==================== 新增 / 编辑(含明细行编辑器) ==================== // ==================== 新增 / 编辑(含明细行编辑器) ====================
/** 金额/数量形态:非负数字、最多两位小数(与后端 `_check_amount` 同口径) */
const AMOUNT_PATTERN = /^(\d+)(\.\d{1,2})?$/
// 发货单价列建表为 Numeric(14,4):后端 str 直出带最多 4 位小数,前端放行到 4 位
const PRICE_PATTERN = /^(\d+)(\.\d{1,4})?$/
/** 全零形态:数量排除「>0」语义,不走 float 比较 */
const ZERO_PATTERN = /^0+(\.0+)?$/
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */ /** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
@@ -95,9 +87,7 @@ const formRef = ref<FormInstance>()
const submitting = ref(false) const submitting = ref(false)
/** 编辑中的发货单(回显业务编号用,编辑挂靠不可改) */ /** 编辑中的发货单(回显业务编号用,编辑挂靠不可改) */
const editingRow = computed(() => const editingRow = useEditingRow(records, editingId)
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
/** 明细行草稿(编辑时由详情回显;全量替换,行 id 不参与提交) */ /** 明细行草稿(编辑时由详情回显;全量替换,行 id 不参与提交) */
interface ItemDraft { interface ItemDraft {
+32 -43
查看文件
@@ -14,6 +14,7 @@ import type {
} from '@/api/params' } from '@/api/params'
import AppPagination from '@/components/table/AppPagination.vue' import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction' import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error' import { handledError } from '@/utils/error'
/** /**
@@ -189,25 +190,20 @@ async function submitAll(): Promise<void> {
// ==================== 版本管理抽屉 ==================== // ==================== 版本管理抽屉 ====================
const versionVisible = ref(false) const versionVisible = ref(false)
const batches = ref<ParamBatch[]>([])
const batchesTotal = ref(0)
const batchesPage = ref(1)
const batchesPageSize = ref(10)
const batchesLoading = ref(false)
const detailCache = reactive<Record<number, ParamBatchDetail>>({}) const detailCache = reactive<Record<number, ParamBatchDetail>>({})
async function loadBatches(): Promise<void> { // 分页五件套换 usePagedList:手写版没有请求序号防竞态(快速翻页时旧响应
batchesLoading.value = true // 后到会覆盖新页数据)与页码回退——正是该 composable 注释里写明已修过的坑
try { const {
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: batchesPageSize.value }) records: batches,
batches.value = page.list total: batchesTotal,
batchesTotal.value = page.total pageNum: batchesPage,
} catch (error) { pageSize: batchesPageSize,
handledError(error) loading: batchesLoading,
} finally { load: loadBatches,
batchesLoading.value = false onPageChange: onBatchesPageChange,
} onSizeChange: onBatchesSizeChange,
} } = usePagedList<ParamBatch, Record<string, never>>(paramsApi.listBatches, () => ({}))
async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> { async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> {
if (detailCache[batch.id]) return detailCache[batch.id] if (detailCache[batch.id]) return detailCache[batch.id]
@@ -299,11 +295,21 @@ function openVersionDrawer(): void {
const historyVisible = ref(false) const historyVisible = ref(false)
const historyItem = ref<ParamCatalogItem | null>(null) const historyItem = ref<ParamCatalogItem | null>(null)
const historyRows = ref<ParamHistory[]>([])
const historyTotal = ref(0) // 历史抽屉分页同样收口 usePagedList;fetcher 闭包当前参数的编码
const historyPage = ref(1) const {
const historyPageSize = ref(10) records: historyRows,
const historyLoading = ref(false) total: historyTotal,
pageNum: historyPage,
pageSize: historyPageSize,
loading: historyLoading,
load: loadHistory,
onPageChange: onHistoryPageChange,
onSizeChange: onHistorySizeChange,
} = usePagedList<ParamHistory, Record<string, never>>(
(params) => paramsApi.getParamHistory(historyItem.value!.param_code, params),
() => ({}),
)
function openHistory(item: ParamCatalogItem): void { function openHistory(item: ParamCatalogItem): void {
historyItem.value = item historyItem.value = item
@@ -312,23 +318,6 @@ function openHistory(item: ParamCatalogItem): void {
void loadHistory() void loadHistory()
} }
async function loadHistory(): Promise<void> {
if (!historyItem.value) return
historyLoading.value = true
try {
const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
pageNum: historyPage.value,
pageSize: historyPageSize.value,
})
historyRows.value = page.list
historyTotal.value = page.total
} catch (error) {
handledError(error)
} finally {
historyLoading.value = false
}
}
// ==================== 计算预览抽屉 ==================== // ==================== 计算预览抽屉 ====================
const previewVisible = ref(false) const previewVisible = ref(false)
@@ -667,8 +656,8 @@ onMounted(() => {
:total="batchesTotal" :total="batchesTotal"
:page-num="batchesPage" :page-num="batchesPage"
:page-size="batchesPageSize" :page-size="batchesPageSize"
@page-change="(p: number) => { batchesPage = p; void loadBatches() }" @page-change="onBatchesPageChange"
@size-change="(s: number) => { batchesPageSize = s; batchesPage = 1; void loadBatches() }" @size-change="onBatchesSizeChange"
/> />
</div> </div>
</el-drawer> </el-drawer>
@@ -692,8 +681,8 @@ onMounted(() => {
:total="historyTotal" :total="historyTotal"
:page-num="historyPage" :page-num="historyPage"
:page-size="historyPageSize" :page-size="historyPageSize"
@page-change="(p: number) => { historyPage = p; void loadHistory() }" @page-change="onHistoryPageChange"
@size-change="(s: number) => { historyPageSize = s; historyPage = 1; void loadHistory() }" @size-change="onHistorySizeChange"
/> />
</el-drawer> </el-drawer>