feat: integrate required mobile market backend dependencies.

This commit is contained in:
zlwu committed 2026-10-11 12:00:52 +08:00
1 parent 7e930c77fd
commit 337d31bbe8
30 files changed
+10951 -125

No files matched your search

+2
View File
@@ -40,6 +40,7 @@ from app.api.pre_transit_sample import router as pre_transit_sample_router
from app.api.oa_approval import router as oa_approval_router from app.api.oa_approval import router as oa_approval_router
from app.api.mini_auth import router as mini_auth_router from app.api.mini_auth import router as mini_auth_router
from app.api.mini_menu import router as mini_menu_router from app.api.mini_menu import router as mini_menu_router
from app.api.market_mobile import router as market_mobile_router
from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_router from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_router
__all__ = [ __all__ = [
@@ -84,5 +85,6 @@ __all__ = [
"oa_approval_router", "oa_approval_router",
"mini_auth_router", "mini_auth_router",
"mini_menu_router", "mini_menu_router",
"market_mobile_router",
"sample_storage_cert_qc_router", "sample_storage_cert_qc_router",
] ]
+1 -1
View File
@@ -67,7 +67,7 @@ def _resolve_user_role_scopes(
""" """
from app.services.web_auth_roles import get_effective_role_ids from app.services.web_auth_roles import get_effective_role_ids
role_ids = get_effective_role_ids(db, user_id) role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant_no)
if not role_ids: if not role_ids:
return [] return []
+76
View File
@@ -0,0 +1,76 @@
"""移动市场接口的严格菜单权限守卫。"""
from typing import Callable, Iterable
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.auth import get_current_user
from app.core.database import get_db
from app.services.web_auth_roles import (
iter_user_menu_perm_codes,
resolve_web_role_for_user,
)
def require_market_menu_permission(
*,
user_id: str,
tenant_no: str,
required: str,
granted: Iterable[str],
) -> bool:
"""严格判断当前租户用户是否拥有指定移动市场权限码。"""
uid = (user_id or "").strip()
tenant = (tenant_no or "").strip()
code = (required or "").strip()
if not uid or not tenant or not code:
return False
return code in {(item or "").strip() for item in granted if item}
def user_has_tenant_menu_perm(
db: Session,
*,
user_id: str,
tenant_no: str,
perm_code: str,
) -> bool:
"""按显式租户读取用户权限,不回退默认租户。"""
tenant = (tenant_no or "").strip()
uid = (user_id or "").strip()
if not tenant or not uid:
return False
granted = iter_user_menu_perm_codes(db, uid, tenant_no=tenant)
return require_market_menu_permission(
user_id=uid,
tenant_no=tenant,
required=perm_code,
granted=granted,
)
def require_market_menu_code(menu_code: str) -> Callable:
"""为移动市场 API 提供租户化、精确匹配的菜单权限依赖。"""
required = (menu_code or "").strip()
async def dependency(
user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
tenant_no = str(user.get("tenant_no") or "").strip()
user_id = str(user.get("user_id") or "").strip()
if not tenant_no or not user_id:
raise HTTPException(status_code=403, detail="用户租户或身份信息缺失")
tenant_role = resolve_web_role_for_user(
db, user_id, tenant_no=tenant_no
)
if tenant_role != "super_admin" and not user_has_tenant_menu_perm(
db,
user_id=user_id,
tenant_no=tenant_no,
perm_code=required,
):
raise HTTPException(status_code=403, detail="无移动端菜单权限")
return user
return dependency
+46 -16
View File
@@ -2390,6 +2390,8 @@ def create_collection_protocol(
remark: Optional[str] = None, remark: Optional[str] = None,
tenant_no: str = "zkxw", tenant_no: str = "zkxw",
user_id: Optional[str] = None, user_id: Optional[str] = None,
create_dept_id: str = "",
commit: bool = True,
) -> BizCollectionProtocol: ) -> BizCollectionProtocol:
pn = (protocol_no or "").strip() pn = (protocol_no or "").strip()
if not pn: if not pn:
@@ -2420,15 +2422,18 @@ def create_collection_protocol(
order_num=0, order_num=0,
remark=(remark or "").strip() or None, remark=(remark or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=datetime.now(), create_time=datetime.now(),
update_by=user_id, update_by=user_id,
update_time=datetime.now(), update_time=datetime.now(),
del_flag=0, del_flag=0,
) )
db.add(obj) db.add(obj)
db.commit() if commit:
db.refresh(obj) db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
@@ -2458,6 +2463,7 @@ def update_collection_protocol(
protocol_total_amount: Any = None, protocol_total_amount: Any = None,
source_presign_order_id: Optional[str] = None, source_presign_order_id: Optional[str] = None,
remark: Optional[str] = None, remark: Optional[str] = None,
commit: bool = True,
) -> BizCollectionProtocol: ) -> BizCollectionProtocol:
obj = get_collection_protocol( obj = get_collection_protocol(
db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id
@@ -2519,8 +2525,11 @@ def update_collection_protocol(
obj.update_by = user_id obj.update_by = user_id
obj.update_time = datetime.now() obj.update_time = datetime.now()
db.add(obj) db.add(obj)
db.commit() if commit:
db.refresh(obj) db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
@@ -2575,6 +2584,8 @@ def upsert_party_customer(
collection_protocol_id: str, collection_protocol_id: str,
tenant_no: str, tenant_no: str,
user_id: Optional[str], user_id: Optional[str],
commit: bool = True,
create_dept_id: str = "",
**fields, **fields,
) -> BizProtocolPartyCustomer: ) -> BizProtocolPartyCustomer:
fields = _normalize_fields(fields) fields = _normalize_fields(fields)
@@ -2612,8 +2623,11 @@ def upsert_party_customer(
obj.update_by = user_id obj.update_by = user_id
obj.update_time = datetime.now() obj.update_time = datetime.now()
db.add(obj) db.add(obj)
db.commit() if commit:
db.refresh(obj) db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
obj = BizProtocolPartyCustomer( obj = BizProtocolPartyCustomer(
id=generate_uuid(), id=generate_uuid(),
@@ -2622,7 +2636,7 @@ def upsert_party_customer(
order_num=0, order_num=0,
remark=(fields.get("remark") or "").strip() or None, remark=(fields.get("remark") or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=datetime.now(), create_time=datetime.now(),
update_by=user_id, update_by=user_id,
update_time=datetime.now(), update_time=datetime.now(),
@@ -2644,8 +2658,11 @@ def upsert_party_customer(
if k in fields: if k in fields:
setattr(obj, k, fields[k]) setattr(obj, k, fields[k])
db.add(obj) db.add(obj)
db.commit() if commit:
db.refresh(obj) db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
@@ -2668,6 +2685,8 @@ def upsert_collection_info(
collection_protocol_id: str, collection_protocol_id: str,
tenant_no: str, tenant_no: str,
user_id: Optional[str], user_id: Optional[str],
commit: bool = True,
create_dept_id: str = "",
**fields, **fields,
) -> BizProtocolCollectionInfo: ) -> BizProtocolCollectionInfo:
fields = _normalize_fields(fields) fields = _normalize_fields(fields)
@@ -2692,8 +2711,11 @@ def upsert_collection_info(
obj.update_by = user_id obj.update_by = user_id
obj.update_time = datetime.now() obj.update_time = datetime.now()
db.add(obj) db.add(obj)
db.commit() if commit:
db.refresh(obj) db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
obj = BizProtocolCollectionInfo( obj = BizProtocolCollectionInfo(
id=generate_uuid(), id=generate_uuid(),
@@ -2702,7 +2724,7 @@ def upsert_collection_info(
order_num=0, order_num=0,
remark=(fields.get("remark") or "").strip() or None, remark=(fields.get("remark") or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=datetime.now(), create_time=datetime.now(),
update_by=user_id, update_by=user_id,
update_time=datetime.now(), update_time=datetime.now(),
@@ -2715,8 +2737,11 @@ def upsert_collection_info(
if k in fields: if k in fields:
setattr(obj, k, fields[k]) setattr(obj, k, fields[k])
db.add(obj) db.add(obj)
db.commit() if commit:
db.refresh(obj) db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
@@ -2776,6 +2801,8 @@ def save_samples(
tenant_no: str, tenant_no: str,
user_id: Optional[str], user_id: Optional[str],
samples: list, samples: list,
create_dept_id: str = "",
commit: bool = True,
) -> List[BizProtocolSample]: ) -> List[BizProtocolSample]:
existing = list_samples(db, collection_protocol_id, tenant_no) existing = list_samples(db, collection_protocol_id, tenant_no)
old_id_to_code = { old_id_to_code = {
@@ -2814,7 +2841,7 @@ def save_samples(
order_num=idx, order_num=idx,
remark=(raw.get("remark") or "").strip() or None, remark=(raw.get("remark") or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=datetime.now(), create_time=datetime.now(),
update_by=user_id, update_by=user_id,
update_time=datetime.now(), update_time=datetime.now(),
@@ -2852,6 +2879,9 @@ def save_samples(
new_code_to_id=new_code_to_id, new_code_to_id=new_code_to_id,
user_id=user_id, user_id=user_id,
) )
if not commit:
db.flush()
return result
db.commit() db.commit()
for o in result: for o in result:
db.refresh(o) db.refresh(o)
+227 -72
View File
@@ -7,6 +7,7 @@ from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_ from sqlalchemy import and_, func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.utils import generate_uuid, parse_datetime from app.core.utils import generate_uuid, parse_datetime
@@ -37,11 +38,22 @@ def _dec(v: Any) -> Optional[Decimal]:
return Decimal(str(v)) return Decimal(str(v))
def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]: def _user_nick_name_map(
db: Session, user_ids: List[str], tenant_no: str
) -> Dict[str, str]:
ids = [str(x).strip() for x in user_ids if str(x or "").strip()] ids = [str(x).strip() for x in user_ids if str(x or "").strip()]
if not ids: if not ids:
return {} return {}
rows = db.query(SysUser.id, SysUser.nick_name, SysUser.user_no).filter(SysUser.id.in_(ids)).all() rows = (
db.query(SysUser.id, SysUser.nick_name, SysUser.user_no)
.filter(
SysUser.id.in_(ids),
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
SysUser.status == "1",
)
.all()
)
out: Dict[str, str] = {} out: Dict[str, str] = {}
for u in rows: for u in rows:
out[str(u.id)] = (u.nick_name or u.user_no or "").strip() out[str(u.id)] = (u.nick_name or u.user_no or "").strip()
@@ -75,22 +87,59 @@ def resolve_convert_staff_fields(
def _get_order_or_raise( def _get_order_or_raise(
db: Session, *, order_id: str, tenant_no: str db: Session, *, order_id: str, tenant_no: str, for_update: bool = False
) -> BizPresignOrder: ) -> BizPresignOrder:
obj = ( query = (
db.query(BizPresignOrder) db.query(BizPresignOrder)
.filter( .filter(
BizPresignOrder.id == order_id, BizPresignOrder.id == order_id,
BizPresignOrder.tenant_no == tenant_no, BizPresignOrder.tenant_no == tenant_no,
BizPresignOrder.del_flag == 0, BizPresignOrder.del_flag == 0,
) )
.first()
) )
if for_update:
query = query.populate_existing().with_for_update()
obj = query.first()
if not obj: if not obj:
raise ValueError("预签约订单不存在") raise ValueError("预签约订单不存在")
return obj return obj
def validate_presign_person_ids(
db: Session,
*,
tenant_no: str,
collector_user_id: Optional[str],
sales_user_id: Optional[str],
region_manager_user_id: Optional[str],
) -> None:
"""所有预签人员必须是当前租户有效且启用的系统用户。"""
requested = {
str(user_id).strip()
for user_id in (
collector_user_id,
sales_user_id,
region_manager_user_id,
)
if str(user_id or "").strip()
}
if not requested:
return
existing = {
str(row[0])
for row in db.query(SysUser.id)
.filter(
SysUser.id.in_(requested),
SysUser.tenant_no == tenant_no,
SysUser.del_flag == 0,
SysUser.status == "1",
)
.all()
}
if existing != requested:
raise ValueError("人员用户不存在、不属于当前租户、已删除或已停用")
def _get_transfer_or_raise( def _get_transfer_or_raise(
db: Session, *, transfer_id: str, tenant_no: str db: Session, *, transfer_id: str, tenant_no: str
) -> BizPresignTransfer: ) -> BizPresignTransfer:
@@ -197,6 +246,31 @@ def confirmed_deposit_amount(
return total return total
def confirmed_deposit_amount_map(
db: Session, *, presign_order_ids: List[str], tenant_no: str
) -> Dict[str, Decimal]:
ids = [str(order_id).strip() for order_id in presign_order_ids if str(order_id or "").strip()]
if not ids:
return {}
rows = (
db.query(
BizPresignTransfer.presign_order_id,
func.sum(BizPresignTransfer.transfer_amount),
)
.filter(
BizPresignTransfer.presign_order_id.in_(ids),
BizPresignTransfer.tenant_no == tenant_no,
BizPresignTransfer.del_flag == 0,
BizPresignTransfer.pay_status == "yjf",
)
.group_by(BizPresignTransfer.presign_order_id)
.all()
)
return {
str(order_id): Decimal(str(amount or 0)) for order_id, amount in rows
}
def _sample_type_names( def _sample_type_names(
db: Session, *, presign_order_id: str, tenant_no: str db: Session, *, presign_order_id: str, tenant_no: str
) -> str: ) -> str:
@@ -244,6 +318,7 @@ def replace_sample_types(
tenant_no: str, tenant_no: str,
user_id: Optional[str], user_id: Optional[str],
sample_type_ids: List[str], sample_type_ids: List[str],
create_dept_id: str = "",
) -> None: ) -> None:
now = _now() now = _now()
existing = ( existing = (
@@ -274,7 +349,7 @@ def replace_sample_types(
tenant_no=tenant_no, tenant_no=tenant_no,
order_num=idx, order_num=idx,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=now, create_time=now,
update_by=user_id, update_by=user_id,
update_time=now, update_time=now,
@@ -301,10 +376,20 @@ def list_transfers(
def order_to_dict( def order_to_dict(
db: Session, o: BizPresignOrder, tenant_no: str, *, with_children: bool = True db: Session,
o: BizPresignOrder,
tenant_no: str,
*,
with_children: bool = True,
deposit_amount: Optional[Decimal] = None,
user_name_map: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
deposit = confirmed_deposit_amount( deposit = (
db, presign_order_id=o.id, tenant_no=tenant_no deposit_amount
if deposit_amount is not None
else confirmed_deposit_amount(
db, presign_order_id=o.id, tenant_no=tenant_no
)
) )
data: Dict[str, Any] = { data: Dict[str, Any] = {
"id": o.id, "id": o.id,
@@ -329,14 +414,17 @@ def order_to_dict(
"createTime": o.create_time.strftime("%Y-%m-%d %H:%M:%S") if o.create_time else "", "createTime": o.create_time.strftime("%Y-%m-%d %H:%M:%S") if o.create_time else "",
"updateTime": o.update_time.strftime("%Y-%m-%d %H:%M:%S") if o.update_time else "", "updateTime": o.update_time.strftime("%Y-%m-%d %H:%M:%S") if o.update_time else "",
} }
name_map = _user_nick_name_map( name_map = user_name_map
db, if name_map is None:
[ name_map = _user_nick_name_map(
data["collectorUserId"], db,
data["salesUserId"], [
data["regionManagerUserId"], data["collectorUserId"],
], data["salesUserId"],
) data["regionManagerUserId"],
],
tenant_no,
)
data["collectorUserName"] = name_map.get(data["collectorUserId"], "") data["collectorUserName"] = name_map.get(data["collectorUserId"], "")
data["salesUserName"] = name_map.get(data["salesUserId"], "") data["salesUserName"] = name_map.get(data["salesUserId"], "")
data["regionManagerUserName"] = name_map.get(data["regionManagerUserId"], "") data["regionManagerUserName"] = name_map.get(data["regionManagerUserId"], "")
@@ -419,6 +507,8 @@ def create_presign_order(
fields: Dict[str, Any], fields: Dict[str, Any],
sample_type_ids: Optional[List[str]] = None, sample_type_ids: Optional[List[str]] = None,
transfers: Optional[List[Dict[str, Any]]] = None, transfers: Optional[List[Dict[str, Any]]] = None,
create_dept_id: str = "",
commit: bool = True,
) -> BizPresignOrder: ) -> BizPresignOrder:
now = _now() now = _now()
sample_collect = fields.get("sample_collect_time") or fields.get("sampleCollectTime") sample_collect = fields.get("sample_collect_time") or fields.get("sampleCollectTime")
@@ -472,7 +562,7 @@ def create_presign_order(
order_num=0, order_num=0,
remark=(fields.get("remark") or "").strip() or None, remark=(fields.get("remark") or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=now, create_time=now,
update_by=user_id, update_by=user_id,
update_time=now, update_time=now,
@@ -486,6 +576,7 @@ def create_presign_order(
tenant_no=tenant_no, tenant_no=tenant_no,
user_id=user_id, user_id=user_id,
sample_type_ids=sample_type_ids or [], sample_type_ids=sample_type_ids or [],
create_dept_id=create_dept_id,
) )
for raw in transfers or []: for raw in transfers or []:
if isinstance(raw, dict): if isinstance(raw, dict):
@@ -495,10 +586,14 @@ def create_presign_order(
tenant_no=tenant_no, tenant_no=tenant_no,
user_id=user_id, user_id=user_id,
fields=raw, fields=raw,
create_dept_id=create_dept_id,
commit=False, commit=False,
) )
db.commit() if commit:
db.refresh(obj) db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
@@ -510,6 +605,8 @@ def update_presign_order(
user_id: Optional[str], user_id: Optional[str],
fields: Dict[str, Any], fields: Dict[str, Any],
sample_type_ids: Optional[List[str]] = None, sample_type_ids: Optional[List[str]] = None,
create_dept_id: str = "",
commit: bool = True,
) -> BizPresignOrder: ) -> BizPresignOrder:
obj = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no) obj = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no)
if (obj.order_status or "") == "converted": if (obj.order_status or "") == "converted":
@@ -581,9 +678,13 @@ def update_presign_order(
tenant_no=tenant_no, tenant_no=tenant_no,
user_id=user_id, user_id=user_id,
sample_type_ids=sample_type_ids, sample_type_ids=sample_type_ids,
create_dept_id=create_dept_id,
) )
db.commit() if commit:
db.refresh(obj) db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
@@ -608,6 +709,7 @@ def add_transfer(
tenant_no: str, tenant_no: str,
user_id: Optional[str], user_id: Optional[str],
fields: Dict[str, Any], fields: Dict[str, Any],
create_dept_id: str = "",
commit: bool = True, commit: bool = True,
) -> BizPresignTransfer: ) -> BizPresignTransfer:
_get_order_or_raise(db, order_id=presign_order_id, tenant_no=tenant_no) _get_order_or_raise(db, order_id=presign_order_id, tenant_no=tenant_no)
@@ -636,7 +738,7 @@ def add_transfer(
order_num=0, order_num=0,
remark=(fields.get("remark") or "").strip() or None, remark=(fields.get("remark") or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=now, create_time=now,
update_by=user_id, update_by=user_id,
update_time=now, update_time=now,
@@ -905,6 +1007,7 @@ def list_presign_transfers_for_finance(
(order.sales_user_id if order else "") or "", (order.sales_user_id if order else "") or "",
(order.region_manager_user_id if order else "") or "", (order.region_manager_user_id if order else "") or "",
], ],
tenant_no,
) )
item["collectorUserName"] = name_map.get( item["collectorUserName"] = name_map.get(
(order.collector_user_id if order else "") or "", "" (order.collector_user_id if order else "") or "", ""
@@ -932,12 +1035,23 @@ def convert_to_protocol(
order_id: str, order_id: str,
tenant_no: str, tenant_no: str,
user_id: Optional[str], user_id: Optional[str],
create_dept_id: str = "",
commit: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""转正式协议:创建协议主单 + 当事人/采集信息 + 样本类型行;意向金仅关联预签订单 ID,不拷贝。""" """转正式协议:创建协议主单 + 当事人/采集信息 + 样本类型行;意向金仅关联预签订单 ID,不拷贝。"""
from app.crud import collection_protocol as cp_svc from app.crud import collection_protocol as cp_svc
order = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no) order = _get_order_or_raise(
db, order_id=order_id, tenant_no=tenant_no, for_update=True
)
assert_presign_convertible(order_status=order.order_status) assert_presign_convertible(order_status=order.order_status)
validate_presign_person_ids(
db,
tenant_no=tenant_no,
collector_user_id=order.collector_user_id or user_id,
sales_user_id=order.sales_user_id,
region_manager_user_id=order.region_manager_user_id,
)
staff = resolve_convert_staff_fields( staff = resolve_convert_staff_fields(
collector_user_id=order.collector_user_id, collector_user_id=order.collector_user_id,
@@ -947,55 +1061,96 @@ def convert_to_protocol(
) )
type_ids = list_sample_type_ids(db, presign_order_id=order.id, tenant_no=tenant_no) type_ids = list_sample_type_ids(db, presign_order_id=order.id, tenant_no=tenant_no)
protocol = cp_svc.create_collection_protocol( try:
db, protocol = cp_svc.create_collection_protocol(
protocol_no=order.protocol_no or "", db,
protocol_status="dsx", protocol_no=order.protocol_no or "",
register_date=order.order_date, protocol_status="dsx",
collector_user_id=staff["collector_user_id"], register_date=order.order_date,
source_presign_order_id=order.id, collector_user_id=staff["collector_user_id"],
payment_model_version=2, source_presign_order_id=order.id,
protocol_total_amount=None, payment_model_version=2,
remark=order.remark, protocol_total_amount=None,
tenant_no=tenant_no, remark=order.remark,
user_id=user_id, tenant_no=tenant_no,
) user_id=user_id,
party_fields: Dict[str, Any] = { create_dept_id=create_dept_id,
"mother_name": order.mother_name, commit=False,
"expected_delivery_date": order.expected_delivery_date, )
} party_fields: Dict[str, Any] = {
cp_svc.upsert_party_customer( "mother_name": order.mother_name,
db, protocol.id, tenant_no, user_id, **party_fields "expected_delivery_date": order.expected_delivery_date,
) }
collection_fields: Dict[str, Any] = { cp_svc.upsert_party_customer(
"region_id": order.region_id, db,
"hospital_id": order.hospital_id, protocol.id,
"sales_user_id": staff["sales_user_id"], tenant_no,
"region_manager_user_id": staff["region_manager_user_id"], user_id,
"sample_collect_time": order.sample_collect_time, create_dept_id=create_dept_id,
} commit=False,
cp_svc.upsert_collection_info( **party_fields,
db, protocol.id, tenant_no, user_id, **collection_fields )
) collection_fields: Dict[str, Any] = {
if type_ids: "region_id": order.region_id,
samples = [ "hospital_id": order.hospital_id,
{ "sales_user_id": staff["sales_user_id"],
"sampleTypeId": tid, "region_manager_user_id": staff["region_manager_user_id"],
"sampleStatus": None, "sample_collect_time": order.sample_collect_time,
} }
for tid in type_ids cp_svc.upsert_collection_info(
] db,
cp_svc.save_samples(db, protocol.id, tenant_no, user_id, samples) protocol.id,
tenant_no,
user_id,
create_dept_id=create_dept_id,
commit=False,
**collection_fields,
)
if type_ids:
samples = [
{
"sampleTypeId": tid,
"sampleStatus": None,
}
for tid in type_ids
]
cp_svc.save_samples(
db,
protocol.id,
tenant_no,
user_id,
samples,
create_dept_id=create_dept_id,
commit=False,
)
order.order_status = "converted" order.order_status = "converted"
order.converted_protocol_id = protocol.id order.converted_protocol_id = protocol.id
if not (order.protocol_no or "").strip(): if not (order.protocol_no or "").strip():
order.protocol_no = protocol.protocol_no order.protocol_no = protocol.protocol_no
order.update_by = user_id order.update_by = user_id
order.update_time = _now() order.update_time = _now()
db.add(order) db.add(order)
db.commit() if commit:
db.refresh(order) db.commit()
db.refresh(order)
else:
db.flush()
except IntegrityError as exc:
db.rollback()
message = str(exc).lower()
if (
"uk_collection_protocol_presign_source" in message
or (
"unique constraint failed" in message
and "source_presign_order_id" in message
)
):
raise ValueError("该预签约订单已转正式协议") from exc
raise
except Exception:
db.rollback()
raise
return { return {
"presignOrder": order_to_dict(db, order, tenant_no, with_children=True), "presignOrder": order_to_dict(db, order, tenant_no, with_children=True),
"protocolId": protocol.id, "protocolId": protocol.id,
+2 -1
View File
@@ -722,6 +722,7 @@ def add_transfer(
transfer_type: Optional[str] = None, transfer_type: Optional[str] = None,
transfer_attachments: Optional[str] = None, transfer_attachments: Optional[str] = None,
remark: Optional[str] = None, remark: Optional[str] = None,
create_dept_id: str = "",
commit: bool = True, commit: bool = True,
) -> BizProtocolPaymentTransfer: ) -> BizProtocolPaymentTransfer:
sch = _get_schedule_or_raise(db, schedule_id=schedule_id, tenant_no=tenant_no) sch = _get_schedule_or_raise(db, schedule_id=schedule_id, tenant_no=tenant_no)
@@ -748,7 +749,7 @@ def add_transfer(
order_num=0, order_num=0,
remark=(remark or "").strip() or None, remark=(remark or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=(create_dept_id or "").strip(),
create_time=now, create_time=now,
update_by=user_id, update_by=user_id,
update_time=now, update_time=now,
+64 -17
View File
@@ -982,6 +982,9 @@ def create_donation(
data: dict, data: dict,
tenant_no: str, tenant_no: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
create_dept_id: str = "",
commit: bool = True,
sync_customers: bool = True,
) -> BizResearchDonation: ) -> BizResearchDonation:
"""创建科研捐赠单(含 party / collection / samples + transports)。""" """创建科研捐赠单(含 party / collection / samples + transports)。"""
raw = _normalize_fields(data) raw = _normalize_fields(data)
@@ -1016,7 +1019,7 @@ def create_donation(
order_num=0, order_num=0,
remark=(raw.get("remark") or "").strip() or None, remark=(raw.get("remark") or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=now, create_time=now,
update_by=user_id, update_by=user_id,
update_time=now, update_time=now,
@@ -1031,19 +1034,34 @@ def create_donation(
from app.services.party_customer_sync import sync_party_customers from app.services.party_customer_sync import sync_party_customers
party_raw = _normalize_fields(raw.get("party") or {}) party_raw = _normalize_fields(raw.get("party") or {})
party_raw = sync_party_customers(db, party_raw, tenant_no=tenant_no, user_id=user_id or "") if sync_customers:
_create_party(db, donation_id, tenant_no, user_id, party_raw, now) party_raw = sync_party_customers(
db,
party_raw,
tenant_no=tenant_no,
user_id=user_id or "",
commit=commit,
create_dept_id=create_dept_id,
)
_create_party(
db, donation_id, tenant_no, user_id, party_raw, now,
create_dept_id=create_dept_id,
)
# collection # collection
coll_raw = _normalize_fields(raw.get("collection") or {}) coll_raw = _normalize_fields(raw.get("collection") or {})
_create_collection(db, donation_id, tenant_no, user_id, coll_raw, now) _create_collection(
db, donation_id, tenant_no, user_id, coll_raw, now,
create_dept_id=create_dept_id,
)
# samples(运费批次在 commit 后挂钩;停写旧运输依赖) # samples(运费批次在 commit 后挂钩;停写旧运输依赖)
samples_raw = raw.get("samples") or [] samples_raw = raw.get("samples") or []
new_samples = _create_samples(db, donation_id, tenant_no, user_id, samples_raw, now) new_samples = _create_samples(
db, donation_id, tenant_no, user_id, samples_raw, now,
create_dept_id=create_dept_id,
)
db.commit()
db.refresh(obj)
from app.crud import sample_freight as sample_freight_svc from app.crud import sample_freight as sample_freight_svc
sample_freight_svc.apply_freight_on_protocol_save( sample_freight_svc.apply_freight_on_protocol_save(
@@ -1055,7 +1073,11 @@ def create_donation(
saved_samples=new_samples, saved_samples=new_samples,
input_samples=samples_raw, input_samples=samples_raw,
) )
db.commit() if commit:
db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
@@ -1071,6 +1093,9 @@ def update_donation(
data: dict, data: dict,
tenant_no: str, tenant_no: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
create_dept_id: str = "",
commit: bool = True,
sync_customers: bool = True,
) -> BizResearchDonation: ) -> BizResearchDonation:
"""更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。""" """更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。"""
obj = ( obj = (
@@ -1131,14 +1156,28 @@ def update_donation(
_soft_delete_party(db, donation_id, tenant_no, user_id) _soft_delete_party(db, donation_id, tenant_no, user_id)
party_raw = _normalize_fields(raw["party"] or {}) party_raw = _normalize_fields(raw["party"] or {})
party_raw = sync_party_customers(db, party_raw, tenant_no=tenant_no, user_id=user_id or "") if sync_customers:
_create_party(db, donation_id, tenant_no, user_id, party_raw, now) party_raw = sync_party_customers(
db,
party_raw,
tenant_no=tenant_no,
user_id=user_id or "",
commit=commit,
create_dept_id=create_dept_id,
)
_create_party(
db, donation_id, tenant_no, user_id, party_raw, now,
create_dept_id=create_dept_id,
)
# collection upsert # collection upsert
if "collection" in raw: if "collection" in raw:
_soft_delete_collection(db, donation_id, tenant_no, user_id) _soft_delete_collection(db, donation_id, tenant_no, user_id)
coll_raw = _normalize_fields(raw["collection"] or {}) coll_raw = _normalize_fields(raw["collection"] or {})
_create_collection(db, donation_id, tenant_no, user_id, coll_raw, now) _create_collection(
db, donation_id, tenant_no, user_id, coll_raw, now,
create_dept_id=create_dept_id,
)
# samples upsert # samples upsert
if "samples" in raw: if "samples" in raw:
@@ -1151,7 +1190,10 @@ def update_donation(
_soft_delete_samples(db, donation_id, tenant_no, user_id) _soft_delete_samples(db, donation_id, tenant_no, user_id)
_soft_delete_transports(db, donation_id, tenant_no, user_id) _soft_delete_transports(db, donation_id, tenant_no, user_id)
samples_raw = raw["samples"] or [] samples_raw = raw["samples"] or []
new_samples = _create_samples(db, donation_id, tenant_no, user_id, samples_raw, now) new_samples = _create_samples(
db, donation_id, tenant_no, user_id, samples_raw, now,
create_dept_id=create_dept_id,
)
new_code_to_id = { new_code_to_id = {
(s.sample_code or "").strip(): s.id (s.sample_code or "").strip(): s.id
for s in new_samples for s in new_samples
@@ -1168,8 +1210,6 @@ def update_donation(
samples_raw = None samples_raw = None
new_samples = None new_samples = None
db.commit()
db.refresh(obj)
if new_samples is not None and samples_raw is not None: if new_samples is not None and samples_raw is not None:
from app.crud import sample_freight as sample_freight_svc from app.crud import sample_freight as sample_freight_svc
@@ -1182,7 +1222,11 @@ def update_donation(
saved_samples=new_samples, saved_samples=new_samples,
input_samples=samples_raw, input_samples=samples_raw,
) )
if commit:
db.commit() db.commit()
db.refresh(obj)
else:
db.flush()
return obj return obj
@@ -1995,6 +2039,7 @@ def _create_party(
user_id: Optional[str], user_id: Optional[str],
raw: dict, raw: dict,
now: datetime, now: datetime,
create_dept_id: str = "",
) -> BizResearchDonationParty: ) -> BizResearchDonationParty:
obj = BizResearchDonationParty( obj = BizResearchDonationParty(
id=generate_uuid(), id=generate_uuid(),
@@ -2003,7 +2048,7 @@ def _create_party(
order_num=0, order_num=0,
remark=(raw.get("remark") or "").strip() or None, remark=(raw.get("remark") or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=now, create_time=now,
update_by=user_id, update_by=user_id,
update_time=now, update_time=now,
@@ -2049,6 +2094,7 @@ def _create_collection(
user_id: Optional[str], user_id: Optional[str],
raw: dict, raw: dict,
now: datetime, now: datetime,
create_dept_id: str = "",
) -> BizResearchDonationCollection: ) -> BizResearchDonationCollection:
obj = BizResearchDonationCollection( obj = BizResearchDonationCollection(
id=generate_uuid(), id=generate_uuid(),
@@ -2057,7 +2103,7 @@ def _create_collection(
order_num=0, order_num=0,
remark=(raw.get("remark") or "").strip() or None, remark=(raw.get("remark") or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=now, create_time=now,
update_by=user_id, update_by=user_id,
update_time=now, update_time=now,
@@ -2149,6 +2195,7 @@ def _create_samples(
user_id: Optional[str], user_id: Optional[str],
samples: list, samples: list,
now: datetime, now: datetime,
create_dept_id: str = "",
) -> List[BizResearchDonationSample]: ) -> List[BizResearchDonationSample]:
validate_sample_codes_not_used( validate_sample_codes_not_used(
db, db,
@@ -2178,7 +2225,7 @@ def _create_samples(
order_num=idx, order_num=idx,
remark=(raw.get("remark") or "").strip() or None, remark=(raw.get("remark") or "").strip() or None,
create_by=user_id, create_by=user_id,
create_dept_id="", create_dept_id=create_dept_id,
create_time=now, create_time=now,
update_by=user_id, update_by=user_id,
update_time=now, update_time=now,
+2 -1
View File
@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
from app.core.api_request_log_middleware import ApiRequestLogMiddleware from app.core.api_request_log_middleware import ApiRequestLogMiddleware
from app.core.api_crypto_middleware import ApiCryptoMiddleware from app.core.api_crypto_middleware import ApiCryptoMiddleware
from app.models import models # noqa: F401 from app.models import models # noqa: F401
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, sample_storage_cert_qc_router from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router
@asynccontextmanager @asynccontextmanager
@@ -152,6 +152,7 @@ app.include_router(pre_transit_sample_router, prefix=settings.API_V1_PREFIX)
app.include_router(oa_approval_router, prefix=settings.API_V1_PREFIX) app.include_router(oa_approval_router, prefix=settings.API_V1_PREFIX)
app.include_router(mini_auth_router, prefix=settings.API_V1_PREFIX) app.include_router(mini_auth_router, prefix=settings.API_V1_PREFIX)
app.include_router(mini_menu_router, prefix=settings.API_V1_PREFIX) app.include_router(mini_menu_router, prefix=settings.API_V1_PREFIX)
app.include_router(market_mobile_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX) app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX)
+8 -1
View File
@@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
import enum import enum
from sqlalchemy import Column, String, DateTime, Date, Integer, Text, CHAR, SmallInteger, Numeric from sqlalchemy import Column, String, DateTime, Date, Integer, Text, CHAR, SmallInteger, Numeric, UniqueConstraint
from app.core.database import Base from app.core.database import Base
@@ -1060,6 +1060,13 @@ class BizCollectionProtocol(Base):
"""采集协议单 biz_collection_protocol""" """采集协议单 biz_collection_protocol"""
__tablename__ = "biz_collection_protocol" __tablename__ = "biz_collection_protocol"
__table_args__ = (
UniqueConstraint(
"tenant_no",
"source_presign_order_id",
name="uk_collection_protocol_presign_source",
),
)
id = Column(String(64), primary_key=True, comment="主键ID") id = Column(String(64), primary_key=True, comment="主键ID")
protocol_no_id = Column(String(64), nullable=True, comment="采集协议号表ID") protocol_no_id = Column(String(64), nullable=True, comment="采集协议号表ID")
+209
View File
@@ -0,0 +1,209 @@
"""移动市场专员/地区经理的现场字段范围。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.core.data_scope import apply_data_scope
from app.models.models import (
BizCollectionProtocol,
BizCollectionRegionStaff,
BizPresignOrder,
BizProtocolCollectionInfo,
)
from app.models.research_donation import (
BizResearchDonation,
BizResearchDonationCollection,
)
ScopeKind = Literal["specialist", "manager"]
@dataclass(frozen=True)
class MarketFieldContext:
"""由固定移动路由创建的现场范围上下文。"""
tenant_no: str
user_id: str
scope_kind: ScopeKind
def __post_init__(self) -> None:
tenant_no = (self.tenant_no or "").strip()
user_id = (self.user_id or "").strip()
if not tenant_no or not user_id:
raise ValueError("现场范围要求明确的租户和用户")
if self.scope_kind not in ("specialist", "manager"):
raise ValueError("不支持的现场范围类型")
object.__setattr__(self, "tenant_no", tenant_no)
object.__setattr__(self, "user_id", user_id)
def leader_region_ids_query(db: Session, ctx: MarketFieldContext):
"""返回当前经理在当前租户有效负责的地区 ID 查询。"""
return db.query(BizCollectionRegionStaff.region_id).filter(
BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
BizCollectionRegionStaff.del_flag == 0,
BizCollectionRegionStaff.user_id == ctx.user_id,
BizCollectionRegionStaff.is_leader == 1,
)
def _collection_scope_condition(model, db: Session, ctx: MarketFieldContext):
if ctx.scope_kind == "specialist":
return model.sales_user_id == ctx.user_id
return or_(
model.region_id.in_(leader_region_ids_query(db, ctx)),
model.region_manager_user_id == ctx.user_id,
)
def apply_market_data_scope(
query,
model,
db: Session,
ctx: MarketFieldContext,
user_info: dict,
):
"""组合租户、软删与通用多角色数据权限。"""
user_id = (
str(user_info.get("user_id") or "").strip()
if isinstance(user_info, dict)
else ""
)
if not user_id or user_id != ctx.user_id:
raise PermissionError("用户身份与现场范围上下文不一致")
query = query.filter(
model.tenant_no == ctx.tenant_no,
model.del_flag == 0,
)
return apply_data_scope(
query,
model,
user_info,
db,
tenant_no=ctx.tenant_no,
)
def apply_presign_scope(
query,
db: Session,
ctx: MarketFieldContext,
*,
user_info: dict,
):
"""组合租户、软删、通用数据权限与预签现场范围。"""
query = apply_market_data_scope(
query, BizPresignOrder, db, ctx, user_info
)
return query.filter(_collection_scope_condition(BizPresignOrder, db, ctx))
def apply_cell_protocol_scope(
query,
db: Session,
ctx: MarketFieldContext,
*,
user_info: dict,
):
"""按细胞协议的有效采集信息施加现场范围。"""
query = apply_market_data_scope(
query, BizCollectionProtocol, db, ctx, user_info
)
collection_exists = (
db.query(BizProtocolCollectionInfo.id)
.filter(
BizProtocolCollectionInfo.collection_protocol_id
== BizCollectionProtocol.id,
BizProtocolCollectionInfo.tenant_no == ctx.tenant_no,
BizProtocolCollectionInfo.del_flag == 0,
_collection_scope_condition(BizProtocolCollectionInfo, db, ctx),
)
.exists()
.correlate(BizCollectionProtocol)
)
return query.filter(collection_exists)
def apply_donation_scope(
query,
db: Session,
ctx: MarketFieldContext,
*,
user_info: dict,
):
"""按科研捐赠的有效采集信息施加现场范围。"""
query = apply_market_data_scope(
query, BizResearchDonation, db, ctx, user_info
)
collection_exists = (
db.query(BizResearchDonationCollection.id)
.filter(
BizResearchDonationCollection.research_donation_id
== BizResearchDonation.id,
BizResearchDonationCollection.tenant_no == ctx.tenant_no,
BizResearchDonationCollection.del_flag == 0,
_collection_scope_condition(
BizResearchDonationCollection, db, ctx
),
)
.exists()
.correlate(BizResearchDonation)
)
return query.filter(collection_exists)
def _assert_access(query, record_id: str):
row = query.filter_by(id=record_id).first()
if row is None:
raise HTTPException(status_code=404, detail="数据不存在或无权访问")
return row
def assert_presign_access(
db: Session,
record_id: str,
ctx: MarketFieldContext,
*,
user_info: dict,
):
return _assert_access(
apply_presign_scope(
db.query(BizPresignOrder), db, ctx, user_info=user_info
),
record_id,
)
def assert_cell_protocol_access(
db: Session,
record_id: str,
ctx: MarketFieldContext,
*,
user_info: dict,
):
return _assert_access(
apply_cell_protocol_scope(
db.query(BizCollectionProtocol), db, ctx, user_info=user_info
),
record_id,
)
def assert_donation_access(
db: Session,
record_id: str,
ctx: MarketFieldContext,
*,
user_info: dict,
):
return _assert_access(
apply_donation_scope(
db.query(BizResearchDonation), db, ctx, user_info=user_info
),
record_id,
)
File diff suppressed because it is too large. Load diff
+821
View File
@@ -0,0 +1,821 @@
"""市场专员/地区经理移动端的催缴与按样本缴费服务。"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy import Integer, and_, case, cast, func, or_
from sqlalchemy.orm import Session, aliased
from app.core.utils import parse_datetime
from app.crud import protocol_sample_payment as payment_crud
from app.models.models import (
BizCollectionProtocol,
BizProtocolCollectionInfo,
BizProtocolPartyCustomer,
BizProtocolPaymentSchedule,
BizProtocolPaymentTransfer,
BizProtocolSamplePayment,
)
from app.services.market_field_scope import (
MarketFieldContext,
apply_cell_protocol_scope,
)
from app.services.payment_chase import (
chase_period_money,
parse_due_date_ymd,
)
from app.services.market_support import (
PAYMENT_TERMINAL_PROTOCOL_STATUSES,
PAYMENT_WRITABLE_SCHEDULE_STATUS,
validate_attachment_refs,
)
def _current_dept_id(user_info: dict) -> str:
return str(
user_info.get('dept_id')
or user_info.get('deptId')
or user_info.get('region_id')
or ''
).strip()
def _fmt_time(value: Any) -> str:
return value.strftime('%Y-%m-%d %H:%M:%S') if value else ''
def _money(value: Any) -> Optional[str]:
"""以固定两位小数字符串输出,避免 JSON float 破坏 DECIMAL(18,2)。"""
if value is None or value == '':
return None
return format(Decimal(str(value)).quantize(Decimal('0.01')), 'f')
def _transfer_dict(row: BizProtocolPaymentTransfer) -> Dict[str, Any]:
return {
'id': row.id,
'scheduleId': row.schedule_id,
'samplePaymentId': row.sample_payment_id,
'collectionProtocolId': row.collection_protocol_id,
'sampleCode': row.sample_code or '',
'transferAmount': _money(row.transfer_amount),
'transferTime': _fmt_time(row.transfer_time),
'transferType': row.transfer_type or '',
'transferAttachments': row.transfer_attachments or '',
'payStatus': row.pay_status or '',
'remark': row.remark or '',
'createTime': _fmt_time(row.create_time),
'updateTime': _fmt_time(row.update_time),
}
def _period_dict(
row: BizProtocolPaymentSchedule,
transfers: List[BizProtocolPaymentTransfer],
) -> Dict[str, Any]:
paid, arrears = chase_period_money(
due_amount=row.due_amount,
transfers=[
{'transferAmount': item.transfer_amount, 'payStatus': item.pay_status}
for item in transfers
],
)
return {
'id': row.id,
'scheduleNo': row.schedule_no,
'dueAmount': _money(row.due_amount),
'dueDate': row.due_date or '',
'scheduleStatus': row.schedule_status or '',
'paidAmount': _money(paid),
'arrearsAmount': _money(arrears),
'transfers': [_transfer_dict(item) for item in transfers],
}
def _payment_dict(
payment: BizProtocolSamplePayment,
protocol: BizCollectionProtocol,
party: Optional[BizProtocolPartyCustomer],
schedules: List[BizProtocolPaymentSchedule],
transfers_by_schedule: Dict[str, List[BizProtocolPaymentTransfer]],
) -> Dict[str, Any]:
return {
'id': payment.id,
'collectionProtocolId': protocol.id,
'protocolNo': protocol.protocol_no or '',
'protocolStatus': protocol.protocol_status or '',
'paymentModelVersion': 2,
'motherName': (party.mother_name if party else '') or '',
'motherPhone': (party.mother_phone if party else '') or '',
'sampleCode': payment.sample_code or '',
'payMode': payment.pay_mode or '',
'contractAmount': _money(payment.contract_amount),
'remark': payment.remark or '',
'schedules': [
_period_dict(row, transfers_by_schedule.get(row.id, []))
for row in schedules
],
}
def _scoped_payment_query(
db: Session,
*,
ctx: MarketFieldContext,
user_info: dict,
v2_only: bool = True,
):
protocol_conditions = [
BizCollectionProtocol.id
== BizProtocolSamplePayment.collection_protocol_id,
BizCollectionProtocol.tenant_no == ctx.tenant_no,
BizCollectionProtocol.del_flag == 0,
]
if v2_only:
protocol_conditions.append(BizCollectionProtocol.payment_model_version == 2)
query = (
db.query(
BizProtocolSamplePayment,
BizCollectionProtocol,
BizProtocolPartyCustomer,
)
.join(
BizCollectionProtocol,
and_(*protocol_conditions),
)
.outerjoin(
BizProtocolPartyCustomer,
and_(
BizProtocolPartyCustomer.collection_protocol_id
== BizCollectionProtocol.id,
BizProtocolPartyCustomer.tenant_no == ctx.tenant_no,
BizProtocolPartyCustomer.del_flag == 0,
),
)
.filter(
BizProtocolSamplePayment.tenant_no == ctx.tenant_no,
BizProtocolSamplePayment.del_flag == 0,
)
)
return apply_cell_protocol_scope(query, db, ctx, user_info=user_info)
def _load_children(
db: Session,
payment_ids: List[str],
tenant_no: str,
) -> Tuple[
Dict[str, List[BizProtocolPaymentSchedule]],
Dict[str, List[BizProtocolPaymentTransfer]],
]:
if not payment_ids:
return {}, {}
schedules = (
db.query(BizProtocolPaymentSchedule)
.filter(
BizProtocolPaymentSchedule.sample_payment_id.in_(payment_ids),
BizProtocolPaymentSchedule.tenant_no == tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
)
.order_by(
BizProtocolPaymentSchedule.order_num.asc(),
BizProtocolPaymentSchedule.schedule_no.asc(),
)
.all()
)
schedule_ids = [row.id for row in schedules]
transfers = []
if schedule_ids:
transfers = (
db.query(BizProtocolPaymentTransfer)
.filter(
BizProtocolPaymentTransfer.schedule_id.in_(schedule_ids),
BizProtocolPaymentTransfer.tenant_no == tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
)
.order_by(BizProtocolPaymentTransfer.create_time.asc())
.all()
)
schedules_by_payment: Dict[str, List[BizProtocolPaymentSchedule]] = {}
transfers_by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
for row in schedules:
schedules_by_payment.setdefault(row.sample_payment_id, []).append(row)
for row in transfers:
transfers_by_schedule.setdefault(row.schedule_id, []).append(row)
return schedules_by_payment, transfers_by_schedule
def list_payments(
db: Session,
*,
ctx: MarketFieldContext,
user_info: dict,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
pay_mode: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
query = _scoped_payment_query(db, ctx=ctx, user_info=user_info)
keyword_value = (keyword or '').strip()
if keyword_value:
like = f'%{keyword_value}%'
query = query.filter(
or_(
BizCollectionProtocol.protocol_no.like(like),
BizProtocolSamplePayment.sample_code.like(like),
BizProtocolPartyCustomer.mother_name.like(like),
BizProtocolPartyCustomer.mother_phone.like(like),
)
)
mode = (pay_mode or '').strip()
if mode:
query = query.filter(BizProtocolSamplePayment.pay_mode == mode)
total = (
query.order_by(None)
.with_entities(func.count(BizProtocolSamplePayment.id))
.scalar()
or 0
)
page = max(1, page)
page_size = min(max(1, page_size), 100)
rows = (
query.order_by(
BizProtocolSamplePayment.create_time.desc(),
BizProtocolSamplePayment.id.desc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
schedules, transfers = _load_children(
db, [row[0].id for row in rows], ctx.tenant_no
)
return [
_payment_dict(
payment,
protocol,
party,
schedules.get(payment.id, []),
transfers,
)
for payment, protocol, party in rows
], int(total)
def get_payment_detail(
db: Session,
*,
payment_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
row = _scoped_payment_query(
db, ctx=ctx, user_info=user_info, v2_only=False
).filter(
BizProtocolSamplePayment.id == payment_id
).first()
if not row:
raise HTTPException(status_code=404, detail='缴费方案不存在或无权访问')
payment, protocol, party = row
if protocol.payment_model_version != 2:
raise HTTPException(
status_code=409,
detail='旧版协议缴费模型不支持移动端办理,请前往管理端查看',
)
schedules, transfers = _load_children(db, [payment.id], ctx.tenant_no)
return _payment_dict(
payment,
protocol,
party,
schedules.get(payment.id, []),
transfers,
)
def list_payment_chase(
db: Session,
*,
ctx: MarketFieldContext,
user_info: dict,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
chase_type: Optional[str] = None,
pay_mode: Optional[str] = None,
sales_user_id: Optional[str] = None,
today: Optional[date] = None,
) -> Tuple[List[Dict[str, Any]], int]:
today_value = today or date.today()
today_key = today_value.strftime('%Y%m%d')
end_key = (today_value + timedelta(days=30)).strftime('%Y%m%d')
normalized_due = func.replace(BizProtocolPaymentSchedule.due_date, '-', '')
dialect_name = db.get_bind().dialect.name
if dialect_name == 'mysql':
parsed_due = func.str_to_date(normalized_due, '%Y%m%d')
month_start = func.str_to_date(
func.concat(func.substr(normalized_due, 1, 6), '01'),
'%Y%m%d',
)
month_number = cast(func.substr(normalized_due, 5, 2), Integer)
day_number = cast(func.substr(normalized_due, 7, 2), Integer)
valid_due = and_(
func.length(normalized_due) == 8,
normalized_due.op('REGEXP')('^[0-9]{8}$'),
month_number.between(1, 12),
day_number.between(1, func.day(func.last_day(month_start))),
func.date_format(parsed_due, '%Y%m%d') == normalized_due,
)
else:
parsed_due = func.date(
func.printf(
'%s-%s-%s',
func.substr(normalized_due, 1, 4),
func.substr(normalized_due, 5, 2),
func.substr(normalized_due, 7, 2),
)
)
valid_due = and_(
func.length(normalized_due) == 8,
func.strftime('%Y%m%d', parsed_due) == normalized_due,
)
paid_subquery = (
db.query(
BizProtocolPaymentTransfer.schedule_id.label('schedule_id'),
func.sum(
case(
(
BizProtocolPaymentTransfer.pay_status.in_(
('dfj', 'jfqrz', 'yjf')
),
BizProtocolPaymentTransfer.transfer_amount,
),
else_=Decimal('0'),
)
).label('paid_amount'),
)
.filter(
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
)
.group_by(BizProtocolPaymentTransfer.schedule_id)
.subquery()
)
paid_amount = func.coalesce(paid_subquery.c.paid_amount, Decimal('0'))
arrears_amount = (
func.coalesce(BizProtocolPaymentSchedule.due_amount, Decimal('0'))
- paid_amount
)
collection_row = aliased(BizProtocolCollectionInfo)
query = (
db.query(
BizProtocolPaymentSchedule,
BizProtocolSamplePayment,
BizCollectionProtocol,
BizProtocolPartyCustomer,
collection_row,
paid_amount.label('paid_amount'),
)
.join(
BizProtocolSamplePayment,
and_(
BizProtocolSamplePayment.id
== BizProtocolPaymentSchedule.sample_payment_id,
BizProtocolSamplePayment.tenant_no == ctx.tenant_no,
BizProtocolSamplePayment.del_flag == 0,
),
)
.join(
BizCollectionProtocol,
and_(
BizCollectionProtocol.id
== BizProtocolPaymentSchedule.collection_protocol_id,
BizCollectionProtocol.tenant_no == ctx.tenant_no,
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.payment_model_version == 2,
BizCollectionProtocol.protocol_status.notin_(
PAYMENT_TERMINAL_PROTOCOL_STATUSES
),
),
)
.join(
collection_row,
and_(
collection_row.collection_protocol_id
== BizCollectionProtocol.id,
collection_row.tenant_no == ctx.tenant_no,
collection_row.del_flag == 0,
),
)
.outerjoin(
BizProtocolPartyCustomer,
and_(
BizProtocolPartyCustomer.collection_protocol_id
== BizCollectionProtocol.id,
BizProtocolPartyCustomer.tenant_no == ctx.tenant_no,
BizProtocolPartyCustomer.del_flag == 0,
),
)
.outerjoin(
paid_subquery,
paid_subquery.c.schedule_id == BizProtocolPaymentSchedule.id,
)
.filter(
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
BizProtocolPaymentSchedule.schedule_status
== PAYMENT_WRITABLE_SCHEDULE_STATUS,
valid_due,
normalized_due <= end_key,
arrears_amount > 0,
)
)
query = apply_cell_protocol_scope(query, db, ctx, user_info=user_info)
keyword_value = (keyword or '').strip()
if keyword_value:
like = f'%{keyword_value}%'
query = query.filter(
or_(
BizCollectionProtocol.protocol_no.like(like),
BizProtocolSamplePayment.sample_code.like(like),
BizProtocolPartyCustomer.mother_name.like(like),
BizProtocolPartyCustomer.mother_phone.like(like),
)
)
mode = (pay_mode or '').strip()
if mode:
query = query.filter(BizProtocolSamplePayment.pay_mode == mode)
sales = (sales_user_id or '').strip()
if sales:
query = query.filter(collection_row.sales_user_id == sales)
kind = (chase_type or '').strip()
if kind == 'overdue':
query = query.filter(normalized_due < today_key)
elif kind == 'upcoming':
query = query.filter(normalized_due >= today_key)
total = (
query.order_by(None)
.with_entities(func.count(BizProtocolPaymentSchedule.id))
.scalar()
or 0
)
page = max(1, page)
page_size = min(max(1, page_size), 100)
candidates = (
query.order_by(
normalized_due.asc(),
BizCollectionProtocol.protocol_no.asc(),
BizProtocolPaymentSchedule.id.asc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
rows: List[Dict[str, Any]] = []
for schedule, payment, protocol, party, collection, paid in candidates:
due_date = parse_due_date_ymd(schedule.due_date)
if due_date is None:
continue
paid_value = Decimal(str(paid or 0))
due_value = Decimal(str(schedule.due_amount or 0))
arrears = max(due_value - paid_value, Decimal('0'))
is_overdue = due_date < today_value
rows.append(
{
'scheduleId': schedule.id,
'samplePaymentId': payment.id,
'protocolId': protocol.id,
'protocolNo': protocol.protocol_no or '',
'motherName': (party.mother_name if party else '') or '',
'motherPhone': (party.mother_phone if party else '') or '',
'sampleCode': payment.sample_code or schedule.sample_code or '',
'salesUserId': collection.sales_user_id or '',
'payMode': payment.pay_mode or '',
'scheduleNo': schedule.schedule_no,
'dueDate': due_date.strftime('%Y-%m-%d'),
'dueAmount': _money(schedule.due_amount),
'paidAmount': _money(paid_value),
'arrearsAmount': _money(arrears),
'chaseStatus': 'overdue' if is_overdue else 'upcoming',
'daysOverdue': (today_value - due_date).days if is_overdue else 0,
'daysUntilDue': (due_date - today_value).days
if not is_overdue
else 0,
'paymentModelVersion': 2,
}
)
return rows, int(total)
def _scoped_schedule(
db: Session,
*,
schedule_id: str,
ctx: MarketFieldContext,
user_info: dict,
):
query = (
db.query(BizProtocolPaymentSchedule)
.join(
BizProtocolSamplePayment,
and_(
BizProtocolSamplePayment.id
== BizProtocolPaymentSchedule.sample_payment_id,
BizProtocolSamplePayment.tenant_no == ctx.tenant_no,
BizProtocolSamplePayment.del_flag == 0,
),
)
.join(
BizCollectionProtocol,
and_(
BizCollectionProtocol.id
== BizProtocolPaymentSchedule.collection_protocol_id,
BizCollectionProtocol.tenant_no == ctx.tenant_no,
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.payment_model_version == 2,
BizCollectionProtocol.protocol_status.notin_(
PAYMENT_TERMINAL_PROTOCOL_STATUSES
),
),
)
.filter(
BizProtocolPaymentSchedule.id == schedule_id,
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
BizProtocolPaymentSchedule.schedule_status
== PAYMENT_WRITABLE_SCHEDULE_STATUS,
)
)
query = apply_cell_protocol_scope(query, db, ctx, user_info=user_info)
row = query.with_for_update().first()
if not row:
raise HTTPException(status_code=404, detail='应缴批次不存在或无权访问')
return row
def _scoped_transfer(
db: Session,
*,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
):
query = (
db.query(BizProtocolPaymentTransfer)
.join(
BizProtocolPaymentSchedule,
and_(
BizProtocolPaymentSchedule.id
== BizProtocolPaymentTransfer.schedule_id,
BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
BizProtocolPaymentSchedule.del_flag == 0,
BizProtocolPaymentSchedule.schedule_status
== PAYMENT_WRITABLE_SCHEDULE_STATUS,
),
)
.join(
BizProtocolSamplePayment,
and_(
BizProtocolSamplePayment.id
== BizProtocolPaymentTransfer.sample_payment_id,
BizProtocolSamplePayment.tenant_no == ctx.tenant_no,
BizProtocolSamplePayment.del_flag == 0,
),
)
.join(
BizCollectionProtocol,
and_(
BizCollectionProtocol.id
== BizProtocolPaymentTransfer.collection_protocol_id,
BizCollectionProtocol.tenant_no == ctx.tenant_no,
BizCollectionProtocol.del_flag == 0,
BizCollectionProtocol.payment_model_version == 2,
BizCollectionProtocol.protocol_status.notin_(
PAYMENT_TERMINAL_PROTOCOL_STATUSES
),
),
)
.filter(
BizProtocolPaymentTransfer.id == transfer_id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
)
)
query = apply_cell_protocol_scope(query, db, ctx, user_info=user_info)
row = query.with_for_update().first()
if not row:
raise HTTPException(status_code=404, detail='转账记录不存在或无权访问')
return row
def _editable_fields(fields: Dict[str, Any]) -> Dict[str, Any]:
aliases = {
'transfer_amount': 'transferAmount',
'transfer_time': 'transferTime',
'transfer_type': 'transferType',
'transfer_attachments': 'transferAttachments',
'remark': 'remark',
}
result: Dict[str, Any] = {}
for python_name, alias in aliases.items():
if python_name in fields:
result[alias] = fields[python_name]
elif alias in fields:
result[alias] = fields[alias]
amount = result.get('transferAmount')
if amount is not None and Decimal(str(amount)) <= 0:
raise ValueError('转账金额必须大于 0')
if 'transferTime' in result and result['transferTime'] not in (None, ''):
value = result['transferTime']
result['transferTime'] = value if isinstance(value, datetime) else parse_datetime(str(value))
if 'transferType' in result:
result['transferType'] = str(result['transferType'] or '').strip() or None
if 'transferAttachments' in result:
result['transferAttachments'] = str(
result['transferAttachments'] or ''
).strip() or None
if 'remark' in result:
result['remark'] = str(result['remark'] or '').strip() or None
return result
def create_transfer(
db: Session,
*,
schedule_id: str,
fields: Dict[str, Any],
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
try:
_scoped_schedule(
db,
schedule_id=schedule_id,
ctx=ctx,
user_info=user_info,
)
values = _editable_fields(fields)
row = payment_crud.add_transfer(
db,
schedule_id=schedule_id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
transfer_amount=values.get('transferAmount'),
transfer_time=values.get('transferTime'),
transfer_type=values.get('transferType'),
transfer_attachments=values.get('transferAttachments'),
remark=values.get('remark'),
create_dept_id=_current_dept_id(user_info),
commit=False,
)
if 'transferAttachments' in values:
row.transfer_attachments = validate_attachment_refs(
db,
values['transferAttachments'],
module='payment',
ctx=ctx,
creating=True,
business_id=row.id,
)
db.add(row)
db.flush()
result = _transfer_dict(row)
db.commit()
return result
except Exception:
db.rollback()
raise
def update_transfer(
db: Session,
*,
transfer_id: str,
fields: Dict[str, Any],
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
try:
row = _scoped_transfer(
db, transfer_id=transfer_id, ctx=ctx, user_info=user_info
)
if (row.pay_status or '') != 'dfj':
raise ValueError('仅待缴费草稿状态可编辑')
values = _editable_fields(fields)
if 'transferAttachments' in values:
values['transferAttachments'] = validate_attachment_refs(
db,
values['transferAttachments'],
module='payment',
ctx=ctx,
creating=False,
existing_refs=row.transfer_attachments,
business_id=row.id,
)
if 'transferAmount' in values:
row.transfer_amount = Decimal(str(values['transferAmount'])) if values['transferAmount'] is not None else None
if 'transferTime' in values:
row.transfer_time = values['transferTime']
if 'transferType' in values:
row.transfer_type = values['transferType']
if 'transferAttachments' in values:
row.transfer_attachments = values['transferAttachments']
if 'remark' in values:
row.remark = values['remark']
row.update_by = ctx.user_id
row.update_time = datetime.now()
db.add(row)
db.commit()
db.refresh(row)
return _transfer_dict(row)
except Exception:
db.rollback()
raise
def submit_transfer(
db: Session,
*,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
try:
row = _scoped_transfer(
db, transfer_id=transfer_id, ctx=ctx, user_info=user_info
)
if (row.pay_status or '') != 'dfj':
raise ValueError('当前状态不可提交财务确认')
if row.transfer_amount is None or Decimal(str(row.transfer_amount)) <= 0:
raise ValueError('请填写转账金额')
if not (row.transfer_type or '').strip():
raise ValueError('请选择转账方式')
now = datetime.now()
changed = (
db.query(BizProtocolPaymentTransfer)
.filter(
BizProtocolPaymentTransfer.id == transfer_id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
BizProtocolPaymentTransfer.pay_status == 'dfj',
)
.update(
{
BizProtocolPaymentTransfer.pay_status: 'jfqrz',
BizProtocolPaymentTransfer.update_by: ctx.user_id,
BizProtocolPaymentTransfer.update_time: now,
},
synchronize_session=False,
)
)
if changed != 1:
raise ValueError('转账状态已变化,请刷新后重试')
db.commit()
db.expire(row)
return _transfer_dict(row)
except Exception:
db.rollback()
raise
def withdraw_transfer(
db: Session,
*,
transfer_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> Dict[str, Any]:
try:
row = _scoped_transfer(
db, transfer_id=transfer_id, ctx=ctx, user_info=user_info
)
if (row.pay_status or '') != 'jfqrz':
raise ValueError('仅缴费确认中可撤回')
now = datetime.now()
changed = (
db.query(BizProtocolPaymentTransfer)
.filter(
BizProtocolPaymentTransfer.id == transfer_id,
BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
BizProtocolPaymentTransfer.del_flag == 0,
BizProtocolPaymentTransfer.pay_status == 'jfqrz',
)
.update(
{
BizProtocolPaymentTransfer.pay_status: 'dfj',
BizProtocolPaymentTransfer.update_by: ctx.user_id,
BizProtocolPaymentTransfer.update_time: now,
},
synchronize_session=False,
)
)
if changed != 1:
raise ValueError('转账状态已变化,请刷新后重试')
db.commit()
db.expire(row)
return _transfer_dict(row)
except Exception:
db.rollback()
raise
+936
View File
@@ -0,0 +1,936 @@
"""移动市场协议服务:双来源统一展示,写入保持来源内原子性。"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.core.utils import parse_datetime
from app.crud import collection_protocol as cell_crud
from app.crud import research_donation as donation_crud
from app.models.models import (
BizCellSampleType,
BizCollectionHospital,
BizCollectionProtocol,
BizCollectionRegion,
BizCollectionRegionStaff,
BizCustomer,
BizItemUnit,
BizProtocolCollectionInfo,
BizProtocolPartyCustomer,
BizProtocolSample,
BizSampleCode,
SysUser,
)
from app.models.research_donation import (
BizResearchDonation,
BizResearchDonationCollection,
BizResearchDonationParty,
BizResearchDonationSample,
)
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_field_scope import (
MarketFieldContext,
apply_cell_protocol_scope,
apply_donation_scope,
assert_cell_protocol_access,
assert_donation_access,
)
from app.services.market_support import validate_attachment_refs
SOURCE_CELL = 'cell_storage'
SOURCE_DONATION = 'research_donation'
SOURCES = (SOURCE_CELL, SOURCE_DONATION)
_CUSTOMER_LINK_FIELDS = (
'customer_id',
'father_customer_id',
'newborn_customer_id',
)
def _current_dept_id(user_info: dict) -> str:
if not isinstance(user_info, dict):
return ''
return str(
user_info.get('dept_id')
or user_info.get('deptId')
or user_info.get('region_id')
or ''
).strip()
def _dump(value) -> dict:
if value is None:
return {}
if hasattr(value, 'model_dump'):
return value.model_dump(by_alias=False, exclude_unset=True)
return dict(value)
def _assert_customer_link_policy(
customer: dict, *, current_party=None, creating: bool
) -> None:
"""移动端不创建/改挂客户主档,只保留单据原有客户关联。"""
for key in _CUSTOMER_LINK_FIELDS:
if key not in customer:
continue
supplied = str(customer.get(key) or '').strip()
if creating:
if supplied:
raise ValueError('移动端创建协议不允许指定已有客户')
continue
current = str(getattr(current_party, key, None) or '').strip()
if supplied != current:
raise ValueError('客户关联不属于当前协议,禁止改挂')
def _fmt_dt(value) -> str:
if value is None:
return ''
if isinstance(value, str):
return value
return value.strftime('%Y-%m-%d %H:%M:%S')
def _manager_assignment_allowed(
db: Session,
ctx: MarketFieldContext,
*,
region_id: Optional[str],
region_manager_user_id: Optional[str],
) -> bool:
if str(region_manager_user_id or '').strip() == ctx.user_id:
return True
region = str(region_id or '').strip()
if not region:
return False
return (
db.query(BizCollectionRegionStaff.id)
.filter(
BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
BizCollectionRegionStaff.del_flag == 0,
BizCollectionRegionStaff.user_id == ctx.user_id,
BizCollectionRegionStaff.is_leader == 1,
BizCollectionRegionStaff.region_id == region,
)
.first()
is not None
)
def _ensure_manager_assignment(
db: Session,
ctx: MarketFieldContext,
*,
region_id: Optional[str],
region_manager_user_id: Optional[str],
) -> None:
if not _manager_assignment_allowed(
db,
ctx,
region_id=region_id,
region_manager_user_id=region_manager_user_id,
):
raise ValueError('协议归属不在当前地区经理范围内')
def _active_user_ids(db: Session, ctx: MarketFieldContext, ids: set) -> set:
if not ids:
return set()
return {
str(row[0])
for row in db.query(SysUser.id)
.filter(
SysUser.id.in_(ids),
SysUser.tenant_no == ctx.tenant_no,
SysUser.del_flag == 0,
SysUser.status == '1',
)
.all()
}
def _validate_references(
db: Session,
ctx: MarketFieldContext,
*,
customer: dict,
collection: dict,
samples: List[dict],
extra_user_ids: Optional[set] = None,
) -> None:
region_id = str(collection.get('region_id') or '').strip()
hospital_id = str(collection.get('hospital_id') or '').strip()
if region_id:
exists = (
db.query(BizCollectionRegion.id)
.filter(
BizCollectionRegion.id == region_id,
BizCollectionRegion.tenant_no == ctx.tenant_no,
BizCollectionRegion.del_flag == 0,
)
.first()
)
if not exists:
raise ValueError('采集地区不存在或不属于当前租户')
if hospital_id:
exists = (
db.query(BizCollectionHospital.id)
.filter(
BizCollectionHospital.id == hospital_id,
BizCollectionHospital.region_id == region_id,
BizCollectionHospital.tenant_no == ctx.tenant_no,
BizCollectionHospital.del_flag == 0,
)
.first()
)
if not exists:
raise ValueError('医院不存在、不属于当前租户或与采集地区不匹配')
user_ids = {
str(value).strip()
for value in (
customer.get('collector_user_id'),
collection.get('sales_user_id'),
collection.get('region_manager_user_id'),
)
if str(value or '').strip()
}
user_ids.update(
str(value).strip()
for value in (extra_user_ids or set())
if str(value or '').strip()
)
if _active_user_ids(db, ctx, user_ids) != user_ids:
raise ValueError('引用用户不存在、已停用或不属于当前租户')
customer_ids = {
str(customer.get(key) or '').strip()
for key in ('customer_id', 'father_customer_id', 'newborn_customer_id')
if str(customer.get(key) or '').strip()
}
if customer_ids:
existing = {
str(row[0])
for row in db.query(BizCustomer.id)
.filter(
BizCustomer.id.in_(customer_ids),
BizCustomer.tenant_no == ctx.tenant_no,
BizCustomer.del_flag == 0,
)
.all()
}
if existing != customer_ids:
raise ValueError('客户不存在或不属于当前租户')
type_ids = {
str(item.get('sample_type_id') or '').strip()
for item in samples
if str(item.get('sample_type_id') or '').strip()
}
if type_ids:
existing = {
str(row[0])
for row in db.query(BizCellSampleType.id)
.filter(
BizCellSampleType.id.in_(type_ids),
BizCellSampleType.tenant_no == ctx.tenant_no,
BizCellSampleType.del_flag == 0,
)
.all()
}
if existing != type_ids:
raise ValueError('样本类型不存在或不属于当前租户')
unit_ids = {
str(item.get('unit_id') or '').strip()
for item in samples
if str(item.get('unit_id') or '').strip()
}
if unit_ids:
existing = {
str(row[0])
for row in db.query(BizItemUnit.id)
.filter(
BizItemUnit.id.in_(unit_ids),
BizItemUnit.tenant_no == ctx.tenant_no,
BizItemUnit.del_flag == 0,
)
.all()
}
if existing != unit_ids:
raise ValueError('计量单位不存在或不属于当前租户')
sample_code_ids = {
str(item.get('sample_code_id') or '').strip()
for item in samples
if str(item.get('sample_code_id') or '').strip()
}
sample_code_map = {}
if sample_code_ids:
sample_code_map = {
str(row.id): str(row.sample_code or '').strip()
for row in db.query(BizSampleCode)
.filter(
BizSampleCode.id.in_(sample_code_ids),
BizSampleCode.tenant_no == ctx.tenant_no,
BizSampleCode.del_flag == 0,
)
.all()
}
if set(sample_code_map) != sample_code_ids:
raise ValueError('样本编码不存在、已删除或不属于当前租户')
for item in samples:
sample_code_id = str(item.get('sample_code_id') or '').strip()
if not sample_code_id:
continue
expected_code = sample_code_map[sample_code_id]
supplied_code = str(item.get('sample_code') or '').strip()
if supplied_code and supplied_code != expected_code:
raise ValueError('样本编码ID与样本编码不一致')
item['sample_code'] = expected_code
def _normalize_write(
db: Session,
*,
body: MarketProtocolBody,
ctx: MarketFieldContext,
current_collection=None,
) -> Tuple[dict, dict, List[dict], dict]:
customer = _dump(body.customer)
collection = _dump(body.collection)
samples = [_dump(item) for item in (body.samples or [])]
attachments = _dump(body.attachments)
effective_collection = {
key: getattr(current_collection, key, None)
for key in (
'region_id',
'hospital_id',
'sales_user_id',
'region_manager_user_id',
)
}
effective_collection.update(collection)
if ctx.scope_kind == 'specialist':
customer['collector_user_id'] = ctx.user_id
collection['sales_user_id'] = ctx.user_id
effective_collection['sales_user_id'] = ctx.user_id
else:
_ensure_manager_assignment(
db,
ctx,
region_id=effective_collection.get('region_id'),
region_manager_user_id=effective_collection.get(
'region_manager_user_id'
),
)
for key in ('sample_collect_time', 'maternal_blood_collect_time'):
if key in collection and isinstance(collection[key], str):
collection[key] = parse_datetime(collection[key])
if 'newborn_delivery_time' in customer and isinstance(
customer['newborn_delivery_time'], str
):
customer['newborn_delivery_time'] = parse_datetime(
customer['newborn_delivery_time']
)
_validate_references(
db,
ctx,
customer=customer,
collection=effective_collection,
samples=samples,
extra_user_ids={body.collector_user_id}
if ctx.scope_kind == 'manager' and body.collector_user_id
else set(),
)
return customer, collection, samples, attachments
def _validate_mobile_attachments(
db: Session,
attachments: dict,
*,
ctx: MarketFieldContext,
creating: bool,
existing: Optional[dict] = None,
business_id: Optional[str] = None,
) -> dict:
current = existing or {}
normalized = dict(attachments)
for key, refs in attachments.items():
if refs is None:
continue
normalized[key] = validate_attachment_refs(
db,
refs,
module='protocol',
ctx=ctx,
creating=creating,
existing_refs=current.get(key),
business_id=business_id,
)
return normalized
def _party_name_cell(db: Session, ids: List[str], tenant_no: str) -> dict:
if not ids:
return {}
rows = (
db.query(BizProtocolPartyCustomer)
.filter(
BizProtocolPartyCustomer.collection_protocol_id.in_(ids),
BizProtocolPartyCustomer.tenant_no == tenant_no,
BizProtocolPartyCustomer.del_flag == 0,
)
.all()
)
return {row.collection_protocol_id: row.mother_name or '' for row in rows}
def _party_name_donation(db: Session, ids: List[str], tenant_no: str) -> dict:
if not ids:
return {}
rows = (
db.query(BizResearchDonationParty)
.filter(
BizResearchDonationParty.research_donation_id.in_(ids),
BizResearchDonationParty.tenant_no == tenant_no,
BizResearchDonationParty.del_flag == 0,
)
.all()
)
return {row.research_donation_id: row.mother_name or '' for row in rows}
def list_protocols(
db: Session,
*,
ctx: MarketFieldContext,
user_info: dict,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
source: Optional[str] = None,
) -> Tuple[List[dict], int]:
if source and source not in SOURCES:
raise ValueError('不支持的协议来源')
search = str(keyword or '').strip()
rows = []
fetch_limit = max(1, page) * min(max(1, page_size), 100)
total = 0
if source in (None, SOURCE_CELL):
query = apply_cell_protocol_scope(
db.query(BizCollectionProtocol), db, ctx, user_info=user_info
)
if search:
query = query.filter(BizCollectionProtocol.protocol_no.like(f'%{search}%'))
total += int(
query.order_by(None)
.with_entities(func.count(BizCollectionProtocol.id))
.scalar()
or 0
)
cell_rows = (
query.order_by(
BizCollectionProtocol.create_time.desc(),
BizCollectionProtocol.id.desc(),
)
.limit(fetch_limit)
.all()
)
names = _party_name_cell(
db, [row.id for row in cell_rows], ctx.tenant_no
)
rows.extend(
{
'id': row.id,
'source': SOURCE_CELL,
'code': row.protocol_no or '',
'protocolNo': row.protocol_no or '',
'status': row.protocol_status or '',
'customerName': names.get(row.id, ''),
'createTime': _fmt_dt(row.create_time),
'_created': row.create_time or datetime.min,
}
for row in cell_rows
)
if source in (None, SOURCE_DONATION):
query = apply_donation_scope(
db.query(BizResearchDonation), db, ctx, user_info=user_info
)
if search:
query = query.filter(BizResearchDonation.donation_no.like(f'%{search}%'))
total += int(
query.order_by(None)
.with_entities(func.count(BizResearchDonation.id))
.scalar()
or 0
)
donation_rows = (
query.order_by(
BizResearchDonation.create_time.desc(),
BizResearchDonation.id.desc(),
)
.limit(fetch_limit)
.all()
)
names = _party_name_donation(
db, [row.id for row in donation_rows], ctx.tenant_no
)
rows.extend(
{
'id': row.id,
'source': SOURCE_DONATION,
'code': row.donation_no or '',
'protocolNo': row.donation_no or '',
'status': row.donation_status or '',
'customerName': names.get(row.id, ''),
'createTime': _fmt_dt(row.create_time),
'_created': row.create_time or datetime.min,
}
for row in donation_rows
)
rows.sort(
key=lambda item: (item['_created'], item['id']), reverse=True
)
safe_page = max(1, page)
safe_size = min(max(1, page_size), 100)
page_rows = rows[(safe_page - 1) * safe_size : safe_page * safe_size]
for item in page_rows:
item.pop('_created', None)
return page_rows, total
def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dict:
party = cell_crud.get_party_customer(db, row.id, tenant_no)
collection = cell_crud.get_collection_info(db, row.id, tenant_no)
samples = cell_crud.list_samples(db, row.id, tenant_no)
payment = cell_crud.get_payment_by_protocol_id(db, row.id, tenant_no)
return {
'source': SOURCE_CELL,
'basic': cell_crud._protocol_to_dict(row),
'collection': cell_crud._collection_info_to_dict(collection)
if collection
else None,
'customer': cell_crud._party_customer_to_dict(party) if party else None,
'samples': [cell_crud._sample_to_dict(item) for item in samples],
'payment': cell_crud._payment_to_dict(payment) if payment else None,
'attachments': {
'protocolAttachments': row.protocol_attachments or '',
'consentFormUrls': row.consent_form_urls or '',
'healthSurveyFormUrls': row.health_survey_form_urls or '',
},
}
def _donation_detail(
db: Session, row: BizResearchDonation, tenant_no: str
) -> dict:
party = donation_crud.get_party(db, row.id, tenant_no)
collection = donation_crud.get_collection(db, row.id, tenant_no)
samples = donation_crud.list_samples(db, row.id, tenant_no)
return {
'source': SOURCE_DONATION,
'basic': donation_crud._donation_to_dict(row),
'collection': donation_crud._collection_to_dict(collection)
if collection
else None,
'customer': donation_crud._party_to_dict(party) if party else None,
'samples': [donation_crud._sample_to_dict(item) for item in samples],
'attachments': {
'consentFormUrls': row.consent_form_urls or '',
'attachmentUrls': row.attachment_urls or '',
'infectiousDiseaseReportUrls': row.infectious_disease_report_urls
or '',
},
}
def get_protocol(
db: Session,
*,
source: Optional[str],
protocol_id: str,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
if source == SOURCE_CELL:
row = assert_cell_protocol_access(
db, protocol_id, ctx, user_info=user_info
)
return _cell_detail(db, row, ctx.tenant_no)
if source == SOURCE_DONATION:
row = assert_donation_access(db, protocol_id, ctx, user_info=user_info)
return _donation_detail(db, row, ctx.tenant_no)
if source is None:
cell = (
apply_cell_protocol_scope(
db.query(BizCollectionProtocol), db, ctx, user_info=user_info
)
.filter(BizCollectionProtocol.id == protocol_id)
.first()
)
if cell is not None:
return _cell_detail(db, cell, ctx.tenant_no)
donation = (
apply_donation_scope(
db.query(BizResearchDonation), db, ctx, user_info=user_info
)
.filter(BizResearchDonation.id == protocol_id)
.first()
)
if donation is not None:
return _donation_detail(db, donation, ctx.tenant_no)
raise HTTPException(status_code=404, detail='数据不存在或无权访问')
raise ValueError('不支持的协议来源')
def create_protocol(
db: Session,
*,
body: MarketProtocolBody,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
customer, collection, samples, attachments = _normalize_write(
db, body=body, ctx=ctx
)
_assert_customer_link_policy(customer, creating=True)
dept_id = _current_dept_id(user_info)
try:
if body.source == SOURCE_CELL:
row = cell_crud.create_collection_protocol(
db,
protocol_no=body.protocol_no or '',
protocol_attachments=attachments.get('protocol_attachments'),
consent_form_urls=attachments.get('consent_form_urls'),
health_survey_form_urls=attachments.get(
'health_survey_form_urls'
),
register_date=body.register_date,
collector_user_id=ctx.user_id
if ctx.scope_kind == 'specialist'
else body.collector_user_id,
protocol_total_amount=body.protocol_total_amount,
remark=body.remark,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
create_dept_id=dept_id,
commit=False,
)
attachments = _validate_mobile_attachments(
db,
attachments,
ctx=ctx,
creating=True,
business_id=row.id,
)
row.protocol_attachments = attachments.get('protocol_attachments')
row.consent_form_urls = attachments.get('consent_form_urls')
row.health_survey_form_urls = attachments.get(
'health_survey_form_urls'
)
db.add(row)
cell_crud.upsert_party_customer(
db,
row.id,
ctx.tenant_no,
ctx.user_id,
commit=False,
create_dept_id=dept_id,
**customer,
)
cell_crud.upsert_collection_info(
db,
row.id,
ctx.tenant_no,
ctx.user_id,
commit=False,
create_dept_id=dept_id,
**collection,
)
cell_crud.save_samples(
db,
row.id,
ctx.tenant_no,
ctx.user_id,
samples,
create_dept_id=dept_id,
commit=False,
)
assert_cell_protocol_access(db, row.id, ctx, user_info=user_info)
db.commit()
db.refresh(row)
return _cell_detail(db, row, ctx.tenant_no)
data = {
'donation_no': body.donation_no or '',
'donation_date': body.donation_date,
'collector_user_id': ctx.user_id
if ctx.scope_kind == 'specialist'
else body.collector_user_id,
'remark': body.remark,
'party': customer,
'collection': collection,
'samples': samples,
'consent_form_urls': attachments.get('consent_form_urls'),
'attachment_urls': attachments.get('attachment_urls'),
'infectious_disease_report_urls': attachments.get(
'infectious_disease_report_urls'
),
}
row = donation_crud.create_donation(
db,
data=data,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
create_dept_id=dept_id,
commit=False,
sync_customers=False,
)
attachments = _validate_mobile_attachments(
db,
attachments,
ctx=ctx,
creating=True,
business_id=row.id,
)
row.consent_form_urls = attachments.get('consent_form_urls')
row.attachment_urls = attachments.get('attachment_urls')
row.infectious_disease_report_urls = attachments.get(
'infectious_disease_report_urls'
)
db.add(row)
assert_donation_access(db, row.id, ctx, user_info=user_info)
db.commit()
db.refresh(row)
return _donation_detail(db, row, ctx.tenant_no)
except Exception:
db.rollback()
raise
def update_protocol(
db: Session,
*,
source: str,
protocol_id: str,
body: MarketProtocolBody,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
if body.source != source:
raise ValueError('请求来源与路径来源不一致')
dept_id = _current_dept_id(user_info)
try:
if source == SOURCE_CELL:
row = assert_cell_protocol_access(
db, protocol_id, ctx, user_info=user_info
)
if (row.protocol_status or '').strip() not in (
'',
'draft',
'dsx',
'dqr',
'sxz',
'ygq',
):
raise ValueError('当前状态的协议不可修改')
current_collection = cell_crud.get_collection_info(
db, row.id, ctx.tenant_no
)
current_party = cell_crud.get_party_customer(
db, row.id, ctx.tenant_no
)
customer, collection, samples, attachments = _normalize_write(
db,
body=body,
ctx=ctx,
current_collection=current_collection,
)
attachments = _validate_mobile_attachments(
db,
attachments,
ctx=ctx,
creating=False,
existing={
'protocol_attachments': row.protocol_attachments,
'consent_form_urls': row.consent_form_urls,
'health_survey_form_urls': row.health_survey_form_urls,
},
business_id=row.id,
)
_assert_customer_link_policy(
customer, current_party=current_party, creating=False
)
cell_crud.update_collection_protocol(
db,
row.id,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
protocol_no=body.protocol_no,
protocol_attachments=attachments.get('protocol_attachments'),
consent_form_urls=attachments.get('consent_form_urls'),
health_survey_form_urls=attachments.get(
'health_survey_form_urls'
),
register_date=body.register_date,
collector_user_id=ctx.user_id
if ctx.scope_kind == 'specialist'
else body.collector_user_id,
protocol_total_amount=body.protocol_total_amount,
remark=body.remark,
commit=False,
)
if body.customer is not None:
if current_party is not None:
customer = {
**cell_crud._normalize_fields(
cell_crud._party_customer_to_dict(current_party)
),
**customer,
}
for metadata_key in (
'id',
'collection_protocol_id',
'tenant_no',
'order_num',
'create_time',
'update_time',
):
customer.pop(metadata_key, None)
newborn_delivery_time = customer.get(
'newborn_delivery_time'
)
if isinstance(newborn_delivery_time, str):
if newborn_delivery_time.strip():
customer['newborn_delivery_time'] = parse_datetime(
newborn_delivery_time
)
else:
customer.pop('newborn_delivery_time', None)
cell_crud.upsert_party_customer(
db,
row.id,
ctx.tenant_no,
ctx.user_id,
commit=False,
create_dept_id=dept_id,
**customer,
)
if body.collection is not None:
cell_crud.upsert_collection_info(
db,
row.id,
ctx.tenant_no,
ctx.user_id,
commit=False,
create_dept_id=dept_id,
**collection,
)
if body.samples is not None:
cell_crud.save_samples(
db,
row.id,
ctx.tenant_no,
ctx.user_id,
samples,
create_dept_id=dept_id,
commit=False,
)
row = assert_cell_protocol_access(
db, row.id, ctx, user_info=user_info
)
db.commit()
db.refresh(row)
return _cell_detail(db, row, ctx.tenant_no)
row = assert_donation_access(db, protocol_id, ctx, user_info=user_info)
if (row.donation_status or '').strip() not in (
'',
'draft',
'dsx',
'dqr',
'sxz',
'ygq',
):
raise ValueError('当前状态的协议不可修改')
current_collection = donation_crud.get_collection(
db, row.id, ctx.tenant_no
)
current_party = donation_crud.get_party(db, row.id, ctx.tenant_no)
customer, collection, samples, attachments = _normalize_write(
db,
body=body,
ctx=ctx,
current_collection=current_collection,
)
attachments = _validate_mobile_attachments(
db,
attachments,
ctx=ctx,
creating=False,
existing={
'consent_form_urls': row.consent_form_urls,
'attachment_urls': row.attachment_urls,
'infectious_disease_report_urls': row.infectious_disease_report_urls,
},
business_id=row.id,
)
_assert_customer_link_policy(
customer, current_party=current_party, creating=False
)
if body.customer is not None and current_party is not None:
customer = {
**donation_crud._normalize_fields(
donation_crud._party_to_dict(current_party)
),
**customer,
}
if body.collection is not None and current_collection is not None:
collection = {
**donation_crud._normalize_fields(
donation_crud._collection_to_dict(current_collection)
),
**collection,
}
data = {
'donation_no': body.donation_no,
'donation_date': body.donation_date,
'collector_user_id': ctx.user_id
if ctx.scope_kind == 'specialist'
else body.collector_user_id,
'remark': body.remark,
'consent_form_urls': attachments.get('consent_form_urls'),
'attachment_urls': attachments.get('attachment_urls'),
'infectious_disease_report_urls': attachments.get(
'infectious_disease_report_urls'
),
}
if body.customer is not None:
data['party'] = customer
if body.collection is not None:
data['collection'] = collection
if body.samples is not None:
data['samples'] = samples
data = {key: value for key, value in data.items() if value is not None}
row = donation_crud.update_donation(
db,
row.id,
data=data,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
create_dept_id=dept_id,
commit=False,
sync_customers=False,
)
row = assert_donation_access(db, row.id, ctx, user_info=user_info)
db.commit()
db.refresh(row)
return _donation_detail(db, row, ctx.tenant_no)
except Exception:
db.rollback()
raise
File diff suppressed because it is too large. Load diff
+36 -16
View File
@@ -50,11 +50,13 @@ def _dedupe_role_id_rows(rows) -> List[str]:
return out return out
def get_direct_enabled_role_ids(db: Session, user_id: str) -> List[str]: def get_direct_enabled_role_ids(
db: Session, user_id: str, tenant_no: Optional[str] = None
) -> List[str]:
"""用户直接绑定且启用的角色 ID(sys_user_role → sys_role)。""" """用户直接绑定且启用的角色 ID(sys_user_role → sys_role)。"""
if not user_id: if not user_id:
return [] return []
tenant = settings.DEFAULT_TENANT_NO tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip()
rows = ( rows = (
db.query(SysRole.id) db.query(SysRole.id)
.join(SysUserRole, SysUserRole.role_id == SysRole.id) .join(SysUserRole, SysUserRole.role_id == SysRole.id)
@@ -72,11 +74,13 @@ def get_direct_enabled_role_ids(db: Session, user_id: str) -> List[str]:
return _dedupe_role_id_rows(rows) return _dedupe_role_id_rows(rows)
def get_post_inherited_role_ids(db: Session, user_id: str) -> List[str]: def get_post_inherited_role_ids(
db: Session, user_id: str, tenant_no: Optional[str] = None
) -> List[str]:
"""经启用职位继承的启用角色 ID(sys_user_post → sys_post → sys_post_role → sys_role)。""" """经启用职位继承的启用角色 ID(sys_user_post → sys_post → sys_post_role → sys_role)。"""
if not user_id: if not user_id:
return [] return []
tenant = settings.DEFAULT_TENANT_NO tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip()
rows = ( rows = (
db.query(SysRole.id) db.query(SysRole.id)
.join(SysPostRole, SysPostRole.role_id == SysRole.id) .join(SysPostRole, SysPostRole.role_id == SysRole.id)
@@ -111,14 +115,18 @@ def get_post_inherited_role_ids(db: Session, user_id: str) -> List[str]:
return _dedupe_role_id_rows(rows) return _dedupe_role_id_rows(rows)
def get_effective_role_ids(db: Session, user_id: str) -> List[str]: def get_effective_role_ids(
db: Session, user_id: str, tenant_no: Optional[str] = None
) -> List[str]:
"""用户有效角色 ID:直接绑定 ∪ 启用职位关联的启用角色(去重保序)。""" """用户有效角色 ID:直接绑定 ∪ 启用职位关联的启用角色(去重保序)。"""
if not user_id: if not user_id:
return [] return []
out: List[str] = [] out: List[str] = []
seen: Set[str] = set() seen: Set[str] = set()
for rid in get_direct_enabled_role_ids(db, user_id) + get_post_inherited_role_ids( for rid in get_direct_enabled_role_ids(
db, user_id db, user_id, tenant_no=tenant_no
) + get_post_inherited_role_ids(
db, user_id, tenant_no=tenant_no
): ):
if rid not in seen: if rid not in seen:
seen.add(rid) seen.add(rid)
@@ -282,12 +290,14 @@ def list_user_ids_with_any_effective_role_ids(
return out return out
def get_role_names_by_ids(db: Session, role_ids: List[str]) -> List[str]: def get_role_names_by_ids(
db: Session, role_ids: List[str], tenant_no: Optional[str] = None
) -> List[str]:
"""按给定角色 ID 返回启用角色名称(保序、去重)。""" """按给定角色 ID 返回启用角色名称(保序、去重)。"""
ordered = list(dict.fromkeys([str(r).strip() for r in (role_ids or []) if r])) ordered = list(dict.fromkeys([str(r).strip() for r in (role_ids or []) if r]))
if not ordered: if not ordered:
return [] return []
tenant = settings.DEFAULT_TENANT_NO tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip()
rows = ( rows = (
db.query(SysRole.id, SysRole.role_name) db.query(SysRole.id, SysRole.role_name)
.filter( .filter(
@@ -313,9 +323,15 @@ def get_role_names_by_ids(db: Session, role_ids: List[str]) -> List[str]:
return out return out
def _active_role_name_rows(db: Session, user_id: str) -> list: def _active_role_name_rows(
db: Session, user_id: str, tenant_no: Optional[str] = None
) -> list:
"""查询用户当前有效的 sys_role.role_name 列表(含职位继承)。""" """查询用户当前有效的 sys_role.role_name 列表(含职位继承)。"""
return get_role_names_by_ids(db, get_effective_role_ids(db, user_id)) return get_role_names_by_ids(
db,
get_effective_role_ids(db, user_id, tenant_no=tenant_no),
tenant_no=tenant_no,
)
def user_has_active_roles(db: Session, user_id: str) -> bool: def user_has_active_roles(db: Session, user_id: str) -> bool:
@@ -329,9 +345,11 @@ def get_user_role_names(db: Session, user_id: str) -> list:
return list(dict.fromkeys(names)) return list(dict.fromkeys(names))
def resolve_web_role_for_user(db: Session, user_id: str) -> str: def resolve_web_role_for_user(
db: Session, user_id: str, tenant_no: Optional[str] = None
) -> str:
"""根据用户有效角色解析 Web 端 role(admin / super_admin / guest)。""" """根据用户有效角色解析 Web 端 role(admin / super_admin / guest)。"""
names = _active_role_name_rows(db, user_id) names = _active_role_name_rows(db, user_id, tenant_no=tenant_no)
if not names: if not names:
return NO_ROLE_WEB_ROLE return NO_ROLE_WEB_ROLE
mapped = [_map_role_name_to_web(n) for n in names] mapped = [_map_role_name_to_web(n) for n in names]
@@ -340,7 +358,9 @@ def resolve_web_role_for_user(db: Session, user_id: str) -> str:
return "admin" return "admin"
def iter_user_menu_perm_codes(db: Session, user_id: str) -> Set[str]: def iter_user_menu_perm_codes(
db: Session, user_id: str, tenant_no: Optional[str] = None
) -> Set[str]:
"""用户通过有效角色关联得到的菜单权限标识(sys_menu.perms,空则退回菜单 id)。 """用户通过有效角色关联得到的菜单权限标识(sys_menu.perms,空则退回菜单 id)。
注:不过滤 visible——F 类型按钮等 visible=0 的菜单仍可通过角色授权生效; 注:不过滤 visible——F 类型按钮等 visible=0 的菜单仍可通过角色授权生效;
@@ -349,10 +369,10 @@ def iter_user_menu_perm_codes(db: Session, user_id: str) -> Set[str]:
""" """
if not user_id: if not user_id:
return set() return set()
role_ids = get_effective_role_ids(db, user_id) tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip()
role_ids = get_effective_role_ids(db, user_id, tenant_no=tenant)
if not role_ids: if not role_ids:
return set() return set()
tenant = settings.DEFAULT_TENANT_NO
try: try:
q = ( q = (
db.query(SysMenu.perms, SysMenu.id) db.query(SysMenu.perms, SysMenu.id)
+709
View File
@@ -0,0 +1,709 @@
import re
from pathlib import Path
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.base import Base
from app.models.models import (
BizCollectionProtocol,
BizCollectionRegionStaff,
BizPresignOrder,
BizProtocolCollectionInfo,
SysPost,
SysPostRole,
SysRole,
SysRoleDataScopeDetail,
SysUserPost,
SysUserRole,
)
from app.models.research_donation import (
BizResearchDonation,
BizResearchDonationCollection,
)
def _session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
BizCollectionRegionStaff.__table__,
BizPresignOrder.__table__,
BizCollectionProtocol.__table__,
BizProtocolCollectionInfo.__table__,
BizResearchDonation.__table__,
BizResearchDonationCollection.__table__,
SysRole.__table__,
SysRoleDataScopeDetail.__table__,
SysUserRole.__table__,
SysPost.__table__,
SysPostRole.__table__,
SysUserPost.__table__,
],
)
return sessionmaker(bind=engine)()
def _presign(
order_id,
*,
tenant_no="tenant-a",
region_id="region-a",
sales_user_id="sales-a",
manager_user_id="manager-a",
create_by="creator-a",
del_flag=0,
):
return BizPresignOrder(
id=order_id,
tenant_no=tenant_no,
region_id=region_id,
sales_user_id=sales_user_id,
region_manager_user_id=manager_user_id,
create_by=create_by,
create_dept_id="dept-a",
del_flag=del_flag,
)
def _cell_protocol(
protocol_id,
*,
tenant_no="tenant-a",
collection_tenant_no=None,
region_id="region-a",
sales_user_id="sales-a",
manager_user_id="manager-a",
parent_del_flag=0,
collection_del_flag=0,
):
collection_tenant_no = collection_tenant_no or tenant_no
return [
BizCollectionProtocol(
id=protocol_id,
protocol_no=f"CP-{protocol_id}",
tenant_no=tenant_no,
create_by=sales_user_id,
create_dept_id="dept-a",
del_flag=parent_del_flag,
),
BizProtocolCollectionInfo(
id=f"ci-{protocol_id}",
collection_protocol_id=protocol_id,
tenant_no=collection_tenant_no,
region_id=region_id,
sales_user_id=sales_user_id,
region_manager_user_id=manager_user_id,
create_dept_id="dept-a",
del_flag=collection_del_flag,
),
]
def _donation(
donation_id,
*,
tenant_no="tenant-a",
collection_tenant_no=None,
region_id="region-a",
sales_user_id="sales-a",
manager_user_id="manager-a",
parent_del_flag=0,
collection_del_flag=0,
):
collection_tenant_no = collection_tenant_no or tenant_no
return [
BizResearchDonation(
id=donation_id,
donation_no=f"RD-{donation_id}",
tenant_no=tenant_no,
create_by=sales_user_id,
create_dept_id="dept-a",
del_flag=parent_del_flag,
),
BizResearchDonationCollection(
id=f"dc-{donation_id}",
research_donation_id=donation_id,
tenant_no=collection_tenant_no,
region_id=region_id,
sales_user_id=sales_user_id,
region_manager_user_id=manager_user_id,
create_dept_id="dept-a",
del_flag=collection_del_flag,
),
]
def _ids(rows):
return {row.id for row in rows}
def _user_info(user_id="u1", role="super_admin"):
return {"user_id": user_id, "role": role, "dept_id": "dept-a"}
def test_specialist_matches_sales_user_only():
from app.services.market_field_scope import (
MarketFieldContext,
apply_presign_scope,
)
db = _session()
db.add_all(
[
_presign("own", sales_user_id="u1", manager_user_id="other"),
_presign("managed", sales_user_id="other", manager_user_id="u1"),
]
)
db.commit()
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
rows = apply_presign_scope(
db.query(BizPresignOrder), db, ctx, user_info=_user_info()
).all()
assert _ids(rows) == {"own"}
def test_manager_matches_leader_region_or_document_manager():
from app.services.market_field_scope import (
MarketFieldContext,
apply_presign_scope,
)
db = _session()
db.add(
BizCollectionRegionStaff(
id="leader",
region_id="region-led",
user_id="u1",
is_leader=1,
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
)
)
db.add_all(
[
_presign("by-region", region_id="region-led", manager_user_id="other"),
_presign("by-manager", region_id="region-other", manager_user_id="u1"),
_presign("outside", region_id="region-other", manager_user_id="other"),
]
)
db.commit()
ctx = MarketFieldContext("tenant-a", "u1", "manager")
rows = apply_presign_scope(
db.query(BizPresignOrder), db, ctx, user_info=_user_info()
).all()
assert _ids(rows) == {"by-region", "by-manager"}
def test_manager_ignores_non_leader_staff_rows():
from app.services.market_field_scope import (
MarketFieldContext,
apply_presign_scope,
)
db = _session()
db.add(
BizCollectionRegionStaff(
id="staff",
region_id="region-staff",
user_id="u1",
is_leader=0,
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
)
)
db.add(_presign("staff-region", region_id="region-staff", manager_user_id="other"))
db.commit()
ctx = MarketFieldContext("tenant-a", "u1", "manager")
assert (
apply_presign_scope(
db.query(BizPresignOrder), db, ctx, user_info=_user_info()
).all()
== []
)
def test_soft_deleted_or_cross_tenant_region_never_matches():
from app.services.market_field_scope import (
MarketFieldContext,
apply_presign_scope,
)
db = _session()
db.add_all(
[
BizCollectionRegionStaff(
id="deleted-leader",
region_id="region-deleted",
user_id="u1",
is_leader=1,
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=1,
),
BizCollectionRegionStaff(
id="other-tenant-leader",
region_id="region-cross",
user_id="u1",
is_leader=1,
tenant_no="tenant-b",
create_dept_id="dept-a",
del_flag=0,
),
_presign("deleted-region", region_id="region-deleted", manager_user_id="other"),
_presign("cross-region", region_id="region-cross", manager_user_id="other"),
_presign("soft-deleted-doc", manager_user_id="u1", del_flag=1),
_presign("cross-tenant-doc", tenant_no="tenant-b", manager_user_id="u1"),
]
)
db.commit()
ctx = MarketFieldContext("tenant-a", "u1", "manager")
assert (
apply_presign_scope(
db.query(BizPresignOrder), db, ctx, user_info=_user_info()
).all()
== []
)
def test_dual_role_contexts_do_not_union_specialist_and_manager_scope():
from app.services.market_field_scope import (
MarketFieldContext,
apply_presign_scope,
)
db = _session()
db.add_all(
[
_presign("specialist-only", sales_user_id="u1", manager_user_id="other"),
_presign("manager-only", sales_user_id="other", manager_user_id="u1"),
]
)
db.commit()
specialist = MarketFieldContext("tenant-a", "u1", "specialist")
manager = MarketFieldContext("tenant-a", "u1", "manager")
assert _ids(apply_presign_scope(
db.query(BizPresignOrder), db, specialist, user_info=_user_info()
).all()) == {
"specialist-only"
}
assert _ids(apply_presign_scope(
db.query(BizPresignOrder), db, manager, user_info=_user_info()
).all()) == {
"manager-only"
}
def test_apply_scope_requires_general_data_scope_context():
from app.services.market_field_scope import (
MarketFieldContext,
apply_presign_scope,
)
db = _session()
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
with pytest.raises(TypeError):
apply_presign_scope(db.query(BizPresignOrder), db, ctx)
@pytest.mark.parametrize(
("apply_name", "model"),
[
("apply_presign_scope", BizPresignOrder),
("apply_cell_protocol_scope", BizCollectionProtocol),
("apply_donation_scope", BizResearchDonation),
],
)
@pytest.mark.parametrize(
"user_info",
[{}, {"user_id": ""}, {"user_id": "other", "role": "super_admin"}],
)
def test_apply_scopes_reject_missing_or_mismatched_user_identity(
apply_name, model, user_info
):
from app.services import market_field_scope
db = _session()
ctx = market_field_scope.MarketFieldContext("tenant-a", "u1", "specialist")
apply_scope = getattr(market_field_scope, apply_name)
with pytest.raises(PermissionError):
apply_scope(db.query(model), db, ctx, user_info=user_info)
@pytest.mark.parametrize(
"assert_name",
[
"assert_presign_access",
"assert_cell_protocol_access",
"assert_donation_access",
],
)
@pytest.mark.parametrize(
"user_info",
[{}, {"user_id": ""}, {"user_id": "other", "role": "super_admin"}],
)
def test_assert_access_rejects_missing_or_mismatched_user_identity(
assert_name, user_info
):
from app.services import market_field_scope
db = _session()
ctx = market_field_scope.MarketFieldContext("tenant-a", "u1", "specialist")
assert_access = getattr(market_field_scope, assert_name)
with pytest.raises(PermissionError):
assert_access(db, "any-id", ctx, user_info=user_info)
def test_protocol_and_donation_queries_use_their_collection_info_scope():
from app.services.market_field_scope import (
MarketFieldContext,
apply_cell_protocol_scope,
apply_donation_scope,
)
db = _session()
db.add_all(
_cell_protocol("cell-own", sales_user_id="u1")
+ _cell_protocol("cell-other", sales_user_id="other")
+ _cell_protocol("cell-deleted-info", sales_user_id="u1", collection_del_flag=1)
+ _donation("donation-own", sales_user_id="u1")
+ _donation("donation-other", sales_user_id="other")
+ _donation("donation-deleted-info", sales_user_id="u1", collection_del_flag=1)
)
db.commit()
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
assert _ids(
apply_cell_protocol_scope(
db.query(BizCollectionProtocol), db, ctx, user_info=_user_info()
).all()
) == {"cell-own"}
assert _ids(
apply_donation_scope(
db.query(BizResearchDonation), db, ctx, user_info=_user_info()
).all()
) == {"donation-own"}
def test_cell_and_donation_manager_scope_uses_leader_region_or_document_manager():
from app.services.market_field_scope import (
MarketFieldContext,
apply_cell_protocol_scope,
apply_donation_scope,
)
db = _session()
db.add_all(
[
BizCollectionRegionStaff(
id="leader",
region_id="region-led",
user_id="u1",
is_leader=1,
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
),
BizCollectionRegionStaff(
id="non-leader",
region_id="region-staff",
user_id="u1",
is_leader=0,
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
),
]
+ _cell_protocol("cell-region", region_id="region-led", manager_user_id="other")
+ _cell_protocol("cell-manager", region_id="other", manager_user_id="u1")
+ _cell_protocol("cell-non-leader", region_id="region-staff", manager_user_id="other")
+ _cell_protocol(
"cell-cross-info",
collection_tenant_no="tenant-b",
region_id="region-led",
manager_user_id="u1",
)
+ _cell_protocol("cell-deleted-parent", manager_user_id="u1", parent_del_flag=1)
+ _cell_protocol("cell-cross-parent", tenant_no="tenant-b", manager_user_id="u1")
+ _donation("donation-region", region_id="region-led", manager_user_id="other")
+ _donation("donation-manager", region_id="other", manager_user_id="u1")
+ _donation("donation-non-leader", region_id="region-staff", manager_user_id="other")
+ _donation(
"donation-cross-info",
collection_tenant_no="tenant-b",
region_id="region-led",
manager_user_id="u1",
)
+ _donation("donation-deleted-parent", manager_user_id="u1", parent_del_flag=1)
+ _donation("donation-cross-parent", tenant_no="tenant-b", manager_user_id="u1")
)
db.commit()
ctx = MarketFieldContext("tenant-a", "u1", "manager")
assert _ids(
apply_cell_protocol_scope(
db.query(BizCollectionProtocol), db, ctx, user_info=_user_info()
).all()
) == {"cell-region", "cell-manager"}
assert _ids(
apply_donation_scope(
db.query(BizResearchDonation), db, ctx, user_info=_user_info()
).all()
) == {"donation-region", "donation-manager"}
def test_assert_access_uses_the_same_scope_for_all_document_types():
from app.services.market_field_scope import (
MarketFieldContext,
assert_cell_protocol_access,
assert_donation_access,
assert_presign_access,
)
db = _session()
db.add_all(
[
_presign("presign-own", sales_user_id="u1"),
_presign("presign-outside", sales_user_id="other"),
_presign("presign-cross", tenant_no="tenant-b", sales_user_id="u1"),
_presign("presign-deleted", sales_user_id="u1", del_flag=1),
]
)
db.add_all(_cell_protocol("cell-own", sales_user_id="u1"))
db.add_all(_cell_protocol("cell-outside", sales_user_id="other"))
db.add_all(_cell_protocol("cell-cross", tenant_no="tenant-b", sales_user_id="u1"))
db.add_all(_cell_protocol("cell-deleted", sales_user_id="u1", parent_del_flag=1))
db.add_all(_donation("donation-own", sales_user_id="u1"))
db.add_all(_donation("donation-outside", sales_user_id="other"))
db.add_all(_donation("donation-cross", tenant_no="tenant-b", sales_user_id="u1"))
db.add_all(_donation("donation-deleted", sales_user_id="u1", parent_del_flag=1))
db.commit()
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
assert assert_presign_access(
db, "presign-own", ctx, user_info=_user_info()
).id == "presign-own"
assert assert_cell_protocol_access(
db, "cell-own", ctx, user_info=_user_info()
).id == "cell-own"
assert assert_donation_access(
db, "donation-own", ctx, user_info=_user_info()
).id == "donation-own"
rejected = {
assert_presign_access: [
"presign-outside",
"presign-cross",
"presign-deleted",
],
assert_cell_protocol_access: [
"cell-outside",
"cell-cross",
"cell-deleted",
],
assert_donation_access: [
"donation-outside",
"donation-cross",
"donation-deleted",
],
}
for checker, record_ids in rejected.items():
for record_id in record_ids:
with pytest.raises(HTTPException) as exc_info:
checker(db, record_id, ctx, user_info=_user_info())
assert exc_info.value.status_code == 404
def test_general_data_scope_and_field_scope_are_intersected():
from app.services.market_field_scope import (
MarketFieldContext,
apply_presign_scope,
)
db = _session()
db.add_all(
[
SysRole(
id="self-role",
role_name="仅本人",
data_scope="4",
status="1",
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
),
SysUserRole(
id="user-role",
user_id="u1",
role_id="self-role",
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
),
_presign("both", sales_user_id="u1", create_by="u1"),
_presign("field-only", sales_user_id="u1", create_by="other"),
_presign("data-only", sales_user_id="other", create_by="u1"),
]
)
db.commit()
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
rows = apply_presign_scope(
db.query(BizPresignOrder),
db,
ctx,
user_info={"user_id": "u1", "role": "admin", "dept_id": "dept-a"},
).all()
assert _ids(rows) == {"both"}
def test_multi_role_union_is_still_intersected_with_field_scope():
from app.services.market_field_scope import (
MarketFieldContext,
apply_presign_scope,
)
db = _session()
db.add_all(
[
SysRole(
id="self-role",
role_name="仅本人",
data_scope="4",
status="1",
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
),
SysRole(
id="all-role",
role_name="全部数据",
data_scope="1",
status="1",
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
),
SysUserRole(
id="self-user-role",
user_id="u1",
role_id="self-role",
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
),
SysUserRole(
id="all-user-role",
user_id="u1",
role_id="all-role",
tenant_no="tenant-a",
create_dept_id="dept-a",
del_flag=0,
),
_presign("both", sales_user_id="u1", create_by="u1"),
_presign("field-only", sales_user_id="u1", create_by="other"),
_presign("data-only", sales_user_id="other", create_by="u1"),
]
)
db.commit()
ctx = MarketFieldContext("tenant-a", "u1", "specialist")
rows = apply_presign_scope(
db.query(BizPresignOrder),
db,
ctx,
user_info=_user_info(role="admin"),
).all()
assert _ids(rows) == {"both", "field-only"}
def test_market_field_scope_index_migration_is_idempotent_and_complete():
migration = (
Path(__file__).resolve().parents[2]
/ "docs/sql/migrations/2026-10-10-market-field-scope-indexes.sql"
)
sql = migration.read_text(encoding="utf-8")
compact = re.sub(r"\s+", " ", re.sub(r"--[^\n]*", "", sql)).lower()
expected = {
"idx_crs_market_leader_scope": (
"biz_collection_region_staff",
"tenant_no,user_id,is_leader,del_flag,region_id",
),
"idx_po_market_sales_scope": (
"biz_presign_order",
"tenant_no,del_flag,sales_user_id",
),
"idx_po_market_region_scope": (
"biz_presign_order",
"tenant_no,del_flag,region_id",
),
"idx_po_market_manager_scope": (
"biz_presign_order",
"tenant_no,del_flag,region_manager_user_id",
),
"idx_pci_market_parent_scope": (
"biz_protocol_collection_info",
"tenant_no,del_flag,collection_protocol_id",
),
"idx_pci_market_sales_scope": (
"biz_protocol_collection_info",
"tenant_no,del_flag,sales_user_id,collection_protocol_id",
),
"idx_pci_market_region_scope": (
"biz_protocol_collection_info",
"tenant_no,del_flag,region_id,collection_protocol_id",
),
"idx_pci_market_manager_scope": (
"biz_protocol_collection_info",
"tenant_no,del_flag,region_manager_user_id,collection_protocol_id",
),
"idx_rdc_market_parent_scope": (
"biz_research_donation_collection",
"tenant_no,del_flag,research_donation_id",
),
"idx_rdc_market_sales_scope": (
"biz_research_donation_collection",
"tenant_no,del_flag,sales_user_id,research_donation_id",
),
"idx_rdc_market_region_scope": (
"biz_research_donation_collection",
"tenant_no,del_flag,region_id,research_donation_id",
),
"idx_rdc_market_manager_scope": (
"biz_research_donation_collection",
"tenant_no,del_flag,region_manager_user_id,research_donation_id",
),
}
assert compact.count("information_schema.statistics") == len(expected)
assert compact.count("prepare stmt from @sql") == len(expected)
assert compact.count("deallocate prepare stmt") == len(expected)
for index_name, (table_name, columns) in expected.items():
assert f"table_name = '{table_name}'" in compact
assert f"index_name = '{index_name}'" in compact
column_sql = ",".join(f"`{column}`" for column in columns.split(","))
assert re.search(
rf"alter table `{table_name}` add index `{index_name}` \({column_sql}\)",
compact,
)
+845
View File
@@ -0,0 +1,845 @@
"""市场移动端运费批次:作用域、脱敏与原子写规则。"""
from datetime import datetime, timedelta
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import create_engine
from sqlalchemy.dialects import mysql
from sqlalchemy.orm import sessionmaker
from app.models.base import Base
from app.models.models import (
BizCellSampleType, BizCollectionProtocol, BizCollectionRegionStaff, BizProtocolCollectionInfo,
BizProtocolPartyCustomer, BizProtocolSample, BizSampleFreight,
BizSampleFreightItem, SysDictData, SysDictType, SysPost, SysPostRole,
SysDept, SysRole, SysRoleDataScopeDetail, SysUser, SysUserPost, SysUserRole,
)
from app.models.research_donation import (
BizResearchDonation, BizResearchDonationCollection,
BizResearchDonationParty, BizResearchDonationSample,
)
from app.services.market_field_scope import MarketFieldContext
TABLES = [
BizCollectionProtocol.__table__, BizProtocolCollectionInfo.__table__,
BizProtocolPartyCustomer.__table__, BizProtocolSample.__table__,
BizResearchDonation.__table__, BizResearchDonationCollection.__table__,
BizResearchDonationParty.__table__, BizResearchDonationSample.__table__,
BizSampleFreight.__table__, BizSampleFreightItem.__table__,
BizCellSampleType.__table__,
BizCollectionRegionStaff.__table__, SysDictType.__table__,
SysDictData.__table__, SysUser.__table__, SysRole.__table__,
SysRoleDataScopeDetail.__table__, SysUserRole.__table__, SysPost.__table__,
SysPostRole.__table__, SysUserPost.__table__, SysDept.__table__,
]
def _session():
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(engine, tables=TABLES)
db = sessionmaker(bind=engine)()
now = datetime(2026, 10, 11, 8, 0)
db.add_all([
SysDictType(id='dt', dict_name='运输方式', dict_type='sample_transport_mode', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
SysDictData(id='dd', dict_label='顺丰', dict_value='sf', dict_type='sample_transport_mode', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
SysUser(id='u1', dept_id='', user_no='u1', nick_name='专员', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
SysUser(id='m1', dept_id='', user_no='m1', nick_name='经理', password='', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
SysRole(id='fixture-super-role', role_name='超级管理员', data_scope='1', status='1', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
SysUserRole(id='fixture-super-u1', user_id='u1', role_id='fixture-super-role', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
SysUserRole(id='fixture-super-m1', user_id='m1', role_id='fixture-super-role', tenant_no='tenant-a', create_dept_id='', create_time=now, update_time=now, del_flag=0),
])
db.commit()
return db
def _ctx(kind='specialist', user_id='u1'):
return MarketFieldContext('tenant-a', user_id, kind)
def _user(user_id='u1'):
return {'user_id': user_id, 'tenant_no': 'tenant-a', 'role': 'super_admin'}
def _sample(db, key, *, source='cell_storage', sales='u1', manager='m1', region='r1', tenant='tenant-a', deleted=0, created=None):
created = created or datetime(2026, 10, 10, 8, 0)
if source == 'cell_storage':
parent = BizCollectionProtocol(id=f'cp-{key}', protocol_no=f'CP-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
collection = BizProtocolCollectionInfo(id=f'cc-{key}', collection_protocol_id=parent.id, region_id=region, sales_user_id=sales, region_manager_user_id=manager, tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
party = BizProtocolPartyCustomer(id=f'pc-{key}', collection_protocol_id=parent.id, mother_name=f'客户-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
sample = BizProtocolSample(id=f'cs-{key}', collection_protocol_id=parent.id, sample_code=f'C-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
else:
parent = BizResearchDonation(id=f'rd-{key}', donation_no=f'RD-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
collection = BizResearchDonationCollection(id=f'rc-{key}', research_donation_id=parent.id, region_id=region, sales_user_id=sales, region_manager_user_id=manager, tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
party = BizResearchDonationParty(id=f'rp-{key}', research_donation_id=parent.id, mother_name=f'客户-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
sample = BizResearchDonationSample(id=f'rs-{key}', research_donation_id=parent.id, sample_code=f'R-{key}', tenant_no=tenant, create_dept_id='', create_time=created, del_flag=deleted)
db.add_all([parent, collection, party, sample])
db.commit()
return sample, parent
def _freight(db, key, samples, *, tenant='tenant-a', amount='12.30', deleted=0):
now = datetime(2026, 10, 11, 8, 0)
freight = BizSampleFreight(id=f'f-{key}', freight_no=f'YF-{key}', transport_mode='sf', freight_amount=Decimal(amount), tenant_no=tenant, create_dept_id='', create_time=now, update_time=now, del_flag=deleted)
db.add(freight)
for pos, (source, sample, parent) in enumerate(samples):
db.add(BizSampleFreightItem(id=f'fi-{key}-{pos}', freight_id=freight.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=tenant, create_dept_id='', create_time=now, update_time=now, del_flag=0))
db.commit()
return freight
def _items(*pairs):
return [{'sampleSource': source, 'sampleId': sample.id} for source, sample in pairs]
def test_specialist_sees_shared_batch_but_only_own_items_and_count():
from app.services.market_freight import get_freight_detail, list_freights
db = _session()
own, own_parent = _sample(db, 'own')
other, other_parent = _sample(db, 'other', sales='u2')
freight = _freight(db, 'mixed', [('cell_storage', own, own_parent), ('cell_storage', other, other_parent)])
rows, total = list_freights(db, ctx=_ctx(), user_info=_user(), include_items=True)
detail = get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())
assert total == 1 and rows[0]['visibleItemCount'] == 1
assert [item['sampleCode'] for item in rows[0]['items']] == ['C-own']
assert [item['sampleCode'] for item in detail['items']] == ['C-own']
assert 'linkedSampleCount' not in rows[0] and 'linkedSampleCount' not in detail
assert 'hasHiddenItems' not in detail
def test_batch_without_visible_item_is_not_listed_or_addressable():
from app.services.market_freight import get_freight_detail, list_freights
db = _session()
other, parent = _sample(db, 'other', sales='u2')
freight = _freight(db, 'hidden', [('cell_storage', other, parent)])
assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 0
with pytest.raises(HTTPException) as exc:
get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())
assert exc.value.status_code == 404
def test_keyword_and_source_filters_use_only_visible_items():
from app.services.market_freight import list_freights
db = _session()
own, op = _sample(db, 'own')
secret, sp = _sample(db, 'SECRET', source='research_donation', sales='u2')
_freight(db, 'mixed', [('cell_storage', own, op), ('research_donation', secret, sp)])
assert list_freights(db, ctx=_ctx(), user_info=_user(), keyword='SECRET')[1] == 0
assert list_freights(db, ctx=_ctx(), user_info=_user(), sample_source='research_donation')[1] == 0
assert list_freights(db, ctx=_ctx(), user_info=_user(), sample_source='cell_storage')[1] == 1
def test_manager_scope_is_region_union_document_manager_for_both_sources():
from app.services.market_freight import get_freight_detail
db = _session()
db.add(BizCollectionRegionStaff(id='staff', region_id='managed', user_id='m1', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0))
db.commit()
region_sample, rp = _sample(db, 'region', sales='u9', manager='x', region='managed')
assigned, ap = _sample(db, 'assigned', source='research_donation', sales='u9', manager='m1', region='other')
hidden, hp = _sample(db, 'hidden', sales='m1', manager='x', region='other')
freight = _freight(db, 'manager', [('cell_storage', region_sample, rp), ('research_donation', assigned, ap), ('cell_storage', hidden, hp)])
detail = get_freight_detail(db, freight.id, ctx=_ctx('manager', 'm1'), user_info=_user('m1'))
assert {item['sampleCode'] for item in detail['items']} == {'C-region', 'R-assigned'}
assert detail['visibleItemCount'] == 2
assert all('sampleTypeName' in item and 'motherName' in item and 'newbornName' in item for item in detail['items'])
def test_dual_role_uses_fixed_route_context_not_union_between_pages():
from app.services.market_freight import list_freights
db = _session()
specialist, sp = _sample(db, 'specialist', sales='m1', manager='x', region='other')
manager, mp = _sample(db, 'manager', sales='u9', manager='m1', region='other')
_freight(db, 'specialist', [('cell_storage', specialist, sp)])
_freight(db, 'manager', [('cell_storage', manager, mp)])
specialist_rows, _ = list_freights(db, ctx=_ctx('specialist', 'm1'), user_info=_user('m1'))
manager_rows, _ = list_freights(db, ctx=_ctx('manager', 'm1'), user_info=_user('m1'))
assert [row['freightNo'] for row in specialist_rows] == ['YF-specialist']
assert [row['freightNo'] for row in manager_rows] == ['YF-manager']
def test_cross_tenant_deleted_parent_and_deleted_sample_are_invisible():
from app.services.market_freight import list_freights
db = _session()
cross, cp = _sample(db, 'cross', tenant='tenant-b')
deleted, dp = _sample(db, 'deleted', deleted=1)
_freight(db, 'cross', [('cell_storage', cross, cp)], tenant='tenant-b')
_freight(db, 'deleted', [('cell_storage', deleted, dp)])
assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 0
def test_sample_options_are_scoped_sql_paginated_stable_and_derive_link_state():
from app.services.market_freight import list_sample_options
db = _session()
base = datetime(2026, 10, 10, 8, 0)
first, fp = _sample(db, 'first', created=base)
_sample(db, 'second', source='research_donation', created=base + timedelta(hours=1))
_sample(db, 'third', created=base + timedelta(hours=2))
_sample(db, 'hidden', sales='u2', created=base + timedelta(hours=3))
_freight(db, 'linked', [('cell_storage', first, fp)])
page1, total = list_sample_options(db, ctx=_ctx(), user_info=_user(), page=1, page_size=2)
page2, total2 = list_sample_options(db, ctx=_ctx(), user_info=_user(), page=2, page_size=2)
assert total == total2 == 3
assert [x['sampleCode'] for x in page1] == ['C-third', 'R-second']
assert [x['sampleCode'] for x in page2] == ['C-first']
assert page2[0]['freightId'] == 'f-linked'
def test_create_derives_snapshots_and_decimal_string_and_requires_visible_item():
from app.services.market_freight import create_freight
db = _session()
own, parent = _sample(db, 'own')
result = create_freight(db, ctx=_ctx(), user_info=_user(), transport_mode='sf', freight_amount=Decimal('12.30'), items=_items(('cell_storage', own)))
item = db.query(BizSampleFreightItem).filter_by(freight_id=result['id']).one()
assert result['freightAmount'] == '12.30'
assert item.sample_code == 'C-own' and item.protocol_id == parent.id
with pytest.raises(ValueError, match='至少'):
create_freight(db, ctx=_ctx(), user_info=_user(), transport_mode='sf', freight_amount='1.00', items=[])
def test_create_prevalidates_every_item_and_rolls_back_without_partial_batch():
from app.services.market_freight import create_freight
db = _session()
own, _ = _sample(db, 'own')
other, _ = _sample(db, 'other', sales='u2')
before = db.query(BizSampleFreight).count()
with pytest.raises(HTTPException):
create_freight(db, ctx=_ctx(), user_info=_user(), transport_mode='sf', freight_amount='1.00', items=_items(('cell_storage', own), ('cell_storage', other)))
assert db.query(BizSampleFreight).count() == before
def test_link_rejects_hidden_and_existing_other_batch_without_silent_move():
from app.services.market_freight import link_samples
db = _session()
own, op = _sample(db, 'own')
other, _ = _sample(db, 'other', sales='u2')
linked, lp = _sample(db, 'linked')
target = _freight(db, 'target', [('cell_storage', own, op)])
source = _freight(db, 'source', [('cell_storage', linked, lp)])
for sample in (other, linked):
with pytest.raises((HTTPException, ValueError)):
link_samples(db, target.id, ctx=_ctx(), user_info=_user(), items=_items(('cell_storage', sample)))
assert db.query(BizSampleFreightItem).filter_by(sample_id=linked.id, del_flag=0).one().freight_id == source.id
def test_unlink_prevalidates_all_items_and_rejects_hidden_or_nonexistent():
from app.services.market_freight import unlink_samples
db = _session()
own, op = _sample(db, 'own')
other, hp = _sample(db, 'hidden', sales='u2')
freight = _freight(db, 'mixed', [('cell_storage', own, op), ('cell_storage', other, hp)])
missing, _ = _sample(db, 'missing')
for sample in (other, missing):
with pytest.raises((HTTPException, ValueError)):
unlink_samples(db, freight.id, ctx=_ctx(), user_info=_user(), items=_items(('cell_storage', sample)))
assert db.query(BizSampleFreightItem).filter_by(freight_id=freight.id, del_flag=0).count() == 2
def test_unlink_last_visible_item_disappears_without_revealing_hidden_remaining():
from app.services.market_freight import list_freights, unlink_samples
db = _session()
own, op = _sample(db, 'own')
other, hp = _sample(db, 'hidden', sales='u2')
freight = _freight(db, 'mixed', [('cell_storage', own, op), ('cell_storage', other, hp)])
result = unlink_samples(db, freight.id, ctx=_ctx(), user_info=_user(), items=_items(('cell_storage', own)))
assert result == {'id': freight.id, 'visibleItemCount': 0, 'disappeared': True}
assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 0
assert db.query(BizSampleFreight).filter_by(id=freight.id).one().del_flag == 0
@pytest.mark.parametrize('kind,user_id', [('specialist', 'u1'), ('manager', 'm1')])
def test_shared_outside_scope_header_is_immutable_for_both_roles(kind, user_id):
from app.services.market_freight import update_freight
db = _session()
visible, vp = _sample(db, 'visible', sales=user_id if kind == 'specialist' else 'u9', manager=user_id if kind == 'manager' else 'x')
hidden, hp = _sample(db, 'hidden', sales='other', manager='other')
freight = _freight(db, 'mixed', [('cell_storage', visible, vp), ('cell_storage', hidden, hp)])
with pytest.raises(PermissionError, match='全部'):
update_freight(db, freight.id, ctx=_ctx(kind, user_id), user_info=_user(user_id), freight_amount='99.00')
assert db.query(BizSampleFreight).filter_by(id=freight.id).one().freight_amount == Decimal('12.30')
def test_header_update_allowed_when_all_active_items_are_visible():
from app.services.market_freight import update_freight
db = _session()
own, op = _sample(db, 'own')
freight = _freight(db, 'own', [('cell_storage', own, op)])
result = update_freight(db, freight.id, ctx=_ctx(), user_info=_user(), freight_amount='99.10')
assert result['freightAmount'] == '99.10'
@pytest.mark.parametrize('kind,user_id', [('specialist', 'u1'), ('manager', 'm1')])
@pytest.mark.parametrize('mixed', [False, True])
def test_detail_edit_action_requires_every_active_item_visible_without_leaking_totals(
kind, user_id, mixed
):
from app.services.market_freight import get_freight_detail
from app.services.market_support import decorate_allowed_actions
db = _session()
visible, visible_parent = _sample(
db,
f'action-visible-{kind}-{mixed}',
sales=user_id if kind == 'specialist' else 'other-sales',
manager=user_id if kind == 'manager' else 'other-manager',
)
rows = [('cell_storage', visible, visible_parent)]
if mixed:
hidden, hidden_parent = _sample(
db,
f'action-hidden-{kind}',
sales='hidden-sales',
manager='hidden-manager',
)
rows.append(('cell_storage', hidden, hidden_parent))
freight = _freight(db, f'action-{kind}-{mixed}', rows)
detail = get_freight_detail(
db,
freight.id,
ctx=_ctx(kind, user_id),
user_info=_user(user_id),
)
rendered = decorate_allowed_actions(
'freight', detail, scope_kind=kind, detail=True
)
expected = ['link', 'unlink'] if mixed else ['edit', 'link', 'unlink']
assert detail['allowedActions'] == expected
assert rendered['allowedActions'] == expected
for hidden_key in (
'allItemsVisible',
'hiddenItemCount',
'totalItemCount',
'activeItemCount',
):
assert hidden_key not in detail
def test_dto_forbids_scope_snapshots_status_and_non_decimal_amounts():
from app.schemas.market_mobile import MarketFreightCreateBody, MarketFreightItemsBody
with pytest.raises(ValidationError):
MarketFreightItemsBody.model_validate({'items': [{'sampleSource': 'cell_storage', 'sampleId': 's1', 'sampleCode': 'forged'}]})
for extra in ('tenantNo', 'scope', 'status'):
with pytest.raises(ValidationError):
MarketFreightCreateBody.model_validate({'transportMode': 'sf', 'freightAmount': '1.00', 'items': [{'sampleSource': 'cell_storage', 'sampleId': 's1'}], extra: 'x'})
with pytest.raises(ValidationError):
MarketFreightCreateBody.model_validate({'transportMode': 'sf', 'freightAmount': 'NaN', 'items': [{'sampleSource': 'cell_storage', 'sampleId': 's1'}]})
def test_fixed_routes_and_precise_permissions_are_registered():
from app.api.market_mobile import router
paths = [(route.path, route.methods) for route in router.routes]
for kind in ('specialist', 'manager'):
prefix = f'/mini/market/{kind}/freights'
assert any(path == prefix and 'GET' in methods for path, methods in paths)
assert any(path == prefix and 'POST' in methods for path, methods in paths)
assert any(path == f'{prefix}/sample-options' and 'GET' in methods for path, methods in paths)
source = (Path(__file__).parents[1] / 'app/api/market_mobile.py').read_text()
assert "f'mini:market:{scope_kind}:freight'" in source
def test_migration_has_mysql57_indexes_unique_active_link_and_duplicate_preflight():
sql = (Path(__file__).parents[2] / 'docs/sql/migrations/2026-10-11-market-freight-indexes.sql').read_text()
lowered = sql.lower()
assert 'information_schema.statistics' in lowered
assert 'generated always as' in lowered
assert 'unique' in lowered and 'active' in lowered
assert 'group by tenant_no, sample_source, sample_id' in lowered
assert 'having count(*) > 1' in lowered
assert 'create procedure' in lowered and 'signal sqlstate' in lowered
assert 'character_maximum_length' in lowered
assert 'data_type' in lowered and 'is_nullable' in lowered
assert 'normalized_generation_expression' in lowered
assert "uk_sample_freight_tenant_no" in lowered
assert 'exact_freight_unique_indexes' in lowered
def test_write_service_contains_locking_postlock_validation_and_integrity_retry():
source = (Path(__file__).parents[1] / 'app/services/market_freight.py').read_text()
assert source.count('with_for_update') >= 3
assert 'IntegrityError' in source
assert '_lock_batch_header' in source
assert '_lock_sample_graph' in source
assert '_lock_active_items' in source
def test_multiple_party_rows_do_not_duplicate_visible_item_or_count():
from app.services.market_freight import get_freight_detail
db = _session()
sample, parent = _sample(db, 'one')
db.add(BizProtocolPartyCustomer(id='pc-one-duplicate', collection_protocol_id=parent.id, mother_name='重复客户行', tenant_no='tenant-a', create_dept_id='', del_flag=0))
db.commit()
freight = _freight(db, 'one', [('cell_storage', sample, parent)])
detail = get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())
assert detail['visibleItemCount'] == 1
assert len(detail['items']) == 1
def test_deleted_batch_is_invisible_even_with_visible_active_item():
from app.services.market_freight import list_freights
db = _session()
sample, parent = _sample(db, 'one')
_freight(db, 'deleted-batch', [('cell_storage', sample, parent)], deleted=1)
assert list_freights(db, ctx=_ctx(), user_info=_user())[1] == 0
def test_context_user_mismatch_fails_closed():
from app.services.market_freight import list_sample_options
db = _session()
_sample(db, 'one')
with pytest.raises(PermissionError):
list_sample_options(db, ctx=_ctx(), user_info=_user('u2'))
def test_manager_can_link_sample_in_managed_region():
from app.services.market_freight import link_samples
db = _session()
db.add(BizCollectionRegionStaff(id='staff-link', region_id='managed', user_id='m1', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0))
db.commit()
existing, ep = _sample(db, 'existing', sales='u9', manager='m1')
new_sample, _ = _sample(db, 'new', sales='u9', manager='x', region='managed')
freight = _freight(db, 'manager-link', [('cell_storage', existing, ep)])
detail = link_samples(db, freight.id, ctx=_ctx('manager', 'm1'), user_info=_user('m1'), items=_items(('cell_storage', new_sample)))
assert {item['sampleCode'] for item in detail['items']} == {'C-existing', 'C-new'}
def test_batch_paging_is_stable_and_total_counts_only_visible_batches():
from app.services.market_freight import list_freights
db = _session()
first, fp = _sample(db, 'first')
second, sp = _sample(db, 'second')
hidden, hp = _sample(db, 'hidden-page', sales='u2')
f1 = _freight(db, 'first', [('cell_storage', first, fp)])
f2 = _freight(db, 'second', [('cell_storage', second, sp)])
_freight(db, 'hidden-page', [('cell_storage', hidden, hp)])
f1.create_time = f2.create_time
db.commit()
page1, total1 = list_freights(db, ctx=_ctx(), user_info=_user(), page=1, page_size=1)
page2, total2 = list_freights(db, ctx=_ctx(), user_info=_user(), page=2, page_size=1)
assert total1 == total2 == 2
assert [page1[0]['id'], page2[0]['id']] == ['f-second', 'f-first']
@pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
def test_sample_options_choose_latest_party_once_before_count_and_paging(source):
from app.services.market_freight import list_sample_options
db = _session()
base = datetime(2026, 10, 10, 8, 0)
target, parent = _sample(db, f'duplicate-{source}', source=source, created=base)
_sample(db, f'other-{source}', source=source, created=base + timedelta(hours=1))
if source == 'cell_storage':
db.add(BizProtocolPartyCustomer(
id='zz-latest-cell-party', collection_protocol_id=parent.id,
mother_name='最新细胞客户', tenant_no='tenant-a', create_dept_id='',
create_time=base + timedelta(hours=2), del_flag=0,
))
latest_name = '最新细胞客户'
else:
db.add(BizResearchDonationParty(
id='zz-latest-donation-party', research_donation_id=parent.id,
mother_name='最新科研客户', tenant_no='tenant-a', create_dept_id='',
create_time=base + timedelta(hours=2), del_flag=0,
))
latest_name = '最新科研客户'
db.commit()
page1, total1 = list_sample_options(
db, ctx=_ctx(), user_info=_user(), page=1, page_size=1,
sample_source=source,
)
page2, total2 = list_sample_options(
db, ctx=_ctx(), user_info=_user(), page=2, page_size=1,
sample_source=source,
)
rows = page1 + page2
assert total1 == total2 == 2
assert sum(row['sampleId'] == target.id for row in rows) == 1
target_row = next(row for row in rows if row['sampleId'] == target.id)
assert target_row['customerName'] == latest_name
def test_mysql_lock_queries_are_current_reads_with_stable_global_order():
from app.services.market_freight import (
_active_item_lock_query,
_collection_lock_query,
_leader_lock_query,
_sample_parent_lock_query,
)
db = _session()
ctx = _ctx()
sample_sql = str(
_sample_parent_lock_query(
db, 'cell_storage', ['z', 'a'], ctx
).statement.compile(dialect=mysql.dialect())
).lower()
collection_sql = str(
_collection_lock_query(
db, 'cell_storage', ['p-z', 'p-a'], ctx
).statement.compile(dialect=mysql.dialect())
).lower()
item_sql = str(
_active_item_lock_query(
db, ctx, freight_id='f1', keys=[('cell_storage', 'z')]
).statement.compile(dialect=mysql.dialect())
).lower()
leader_sql = str(
_leader_lock_query(db, _ctx('manager', 'm1')).statement.compile(
dialect=mysql.dialect()
)
).lower()
for sql in (sample_sql, collection_sql, item_sql, leader_sql):
assert ' for update' in sql
assert ' order by ' in sql
assert 'tenant_no' in sql and 'del_flag' in sql
assert 'sales_user_id' in collection_sql
assert 'biz_protocol_collection_info' in collection_sql
assert 'biz_collection_region_staff' in leader_sql
def test_all_write_paths_use_batch_graph_item_lock_order():
source = (Path(__file__).parents[1] / 'app/services/market_freight.py').read_text()
for start_name, end_name in (
('def update_freight', 'def link_samples'),
('def link_samples', 'def unlink_samples'),
('def unlink_samples', None),
):
block = source[source.index(start_name):]
if end_name:
block = block[:block.index(end_name)]
assert block.index('_lock_batch_header') < block.index('_lock_sample_graph')
assert block.index('_lock_sample_graph') < block.index('_lock_active_items')
create_block = source[source.index('def create_freight'):source.index('def update_freight')]
assert create_block.index('_lock_sample_graph') < create_block.index('_lock_active_items')
def test_header_update_rejects_assignment_changed_after_prior_visible_read():
from app.services.market_freight import get_freight_detail, update_freight
db = _session()
sample, parent = _sample(db, 'scope-race')
freight = _freight(db, 'scope-race', [('cell_storage', sample, parent)])
assert get_freight_detail(db, freight.id, ctx=_ctx(), user_info=_user())['visibleItemCount'] == 1
collection = db.query(BizProtocolCollectionInfo).filter_by(
collection_protocol_id=parent.id
).one()
db.query(BizProtocolCollectionInfo).filter_by(id=collection.id).update(
{'sales_user_id': 'u2'}, synchronize_session=False
)
assert collection.sales_user_id == 'u1'
with pytest.raises(HTTPException) as exc:
update_freight(
db, freight.id, ctx=_ctx(), user_info=_user(), freight_amount='55.00'
)
assert exc.value.status_code == 404
assert db.query(BizSampleFreight).filter_by(id=freight.id).one().freight_amount == Decimal('12.30')
def test_final_item_lock_rejects_participant_snapshot_change():
from app.services.market_freight import _lock_active_items
db = _session()
first, fp = _sample(db, 'snapshot-first')
second, sp = _sample(db, 'snapshot-second')
freight = _freight(db, 'snapshot', [('cell_storage', first, fp)])
expected = [('cell_storage', first.id)]
db.add(BizSampleFreightItem(
id='fi-snapshot-race', freight_id=freight.id,
sample_source='cell_storage', sample_id=second.id,
sample_code=second.sample_code, protocol_id=sp.id,
tenant_no='tenant-a', create_dept_id='', del_flag=0,
))
db.commit()
with pytest.raises(PermissionError, match='发生变化'):
_lock_active_items(
db, _ctx(), freight_id=freight.id,
expected_batch_keys=expected,
)
def test_lock_graph_does_not_rely_on_exists_scope_for_current_field_values():
source = (Path(__file__).parents[1] / 'app/services/market_freight.py').read_text()
block = source[source.index('def _lock_sample_graph'):source.index('def _lock_active_items')]
assert 'apply_cell_protocol_scope' not in block
assert 'apply_donation_scope' not in block
assert '_current_field_visible_parent_ids' in block
assert '_current_data_scope_parent_ids' in block
def test_manager_header_update_rejects_current_leader_revocation():
from app.services.market_freight import get_freight_detail, update_freight
db = _session()
leader = BizCollectionRegionStaff(
id='leader-race', region_id='managed-race', user_id='m1', is_leader=1,
tenant_no='tenant-a', create_dept_id='', del_flag=0,
)
db.add(leader)
db.commit()
sample, parent = _sample(
db, 'leader-race', sales='u9', manager='other', region='managed-race'
)
freight = _freight(db, 'leader-race', [('cell_storage', sample, parent)])
ctx = _ctx('manager', 'm1')
assert get_freight_detail(db, freight.id, ctx=ctx, user_info=_user('m1'))['visibleItemCount'] == 1
db.query(BizCollectionRegionStaff).filter_by(id=leader.id).update(
{'is_leader': 0}, synchronize_session=False
)
assert leader.is_leader == 1
with pytest.raises(HTTPException) as exc:
update_freight(
db, freight.id, ctx=ctx, user_info=_user('m1'), freight_amount='88.00'
)
assert exc.value.status_code == 404
def test_header_update_uses_current_role_data_scope_not_prior_snapshot():
from app.services.market_freight import get_freight_detail, update_freight
db = _session()
now = datetime(2026, 10, 11, 8, 0)
db.add_all([
SysUser(
id='scope-user', dept_id='dept-current', user_no='scope-user',
nick_name='范围用户', password='', tenant_no='tenant-a',
create_dept_id='', create_time=now, update_time=now, del_flag=0,
),
SysRole(
id='scope-role', role_name='范围角色', data_scope='1', status='1',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysUserRole(
id='scope-user-role', user_id='scope-user', role_id='scope-role',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
])
db.commit()
sample, parent = _sample(db, 'data-scope', sales='scope-user')
parent.create_by = 'another-user'
parent.create_dept_id = 'another-dept'
db.commit()
freight = _freight(db, 'data-scope', [('cell_storage', sample, parent)])
ctx = _ctx('specialist', 'scope-user')
user = {'user_id': 'scope-user', 'tenant_no': 'tenant-a', 'role': 'admin'}
assert get_freight_detail(db, freight.id, ctx=ctx, user_info=user)['visibleItemCount'] == 1
cached_role = db.query(SysRole).filter_by(id='scope-role').one()
db.query(SysRole).filter_by(id='scope-role').update(
{'data_scope': '4'}, synchronize_session=False
)
assert cached_role.data_scope == '1'
with pytest.raises(HTTPException) as exc:
update_freight(
db, freight.id, ctx=ctx, user_info=user, freight_amount='77.00'
)
assert exc.value.status_code == 404
def test_current_data_scope_preserves_post_roles_custom_scope_and_union():
from app.services.market_freight import _current_data_scope_parent_ids
db = _session()
now = datetime(2026, 10, 11, 8, 0)
db.add_all([
SysUser(
id='union-user', dept_id='union-home', user_no='union-user',
nick_name='并集用户', password='', tenant_no='tenant-a',
create_dept_id='', create_time=now, update_time=now, del_flag=0,
),
SysRole(
id='custom-role', role_name='自定义范围', data_scope='5', status='1',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysRole(
id='self-role', role_name='本人范围', data_scope='4', status='1',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysUserRole(
id='union-direct', user_id='union-user', role_id='custom-role',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysRoleDataScopeDetail(
id='union-custom-detail', role_id='custom-role', scope_type='dept',
scope_value='custom-dept', tenant_no='tenant-a', create_dept_id='',
create_time=now, update_time=now, del_flag=0,
),
SysPost(
id='union-post', post_name='继承职位', post_code='union-post',
status='1', tenant_no='tenant-a', create_dept_id='',
create_time=now, update_time=now, del_flag=0,
),
SysUserPost(
id='union-user-post', user_id='union-user', post_id='union-post',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysPostRole(
id='union-post-role', post_id='union-post', role_id='self-role',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
])
db.commit()
_, custom_parent = _sample(db, 'union-custom')
_, self_parent = _sample(db, 'union-self')
_, denied_parent = _sample(db, 'union-denied')
custom_parent.create_dept_id = 'custom-dept'
self_parent.create_by = 'union-user'
denied_parent.create_dept_id = 'other-dept'
denied_parent.create_by = 'other-user'
db.commit()
visible = _current_data_scope_parent_ids(
db,
[custom_parent, self_parent, denied_parent],
_ctx('specialist', 'union-user'),
{'user_id': 'union-user', 'tenant_no': 'tenant-a', 'role': 'admin'},
)
assert visible == {custom_parent.id, self_parent.id}
def test_current_data_scope_preserves_department_descendant_scope():
from app.services.market_freight import _current_data_scope_parent_ids
db = _session()
now = datetime(2026, 10, 11, 8, 0)
db.add_all([
SysUser(
id='dept-user', dept_id='dept-root', user_no='dept-user',
nick_name='部门用户', password='', tenant_no='tenant-a',
create_dept_id='', create_time=now, update_time=now, del_flag=0,
),
SysRole(
id='dept-role', role_name='部门以下', data_scope='3', status='1',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysUserRole(
id='dept-user-role', user_id='dept-user', role_id='dept-role',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysDept(
id='dept-root', parent_id='0', dept_name='根部门', status='1',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysDept(
id='dept-child', parent_id='dept-root', dept_name='子部门', status='1',
tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
])
db.commit()
_, child_parent = _sample(db, 'dept-child')
_, denied_parent = _sample(db, 'dept-denied')
child_parent.create_dept_id = 'dept-child'
denied_parent.create_dept_id = 'other-dept'
db.commit()
visible = _current_data_scope_parent_ids(
db,
[child_parent, denied_parent],
_ctx('specialist', 'dept-user'),
{'user_id': 'dept-user', 'tenant_no': 'tenant-a', 'role': 'admin'},
)
assert visible == {child_parent.id}
def test_locked_data_scope_ignores_payload_superadmin_across_tenants():
from app.services.market_freight import (
_current_data_scope_parent_ids,
get_freight_detail,
)
db = _session()
now = datetime(2026, 10, 11, 8, 0)
db.add_all([
SysUser(
id='cross-user', dept_id='cross-dept', user_no='cross-user',
nick_name='跨租户用户', password='', tenant_no='tenant-a',
create_dept_id='', create_time=now, update_time=now, del_flag=0,
),
SysRole(
id='default-super-role', role_name='超级管理员', data_scope='1',
status='1', tenant_no='zkxw', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysUserRole(
id='default-super-link', user_id='cross-user',
role_id='default-super-role', tenant_no='zkxw', create_dept_id='',
create_time=now, update_time=now, del_flag=0,
),
SysRole(
id='business-self-role', role_name='业务专员', data_scope='4',
status='1', tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysUserRole(
id='business-self-link', user_id='cross-user',
role_id='business-self-role', tenant_no='tenant-a',
create_dept_id='', create_time=now, update_time=now, del_flag=0,
),
])
db.commit()
sample, parent = _sample(
db, 'cross-tenant-scope', sales='cross-user'
)
parent.create_by = 'another-user'
parent.create_dept_id = 'another-dept'
db.commit()
ctx = _ctx('specialist', 'cross-user')
forged_default_tenant_payload = {
'user_id': 'cross-user', 'tenant_no': 'tenant-a', 'role': 'super_admin'
}
freight = _freight(
db, 'cross-tenant-scope', [('cell_storage', sample, parent)]
)
assert _current_data_scope_parent_ids(
db, [parent], ctx, forged_default_tenant_payload
) == set()
with pytest.raises(HTTPException) as exc:
get_freight_detail(
db, freight.id, ctx=ctx, user_info=forged_default_tenant_payload
)
assert exc.value.status_code == 404
db.add_all([
SysRole(
id='business-super-role', role_name='超级管理员', data_scope='4',
status='1', tenant_no='tenant-a', create_dept_id='', create_time=now,
update_time=now, del_flag=0,
),
SysPost(
id='business-super-post', post_name='业务租户超级管理职位',
post_code='business-super-post', status='1', tenant_no='tenant-a',
create_dept_id='', create_time=now, update_time=now, del_flag=0,
),
SysUserPost(
id='business-super-user-post', user_id='cross-user',
post_id='business-super-post', tenant_no='tenant-a',
create_dept_id='', create_time=now, update_time=now, del_flag=0,
),
SysPostRole(
id='business-super-post-role', post_id='business-super-post',
role_id='business-super-role', tenant_no='tenant-a',
create_dept_id='', create_time=now, update_time=now, del_flag=0,
),
])
db.commit()
assert _current_data_scope_parent_ids(
db, [parent], ctx, forged_default_tenant_payload
) == {parent.id}
assert get_freight_detail(
db, freight.id, ctx=ctx, user_info=forged_default_tenant_payload
)['visibleItemCount'] == 1
@@ -0,0 +1,175 @@
import re
from pathlib import Path
MIGRATION = (
Path(__file__).resolve().parents[2]
/ "docs/sql/migrations/2026-10-10-market-mobile-menus.sql"
)
ROLES = {
"specialist": "市场专员",
"manager": "地区经理",
}
MODULES = {
"presign": "预签约订单",
"protocol": "协议单",
"payment-chase": "催缴信息",
"payment": "缴费信息",
"sample": "样本信息",
"freight": "运费信息",
}
def _sql() -> str:
return MIGRATION.read_text(encoding="utf-8")
def _compact(sql: str) -> str:
return re.sub(r"\s+", " ", sql)
def _without_comments(sql: str) -> str:
return re.sub(r"--[^\n]*", "", sql)
def _insert_statements(sql: str) -> list[str]:
return [
_compact(statement)
for statement in _without_comments(sql).split(";")
if re.search(r"\bINSERT\s+INTO\s+sys_menu\b", statement, re.IGNORECASE)
]
def test_market_mobile_menu_migration_defines_two_empty_path_directories():
sql = _compact(_without_comments(_sql()))
for directory_name in ROLES.values():
assert re.search(
rf"'{directory_name}'\s*,\s*root\.id\s*,\s*'M'\s*,\s*''",
sql,
)
def test_market_mobile_menu_migration_defines_exactly_twelve_fixed_leaves():
sql = _without_comments(_sql())
expected_paths = {
f"/pages/market/webview/index?entry={role}-{module}"
for role in ROLES
for module in MODULES
}
expected_perms = {
f"mini:market:{role}:{module}"
for role in ROLES
for module in MODULES
}
paths = re.findall(
r"['\"](/pages/market/webview/index\?entry=[a-z-]+)['\"]", sql
)
perms = re.findall(r"['\"](mini:market:[a-z-]+:[a-z-]+)['\"]", sql)
assert len(paths) == 12
assert set(paths) == expected_paths
assert len(perms) == 12
assert set(perms) == expected_perms
for role in ROLES:
for module in MODULES:
path = f"/pages/market/webview/index?entry={role}-{module}"
permission = f"mini:market:{role}:{module}"
assert re.search(
rf"'{re.escape(path)}'\s*(?:AS\s+path)?\s*,\s*"
rf"'{re.escape(permission)}'\s*(?:AS\s+perms)?",
sql,
re.IGNORECASE,
)
assert re.search(
r"entry\.menu_name\s*,\s*directory\.id\s*,\s*'C'\s*,\s*entry\.path",
_compact(sql),
)
def test_market_mobile_menu_migration_is_idempotent_and_tenant_aware():
sql = _sql()
compact = _compact(_without_comments(sql))
inserts = _insert_statements(sql)
assert len(inserts) == 3
assert all(re.search(r"\bNOT\s+EXISTS\b", item, re.IGNORECASE) for item in inserts)
assert re.search(r"root\.parent_id\s*=\s*'0'", compact)
assert re.search(r"root\.menu_name\s*=\s*'移动端菜单'", compact)
assert re.search(r"root\.del_flag\s*=\s*0", compact)
assert compact.count("root.tenant_no") >= len(inserts)
assert "'zkxw'" not in sql
assert re.search(r"existing\.parent_id\s*=\s*directory\.id", compact)
assert re.search(r"existing\.path\s*=\s*entry\.path", compact)
assert re.search(r"existing\.menu_type\s*=\s*'C'", compact)
def test_each_insert_uses_the_complete_active_natural_key_without_bare_id_or():
specialist, manager, leaves = _insert_statements(_sql())
for directory, name in ((specialist, "市场专员"), (manager, "地区经理")):
assert "existing.id" not in directory
assert re.search(r"existing\.tenant_no\s*=\s*root\.tenant_no", directory)
assert re.search(r"existing\.parent_id\s*=\s*root\.id", directory)
assert re.search(r"existing\.menu_name\s*=\s*'" + name + r"'", directory)
assert re.search(r"existing\.menu_type\s*=\s*'M'", directory)
assert re.search(r"existing\.path\s*=\s*''", directory)
assert re.search(r"existing\.del_flag\s*=\s*0", directory)
assert "existing.id" not in leaves
assert re.search(r"existing\.tenant_no\s*=\s*root\.tenant_no", leaves)
assert re.search(r"existing\.parent_id\s*=\s*directory\.id", leaves)
assert re.search(r"existing\.menu_name\s*=\s*entry\.menu_name", leaves)
assert re.search(r"existing\.menu_type\s*=\s*'C'", leaves)
assert re.search(r"existing\.path\s*=\s*entry\.path", leaves)
assert re.search(r"existing\.perms\s*=\s*entry\.perms", leaves)
assert re.search(r"existing\.del_flag\s*=\s*0", leaves)
def test_ids_and_directory_join_are_scoped_to_the_expected_tree_location():
specialist, manager, leaves = _insert_statements(_sql())
for directory in (specialist, manager):
id_expression = re.search(
r"\bSELECT\s+(MD5\(.*?\))\s*,\s*'(?:市场专员|地区经理)'",
directory,
).group(1)
assert "root.tenant_no" in id_expression
assert "root.id" in id_expression
leaf_id_expression = re.search(
r"\bSELECT\s+(MD5\(.*?\))\s*,\s*entry\.menu_name", leaves
).group(1)
assert "root.tenant_no" in leaf_id_expression
assert "root.id" in leaf_id_expression
assert "directory.id" in leaf_id_expression
assert "entry.path" in leaf_id_expression
assert re.search(r"directory\.path\s*=\s*''", leaves)
def test_same_id_wrong_or_soft_deleted_rows_are_repaired_instead_of_blocking_insert():
specialist, manager, leaves = _insert_statements(_sql())
for statement in (specialist, manager, leaves):
assert re.search(r"\bON\s+DUPLICATE\s+KEY\s+UPDATE\b", statement)
repair = re.split(
r"\bON\s+DUPLICATE\s+KEY\s+UPDATE\b", statement, maxsplit=1
)[1]
for field in (
"tenant_no",
"parent_id",
"menu_name",
"menu_type",
"path",
"perms",
"del_flag",
):
assert re.search(
rf"\b{field}\s*=\s*VALUES\s*\(\s*{field}\s*\)", repair
), field
def test_market_mobile_menu_migration_does_not_grant_roles_automatically():
assert "sys_role_menu" not in _without_comments(_sql())
@@ -0,0 +1,268 @@
import asyncio
import pytest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.mobile_menu_guard import (
require_market_menu_code,
require_market_menu_permission,
user_has_tenant_menu_perm,
)
from app.models.base import Base
from app.models.models import (
SysMenu,
SysPost,
SysPostRole,
SysRole,
SysRoleMenu,
SysUserPost,
SysUserRole,
)
def _session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[
SysMenu.__table__,
SysPost.__table__,
SysPostRole.__table__,
SysRole.__table__,
SysRoleMenu.__table__,
SysUserPost.__table__,
SysUserRole.__table__,
],
)
return sessionmaker(bind=engine)()
def _add_role_menu(db, *, tenant_no, role_id, menu_id, perm_code):
db.add(
SysRole(
id=role_id,
role_name="市场角色",
status="1",
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
)
)
db.add(
SysMenu(
id=menu_id,
menu_name=perm_code,
parent_id="root",
menu_type="C",
path=f"/{menu_id}",
perms=perm_code,
visible="1",
status="1",
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
)
)
db.add(
SysRoleMenu(
id=f"rm-{role_id}-{menu_id}",
role_id=role_id,
menu_id=menu_id,
tenant_no=tenant_no,
create_dept_id="",
del_flag=0,
)
)
def test_specialist_permission_does_not_grant_manager_access():
assert not require_market_menu_permission(
user_id="u1",
tenant_no="t1",
required="mini:market:manager:presign",
granted={"mini:market:specialist:presign"},
)
def test_tenant_menu_permission_does_not_fall_back_to_default_tenant():
db = _session()
_add_role_menu(
db,
tenant_no="tenant-a",
role_id="role-a",
menu_id="menu-a",
perm_code="mini:market:manager:presign",
)
_add_role_menu(
db,
tenant_no="tenant-b",
role_id="role-b",
menu_id="menu-b",
perm_code="mini:market:specialist:presign",
)
db.add_all(
[
SysUserRole(
id="ur-a",
user_id="u1",
role_id="role-a",
tenant_no="tenant-a",
create_dept_id="",
del_flag=0,
),
SysUserRole(
id="ur-b",
user_id="u1",
role_id="role-b",
tenant_no="tenant-b",
create_dept_id="",
del_flag=0,
),
]
)
db.commit()
assert user_has_tenant_menu_perm(
db,
user_id="u1",
tenant_no="tenant-b",
perm_code="mini:market:specialist:presign",
)
assert not user_has_tenant_menu_perm(
db,
user_id="u1",
tenant_no="tenant-b",
perm_code="mini:market:manager:presign",
)
def test_tenant_menu_permission_includes_post_inherited_roles():
db = _session()
_add_role_menu(
db,
tenant_no="tenant-b",
role_id="role-b",
menu_id="menu-b",
perm_code="mini:market:specialist:presign",
)
db.add_all(
[
SysPost(
id="post-b",
post_name="市场专员",
post_code="specialist",
status="1",
tenant_no="tenant-b",
create_dept_id="",
del_flag=0,
),
SysUserPost(
id="up-b",
user_id="u1",
post_id="post-b",
tenant_no="tenant-b",
create_dept_id="",
del_flag=0,
),
SysPostRole(
id="pr-b",
post_id="post-b",
role_id="role-b",
tenant_no="tenant-b",
create_dept_id="",
del_flag=0,
),
]
)
db.commit()
assert user_has_tenant_menu_perm(
db,
user_id="u1",
tenant_no="tenant-b",
perm_code="mini:market:specialist:presign",
)
def test_strict_guard_rejects_missing_tenant_identity():
dependency = require_market_menu_code("mini:market:specialist:presign")
with pytest.raises(HTTPException) as exc_info:
asyncio.run(dependency(user={"user_id": "u1", "tenant_no": ""}, db=None))
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "用户租户或身份信息缺失"
def test_strict_guard_does_not_trust_cross_tenant_super_admin_role():
db = _session()
db.add_all(
[
SysRole(
id="tenant-b-role",
role_name="市场专员",
status="1",
tenant_no="tenant-b",
create_dept_id="",
del_flag=0,
),
SysUserRole(
id="tenant-b-user-role",
user_id="u1",
role_id="tenant-b-role",
tenant_no="tenant-b",
create_dept_id="",
del_flag=0,
),
]
)
db.commit()
dependency = require_market_menu_code("mini:market:specialist:presign")
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
dependency(
user={
"user_id": "u1",
"tenant_no": "tenant-b",
"role": "super_admin",
},
db=db,
)
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == "无移动端菜单权限"
def test_strict_guard_uses_current_tenant_super_admin_role():
db = _session()
db.add_all(
[
SysRole(
id="tenant-b-super-role",
role_name="超级管理员",
status="1",
tenant_no="tenant-b",
create_dept_id="",
del_flag=0,
),
SysUserRole(
id="tenant-b-super-user-role",
user_id="u1",
role_id="tenant-b-super-role",
tenant_no="tenant-b",
create_dept_id="",
del_flag=0,
),
]
)
db.commit()
dependency = require_market_menu_code("mini:market:specialist:presign")
user = {"user_id": "u1", "tenant_no": "tenant-b", "role": "admin"}
result = asyncio.run(dependency(user=user, db=db))
assert result is user
+759
View File
@@ -0,0 +1,759 @@
from datetime import date, datetime, timedelta
from decimal import Decimal
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import ValidationError
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from starlette.responses import JSONResponse
from app.models.base import Base
from app.models.models import (
BizAttachment,
BizCollectionProtocol,
BizCollectionRegionStaff,
BizProtocolCollectionInfo,
BizProtocolPartyCustomer,
BizProtocolPaymentSchedule,
BizProtocolPaymentTransfer,
BizProtocolSamplePayment,
SysPost,
SysPostRole,
SysRole,
SysRoleDataScopeDetail,
SysUser,
SysUserPost,
SysUserRole,
)
def _session():
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(
engine,
tables=[
BizCollectionProtocol.__table__,
BizProtocolCollectionInfo.__table__,
BizProtocolPartyCustomer.__table__,
BizProtocolSamplePayment.__table__,
BizProtocolPaymentSchedule.__table__,
BizProtocolPaymentTransfer.__table__,
BizAttachment.__table__,
BizCollectionRegionStaff.__table__,
SysUser.__table__,
SysRole.__table__,
SysRoleDataScopeDetail.__table__,
SysUserRole.__table__,
SysPost.__table__,
SysPostRole.__table__,
SysUserPost.__table__,
],
)
return sessionmaker(bind=engine)()
def _ctx(scope_kind='specialist', user_id='u1'):
from app.services.market_field_scope import MarketFieldContext
return MarketFieldContext('tenant-a', user_id, scope_kind)
def _user(user_id='u1'):
return {
'user_id': user_id,
'tenant_no': 'tenant-a',
'role': 'super_admin',
'region_id': 'dept-from-auth',
}
def _payment_tree(
db,
key,
*,
sales='u1',
manager='manager-a',
region='region-a',
tenant='tenant-a',
protocol_status='sxz',
payment_model_version=2,
due_date=None,
due_amount=100,
schedule_status='open',
deleted=0,
transfer_statuses=(),
transfer_dept='',
):
protocol = BizCollectionProtocol(
id=f'p-{key}',
protocol_no=f'P-{key}',
protocol_status=protocol_status,
payment_model_version=payment_model_version,
tenant_no=tenant,
create_dept_id='dept-a',
create_time=datetime.now(),
del_flag=deleted,
)
collection = BizProtocolCollectionInfo(
id=f'ci-{key}',
collection_protocol_id=protocol.id,
region_id=region,
sales_user_id=sales,
region_manager_user_id=manager,
tenant_no=tenant,
create_dept_id='dept-a',
del_flag=deleted,
)
party = BizProtocolPartyCustomer(
id=f'party-{key}',
collection_protocol_id=protocol.id,
mother_name=f'客户-{key}',
tenant_no=tenant,
create_dept_id='dept-a',
del_flag=deleted,
)
payment = BizProtocolSamplePayment(
id=f'sp-{key}',
collection_protocol_id=protocol.id,
sample_code=f'S-{key}',
pay_mode='fq',
contract_amount=due_amount,
tenant_no=tenant,
create_dept_id='dept-a',
create_time=datetime.now(),
del_flag=deleted,
)
schedule = BizProtocolPaymentSchedule(
id=f'sch-{key}',
sample_payment_id=payment.id,
collection_protocol_id=protocol.id,
sample_code=payment.sample_code,
schedule_no=1,
due_amount=due_amount,
due_date=due_date or date.today().strftime('%Y%m%d'),
schedule_status=schedule_status,
tenant_no=tenant,
create_dept_id='dept-a',
del_flag=deleted,
)
db.add_all([protocol, collection, party, payment, schedule])
for index, status in enumerate(transfer_statuses):
db.add(
BizProtocolPaymentTransfer(
id=f't-{key}-{index}',
schedule_id=schedule.id,
sample_payment_id=payment.id,
collection_protocol_id=protocol.id,
sample_code=payment.sample_code,
transfer_amount=20,
transfer_time=datetime.now(),
transfer_type='bank',
pay_status=status,
tenant_no=tenant,
create_dept_id=transfer_dept,
del_flag=deleted,
)
)
db.commit()
return protocol, payment, schedule
def test_chase_is_overdue_union_next_30_days_with_parent_scope():
from app.services.market_payment import list_payment_chase
db = _session()
today = date(2026, 10, 11)
_payment_tree(db, 'overdue', due_date=(today - timedelta(days=1)).strftime('%Y%m%d'))
_payment_tree(db, 'today', due_date=today.strftime('%Y%m%d'))
_payment_tree(db, 'day30', due_date=(today + timedelta(days=30)).strftime('%Y%m%d'))
_payment_tree(db, 'day31', due_date=(today + timedelta(days=31)).strftime('%Y%m%d'))
_payment_tree(db, 'other', sales='u2', due_date=today.strftime('%Y%m%d'))
_payment_tree(db, 'void', protocol_status='yzf', due_date=today.strftime('%Y%m%d'))
_payment_tree(db, 'bad-date', due_date='not-a-date')
items, total = list_payment_chase(
db, ctx=_ctx(), user_info=_user(), today=today
)
assert total == 3
assert {item['scheduleId'] for item in items} == {
'sch-overdue',
'sch-today',
'sch-day30',
}
def test_chase_counts_dfj_jfqrz_and_yjf_and_excludes_fully_paid():
from app.services.market_payment import list_payment_chase
db = _session()
_payment_tree(
db,
'part-paid',
due_amount=100,
transfer_statuses=('dfj', 'jfqrz', 'yjf'),
)
_payment_tree(
db,
'fully-paid',
due_amount=60,
transfer_statuses=('dfj', 'jfqrz', 'yjf'),
)
items, total = list_payment_chase(db, ctx=_ctx(), user_info=_user())
assert total == 1
assert items[0]['scheduleId'] == 'sch-part-paid'
assert items[0]['paidAmount'] == '60.00'
assert items[0]['arrearsAmount'] == '40.00'
def test_chase_filters_counts_orders_and_pages_in_sql_before_materialization():
from app.services.market_payment import list_payment_chase
db = _session()
today = date(2026, 10, 11)
for index in range(35):
_payment_tree(
db,
f'history-{index:02d}',
due_date=(today - timedelta(days=index + 1)).strftime('%Y%m%d'),
transfer_statuses=('dfj',),
)
_payment_tree(db, 'invalid-day', due_date='20260230')
statements = []
def capture(_conn, _cursor, statement, _parameters, _context, _many):
statements.append(statement)
event.listen(db.get_bind(), 'before_cursor_execute', capture)
try:
items, total = list_payment_chase(
db,
ctx=_ctx(),
user_info=_user(),
today=today,
page=3,
page_size=5,
)
finally:
event.remove(db.get_bind(), 'before_cursor_execute', capture)
assert total == 35
assert len(items) == 5
assert [item['scheduleId'] for item in items] == [
'sch-history-24',
'sch-history-23',
'sch-history-22',
'sch-history-21',
'sch-history-20',
]
chase_selects = [sql for sql in statements if 'biz_protocol_payment_schedule' in sql]
assert len(chase_selects) == 2
assert ' LIMIT ' in chase_selects[-1].upper()
assert 'SUM(CASE' in chase_selects[-1].upper()
assert not any(
'FROM biz_protocol_payment_transfer' in sql and 'SUM(CASE' not in sql.upper()
for sql in statements
)
def test_payment_list_is_v2_only_and_uses_parent_scope_not_transfer_department():
from app.services.market_payment import list_payments
db = _session()
_payment_tree(db, 'v2', transfer_statuses=('dfj',), transfer_dept='')
_payment_tree(db, 'v1', payment_model_version=1)
_payment_tree(db, 'other', sales='u2')
_payment_tree(db, 'cross', tenant='tenant-b')
_payment_tree(db, 'deleted', deleted=1)
items, total = list_payments(db, ctx=_ctx(), user_info=_user())
assert total == 1
assert items[0]['id'] == 'sp-v2'
assert items[0]['paymentModelVersion'] == 2
def test_manager_scope_is_fixed_by_route_context_and_client_filter_cannot_expand():
from app.services.market_payment import list_payment_chase
db = _session()
_payment_tree(db, 'manager', manager='manager-a', sales='u2')
_payment_tree(db, 'outside', manager='manager-b', sales='u1')
items, total = list_payment_chase(
db,
ctx=_ctx('manager', 'manager-a'),
user_info=_user('manager-a'),
sales_user_id='u1',
)
assert total == 0
assert items == []
def test_payment_detail_rejects_out_of_scope_sample():
from app.services.market_payment import get_payment_detail
db = _session()
_payment_tree(db, 'other', sales='u2')
with pytest.raises(HTTPException) as exc:
get_payment_detail(
db, payment_id='sp-other', ctx=_ctx(), user_info=_user()
)
assert exc.value.status_code == 404
def test_payment_detail_identifies_legacy_v1_without_reading_wrong_tables():
from app.services.market_payment import get_payment_detail
db = _session()
_payment_tree(db, 'legacy', payment_model_version=1)
with pytest.raises(HTTPException) as exc:
get_payment_detail(
db, payment_id='sp-legacy', ctx=_ctx(), user_info=_user()
)
assert exc.value.status_code == 409
assert '旧版' in str(exc.value.detail)
def test_market_can_save_submit_and_withdraw_transfer_atomically():
from app.services.market_payment import (
create_transfer,
submit_transfer,
update_transfer,
withdraw_transfer,
)
db = _session()
_, _, schedule = _payment_tree(db, 'own')
db.add(
BizAttachment(
id='att-pay',
business_type='market:specialist:payment:staging',
original_name='a.jpg',
file_ext='jpg',
file_url='a.jpg',
file_size=10,
file_type='image/jpeg',
tenant_no='tenant-a',
create_by='u1',
create_dept_id='',
del_flag=0,
)
)
db.commit()
created = create_transfer(
db,
schedule_id=schedule.id,
fields={
'transferAmount': 50,
'transferTime': '2026-10-11 10:00:00',
'transferType': 'bank',
'transferAttachments': 'a.jpg',
},
ctx=_ctx(),
user_info=_user(),
)
assert created['payStatus'] == 'dfj'
row = db.get(BizProtocolPaymentTransfer, created['id'])
assert row.create_dept_id == 'dept-from-auth'
attachment = db.get(BizAttachment, 'att-pay')
assert (attachment.business_type, attachment.business_id) == (
'market:specialist:payment:bound',
created['id'],
)
updated = update_transfer(
db,
transfer_id=created['id'],
fields={'remark': '补充说明'},
ctx=_ctx(),
user_info=_user(),
)
assert updated['remark'] == '补充说明'
assert submit_transfer(
db, transfer_id=created['id'], ctx=_ctx(), user_info=_user()
)['payStatus'] == 'jfqrz'
assert withdraw_transfer(
db, transfer_id=created['id'], ctx=_ctx(), user_info=_user()
)['payStatus'] == 'dfj'
def test_transfer_create_rejects_external_attachment_reference():
from app.services.market_payment import create_transfer
db = _session()
_, _, schedule = _payment_tree(db, 'attachment-reject')
with pytest.raises(ValueError, match='附件'):
create_transfer(
db,
schedule_id=schedule.id,
fields={
'transferAmount': 10,
'transferType': 'bank',
'transferAttachments': 'https://evil.test/a.jpg',
},
ctx=_ctx(),
user_info=_user(),
)
def test_transfer_create_rollback_restores_staging_attachment_on_business_failure(
monkeypatch,
):
from app.services.market_payment import create_transfer
db = _session()
_, _, schedule = _payment_tree(db, 'attachment-rollback')
attachment = BizAttachment(
id='att-rollback',
business_type='market:specialist:payment:staging',
original_name='rollback.jpg',
file_ext='jpg',
file_url='rollback.jpg',
file_size=10,
file_type='image/jpeg',
tenant_no='tenant-a',
create_by='u1',
create_dept_id='',
del_flag=0,
)
db.add(attachment)
db.commit()
monkeypatch.setattr(
db, 'commit', lambda: (_ for _ in ()).throw(RuntimeError('db down'))
)
with pytest.raises(RuntimeError, match='db down'):
create_transfer(
db,
schedule_id=schedule.id,
fields={
'transferAmount': 10,
'transferType': 'bank',
'transferAttachments': 'att-rollback',
},
ctx=_ctx(),
user_info=_user(),
)
db.expire_all()
restored = db.get(BizAttachment, 'att-rollback')
assert (restored.business_type, restored.business_id) == (
'market:specialist:payment:staging',
None,
)
assert db.query(BizProtocolPaymentTransfer).count() == 0
def test_out_of_scope_write_is_checked_before_any_mutation():
from app.services.market_payment import update_transfer
db = _session()
_payment_tree(db, 'other', sales='u2', transfer_statuses=('dfj',))
before = db.get(BizProtocolPaymentTransfer, 't-other-0').remark
with pytest.raises(HTTPException):
update_transfer(
db,
transfer_id='t-other-0',
fields={'remark': 'must-not-write'},
ctx=_ctx(),
user_info=_user(),
)
db.expire_all()
assert db.get(BizProtocolPaymentTransfer, 't-other-0').remark == before
@pytest.mark.parametrize('protocol_status', ['yzf', 'sdfq', 'zky'])
def test_terminal_protocol_rejects_direct_transfer_create_without_mutation(protocol_status):
from app.services.market_payment import create_transfer
db = _session()
_, _, schedule = _payment_tree(
db, f'terminal-create-{protocol_status}', protocol_status=protocol_status
)
with pytest.raises(HTTPException):
create_transfer(
db,
schedule_id=schedule.id,
fields={'transferAmount': 10, 'transferType': 'bank'},
ctx=_ctx(),
user_info=_user(),
)
assert db.query(BizProtocolPaymentTransfer).count() == 0
@pytest.mark.parametrize('schedule_status', ['cleared', 'void'])
def test_non_open_schedule_rejects_direct_transfer_create_without_mutation(schedule_status):
from app.services.market_payment import create_transfer
db = _session()
_, _, schedule = _payment_tree(
db, f'closed-create-{schedule_status}', schedule_status=schedule_status
)
with pytest.raises(HTTPException):
create_transfer(
db,
schedule_id=schedule.id,
fields={'transferAmount': 10, 'transferType': 'bank'},
ctx=_ctx(),
user_info=_user(),
)
assert db.query(BizProtocolPaymentTransfer).count() == 0
@pytest.mark.parametrize(
('protocol_status', 'schedule_status', 'pay_status', 'operation'),
[
('yzf', 'open', 'dfj', 'update'),
('sdfq', 'open', 'dfj', 'submit'),
('zky', 'open', 'jfqrz', 'withdraw'),
('sxz', 'cleared', 'dfj', 'update'),
('sxz', 'void', 'dfj', 'submit'),
],
)
def test_ineligible_parent_rejects_direct_transfer_mutations_without_mutation(
protocol_status, schedule_status, pay_status, operation
):
from app.services.market_payment import submit_transfer, update_transfer, withdraw_transfer
db = _session()
key = f'ineligible-{protocol_status}-{schedule_status}-{operation}'
_payment_tree(
db,
key,
protocol_status=protocol_status,
schedule_status=schedule_status,
transfer_statuses=(pay_status,),
)
transfer_id = f't-{key}-0'
before = db.get(BizProtocolPaymentTransfer, transfer_id)
before_values = (before.pay_status, before.remark, before.update_by, before.update_time)
with pytest.raises(HTTPException):
if operation == 'update':
update_transfer(
db,
transfer_id=transfer_id,
fields={'remark': 'must-not-write'},
ctx=_ctx(),
user_info=_user(),
)
elif operation == 'submit':
submit_transfer(db, transfer_id=transfer_id, ctx=_ctx(), user_info=_user())
else:
withdraw_transfer(db, transfer_id=transfer_id, ctx=_ctx(), user_info=_user())
db.expire_all()
after = db.get(BizProtocolPaymentTransfer, transfer_id)
assert (after.pay_status, after.remark, after.update_by, after.update_time) == before_values
def test_chase_excludes_all_terminal_protocols_and_non_open_schedules():
from app.services.market_payment import list_payment_chase
db = _session()
_payment_tree(db, 'eligible-chase')
for status in ('yzf', 'sdfq', 'zky'):
_payment_tree(db, f'terminal-chase-{status}', protocol_status=status)
_payment_tree(db, 'cleared-chase', schedule_status='cleared')
_payment_tree(db, 'void-chase', schedule_status='void')
items, total = list_payment_chase(db, ctx=_ctx(), user_info=_user())
assert total == 1
assert [item['scheduleId'] for item in items] == ['sch-eligible-chase']
def test_yjf_is_immutable_and_double_submit_fails_closed():
from app.services.market_payment import submit_transfer, update_transfer, withdraw_transfer
db = _session()
_payment_tree(db, 'draft', transfer_statuses=('dfj',))
submit_transfer(db, transfer_id='t-draft-0', ctx=_ctx(), user_info=_user())
with pytest.raises(ValueError, match='状态'):
submit_transfer(db, transfer_id='t-draft-0', ctx=_ctx(), user_info=_user())
_payment_tree(db, 'confirmed', transfer_statuses=('yjf',))
with pytest.raises(ValueError):
update_transfer(
db,
transfer_id='t-confirmed-0',
fields={'remark': 'no'},
ctx=_ctx(),
user_info=_user(),
)
with pytest.raises(ValueError):
withdraw_transfer(
db,
transfer_id='t-confirmed-0',
ctx=_ctx(),
user_info=_user(),
)
def test_transfer_dto_forbids_authorization_status_and_finance_fields():
from app.schemas.market_mobile import MarketPaymentTransferBody
forbidden = (
{'tenantNo': 'tenant-b'},
{'scope': 'manager'},
{'payStatus': 'yjf'},
{'financeRemark': 'confirmed'},
{'scheduleStatus': 'void'},
)
for extra in forbidden:
with pytest.raises(ValidationError):
MarketPaymentTransferBody.model_validate(
{'transferAmount': 10, 'transferType': 'bank', **extra}
)
@pytest.mark.parametrize(
'amount',
[float('inf'), float('-inf'), float('nan'), '0.001', '10000000000000000.00'],
)
def test_transfer_amount_rejects_non_finite_over_precision_and_overflow(amount):
from app.schemas.market_mobile import MarketPaymentTransferBody
with pytest.raises(ValidationError):
MarketPaymentTransferBody.model_validate({'transferAmount': amount})
@pytest.mark.parametrize('amount', ['0.01', '9999999999999999.99'])
def test_transfer_amount_accepts_decimal_18_2_boundaries_exactly(amount):
from app.schemas.market_mobile import MarketPaymentTransferBody
body = MarketPaymentTransferBody.model_validate({'transferAmount': amount})
assert body.transfer_amount == Decimal(amount)
def test_task5_money_is_fixed_two_decimal_string_in_actual_json_response():
from app.core.response import success_response
from app.schemas.market_mobile import MarketPaymentTransferCreateBody
from app.services.market_payment import _payment_dict, create_transfer
db = _session()
_, _, schedule = _payment_tree(
db, 'money-json', due_amount=Decimal('9999999999999999.99')
)
body = MarketPaymentTransferCreateBody.model_validate(
{
'scheduleId': schedule.id,
'transferAmount': '9999999999999999.99',
'transferType': 'bank',
}
)
payload = body.model_dump(by_alias=True, exclude_unset=True)
schedule_id = payload.pop('scheduleId')
transfer = create_transfer(
db,
schedule_id=schedule_id,
fields=payload,
ctx=_ctx(),
user_info=_user(),
)
max_money = Decimal('9999999999999999.99')
payment = _payment_dict(
SimpleNamespace(
id='sp-json',
sample_code='S-json',
pay_mode='fq',
contract_amount=max_money,
remark=None,
),
SimpleNamespace(id='p-json', protocol_no='P-json', protocol_status='sxz'),
None,
[
SimpleNamespace(
id='sch-json',
schedule_no=1,
due_amount=max_money,
due_date='20261011',
schedule_status='open',
)
],
{},
)
encoded = jsonable_encoder(
success_response({'transfer': transfer, 'payment': payment})
)
response = JSONResponse(encoded)
decoded = response.body.decode('utf-8')
assert transfer['transferAmount'] == '9999999999999999.99'
assert payment['contractAmount'] == '9999999999999999.99'
assert payment['schedules'][0]['dueAmount'] == '9999999999999999.99'
assert '"9999999999999999.99"' in decoded
def test_task5_normal_money_is_always_serialized_with_two_decimals():
from app.services.market_payment import list_payment_chase
db = _session()
_payment_tree(db, 'normal-money', due_amount=Decimal('100.00'))
items, _ = list_payment_chase(db, ctx=_ctx(), user_info=_user())
assert items[0]['dueAmount'] == '100.00'
assert items[0]['paidAmount'] == '0.00'
assert items[0]['arrearsAmount'] == '100.00'
def test_market_routes_expose_only_allowed_payment_mutations():
from app.api.market_mobile import router
paths = {route.path for route in router.routes}
for scope in ('specialist', 'manager'):
prefix = f'/mini/market/{scope}'
assert f'{prefix}/payment-chase' in paths
assert f'{prefix}/payment-chases' not in paths
assert f'{prefix}/payments' in paths
assert f'{prefix}/payment-transfers' in paths
assert f'{prefix}/payment-transfers/{{transfer_id}}/submit' in paths
assert f'{prefix}/payment-transfers/{{transfer_id}}/withdraw' in paths
assert f'{prefix}/payment-transfers/{{transfer_id}}/confirm' not in paths
assert f'{prefix}/payment-transfers/{{transfer_id}}/reject' not in paths
assert f'{prefix}/payment-transfers/{{transfer_id}}/refund' not in paths
assert f'{prefix}/payment-transfers/{{transfer_id}}/invoice' not in paths
def test_list_queries_batch_children_without_n_plus_one():
from app.services.market_payment import list_payments
db = _session()
for index in range(8):
_payment_tree(db, f'row-{index}', transfer_statuses=('dfj', 'yjf'))
statements = []
def capture(_conn, _cursor, statement, _parameters, _context, _many):
statements.append(statement)
event.listen(db.get_bind(), 'before_cursor_execute', capture)
try:
items, total = list_payments(
db, ctx=_ctx(), user_info=_user(), page=1, page_size=5
)
finally:
event.remove(db.get_bind(), 'before_cursor_execute', capture)
assert total == 8
assert len(items) == 5
assert len(statements) <= 5
@@ -0,0 +1,43 @@
from pathlib import Path
SQL_PATH = (
Path(__file__).resolve().parents[2]
/ 'docs/sql/migrations/2026-10-11-market-payment-indexes.sql'
)
def test_market_payment_index_migration_is_mysql57_idempotent_and_repairs_names():
sql = SQL_PATH.read_text(encoding='utf-8')
assert 'CREATE INDEX IF NOT EXISTS' not in sql.upper()
assert sql.count('information_schema.statistics') >= 2
assert sql.count('GROUP_CONCAT(column_name ORDER BY seq_in_index') >= 2
assert sql.count('PREPARE stmt FROM @ddl') >= 2
assert sql.count('MIN(non_unique)') >= 2
assert sql.count('MAX(non_unique)') >= 2
assert sql.count("SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END)") >= 2
assert sql.count('COUNT(DISTINCT index_type)') >= 2
assert sql.count('MIN(index_type)') >= 2
assert sql.count("@index_non_unique_min = 1") >= 2
assert sql.count("@index_non_unique_max = 1") >= 2
assert sql.count("@index_prefix_columns = 0") >= 2
assert sql.count("@index_type_count = 1") >= 2
assert sql.count("@index_type = 'BTREE'") >= 2
assert 'DROP INDEX idx_biz_protocol_payment_schedule_market_scope' in sql
assert 'DROP INDEX idx_biz_protocol_payment_transfer_market_scope' in sql
def test_market_payment_index_migration_has_exact_required_column_orders():
sql = SQL_PATH.read_text(encoding='utf-8')
assert "'tenant_no,sample_payment_id,del_flag'" in sql
assert "'tenant_no,schedule_id,del_flag'" in sql
assert (
'idx_biz_protocol_payment_schedule_market_scope '
'(tenant_no, sample_payment_id, del_flag)'
) in sql
assert (
'idx_biz_protocol_payment_transfer_market_scope '
'(tenant_no, schedule_id, del_flag)'
) in sql
+937
View File
@@ -0,0 +1,937 @@
from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker
from app.models.base import Base
from app.models.models import (
BizAttachment,
BizCellSampleType,
BizCollectionHospital,
BizCollectionProtocol,
BizCollectionRegion,
BizCollectionRegionStaff,
BizCustomer,
BizItemUnit,
BizProtocolCollectionInfo,
BizProtocolPartyCustomer,
BizProtocolPayment,
BizProtocolSample,
BizSampleCode,
SysPost,
SysPostRole,
SysRole,
SysRoleDataScopeDetail,
SysUser,
SysUserPost,
SysUserRole,
)
from app.models.research_donation import (
BizProtocolDonationConvert,
BizResearchDonation,
BizResearchDonationCollection,
BizResearchDonationParty,
BizResearchDonationSample,
BizResearchDonationSampleTransport,
)
def _session():
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(
engine,
tables=[
BizCollectionProtocol.__table__,
BizProtocolPartyCustomer.__table__,
BizProtocolCollectionInfo.__table__,
BizProtocolSample.__table__,
BizProtocolPayment.__table__,
BizSampleCode.__table__,
BizResearchDonation.__table__,
BizResearchDonationParty.__table__,
BizResearchDonationCollection.__table__,
BizResearchDonationSample.__table__,
BizResearchDonationSampleTransport.__table__,
BizProtocolDonationConvert.__table__,
BizCollectionRegionStaff.__table__,
BizCollectionRegion.__table__,
BizCollectionHospital.__table__,
BizCellSampleType.__table__,
BizItemUnit.__table__,
BizCustomer.__table__,
BizAttachment.__table__,
SysUser.__table__,
SysRole.__table__,
SysRoleDataScopeDetail.__table__,
SysUserRole.__table__,
SysPost.__table__,
SysPostRole.__table__,
SysUserPost.__table__,
],
)
return sessionmaker(bind=engine)()
def _ctx(scope_kind, user_id='u1'):
from app.services.market_field_scope import MarketFieldContext
return MarketFieldContext('tenant-a', user_id, scope_kind)
def _user(user_id='u1'):
return {
'user_id': user_id,
'tenant_no': 'tenant-a',
'role': 'super_admin',
'region_id': 'dept-from-auth',
}
def _cell(db, row_id, *, sales='u1', manager='manager-a', region='region-a', created=None):
row = BizCollectionProtocol(
id=row_id,
protocol_no=f'CELL-{row_id}',
protocol_status='dsx',
tenant_no='tenant-a',
create_dept_id='dept-a',
create_time=created or datetime.now(),
del_flag=0,
)
collection = BizProtocolCollectionInfo(
id=f'ci-{row_id}',
collection_protocol_id=row_id,
region_id=region,
sales_user_id=sales,
region_manager_user_id=manager,
tenant_no='tenant-a',
create_dept_id='dept-a',
del_flag=0,
)
db.add_all([row, collection])
return row
def _donation(db, row_id, *, sales='u1', manager='manager-a', region='region-a', created=None):
row = BizResearchDonation(
id=row_id,
donation_no=f'DON-{row_id}',
donation_status='dsx',
tenant_no='tenant-a',
create_dept_id='dept-a',
create_time=created or datetime.now(),
del_flag=0,
)
collection = BizResearchDonationCollection(
id=f'dci-{row_id}',
research_donation_id=row_id,
region_id=region,
sales_user_id=sales,
region_manager_user_id=manager,
tenant_no='tenant-a',
create_dept_id='dept-a',
del_flag=0,
)
db.add_all([row, collection])
return row
def _masters(db):
db.add_all(
[
BizCollectionRegion(
id='region-a', region_name='A区', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
BizCollectionRegion(
id='region-b', region_name='B区', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
BizCollectionHospital(
id='hospital-a', region_id='region-a', hospital_name='A院', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
BizCellSampleType(
id='type-a', type_code='A', type_name='样本A', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
BizSampleCode(
id='code-a', sample_code='S-001', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
BizSampleCode(
id='code-cross', sample_code='S-CROSS', tenant_no='tenant-b', create_dept_id='', del_flag=0
),
BizSampleCode(
id='code-deleted', sample_code='S-DELETED', tenant_no='tenant-a', create_dept_id='', del_flag=1
),
BizItemUnit(
id='unit-a', unit_code='ml', unit_name='毫升', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
SysUser(
id='u1', user_no='u1', nick_name='当前用户', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
SysUser(
id='manager-a', user_no='manager-a', nick_name='经理', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
SysUser(
id='u2', user_no='u2', nick_name='其他专员', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
BizAttachment(
id='att-protocol-a',
business_type='market:specialist:protocol:staging',
original_name='a.pdf',
file_ext='pdf',
file_url='protocol/a.pdf',
file_size=10,
file_type='application/pdf',
tenant_no='tenant-a',
create_by='u1',
create_dept_id='',
del_flag=0,
),
]
)
db.commit()
def _body(source='cell_storage', **overrides):
from app.schemas.market_mobile import MarketProtocolBody
payload = {
'source': source,
'protocolNo': 'P-001',
'registerDate': '20261010',
'attachments': {'protocolAttachments': 'protocol/a.pdf'},
'customer': {'motherName': '张三', 'motherPhone': '13800000000'},
'collection': {
'regionId': 'region-a',
'hospitalId': 'hospital-a',
'salesUserId': 'someone-else',
'regionManagerUserId': 'manager-a',
},
'samples': [
{
'sampleTypeId': 'type-a',
'sampleCodeId': 'code-a',
'sampleCode': 'S-001',
'unitId': 'unit-a',
'collectAmount': 10,
}
],
'remark': '移动端登记',
}
payload.update(overrides)
return MarketProtocolBody.model_validate(payload)
def test_protocol_create_rejects_unknown_attachment_reference():
from app.services.market_protocol import create_protocol
db = _session()
_masters(db)
body = _body(
attachments={'protocolAttachments': 'https://evil.test/a.pdf'}
)
with pytest.raises(ValueError, match='附件'):
create_protocol(
db,
body=body,
ctx=_ctx('specialist'),
user_info=_user(),
)
def test_terminal_protocol_rejects_hidden_update_action():
from app.services.market_protocol import update_protocol
db = _session()
_masters(db)
row = _cell(db, 'terminal')
row.protocol_status = 'yzf'
db.commit()
with pytest.raises(ValueError, match='不可修改'):
update_protocol(
db,
source='cell_storage',
protocol_id=row.id,
body=_body(protocolNo='CELL-terminal'),
ctx=_ctx('specialist'),
user_info=_user(),
)
def test_protocol_list_combines_cell_and_donation_without_scope_leak():
from app.services.market_protocol import list_protocols
db = _session()
now = datetime.now()
_cell(db, 'cell-own', created=now - timedelta(minutes=2))
_cell(db, 'cell-other', sales='u2', created=now)
_donation(db, 'don-own', created=now - timedelta(minutes=1))
_donation(db, 'don-other', sales='u2', created=now + timedelta(minutes=1))
db.commit()
items, total = list_protocols(db, ctx=_ctx('specialist'), user_info=_user())
assert total == 2
assert [(item['source'], item['id']) for item in items] == [
('research_donation', 'don-own'),
('cell_storage', 'cell-own'),
]
def test_protocol_list_large_page_uses_bounded_source_queries_and_global_order():
from app.services.market_protocol import list_protocols
db = _session()
base = datetime(2026, 10, 10, 12, 0, 0)
expected = []
for index in range(24):
created = base + timedelta(seconds=index)
cell_id = f'cell-{index:02d}'
donation_id = f'don-{index:02d}'
_cell(db, cell_id, created=created)
_donation(db, donation_id, created=created + timedelta(microseconds=1))
expected.extend([(created, cell_id), (created + timedelta(microseconds=1), donation_id)])
db.commit()
statements = []
def capture(_conn, _cursor, statement, _parameters, _context, _many):
if 'FROM biz_collection_protocol ' in statement or 'FROM biz_research_donation ' in statement:
statements.append(statement)
event.listen(db.get_bind(), 'before_cursor_execute', capture)
try:
items, total = list_protocols(
db,
ctx=_ctx('specialist'),
user_info=_user(),
page=4,
page_size=7,
)
finally:
event.remove(db.get_bind(), 'before_cursor_execute', capture)
ordered_ids = [item[1] for item in sorted(expected, key=lambda item: (item[0], item[1]), reverse=True)]
assert total == 48
assert [item['id'] for item in items] == ordered_ids[21:28]
page_selects = [sql for sql in statements if 'count(' not in sql.lower()]
assert len(page_selects) == 2
assert all(' LIMIT ' in sql.upper() for sql in page_selects)
def test_protocol_list_stable_tie_breaker_prevents_page_overlap():
from app.services.market_protocol import list_protocols
db = _session()
same_time = datetime(2026, 10, 10, 12, 0, 0)
for row_id in ('a1', 'a3', 'a5', 'a7'):
_cell(db, row_id, created=same_time)
for row_id in ('a2', 'a4', 'a6', 'a8'):
_donation(db, row_id, created=same_time)
db.commit()
page_one, total = list_protocols(
db, ctx=_ctx('specialist'), user_info=_user(), page=1, page_size=3
)
page_two, _ = list_protocols(
db, ctx=_ctx('specialist'), user_info=_user(), page=2, page_size=3
)
assert total == 8
assert [item['id'] for item in page_one] == ['a8', 'a7', 'a6']
assert [item['id'] for item in page_two] == ['a5', 'a4', 'a3']
assert {item['id'] for item in page_one}.isdisjoint(
{item['id'] for item in page_two}
)
def test_protocol_detail_rechecks_source_specific_idor():
from app.services.market_protocol import get_protocol
db = _session()
_cell(db, 'cell-other', sales='u2')
_donation(db, 'don-other', sales='u2')
db.commit()
with pytest.raises(HTTPException) as cell_error:
get_protocol(
db,
source='cell_storage',
protocol_id='cell-other',
ctx=_ctx('specialist'),
user_info=_user(),
)
with pytest.raises(HTTPException) as donation_error:
get_protocol(
db,
source='research_donation',
protocol_id='don-other',
ctx=_ctx('specialist'),
user_info=_user(),
)
assert cell_error.value.status_code == 404
assert donation_error.value.status_code == 404
def test_protocol_detail_can_resolve_source_from_scoped_record():
from app.services.market_protocol import get_protocol
db = _session()
_donation(db, 'don-own')
db.commit()
result = get_protocol(
db,
source=None,
protocol_id='don-own',
ctx=_ctx('specialist'),
user_info=_user(),
)
assert result['source'] == 'research_donation'
assert result['basic']['id'] == 'don-own'
def test_specialist_cell_create_forces_current_collection_and_collector_users():
from app.services.market_protocol import create_protocol
db = _session()
_masters(db)
result = create_protocol(
db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
)
main = db.get(BizCollectionProtocol, result['basic']['id'])
collection = db.query(BizProtocolCollectionInfo).filter_by(collection_protocol_id=main.id, del_flag=0).one()
party = db.query(BizProtocolPartyCustomer).filter_by(collection_protocol_id=main.id, del_flag=0).one()
sample = db.query(BizProtocolSample).filter_by(collection_protocol_id=main.id, del_flag=0).one()
assert main.collector_user_id == 'u1'
assert main.create_dept_id == 'dept-from-auth'
assert collection.sales_user_id == 'u1'
assert collection.create_dept_id == 'dept-from-auth'
assert party.collector_user_id == 'u1'
assert party.customer_id is None
assert db.query(BizCustomer).count() == 0
assert sample.sample_code == 'S-001'
def test_specialist_donation_create_forces_current_users_and_has_no_payment_section():
from app.services.market_protocol import create_protocol
db = _session()
_masters(db)
result = create_protocol(
db,
body=_body('research_donation', donationNo='D-001', protocolNo=None),
ctx=_ctx('specialist'),
user_info=_user(),
)
donation = db.get(BizResearchDonation, result['basic']['id'])
collection = db.query(BizResearchDonationCollection).filter_by(research_donation_id=donation.id, del_flag=0).one()
party = db.query(BizResearchDonationParty).filter_by(research_donation_id=donation.id, del_flag=0).one()
sample = db.query(BizResearchDonationSample).filter_by(research_donation_id=donation.id, del_flag=0).one()
assert donation.collector_user_id == 'u1'
assert donation.create_dept_id == 'dept-from-auth'
assert collection.sales_user_id == 'u1'
assert collection.create_dept_id == 'dept-from-auth'
assert party.collector_user_id == 'u1'
assert party.create_dept_id == 'dept-from-auth'
assert sample.create_dept_id == 'dept-from-auth'
assert party.customer_id is None
assert db.query(BizCustomer).count() == 0
assert 'payment' not in result
def test_manager_update_cannot_move_document_out_of_scope_and_rolls_back():
from app.services.market_protocol import update_protocol
db = _session()
_masters(db)
_cell(db, 'cell-manager', manager='u1')
db.commit()
with pytest.raises(ValueError, match='范围'):
update_protocol(
db,
source='cell_storage',
protocol_id='cell-manager',
body=_body(collection={'regionId': 'region-b', 'regionManagerUserId': 'manager-a'}),
ctx=_ctx('manager'),
user_info=_user(),
)
db.expire_all()
collection = db.query(BizProtocolCollectionInfo).filter_by(collection_protocol_id='cell-manager', del_flag=0).one()
assert collection.region_id == 'region-a'
assert collection.region_manager_user_id == 'u1'
def test_partial_collection_update_validates_against_resulting_region():
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_protocol import update_protocol
db = _session()
_masters(db)
_cell(db, 'cell-partial', sales='u1')
db.commit()
update_protocol(
db,
source='cell_storage',
protocol_id='cell-partial',
body=MarketProtocolBody.model_validate(
{
'source': 'cell_storage',
'collection': {'hospitalId': 'hospital-a'},
}
),
ctx=_ctx('specialist'),
user_info=_user(),
)
collection = db.query(BizProtocolCollectionInfo).filter_by(
collection_protocol_id='cell-partial', del_flag=0
).one()
assert collection.region_id == 'region-a'
assert collection.hospital_id == 'hospital-a'
def test_cell_partial_customer_update_preserves_ids_and_unspecified_fields():
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_protocol import update_protocol
db = _session()
_masters(db)
_cell(db, 'cell-party')
db.add_all(
[
BizCustomer(id='mother-c', customer_name='母客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCustomer(id='father-c', customer_name='父客户', sex='0', tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCustomer(id='newborn-c', customer_name='子客户', sex='2', tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizProtocolPartyCustomer(
id='party-cell', collection_protocol_id='cell-party',
customer_id='mother-c', father_customer_id='father-c', newborn_customer_id='newborn-c',
mother_name='母亲', mother_phone='旧电话', mother_id_card='ID-M',
father_name='父亲', father_phone='父电话', newborn_name='宝宝',
tenant_no='tenant-a', create_dept_id='', del_flag=0,
),
]
)
db.commit()
update_protocol(
db,
source='cell_storage',
protocol_id='cell-party',
body=MarketProtocolBody.model_validate(
{
'source': 'cell_storage',
'customer': {'motherPhone': '新电话', 'remark': '只改电话'},
}
),
ctx=_ctx('specialist'),
user_info=_user(),
)
party = db.get(BizProtocolPartyCustomer, 'party-cell')
assert (party.customer_id, party.father_customer_id, party.newborn_customer_id) == (
'mother-c', 'father-c', 'newborn-c'
)
assert party.mother_name == '母亲'
assert party.mother_id_card == 'ID-M'
assert party.father_name == '父亲'
assert party.father_phone == '父电话'
assert party.newborn_name == '宝宝'
assert party.mother_phone == '新电话'
assert party.remark == '只改电话'
@pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
def test_protocol_update_rejects_same_tenant_unlinked_customer_id(source):
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_protocol import update_protocol
db = _session()
_masters(db)
db.add_all(
[
BizCustomer(id='linked-c', customer_name='已关联', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
BizCustomer(id='unrelated-c', customer_name='无关客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
]
)
if source == 'cell_storage':
_cell(db, 'customer-idor')
db.add(BizProtocolPartyCustomer(id='linked-party', collection_protocol_id='customer-idor', customer_id='linked-c', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
else:
_donation(db, 'customer-idor')
db.add(BizResearchDonationParty(id='linked-party', research_donation_id='customer-idor', customer_id='linked-c', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
db.commit()
with pytest.raises(ValueError, match='客户'):
update_protocol(
db,
source=source,
protocol_id='customer-idor',
body=MarketProtocolBody.model_validate(
{
'source': source,
'customer': {'customerId': 'unrelated-c', 'motherName': '被篡改'},
}
),
ctx=_ctx('specialist'),
user_info=_user(),
)
db.expire_all()
assert db.get(BizCustomer, 'unrelated-c').customer_name == '无关客户'
assert db.get(BizCustomer, 'linked-c').customer_name == '已关联'
@pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
def test_protocol_update_does_not_mutate_unrelated_customer_by_id_card(source):
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_protocol import update_protocol
db = _session()
_masters(db)
db.add(BizCustomer(id='unrelated-card', customer_name='身份证客户', sex='1', id_card_no='CARD-1', tenant_no='tenant-a', create_dept_id='', del_flag=0))
if source == 'cell_storage':
_cell(db, 'card-idor')
db.add(BizProtocolPartyCustomer(id='card-party', collection_protocol_id='card-idor', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
else:
_donation(db, 'card-idor')
db.add(BizResearchDonationParty(id='card-party', research_donation_id='card-idor', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
db.commit()
update_protocol(
db,
source=source,
protocol_id='card-idor',
body=MarketProtocolBody.model_validate(
{
'source': source,
'customer': {'motherName': '协议姓名', 'motherIdCard': 'CARD-1'},
}
),
ctx=_ctx('specialist'),
user_info=_user(),
)
db.expire_all()
assert db.get(BizCustomer, 'unrelated-card').customer_name == '身份证客户'
@pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
def test_protocol_create_forbids_existing_customer_ids(source):
from app.services.market_protocol import create_protocol
db = _session()
_masters(db)
db.add(BizCustomer(id='existing-c', customer_name='已有客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0))
db.commit()
with pytest.raises(ValueError, match='客户'):
create_protocol(
db,
body=_body(
source,
**({'donationNo': 'D-CUSTOMER', 'protocolNo': None} if source == 'research_donation' else {}),
customer={'customerId': 'existing-c', 'motherName': '已有客户'},
),
ctx=_ctx('specialist'),
user_info=_user(),
)
def test_protocol_body_rejects_status_finance_and_conversion_fields():
from app.schemas.market_mobile import MarketProtocolBody
forbidden = [
{'auditStatus': 'shtg'},
{'protocolStatus': 'yzf'},
{'delFlag': 1},
{'refundAmount': 100},
{'financeRemark': '通过'},
{'toResearchTime': '2026-10-10'},
]
for field in forbidden:
with pytest.raises(ValidationError):
MarketProtocolBody.model_validate({'source': 'cell_storage', **field})
def test_cross_tenant_or_disabled_referenced_user_is_rejected():
from app.services.market_protocol import create_protocol
db = _session()
_masters(db)
db.add(
SysUser(
id='disabled', user_no='disabled', nick_name='停用', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0
)
)
db.commit()
with pytest.raises(ValueError, match='用户'):
create_protocol(
db,
body=_body(collection={'regionId': 'region-a', 'hospitalId': 'hospital-a', 'regionManagerUserId': 'disabled'}),
ctx=_ctx('specialist'),
user_info=_user(),
)
@pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
@pytest.mark.parametrize(
('sample_code_id', 'sample_code'),
[
('code-cross', 'S-CROSS'),
('code-deleted', 'S-DELETED'),
('code-a', 'S-WRONG'),
],
)
def test_protocol_rejects_invalid_or_inconsistent_sample_code_reference(
source, sample_code_id, sample_code
):
from app.services.market_protocol import create_protocol
db = _session()
_masters(db)
payload = {
'samples': [
{
'sampleTypeId': 'type-a',
'sampleCodeId': sample_code_id,
'sampleCode': sample_code,
'unitId': 'unit-a',
}
]
}
if source == 'research_donation':
payload.update({'donationNo': 'D-CODE', 'protocolNo': None})
with pytest.raises(ValueError, match='样本编码'):
create_protocol(
db,
body=_body(source, **payload),
ctx=_ctx('specialist'),
user_info=_user(),
)
def test_cell_update_rolls_back_main_and_existing_children_after_late_failure():
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_protocol import update_protocol
db = _session()
_masters(db)
_cell(db, 'cell-update')
db.add_all(
[
BizProtocolPartyCustomer(
id='party-old', collection_protocol_id='cell-update', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
BizProtocolSample(
id='sample-old', collection_protocol_id='cell-update', sample_code='OLD-CODE', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
]
)
db.commit()
body = MarketProtocolBody.model_validate(
{
'source': 'cell_storage',
'remark': '新备注',
'customer': {'motherName': '新姓名'},
'collection': {'medicalStaff': '新医护'},
'samples': [{'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'}],
}
)
with patch(
'app.services.market_protocol.cell_crud.save_samples',
side_effect=RuntimeError('late failure'),
):
with pytest.raises(RuntimeError, match='late'):
update_protocol(
db,
source='cell_storage',
protocol_id='cell-update',
body=body,
ctx=_ctx('specialist'),
user_info=_user(),
)
db.expire_all()
assert db.get(BizCollectionProtocol, 'cell-update').remark is None
assert db.get(BizProtocolPartyCustomer, 'party-old').mother_name == '旧姓名'
assert db.get(BizProtocolPartyCustomer, 'party-old').del_flag == 0
assert db.get(BizProtocolSample, 'sample-old').sample_code == 'OLD-CODE'
assert db.get(BizProtocolCollectionInfo, 'ci-cell-update').medical_staff is None
def test_donation_update_rolls_back_main_and_old_children_after_soft_delete():
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_protocol import update_protocol
db = _session()
_masters(db)
_donation(db, 'don-update')
db.add_all(
[
BizResearchDonationParty(
id='dparty-old', research_donation_id='don-update', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
BizResearchDonationSample(
id='dsample-old', research_donation_id='don-update', sample_code='OLD-CODE', tenant_no='tenant-a', create_dept_id='', del_flag=0
),
]
)
db.commit()
body = MarketProtocolBody.model_validate(
{
'source': 'research_donation',
'donationNo': 'DON-don-update',
'remark': '新备注',
'customer': {'motherName': '新姓名'},
'collection': {'medicalStaff': '新医护'},
'samples': [{'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'}],
}
)
with patch(
'app.crud.research_donation._create_samples',
side_effect=RuntimeError('late failure'),
):
with pytest.raises(RuntimeError, match='late'):
update_protocol(
db,
source='research_donation',
protocol_id='don-update',
body=body,
ctx=_ctx('specialist'),
user_info=_user(),
)
db.expire_all()
assert db.get(BizResearchDonation, 'don-update').remark is None
assert db.get(BizResearchDonationParty, 'dparty-old').mother_name == '旧姓名'
assert db.get(BizResearchDonationParty, 'dparty-old').del_flag == 0
assert db.get(BizResearchDonationSample, 'dsample-old').del_flag == 0
assert db.get(BizResearchDonationCollection, 'dci-don-update').medical_staff is None
def test_manager_can_update_donation_in_scope_but_cannot_move_it_out():
from app.schemas.market_mobile import MarketProtocolBody
from app.services.market_protocol import update_protocol
db = _session()
_masters(db)
_donation(db, 'don-manager', sales='u2', manager='u1')
db.add(
BizResearchDonationParty(
id='dparty-manager', research_donation_id='don-manager', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
)
)
db.commit()
result = update_protocol(
db,
source='research_donation',
protocol_id='don-manager',
body=MarketProtocolBody.model_validate(
{
'source': 'research_donation',
'donationNo': 'DON-don-manager',
'remark': '经理已修改',
'customer': {'motherName': '新姓名'},
'collection': {'medicalStaff': '新医护'},
}
),
ctx=_ctx('manager'),
user_info=_user(),
)
assert result['basic']['remark'] == '经理已修改'
assert result['customer']['motherName'] == '新姓名'
assert result['collection']['medicalStaff'] == '新医护'
with pytest.raises(ValueError, match='范围'):
update_protocol(
db,
source='research_donation',
protocol_id='don-manager',
body=MarketProtocolBody.model_validate(
{
'source': 'research_donation',
'collection': {
'regionId': 'region-b',
'regionManagerUserId': 'manager-a',
},
}
),
ctx=_ctx('manager'),
user_info=_user(),
)
db.expire_all()
collection = db.query(BizResearchDonationCollection).filter_by(
research_donation_id='don-manager', del_flag=0
).one()
assert collection.region_id == 'region-a'
assert collection.region_manager_user_id == 'u1'
def test_protocol_routes_do_not_expose_audit_void_refund_or_conversion():
from app.api.market_mobile import router
paths = {route.path for route in router.routes}
disallowed = ('audit', 'void', 'refund', 'convert', 'to-research', 'to-storage')
protocol_paths = [path for path in paths if '/protocols' in path]
assert protocol_paths
assert all(not any(word in path for word in disallowed) for path in protocol_paths)
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
from app.services.market_protocol import create_protocol
db = _session()
_masters(db)
with patch(
'app.services.market_protocol.cell_crud.upsert_collection_info',
side_effect=RuntimeError('injected child failure'),
):
with pytest.raises(RuntimeError, match='injected'):
create_protocol(
db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
)
assert db.query(BizCollectionProtocol).count() == 0
assert db.query(BizProtocolPartyCustomer).count() == 0
assert db.query(BizCustomer).count() == 0
def test_donation_create_rolls_back_customer_and_children_on_child_failure():
from app.services.market_protocol import create_protocol
db = _session()
_masters(db)
with patch(
'app.crud.research_donation._create_collection',
side_effect=RuntimeError('injected child failure'),
):
with pytest.raises(RuntimeError, match='injected'):
create_protocol(
db,
body=_body(
'research_donation', donationNo='D-ROLLBACK', protocolNo=None
),
ctx=_ctx('specialist'),
user_info=_user(),
)
assert db.query(BizResearchDonation).count() == 0
assert db.query(BizResearchDonationParty).count() == 0
assert db.query(BizCustomer).count() == 0
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
from pathlib import Path
MIGRATION = (
Path(__file__).resolve().parents[2]
/ 'docs/sql/migrations/2026-10-11-market-sample-indexes.sql'
)
EXPECTED = {
'idx_bps_market_sample_code': 'tenant_no,del_flag,sample_code',
'idx_bps_market_parent': 'tenant_no,del_flag,collection_protocol_id',
'idx_brds_market_sample_code': 'tenant_no,del_flag,sample_code',
'idx_brds_market_parent': 'tenant_no,del_flag,research_donation_id',
'idx_bsr_market_sample_progress': 'tenant_no,reception_source,sample_code,del_flag,reception_time',
'idx_bqwo_market_sample_progress': 'tenant_no,reception_source,trigger_source,sample_code,del_flag',
'idx_bsrr_market_sample_progress': 'tenant_no,reception_source,sample_code,del_flag,storage_time',
'idx_bpsc_market_parent': 'tenant_no,collection_protocol_id,del_flag',
'idx_bpft_market_parent': 'tenant_no,collection_protocol_id,del_flag',
}
def test_market_sample_migration_validates_exact_mysql57_index_definitions():
sql = MIGRATION.read_text(encoding='utf-8')
assert 'information_schema.statistics' in sql.lower()
assert 'PREPARE stmt' in sql
assert 'non_unique' in sql.lower()
assert 'sub_part' in sql.lower()
assert 'index_type' in sql.lower()
for index_name, columns in EXPECTED.items():
assert f"SET @index_name = '{index_name}'" in sql
assert f"@index_columns = '{columns}'" in sql
assert f'DROP INDEX {index_name}, ADD INDEX {index_name}' in sql
def test_market_sample_migration_only_targets_required_tables():
sql = MIGRATION.read_text(encoding='utf-8')
expected_tables = {
'biz_protocol_sample',
'biz_research_donation_sample',
'biz_sample_reception',
'biz_qc_work_order',
'biz_storage_release_review',
'biz_protocol_storage_cert',
'biz_protocol_fullmoon_todo',
}
actual_tables = {
line.split("'")[1]
for line in sql.splitlines()
if line.startswith("SET @table_name = '")
}
assert actual_tables == expected_tables
@@ -0,0 +1,125 @@
-- 移动市场现场 scope 查询复合索引
-- MySQL 5.7;通过 information_schema + PREPARE 保证重复执行安全。
SET NAMES utf8mb4;
SET @db := DATABASE();
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_collection_region_staff'
AND INDEX_NAME = 'idx_crs_market_leader_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_collection_region_staff` ADD INDEX `idx_crs_market_leader_scope` (`tenant_no`,`user_id`,`is_leader`,`del_flag`,`region_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_presign_order'
AND INDEX_NAME = 'idx_po_market_sales_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_presign_order` ADD INDEX `idx_po_market_sales_scope` (`tenant_no`,`del_flag`,`sales_user_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_presign_order'
AND INDEX_NAME = 'idx_po_market_region_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_presign_order` ADD INDEX `idx_po_market_region_scope` (`tenant_no`,`del_flag`,`region_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_presign_order'
AND INDEX_NAME = 'idx_po_market_manager_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_presign_order` ADD INDEX `idx_po_market_manager_scope` (`tenant_no`,`del_flag`,`region_manager_user_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_protocol_collection_info'
AND INDEX_NAME = 'idx_pci_market_parent_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_protocol_collection_info` ADD INDEX `idx_pci_market_parent_scope` (`tenant_no`,`del_flag`,`collection_protocol_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_protocol_collection_info'
AND INDEX_NAME = 'idx_pci_market_sales_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_protocol_collection_info` ADD INDEX `idx_pci_market_sales_scope` (`tenant_no`,`del_flag`,`sales_user_id`,`collection_protocol_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_protocol_collection_info'
AND INDEX_NAME = 'idx_pci_market_region_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_protocol_collection_info` ADD INDEX `idx_pci_market_region_scope` (`tenant_no`,`del_flag`,`region_id`,`collection_protocol_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_protocol_collection_info'
AND INDEX_NAME = 'idx_pci_market_manager_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_protocol_collection_info` ADD INDEX `idx_pci_market_manager_scope` (`tenant_no`,`del_flag`,`region_manager_user_id`,`collection_protocol_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_research_donation_collection'
AND INDEX_NAME = 'idx_rdc_market_parent_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_research_donation_collection` ADD INDEX `idx_rdc_market_parent_scope` (`tenant_no`,`del_flag`,`research_donation_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_research_donation_collection'
AND INDEX_NAME = 'idx_rdc_market_sales_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_research_donation_collection` ADD INDEX `idx_rdc_market_sales_scope` (`tenant_no`,`del_flag`,`sales_user_id`,`research_donation_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_research_donation_collection'
AND INDEX_NAME = 'idx_rdc_market_region_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_research_donation_collection` ADD INDEX `idx_rdc_market_region_scope` (`tenant_no`,`del_flag`,`region_id`,`research_donation_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx_exists := (
SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'biz_research_donation_collection'
AND INDEX_NAME = 'idx_rdc_market_manager_scope'
);
SET @sql := IF(@idx_exists = 0,
'ALTER TABLE `biz_research_donation_collection` ADD INDEX `idx_rdc_market_manager_scope` (`tenant_no`,`del_flag`,`region_manager_user_id`,`research_donation_id`)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,243 @@
-- =============================================================================
-- 小程序市场现场菜单:市场专员 / 地区经理两套目录与十二个业务叶子
--
-- 说明:
-- 1. 面向每个租户已有的「移动端菜单」根生成菜单,不写死租户编号。
-- 2. 目录仅用于角色授权分组,path 为空;首页只展示叶子。
-- 3. 不在此处写入 sys_role_menu;由管理员在 Web 角色管理中手工勾选。
-- 4. MySQL 5.7 可执行;所有 INSERT 均以 NOT EXISTS 保证幂等。
-- =============================================================================
-- 市场专员目录
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
MD5(CONCAT(
'mobile-market-directory|', root.tenant_no, '|', root.id, '|M||市场专员'
)),
'市场专员',
root.id,
'M',
'',
NULL,
NULL,
1,
'1',
'1',
root.tenant_no,
10,
'小程序市场现场:市场专员菜单目录',
NULL,
'',
NOW(),
NULL,
NOW(),
0
FROM sys_menu root
WHERE root.parent_id = '0'
AND root.menu_name = '移动端菜单'
AND root.del_flag = 0
AND NOT EXISTS (
SELECT 1
FROM sys_menu existing
WHERE existing.tenant_no = root.tenant_no
AND existing.parent_id = root.id
AND existing.menu_name = '市场专员'
AND existing.menu_type = 'M'
AND existing.path = ''
AND existing.del_flag = 0
)
ON DUPLICATE KEY UPDATE
tenant_no = VALUES(tenant_no),
parent_id = VALUES(parent_id),
menu_name = VALUES(menu_name),
menu_type = VALUES(menu_type),
path = VALUES(path),
component = VALUES(component),
perms = VALUES(perms),
is_frame = VALUES(is_frame),
visible = VALUES(visible),
status = VALUES(status),
order_num = VALUES(order_num),
remark = VALUES(remark),
update_time = VALUES(update_time),
del_flag = VALUES(del_flag);
-- 地区经理目录
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
MD5(CONCAT(
'mobile-market-directory|', root.tenant_no, '|', root.id, '|M||地区经理'
)),
'地区经理',
root.id,
'M',
'',
NULL,
NULL,
1,
'1',
'1',
root.tenant_no,
20,
'小程序市场现场:地区经理菜单目录',
NULL,
'',
NOW(),
NULL,
NOW(),
0
FROM sys_menu root
WHERE root.parent_id = '0'
AND root.menu_name = '移动端菜单'
AND root.del_flag = 0
AND NOT EXISTS (
SELECT 1
FROM sys_menu existing
WHERE existing.tenant_no = root.tenant_no
AND existing.parent_id = root.id
AND existing.menu_name = '地区经理'
AND existing.menu_type = 'M'
AND existing.path = ''
AND existing.del_flag = 0
)
ON DUPLICATE KEY UPDATE
tenant_no = VALUES(tenant_no),
parent_id = VALUES(parent_id),
menu_name = VALUES(menu_name),
menu_type = VALUES(menu_type),
path = VALUES(path),
component = VALUES(component),
perms = VALUES(perms),
is_frame = VALUES(is_frame),
visible = VALUES(visible),
status = VALUES(status),
order_num = VALUES(order_num),
remark = VALUES(remark),
update_time = VALUES(update_time),
del_flag = VALUES(del_flag);
-- 十二个业务叶子:每个角色目录各六个,均进入小程序受控 web-view。
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
MD5(CONCAT(
'mobile-market-leaf|', root.tenant_no, '|', root.id, '|', directory.id,
'|', entry.path, '|', entry.perms
)),
entry.menu_name,
directory.id,
'C',
entry.path,
NULL,
entry.perms,
1,
'1',
'1',
root.tenant_no,
entry.order_num,
CONCAT('小程序市场现场:', entry.role_name, ' / ', entry.menu_name),
NULL,
'',
NOW(),
NULL,
NOW(),
0
FROM sys_menu root
INNER JOIN (
SELECT 'specialist' AS role_code, '市场专员' AS role_name,
'presign' AS module_code, '预签约订单' AS menu_name,
'/pages/market/webview/index?entry=specialist-presign' AS path,
'mini:market:specialist:presign' AS perms, 10 AS order_num
UNION ALL
SELECT 'specialist', '市场专员', 'protocol', '协议单',
'/pages/market/webview/index?entry=specialist-protocol',
'mini:market:specialist:protocol', 20
UNION ALL
SELECT 'specialist', '市场专员', 'payment-chase', '催缴信息',
'/pages/market/webview/index?entry=specialist-payment-chase',
'mini:market:specialist:payment-chase', 30
UNION ALL
SELECT 'specialist', '市场专员', 'payment', '缴费信息',
'/pages/market/webview/index?entry=specialist-payment',
'mini:market:specialist:payment', 40
UNION ALL
SELECT 'specialist', '市场专员', 'sample', '样本信息',
'/pages/market/webview/index?entry=specialist-sample',
'mini:market:specialist:sample', 50
UNION ALL
SELECT 'specialist', '市场专员', 'freight', '运费信息',
'/pages/market/webview/index?entry=specialist-freight',
'mini:market:specialist:freight', 60
UNION ALL
SELECT 'manager', '地区经理', 'presign', '预签约订单',
'/pages/market/webview/index?entry=manager-presign',
'mini:market:manager:presign', 10
UNION ALL
SELECT 'manager', '地区经理', 'protocol', '协议单',
'/pages/market/webview/index?entry=manager-protocol',
'mini:market:manager:protocol', 20
UNION ALL
SELECT 'manager', '地区经理', 'payment-chase', '催缴信息',
'/pages/market/webview/index?entry=manager-payment-chase',
'mini:market:manager:payment-chase', 30
UNION ALL
SELECT 'manager', '地区经理', 'payment', '缴费信息',
'/pages/market/webview/index?entry=manager-payment',
'mini:market:manager:payment', 40
UNION ALL
SELECT 'manager', '地区经理', 'sample', '样本信息',
'/pages/market/webview/index?entry=manager-sample',
'mini:market:manager:sample', 50
UNION ALL
SELECT 'manager', '地区经理', 'freight', '运费信息',
'/pages/market/webview/index?entry=manager-freight',
'mini:market:manager:freight', 60
) entry
ON 1 = 1
INNER JOIN sys_menu directory
ON directory.tenant_no = root.tenant_no
AND directory.parent_id = root.id
AND directory.menu_name = entry.role_name
AND directory.menu_type = 'M'
AND directory.path = ''
AND directory.del_flag = 0
WHERE root.parent_id = '0'
AND root.menu_name = '移动端菜单'
AND root.del_flag = 0
AND NOT EXISTS (
SELECT 1
FROM sys_menu existing
WHERE existing.tenant_no = root.tenant_no
AND existing.parent_id = directory.id
AND existing.menu_name = entry.menu_name
AND existing.perms = entry.perms
AND existing.path = entry.path
AND existing.menu_type = 'C'
AND existing.del_flag = 0
)
ON DUPLICATE KEY UPDATE
tenant_no = VALUES(tenant_no),
parent_id = VALUES(parent_id),
menu_name = VALUES(menu_name),
menu_type = VALUES(menu_type),
path = VALUES(path),
component = VALUES(component),
perms = VALUES(perms),
is_frame = VALUES(is_frame),
visible = VALUES(visible),
status = VALUES(status),
order_num = VALUES(order_num),
remark = VALUES(remark),
update_time = VALUES(update_time),
del_flag = VALUES(del_flag);
@@ -0,0 +1,56 @@
-- 预签约订单转正式协议唯一约束
-- MySQL 5.7;重复执行安全。历史重复或错误同名索引会给出明确修复提示。
SET NAMES utf8mb4;
DELIMITER $$
DROP PROCEDURE IF EXISTS `ensure_presign_convert_unique`$$
CREATE PROCEDURE `ensure_presign_convert_unique`()
BEGIN
DECLARE duplicate_sources INT DEFAULT 0;
DECLARE idx_entries INT DEFAULT 0;
DECLARE idx_non_unique INT DEFAULT 1;
DECLARE idx_columns VARCHAR(512) DEFAULT '';
SELECT COUNT(*) INTO duplicate_sources
FROM (
SELECT `tenant_no`, `source_presign_order_id`
FROM `biz_collection_protocol`
WHERE `source_presign_order_id` IS NOT NULL
AND `source_presign_order_id` <> ''
GROUP BY `tenant_no`, `source_presign_order_id`
HAVING COUNT(*) > 1
) duplicate_rows;
IF duplicate_sources > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'duplicate presign conversions found; remediation: reconcile duplicate formal protocols before rerunning migration';
END IF;
SELECT
COUNT(*),
COALESCE(MIN(NON_UNIQUE), 1),
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), '')
INTO idx_entries, idx_non_unique, idx_columns
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_collection_protocol'
AND INDEX_NAME = 'uk_collection_protocol_presign_source';
IF idx_entries = 0 THEN
ALTER TABLE `biz_collection_protocol`
ADD UNIQUE INDEX `uk_collection_protocol_presign_source`
(`tenant_no`, `source_presign_order_id`);
ELSEIF idx_entries <> 2
OR idx_non_unique <> 0
OR idx_columns <> 'tenant_no,source_presign_order_id' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'wrong index uk_collection_protocol_presign_source; remediation: drop or rename it, then rerun migration';
END IF;
END$$
CALL `ensure_presign_convert_unique`()$$
DROP PROCEDURE `ensure_presign_convert_unique`$$
DELIMITER ;
@@ -0,0 +1,192 @@
-- 市场移动端运费批次查询与并发关联约束(MySQL 5.7,幂等)。
-- 若存量数据冲突或同名对象定义错误则明确中止,不静默移动/删除数据。
SET NAMES utf8mb4;
DELIMITER $$
DROP PROCEDURE IF EXISTS `ensure_market_freight_indexes`$$
CREATE PROCEDURE `ensure_market_freight_indexes`()
BEGIN
DECLARE duplicate_rows INT DEFAULT 0;
DECLARE object_count INT DEFAULT 0;
DECLARE index_non_unique INT DEFAULT 1;
DECLARE index_columns VARCHAR(512) DEFAULT '';
DECLARE index_prefix_count INT DEFAULT 0;
DECLARE index_type_count INT DEFAULT 0;
DECLARE index_type_name VARCHAR(32) DEFAULT '';
DECLARE generated_extra VARCHAR(255) DEFAULT '';
DECLARE generated_expression TEXT;
DECLARE normalized_generation_expression TEXT;
DECLARE generated_data_type VARCHAR(64) DEFAULT '';
DECLARE generated_length BIGINT DEFAULT 0;
DECLARE generated_nullable VARCHAR(3) DEFAULT '';
DECLARE exact_freight_unique_indexes INT DEFAULT 0;
SELECT COUNT(*) INTO duplicate_rows
FROM (
SELECT tenant_no, freight_no
FROM biz_sample_freight
GROUP BY tenant_no, freight_no
HAVING COUNT(*) > 1
) duplicate_freights;
IF duplicate_rows > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'duplicate freight numbers found; reconcile them before rerunning migration';
END IF;
SELECT COUNT(*) INTO duplicate_rows
FROM (
SELECT tenant_no, sample_source, sample_id
FROM biz_sample_freight_item
WHERE del_flag = 0
GROUP BY tenant_no, sample_source, sample_id
HAVING COUNT(*) > 1
) duplicate_active_links;
IF duplicate_rows > 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'duplicate active sample freight links found; reconcile them before rerunning migration';
END IF;
SELECT COUNT(*) INTO object_count
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_sample_freight_item'
AND COLUMN_NAME = 'active_sample_key';
IF object_count = 0 THEN
ALTER TABLE biz_sample_freight_item
ADD COLUMN active_sample_key VARCHAR(170)
GENERATED ALWAYS AS (
IF(del_flag = 0, CONCAT(tenant_no, '#', sample_source, '#', sample_id), NULL)
) STORED;
ELSE
SELECT EXTRA, GENERATION_EXPRESSION, DATA_TYPE,
CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE
INTO generated_extra, generated_expression, generated_data_type,
generated_length, generated_nullable
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_sample_freight_item'
AND COLUMN_NAME = 'active_sample_key';
SET normalized_generation_expression = LOWER(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
COALESCE(generated_expression, ''),
'`', ''), ' ', ''), CHAR(9), ''), CHAR(10), ''), '(', ''), ')', '')
);
IF UPPER(COALESCE(generated_extra, '')) NOT LIKE '%STORED GENERATED%'
OR LOWER(COALESCE(generated_data_type, '')) <> 'varchar'
OR COALESCE(generated_length, 0) <> 170
OR UPPER(COALESCE(generated_nullable, '')) <> 'YES'
OR normalized_generation_expression <>
'ifdel_flag=0,concattenant_no,''#'',sample_source,''#'',sample_id,null' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'wrong active_sample_key type/expression; drop column and dependent index, then rerun migration';
END IF;
END IF;
SELECT COUNT(*), COALESCE(MIN(NON_UNIQUE), 1),
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), ''),
SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END),
COUNT(DISTINCT INDEX_TYPE), COALESCE(MIN(INDEX_TYPE), '')
INTO object_count, index_non_unique, index_columns,
index_prefix_count, index_type_count, index_type_name
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_sample_freight_item'
AND INDEX_NAME = 'uk_bsfi_active_sample';
IF object_count = 0 THEN
ALTER TABLE biz_sample_freight_item
ADD UNIQUE INDEX uk_bsfi_active_sample (active_sample_key);
ELSEIF object_count <> 1 OR index_non_unique <> 0
OR index_columns <> 'active_sample_key' OR index_prefix_count <> 0
OR index_type_count <> 1 OR index_type_name <> 'BTREE' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'wrong index uk_bsfi_active_sample; drop it and rerun migration';
END IF;
-- 旧迁移已创建 uk_sample_freight_tenant_no;任何名称的等价精确唯一索引均复用。
-- 但若本迁移预留名称已存在且定义错误,必须先修复,不能静默绕过。
SELECT COUNT(*), COALESCE(MIN(NON_UNIQUE), 1),
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), ''),
SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END),
COUNT(DISTINCT INDEX_TYPE), COALESCE(MIN(INDEX_TYPE), '')
INTO object_count, index_non_unique, index_columns,
index_prefix_count, index_type_count, index_type_name
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_sample_freight'
AND INDEX_NAME = 'uk_bsf_tenant_freight_no';
IF object_count > 0 AND (object_count <> 2 OR index_non_unique <> 0
OR index_columns <> 'tenant_no,freight_no' OR index_prefix_count <> 0
OR index_type_count <> 1 OR index_type_name <> 'BTREE') THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'wrong index uk_bsf_tenant_freight_no; drop it and rerun migration';
END IF;
SELECT COUNT(*) INTO exact_freight_unique_indexes
FROM (
SELECT INDEX_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_sample_freight'
GROUP BY INDEX_NAME
HAVING MIN(NON_UNIQUE) = 0
AND MAX(NON_UNIQUE) = 0
AND GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') =
'tenant_no,freight_no'
AND SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END) = 0
AND COUNT(DISTINCT INDEX_TYPE) = 1
AND MIN(INDEX_TYPE) = 'BTREE'
) exact_freight_unique_index_definitions;
IF exact_freight_unique_indexes = 0 THEN
ALTER TABLE biz_sample_freight
ADD UNIQUE INDEX uk_bsf_tenant_freight_no (tenant_no, freight_no);
END IF;
SELECT COUNT(*), COALESCE(MIN(NON_UNIQUE), 1),
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), ''),
SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END),
COUNT(DISTINCT INDEX_TYPE), COALESCE(MIN(INDEX_TYPE), '')
INTO object_count, index_non_unique, index_columns,
index_prefix_count, index_type_count, index_type_name
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_sample_freight'
AND INDEX_NAME = 'idx_bsf_market_list';
IF object_count = 0 THEN
ALTER TABLE biz_sample_freight
ADD INDEX idx_bsf_market_list (tenant_no, del_flag, create_time, id);
ELSEIF object_count <> 4 OR index_non_unique <> 1
OR index_columns <> 'tenant_no,del_flag,create_time,id'
OR index_prefix_count <> 0 OR index_type_count <> 1
OR index_type_name <> 'BTREE' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'wrong index idx_bsf_market_list; drop it and rerun migration';
END IF;
SELECT COUNT(*), COALESCE(MIN(NON_UNIQUE), 1),
COALESCE(GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ','), ''),
SUM(CASE WHEN SUB_PART IS NOT NULL THEN 1 ELSE 0 END),
COUNT(DISTINCT INDEX_TYPE), COALESCE(MIN(INDEX_TYPE), '')
INTO object_count, index_non_unique, index_columns,
index_prefix_count, index_type_count, index_type_name
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'biz_sample_freight_item'
AND INDEX_NAME = 'idx_bsfi_market_batch';
IF object_count = 0 THEN
ALTER TABLE biz_sample_freight_item
ADD INDEX idx_bsfi_market_batch (tenant_no, freight_id, del_flag, create_time, id);
ELSEIF object_count <> 5 OR index_non_unique <> 1
OR index_columns <> 'tenant_no,freight_id,del_flag,create_time,id'
OR index_prefix_count <> 0 OR index_type_count <> 1
OR index_type_name <> 'BTREE' THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'wrong index idx_bsfi_market_batch; drop it and rerun migration';
END IF;
END$$
CALL `ensure_market_freight_indexes`()$$
DROP PROCEDURE `ensure_market_freight_indexes`$$
DELIMITER ;
@@ -0,0 +1,70 @@
-- 市场移动端催缴/缴费查询索引(MySQL 5.7)。
-- 同名索引定义不一致时重建,重复执行保持幂等。
SET @schema_name = DATABASE();
SET @index_name = 'idx_biz_protocol_payment_schedule_market_scope';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','),
MIN(non_unique),
MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END),
COUNT(DISTINCT index_type),
MIN(index_type)
INTO @index_columns,
@index_non_unique_min,
@index_non_unique_max,
@index_prefix_columns,
@index_type_count,
@index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'biz_protocol_payment_schedule'
AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,sample_payment_id,del_flag'
AND @index_non_unique_min = 1
AND @index_non_unique_max = 1
AND @index_prefix_columns = 0
AND @index_type_count = 1
AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_protocol_payment_schedule ADD INDEX idx_biz_protocol_payment_schedule_market_scope (tenant_no, sample_payment_id, del_flag)'
ELSE
'ALTER TABLE biz_protocol_payment_schedule DROP INDEX idx_biz_protocol_payment_schedule_market_scope, ADD INDEX idx_biz_protocol_payment_schedule_market_scope (tenant_no, sample_payment_id, del_flag)'
END;
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_name = 'idx_biz_protocol_payment_transfer_market_scope';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','),
MIN(non_unique),
MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END),
COUNT(DISTINCT index_type),
MIN(index_type)
INTO @index_columns,
@index_non_unique_min,
@index_non_unique_max,
@index_prefix_columns,
@index_type_count,
@index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name
AND table_name = 'biz_protocol_payment_transfer'
AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,schedule_id,del_flag'
AND @index_non_unique_min = 1
AND @index_non_unique_max = 1
AND @index_prefix_columns = 0
AND @index_type_count = 1
AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_protocol_payment_transfer ADD INDEX idx_biz_protocol_payment_transfer_market_scope (tenant_no, schedule_id, del_flag)'
ELSE
'ALTER TABLE biz_protocol_payment_transfer DROP INDEX idx_biz_protocol_payment_transfer_market_scope, ADD INDEX idx_biz_protocol_payment_transfer_market_scope (tenant_no, schedule_id, del_flag)'
END;
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,175 @@
-- 市场移动端样本统一列表、进度与轻量办理索引(MySQL 5.7)。
-- 同名索引的列顺序、唯一性、前缀或类型不一致时重建;重复执行保持幂等。
SET @schema_name = DATABASE();
SET @table_name = 'biz_protocol_sample';
SET @index_name = 'idx_bps_market_sample_code';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
@index_prefix_columns, @index_type_count, @index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,del_flag,sample_code' AND @index_non_unique_min = 1
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_protocol_sample ADD INDEX idx_bps_market_sample_code (tenant_no, del_flag, sample_code)'
ELSE
'ALTER TABLE biz_protocol_sample DROP INDEX idx_bps_market_sample_code, ADD INDEX idx_bps_market_sample_code (tenant_no, del_flag, sample_code)'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @table_name = 'biz_protocol_sample';
SET @index_name = 'idx_bps_market_parent';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
@index_prefix_columns, @index_type_count, @index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,del_flag,collection_protocol_id' AND @index_non_unique_min = 1
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_protocol_sample ADD INDEX idx_bps_market_parent (tenant_no, del_flag, collection_protocol_id)'
ELSE
'ALTER TABLE biz_protocol_sample DROP INDEX idx_bps_market_parent, ADD INDEX idx_bps_market_parent (tenant_no, del_flag, collection_protocol_id)'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @table_name = 'biz_research_donation_sample';
SET @index_name = 'idx_brds_market_sample_code';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
@index_prefix_columns, @index_type_count, @index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,del_flag,sample_code' AND @index_non_unique_min = 1
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_research_donation_sample ADD INDEX idx_brds_market_sample_code (tenant_no, del_flag, sample_code)'
ELSE
'ALTER TABLE biz_research_donation_sample DROP INDEX idx_brds_market_sample_code, ADD INDEX idx_brds_market_sample_code (tenant_no, del_flag, sample_code)'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @table_name = 'biz_research_donation_sample';
SET @index_name = 'idx_brds_market_parent';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
@index_prefix_columns, @index_type_count, @index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,del_flag,research_donation_id' AND @index_non_unique_min = 1
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_research_donation_sample ADD INDEX idx_brds_market_parent (tenant_no, del_flag, research_donation_id)'
ELSE
'ALTER TABLE biz_research_donation_sample DROP INDEX idx_brds_market_parent, ADD INDEX idx_brds_market_parent (tenant_no, del_flag, research_donation_id)'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @table_name = 'biz_sample_reception';
SET @index_name = 'idx_bsr_market_sample_progress';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
@index_prefix_columns, @index_type_count, @index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,reception_source,sample_code,del_flag,reception_time' AND @index_non_unique_min = 1
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_sample_reception ADD INDEX idx_bsr_market_sample_progress (tenant_no, reception_source, sample_code, del_flag, reception_time)'
ELSE
'ALTER TABLE biz_sample_reception DROP INDEX idx_bsr_market_sample_progress, ADD INDEX idx_bsr_market_sample_progress (tenant_no, reception_source, sample_code, del_flag, reception_time)'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @table_name = 'biz_qc_work_order';
SET @index_name = 'idx_bqwo_market_sample_progress';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
@index_prefix_columns, @index_type_count, @index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,reception_source,trigger_source,sample_code,del_flag' AND @index_non_unique_min = 1
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_qc_work_order ADD INDEX idx_bqwo_market_sample_progress (tenant_no, reception_source, trigger_source, sample_code, del_flag)'
ELSE
'ALTER TABLE biz_qc_work_order DROP INDEX idx_bqwo_market_sample_progress, ADD INDEX idx_bqwo_market_sample_progress (tenant_no, reception_source, trigger_source, sample_code, del_flag)'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @table_name = 'biz_storage_release_review';
SET @index_name = 'idx_bsrr_market_sample_progress';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
@index_prefix_columns, @index_type_count, @index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,reception_source,sample_code,del_flag,storage_time' AND @index_non_unique_min = 1
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_storage_release_review ADD INDEX idx_bsrr_market_sample_progress (tenant_no, reception_source, sample_code, del_flag, storage_time)'
ELSE
'ALTER TABLE biz_storage_release_review DROP INDEX idx_bsrr_market_sample_progress, ADD INDEX idx_bsrr_market_sample_progress (tenant_no, reception_source, sample_code, del_flag, storage_time)'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @table_name = 'biz_protocol_storage_cert';
SET @index_name = 'idx_bpsc_market_parent';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
@index_prefix_columns, @index_type_count, @index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,collection_protocol_id,del_flag' AND @index_non_unique_min = 1
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_protocol_storage_cert ADD INDEX idx_bpsc_market_parent (tenant_no, collection_protocol_id, del_flag)'
ELSE
'ALTER TABLE biz_protocol_storage_cert DROP INDEX idx_bpsc_market_parent, ADD INDEX idx_bpsc_market_parent (tenant_no, collection_protocol_id, del_flag)'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @table_name = 'biz_protocol_fullmoon_todo';
SET @index_name = 'idx_bpft_market_parent';
SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ','), MIN(non_unique), MAX(non_unique),
SUM(CASE WHEN sub_part IS NOT NULL THEN 1 ELSE 0 END), COUNT(DISTINCT index_type), MIN(index_type)
INTO @index_columns, @index_non_unique_min, @index_non_unique_max,
@index_prefix_columns, @index_type_count, @index_type
FROM information_schema.statistics
WHERE table_schema = @schema_name AND table_name = @table_name AND index_name = @index_name;
SET @ddl = CASE
WHEN @index_columns = 'tenant_no,collection_protocol_id,del_flag' AND @index_non_unique_min = 1
AND @index_non_unique_max = 1 AND @index_prefix_columns = 0
AND @index_type_count = 1 AND @index_type = 'BTREE' THEN 'SELECT 1'
WHEN @index_columns IS NULL THEN
'ALTER TABLE biz_protocol_fullmoon_todo ADD INDEX idx_bpft_market_parent (tenant_no, collection_protocol_id, del_flag)'
ELSE
'ALTER TABLE biz_protocol_fullmoon_todo DROP INDEX idx_bpft_market_parent, ADD INDEX idx_bpft_market_parent (tenant_no, collection_protocol_id, del_flag)'
END;
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;