Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94c49138bf | ||
|
|
f75a9d534e | ||
|
|
0ef3adec21 | ||
|
|
60f6af6494 |
No files matched your search
@@ -22,6 +22,10 @@ from app.models.base import (
|
||||
mysql_table_args,
|
||||
)
|
||||
|
||||
# personnel.status 的在职值(对照 BUSINESS_STATUS_* 集中在 models/business.py
|
||||
# 的先例:状态语义单点导出,业务/客户两侧的归属校验共用,防魔数各自漂移)
|
||||
PERSONNEL_STATUS_ACTIVE = 1
|
||||
|
||||
|
||||
class Personnel(Base, TimestampMixin, TPlusSyncMixin):
|
||||
"""营销人员表(需求 2.1.1)。
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.models import (
|
||||
Shipment,
|
||||
)
|
||||
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:
|
||||
@@ -229,41 +230,28 @@ def count_status_logs(session: Session, business_id: int) -> int:
|
||||
|
||||
|
||||
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None:
|
||||
"""追加归属变更留痕(不 commit)。"""
|
||||
session.add(log)
|
||||
session.flush()
|
||||
"""追加归属变更留痕(共享实现,不 commit)。"""
|
||||
ownership_log_repository.add(session, log)
|
||||
|
||||
|
||||
def list_ownership_logs(
|
||||
session: Session, business_id: int, *, offset: int, limit: int
|
||||
) -> list[Row]:
|
||||
"""某业务的归属变更留痕(join 人员出姓名;人员删除后为 None,留痕固化 ID 兜底)。"""
|
||||
old_p = aliased(Personnel)
|
||||
new_p = aliased(Personnel)
|
||||
return list(
|
||||
session.execute(
|
||||
select(OwnershipChangeLog, old_p.personnel_name, new_p.personnel_name)
|
||||
.outerjoin(old_p, old_p.id == OwnershipChangeLog.old_owner_personnel_id)
|
||||
.outerjoin(new_p, new_p.id == OwnershipChangeLog.new_owner_personnel_id)
|
||||
.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()
|
||||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||
return int(
|
||||
session.execute(
|
||||
select(func.count())
|
||||
.select_from(OwnershipChangeLog)
|
||||
.where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||
OwnershipChangeLog.business_id == business_id,
|
||||
)
|
||||
).scalar_one()
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_BUSINESS,
|
||||
ref_column=OwnershipChangeLog.business_id,
|
||||
ref_id=business_id,
|
||||
)
|
||||
@@ -4,17 +4,13 @@
|
||||
"""
|
||||
|
||||
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.customer import OWNER_TYPE_CUSTOMER
|
||||
from app.repositories import ownership_log_repository
|
||||
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,
|
||||
limit: int,
|
||||
) -> list[Row]:
|
||||
"""查某客户的归属变更留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
|
||||
|
||||
按 (create_time, id) 双键倒序:create_time 是秒级精度,同一秒内多条
|
||||
记录排序不确定,id 兜底保证翻页稳定(与操作日志同一理由)。
|
||||
"""
|
||||
stmt = (
|
||||
select(OwnershipChangeLog, _old_owner.personnel_name, _new_owner.personnel_name)
|
||||
.outerjoin(_old_owner, OwnershipChangeLog.old_owner_personnel_id == _old_owner.id)
|
||||
.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)
|
||||
"""查某客户的归属变更留痕(共享实现,owner_type/customer_id 参数化)。"""
|
||||
return ownership_log_repository.list_by_ref(
|
||||
session,
|
||||
owner_type=OWNER_TYPE_CUSTOMER,
|
||||
ref_column=OwnershipChangeLog.customer_id,
|
||||
ref_id=customer_id,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return list(session.execute(stmt).all())
|
||||
|
||||
|
||||
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
|
||||
"""统计某客户的归属留痕总数,供分页使用。"""
|
||||
stmt = select(func.count(OwnershipChangeLog.id)).where(
|
||||
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER,
|
||||
OwnershipChangeLog.customer_id == customer_id,
|
||||
return ownership_log_repository.count_by_ref(
|
||||
session,
|
||||
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:
|
||||
@@ -186,7 +174,5 @@ def delete(session: Session, customer: Customer) -> None:
|
||||
|
||||
|
||||
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
|
||||
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
|
||||
session.add(log)
|
||||
session.flush()
|
||||
return log
|
||||
"""写入一条归属变更留痕(共享实现)。只 flush 不 commit,与主表更新同事务。"""
|
||||
return ownership_log_repository.add(session, 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
|
||||
@@ -49,7 +49,7 @@ from app.models.business import (
|
||||
BUSINESS_STATUS_DONE,
|
||||
)
|
||||
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 (
|
||||
BusinessCreateIn,
|
||||
BusinessDetailOut,
|
||||
@@ -66,7 +66,7 @@ from app.schemas.business import (
|
||||
GrossProfitRowOut,
|
||||
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.formula import converted_profit, profit_rate
|
||||
from app.services.no_generator import next_no
|
||||
@@ -77,9 +77,6 @@ logger = logging.getLogger(__name__)
|
||||
# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷
|
||||
_NO_RETRY = 1
|
||||
|
||||
# personnel.status 的在职值(与 customer_service 同口径,注释见彼处)
|
||||
_PERSONNEL_STATUS_ACTIVE = 1
|
||||
|
||||
|
||||
def _to_out(row: tuple) -> BusinessOut:
|
||||
"""(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:
|
||||
"""归属人员须存在且在职(人员侧复用校验文案口径)。"""
|
||||
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
|
||||
"""归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
||||
return personnel_service.ensure_active(session, personnel_id, label=label)
|
||||
|
||||
|
||||
def _ensure_customer(session: Session, customer_id: int) -> Customer:
|
||||
|
||||
@@ -40,15 +40,11 @@ from app.schemas.customer import (
|
||||
CustomerUpdateIn,
|
||||
OwnershipChangeLogOut,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services import audit_service, personnel_service
|
||||
from app.services.audit_service import AuditRecord, RequestMeta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# personnel.status 的在职值(列注释口径:1在职 0离职)。离职人员不可再被
|
||||
# 客户归属引用;人员侧抽出共享常量后此处替换
|
||||
_PERSONNEL_STATUS_ACTIVE = 1
|
||||
|
||||
|
||||
def _to_out(customer: Customer, owner_name: str | None) -> CustomerOut:
|
||||
"""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:
|
||||
"""归属人员必须存在且在职,返回人员行。
|
||||
|
||||
「不存在」与「已离职」合并为同一文案:人员 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
|
||||
"""归属人员必须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
|
||||
return personnel_service.ensure_active(session, personnel_id, label="归属")
|
||||
|
||||
|
||||
def _is_duplicate_entry(exc: IntegrityError) -> bool:
|
||||
|
||||
@@ -35,6 +35,7 @@ from app.schemas.param import (
|
||||
ParamCatalogItemOut,
|
||||
ParamChangeBatchOut,
|
||||
ParamChangeIn,
|
||||
ParamChangeItemIn,
|
||||
ParamDiffOut,
|
||||
ParamDimensionValueOut,
|
||||
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(
|
||||
session: Session,
|
||||
payload: ParamChangeIn,
|
||||
@@ -218,61 +280,8 @@ def submit_change(
|
||||
if payload.change_type == 2 and effective < date.today():
|
||||
raise BadRequestError("指定生效日期不得早于今日")
|
||||
|
||||
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)。
|
||||
# 定义与岗位各一条 IN 批量查询预载(逐条查询在批次扩大时是写侧 N+1);
|
||||
# 岗位预载收集全部非空维度值 —— 集合可能略大于实际所需,但省去按
|
||||
# 维度类型先分类的第二轮遍历,无害多取由校验逻辑自行过滤
|
||||
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))
|
||||
# 先全量校验、再写入:任何一项不合法整批拒绝(不留半批脏数据)
|
||||
normalized = _normalize_items(session, payload.items)
|
||||
|
||||
try:
|
||||
batch = param_repository.create_batch(
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.core.constants import (
|
||||
from app.core.exceptions import BadRequestError, NotFoundError
|
||||
from app.core.response import PageResult
|
||||
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.schemas.personnel import (
|
||||
PersonnelCreateIn,
|
||||
@@ -51,6 +52,22 @@ logger = logging.getLogger(__name__)
|
||||
_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]:
|
||||
"""分页查询人员(含离职)。"""
|
||||
rows = personnel_repository.list_personnel(
|
||||
|
||||
@@ -126,6 +126,7 @@ uv run pytest tests/integration -q # 仅集成
|
||||
| 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 | 全部 | 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 | **单价列(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-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 归一保持等价命中 |
|
||||
|
||||
## 五、待补用例
|
||||
|
||||
|
||||
@@ -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(
|
||||
_BUSINESSES, json=_business_payload(customer_id), headers=normal["headers"]
|
||||
)
|
||||
# 未捕获 IntegrityError 由全局异常兜底收敛 500
|
||||
assert second.status_code == 500, second.text
|
||||
# 未捕获 IntegrityError 由全局 handler 收敛 400(数据完整性冲突)——
|
||||
# 完整性冲突对用户是「操作被拒绝」而非「服务端异常」,400 更贴近事实
|
||||
assert second.status_code == 400, second.text
|
||||
assert "完整性冲突" in second.json()["message"]
|
||||
# 修复前的症状:return _retry_create 无界递归直至 RecursionError,
|
||||
# 每层各写一条记录;修复后重试有上限,最终失败仅一条失败日志
|
||||
assert _log_count(normal["username"], success=False) == base_fail + 1
|
||||
|
||||
@@ -26,57 +26,13 @@ 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
|
||||
from tests.integration.factories import create_business as _create_business
|
||||
|
||||
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:
|
||||
|
||||
@@ -25,6 +25,7 @@ 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
|
||||
from tests.integration.factories import create_business as _create_business
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -32,51 +33,6 @@ _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:
|
||||
|
||||
@@ -35,12 +35,19 @@ from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
E2EFailure,
|
||||
click_query,
|
||||
collect_console_errors,
|
||||
confirm_message_box,
|
||||
current_path,
|
||||
expect_toast,
|
||||
fail,
|
||||
fill_filter,
|
||||
login,
|
||||
open_menu,
|
||||
select_filter_option,
|
||||
table_rows,
|
||||
toast_text,
|
||||
visible_dialog,
|
||||
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:
|
||||
"""当前确认框的完整文本(业务口径文案断言用),轮询等待出现。"""
|
||||
deadline = time.monotonic() + 5
|
||||
|
||||
@@ -156,3 +156,66 @@ def login(page: Page, username: str, password: str) -> None:
|
||||
page.wait_for_load_state("networkidle")
|
||||
finally:
|
||||
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)
|
||||
@@ -29,6 +29,13 @@ import time
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
click_query,
|
||||
confirm_message_box,
|
||||
fill_filter,
|
||||
open_menu,
|
||||
select_filter_option,
|
||||
table_rows,
|
||||
visible_dialog,
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
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(
|
||||
page: Page, username: str, real_name: str, expect_success: bool = True
|
||||
) -> None:
|
||||
@@ -154,7 +101,7 @@ def find_user_row(page: Page, username: str):
|
||||
def step_user_create(page: Page, admin_password: str) -> None:
|
||||
"""登录、菜单导航、新增用户与重名负例。"""
|
||||
login(page, ADMIN, admin_password)
|
||||
open_menu(page, "用户管理")
|
||||
open_menu(page, "用户管理", "系统管理")
|
||||
if current_path(page) != "/system/users":
|
||||
fail("菜单导航", f"未进入用户管理页,当前 {page.url}")
|
||||
print("✓ 1. 菜单导航 经侧边菜单进入用户管理")
|
||||
@@ -217,7 +164,7 @@ def step_user_edit_and_status(page: Page) -> None:
|
||||
|
||||
def step_role_management(page: Page) -> None:
|
||||
"""角色保护与角色 CRUD。"""
|
||||
open_menu(page, "角色管理")
|
||||
open_menu(page, "角色管理", "系统管理")
|
||||
if current_path(page) != "/system/roles":
|
||||
fail("菜单导航", f"未进入角色管理页,当前 {page.url}")
|
||||
|
||||
@@ -259,7 +206,7 @@ def step_role_management(page: Page) -> None:
|
||||
|
||||
def step_logs_query(page: Page) -> None:
|
||||
"""操作日志(含详情懒加载与脱敏断言)与登录日志。"""
|
||||
open_menu(page, "日志查询")
|
||||
open_menu(page, "日志查询", "系统管理")
|
||||
if current_path(page) != "/system/logs":
|
||||
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:
|
||||
"""普通用户菜单隐藏与路由守卫。"""
|
||||
open_menu(page, "用户管理")
|
||||
open_menu(page, "用户管理", "系统管理")
|
||||
create_user_via_ui(page, VIEWER_USERNAME, "E2E访客")
|
||||
page.click("text=退出登录")
|
||||
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:
|
||||
"""删除测试用户与角色(用 expect_toast 校验,失败不假绿)。"""
|
||||
open_menu(page, "用户管理")
|
||||
open_menu(page, "用户管理", "系统管理")
|
||||
for username in (NEW_USERNAME, VIEWER_USERNAME):
|
||||
row = find_user_row(page, username)
|
||||
if row.count() == 0:
|
||||
@@ -332,7 +279,7 @@ def step_cleanup(page: Page) -> None:
|
||||
confirm_message_box(page, "删除")
|
||||
expect_toast(page, "已删除", "数据清理")
|
||||
|
||||
open_menu(page, "角色管理")
|
||||
open_menu(page, "角色管理", "系统管理")
|
||||
fill_filter(page, "关键词", NEW_ROLE_CODE)
|
||||
click_query(page)
|
||||
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 }
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,21 @@ export const PASSWORD_MAX_BYTES = 72
|
||||
|
||||
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 字节数 */
|
||||
export function passwordByteLength(value: string): number {
|
||||
return new TextEncoder().encode(value).length
|
||||
|
||||
@@ -9,6 +9,8 @@ import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { useEditingRow } from '@/composables/useEditingRow'
|
||||
import { AMOUNT_PATTERN } from '@/utils/validate'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -107,9 +109,7 @@ const form = reactive({
|
||||
})
|
||||
|
||||
/** 编辑中的行(用于停用岗位/离职经理的回显兜底) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
const editingRow = useEditingRow(records, editingId)
|
||||
|
||||
/** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */
|
||||
const searchRoleOptions = computed(() =>
|
||||
@@ -139,7 +139,7 @@ const rules = computed<FormRules>(() => ({
|
||||
business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }],
|
||||
phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
|
||||
salary_standard: [
|
||||
{ pattern: /^(\d+)(\.\d{1,2})?$/, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
|
||||
{ pattern: AMOUNT_PATTERN, message: '工资标准须为非负数字,最多两位小数', 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 { usePagedList } from '@/composables/usePagedList'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { AMOUNT_PATTERN, PRICE_PATTERN, ZERO_PATTERN } from '@/utils/validate'
|
||||
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 editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@@ -429,8 +421,11 @@ function personnelName(id: number): string {
|
||||
// ==================== 详情抽屉 ====================
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const drawerRowId = ref<number | null>(null)
|
||||
const drawerRow = ref<BusinessItem | null>(null)
|
||||
// 单一状态源:drawerRowId 派生自 drawerRow(此前与 ownershipLogsId 三份并存、
|
||||
// openDrawer 手工同步——新增写入路径漏更新其一就会出现「标题是 A 业务、
|
||||
// 留痕表拉到 B 业务」且无报错)
|
||||
const drawerRowId = computed(() => drawerRow.value?.id ?? null)
|
||||
const drawerTitle = computed(() =>
|
||||
drawerRow.value ? `「${drawerRow.value.business_no}」· 业务详情` : '业务详情',
|
||||
)
|
||||
@@ -467,12 +462,10 @@ async function refreshDrawer(id: number): Promise<void> {
|
||||
|
||||
function openDrawer(row: BusinessItem): void {
|
||||
drawerRow.value = row
|
||||
drawerRowId.value = row.id
|
||||
drawerVisible.value = true
|
||||
detail.value = null
|
||||
statusLogs.value = []
|
||||
gp.value = null
|
||||
ownershipLogsId.value = row.id
|
||||
ownershipLogPageNum.value = 1
|
||||
void loadDrawerData(row.id)
|
||||
void loadOwnershipLogs()
|
||||
@@ -491,7 +484,7 @@ const {
|
||||
onSizeChange: onOwnershipLogSizeChange,
|
||||
} = usePagedList<businessApi.OwnershipLogItem, object>(
|
||||
(params) => {
|
||||
const id = ownershipLogsId.value
|
||||
const id = drawerRowId.value
|
||||
// 抽屉未指定业务时兜底空数据,避免发出无意义请求
|
||||
return id === null
|
||||
? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
|
||||
@@ -500,8 +493,6 @@ const {
|
||||
() => ({}),
|
||||
)
|
||||
|
||||
const ownershipLogsId = ref<number | null>(null)
|
||||
|
||||
/** 留痕展示:负责/协助的新旧人员(姓名随联表,ID 固化) */
|
||||
function ownershipTransition(row: businessApi.OwnershipLogItem): string {
|
||||
const oldOwner = row.old_owner_name ?? '(无)'
|
||||
|
||||
@@ -10,6 +10,9 @@ import type { ShipmentItem } from '@/api/shipments'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
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'
|
||||
|
||||
/**
|
||||
@@ -63,21 +66,17 @@ interface BusinessOption {
|
||||
name: string
|
||||
}
|
||||
|
||||
const businessOptions = ref<BusinessOption[]>([])
|
||||
|
||||
async function loadBusinessOptions(): Promise<void> {
|
||||
try {
|
||||
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
|
||||
// 业务下拉数据源:一页 200 条覆盖内部系统规模;超出由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
async () => {
|
||||
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
|
||||
businessOptions.value = data.list.map((b) => ({
|
||||
return data.list.map((b) => ({
|
||||
id: b.id,
|
||||
no: b.business_no,
|
||||
name: b.customer_name ?? '',
|
||||
}))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function businessLabel(option: BusinessOption): string {
|
||||
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 editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@@ -124,9 +120,7 @@ const shipmentOptions = ref<ShipmentItem[]>([])
|
||||
const shipmentLoading = ref(false)
|
||||
|
||||
/** 编辑中的回款(回显业务编号/批次语义用) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
const editingRow = useEditingRow(records, editingId)
|
||||
|
||||
/** 下拉展示:发货单号(发货日期,总额)—— 与任务约定文案一致 */
|
||||
function shipmentLabel(row: ShipmentItem): string {
|
||||
|
||||
@@ -8,6 +8,9 @@ import type { ShipmentDetail, ShipmentItem } from '@/api/shipments'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
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'
|
||||
|
||||
/**
|
||||
@@ -55,22 +58,18 @@ interface BusinessOption {
|
||||
status: number
|
||||
}
|
||||
|
||||
const businessOptions = ref<BusinessOption[]>([])
|
||||
|
||||
async function loadBusinessOptions(): Promise<void> {
|
||||
try {
|
||||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||||
// 业务下拉数据源:一页 200 条覆盖标签页规模;超出规模由后端校验兜底
|
||||
const { options: businessOptions, load: loadBusinessOptions } = useDropdownOptions<BusinessOption>(
|
||||
async () => {
|
||||
const data = await businessApi.listBusinesses({ pageNum: 1, pageSize: 200 })
|
||||
businessOptions.value = data.list.map((b) => ({
|
||||
return data.list.map((b) => ({
|
||||
id: b.id,
|
||||
no: b.business_no,
|
||||
name: b.customer_name ?? '',
|
||||
status: b.status,
|
||||
}))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** 下拉项展示:业务编号 + 客户名;终态业务加后缀标注(创建发货单会被后端拦) */
|
||||
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 editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
@@ -95,9 +87,7 @@ const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
/** 编辑中的发货单(回显业务编号用,编辑挂靠不可改) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
const editingRow = useEditingRow(records, editingId)
|
||||
|
||||
/** 明细行草稿(编辑时由详情回显;全量替换,行 id 不参与提交) */
|
||||
interface ItemDraft {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '@/api/params'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
@@ -189,25 +190,20 @@ async function submitAll(): Promise<void> {
|
||||
// ==================== 版本管理抽屉 ====================
|
||||
|
||||
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>>({})
|
||||
|
||||
async function loadBatches(): Promise<void> {
|
||||
batchesLoading.value = true
|
||||
try {
|
||||
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: batchesPageSize.value })
|
||||
batches.value = page.list
|
||||
batchesTotal.value = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
batchesLoading.value = false
|
||||
}
|
||||
}
|
||||
// 分页五件套换 usePagedList:手写版没有请求序号防竞态(快速翻页时旧响应
|
||||
// 后到会覆盖新页数据)与页码回退——正是该 composable 注释里写明已修过的坑
|
||||
const {
|
||||
records: batches,
|
||||
total: batchesTotal,
|
||||
pageNum: batchesPage,
|
||||
pageSize: batchesPageSize,
|
||||
loading: batchesLoading,
|
||||
load: loadBatches,
|
||||
onPageChange: onBatchesPageChange,
|
||||
onSizeChange: onBatchesSizeChange,
|
||||
} = usePagedList<ParamBatch, Record<string, never>>(paramsApi.listBatches, () => ({}))
|
||||
|
||||
async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> {
|
||||
if (detailCache[batch.id]) return detailCache[batch.id]
|
||||
@@ -299,11 +295,21 @@ function openVersionDrawer(): void {
|
||||
|
||||
const historyVisible = ref(false)
|
||||
const historyItem = ref<ParamCatalogItem | null>(null)
|
||||
const historyRows = ref<ParamHistory[]>([])
|
||||
const historyTotal = ref(0)
|
||||
const historyPage = ref(1)
|
||||
const historyPageSize = ref(10)
|
||||
const historyLoading = ref(false)
|
||||
|
||||
// 历史抽屉分页同样收口 usePagedList;fetcher 闭包当前参数的编码
|
||||
const {
|
||||
records: historyRows,
|
||||
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 {
|
||||
historyItem.value = item
|
||||
@@ -312,23 +318,6 @@ function openHistory(item: ParamCatalogItem): void {
|
||||
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)
|
||||
@@ -667,8 +656,8 @@ onMounted(() => {
|
||||
:total="batchesTotal"
|
||||
:page-num="batchesPage"
|
||||
:page-size="batchesPageSize"
|
||||
@page-change="(p: number) => { batchesPage = p; void loadBatches() }"
|
||||
@size-change="(s: number) => { batchesPageSize = s; batchesPage = 1; void loadBatches() }"
|
||||
@page-change="onBatchesPageChange"
|
||||
@size-change="onBatchesSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
@@ -692,8 +681,8 @@ onMounted(() => {
|
||||
:total="historyTotal"
|
||||
:page-num="historyPage"
|
||||
:page-size="historyPageSize"
|
||||
@page-change="(p: number) => { historyPage = p; void loadHistory() }"
|
||||
@size-change="(s: number) => { historyPageSize = s; historyPage = 1; void loadHistory() }"
|
||||
@page-change="onHistoryPageChange"
|
||||
@size-change="onHistorySizeChange"
|
||||
/>
|
||||
</el-drawer>
|
||||
|
||||
|
||||
Reference in new issue
Block a user