feat: integrate required mobile market backend dependencies.
This commit is contained in:
1 parent
7e930c77fd
commit
337d31bbe8
30 files changed
+10951
-125
No files matched your search
@@ -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",
|
||||||
]
|
]
|
||||||
@@ -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 []
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
Reference in new issue
Block a user