比较提交
4
次代码提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
94c49138bf | ||
|
|
f75a9d534e | ||
|
|
0ef3adec21 | ||
|
|
60f6af6494 |
没有与搜索条件相匹配的文件
@@ -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)。
|
||||||
|
|||||||
@@ -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()
|
|
||||||
)
|
)
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 归一保持等价命中 |
|
||||||
|
|
||||||
## 五、待补用例
|
## 五、待补用例
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 字节)'
|
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
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户