diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 0f78c21d..746c49b7 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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.mini_auth import router as mini_auth_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 __all__ = [ @@ -84,5 +85,6 @@ __all__ = [ "oa_approval_router", "mini_auth_router", "mini_menu_router", + "market_mobile_router", "sample_storage_cert_qc_router", ] diff --git a/backend/app/core/data_scope.py b/backend/app/core/data_scope.py index 8cc8017f..f559465d 100644 --- a/backend/app/core/data_scope.py +++ b/backend/app/core/data_scope.py @@ -67,7 +67,7 @@ def _resolve_user_role_scopes( """ 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: return [] diff --git a/backend/app/core/mobile_menu_guard.py b/backend/app/core/mobile_menu_guard.py new file mode 100644 index 00000000..332ca451 --- /dev/null +++ b/backend/app/core/mobile_menu_guard.py @@ -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 diff --git a/backend/app/crud/collection_protocol.py b/backend/app/crud/collection_protocol.py index e8729810..b51a8688 100644 --- a/backend/app/crud/collection_protocol.py +++ b/backend/app/crud/collection_protocol.py @@ -2390,6 +2390,8 @@ def create_collection_protocol( remark: Optional[str] = None, tenant_no: str = "zkxw", user_id: Optional[str] = None, + create_dept_id: str = "", + commit: bool = True, ) -> BizCollectionProtocol: pn = (protocol_no or "").strip() if not pn: @@ -2420,15 +2422,18 @@ def create_collection_protocol( order_num=0, remark=(remark or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), del_flag=0, ) db.add(obj) - db.commit() - db.refresh(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj @@ -2458,6 +2463,7 @@ def update_collection_protocol( protocol_total_amount: Any = None, source_presign_order_id: Optional[str] = None, remark: Optional[str] = None, + commit: bool = True, ) -> BizCollectionProtocol: obj = get_collection_protocol( 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_time = datetime.now() db.add(obj) - db.commit() - db.refresh(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj @@ -2575,6 +2584,8 @@ def upsert_party_customer( collection_protocol_id: str, tenant_no: str, user_id: Optional[str], + commit: bool = True, + create_dept_id: str = "", **fields, ) -> BizProtocolPartyCustomer: fields = _normalize_fields(fields) @@ -2612,8 +2623,11 @@ def upsert_party_customer( obj.update_by = user_id obj.update_time = datetime.now() db.add(obj) - db.commit() - db.refresh(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj obj = BizProtocolPartyCustomer( id=generate_uuid(), @@ -2622,7 +2636,7 @@ def upsert_party_customer( order_num=0, remark=(fields.get("remark") or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), @@ -2644,8 +2658,11 @@ def upsert_party_customer( if k in fields: setattr(obj, k, fields[k]) db.add(obj) - db.commit() - db.refresh(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj @@ -2668,6 +2685,8 @@ def upsert_collection_info( collection_protocol_id: str, tenant_no: str, user_id: Optional[str], + commit: bool = True, + create_dept_id: str = "", **fields, ) -> BizProtocolCollectionInfo: fields = _normalize_fields(fields) @@ -2692,8 +2711,11 @@ def upsert_collection_info( obj.update_by = user_id obj.update_time = datetime.now() db.add(obj) - db.commit() - db.refresh(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj obj = BizProtocolCollectionInfo( id=generate_uuid(), @@ -2702,7 +2724,7 @@ def upsert_collection_info( order_num=0, remark=(fields.get("remark") or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), @@ -2715,8 +2737,11 @@ def upsert_collection_info( if k in fields: setattr(obj, k, fields[k]) db.add(obj) - db.commit() - db.refresh(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj @@ -2776,6 +2801,8 @@ def save_samples( tenant_no: str, user_id: Optional[str], samples: list, + create_dept_id: str = "", + commit: bool = True, ) -> List[BizProtocolSample]: existing = list_samples(db, collection_protocol_id, tenant_no) old_id_to_code = { @@ -2814,7 +2841,7 @@ def save_samples( order_num=idx, remark=(raw.get("remark") or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=datetime.now(), update_by=user_id, update_time=datetime.now(), @@ -2852,6 +2879,9 @@ def save_samples( new_code_to_id=new_code_to_id, user_id=user_id, ) + if not commit: + db.flush() + return result db.commit() for o in result: db.refresh(o) diff --git a/backend/app/crud/presign_order.py b/backend/app/crud/presign_order.py index 0def0a9d..90b3ba23 100644 --- a/backend/app/crud/presign_order.py +++ b/backend/app/crud/presign_order.py @@ -7,6 +7,7 @@ from decimal import Decimal from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import and_, func, or_ +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.utils import generate_uuid, parse_datetime @@ -37,11 +38,22 @@ def _dec(v: Any) -> Optional[Decimal]: 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()] if not ids: 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] = {} for u in rows: 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( - db: Session, *, order_id: str, tenant_no: str + db: Session, *, order_id: str, tenant_no: str, for_update: bool = False ) -> BizPresignOrder: - obj = ( + query = ( db.query(BizPresignOrder) .filter( BizPresignOrder.id == order_id, BizPresignOrder.tenant_no == tenant_no, BizPresignOrder.del_flag == 0, ) - .first() ) + if for_update: + query = query.populate_existing().with_for_update() + obj = query.first() if not obj: raise ValueError("预签约订单不存在") 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( db: Session, *, transfer_id: str, tenant_no: str ) -> BizPresignTransfer: @@ -197,6 +246,31 @@ def confirmed_deposit_amount( 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( db: Session, *, presign_order_id: str, tenant_no: str ) -> str: @@ -244,6 +318,7 @@ def replace_sample_types( tenant_no: str, user_id: Optional[str], sample_type_ids: List[str], + create_dept_id: str = "", ) -> None: now = _now() existing = ( @@ -274,7 +349,7 @@ def replace_sample_types( tenant_no=tenant_no, order_num=idx, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=now, update_by=user_id, update_time=now, @@ -301,10 +376,20 @@ def list_transfers( 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]: - deposit = confirmed_deposit_amount( - db, presign_order_id=o.id, tenant_no=tenant_no + deposit = ( + 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] = { "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 "", "updateTime": o.update_time.strftime("%Y-%m-%d %H:%M:%S") if o.update_time else "", } - name_map = _user_nick_name_map( - db, - [ - data["collectorUserId"], - data["salesUserId"], - data["regionManagerUserId"], - ], - ) + name_map = user_name_map + if name_map is None: + name_map = _user_nick_name_map( + db, + [ + data["collectorUserId"], + data["salesUserId"], + data["regionManagerUserId"], + ], + tenant_no, + ) data["collectorUserName"] = name_map.get(data["collectorUserId"], "") data["salesUserName"] = name_map.get(data["salesUserId"], "") data["regionManagerUserName"] = name_map.get(data["regionManagerUserId"], "") @@ -419,6 +507,8 @@ def create_presign_order( fields: Dict[str, Any], sample_type_ids: Optional[List[str]] = None, transfers: Optional[List[Dict[str, Any]]] = None, + create_dept_id: str = "", + commit: bool = True, ) -> BizPresignOrder: now = _now() sample_collect = fields.get("sample_collect_time") or fields.get("sampleCollectTime") @@ -472,7 +562,7 @@ def create_presign_order( order_num=0, remark=(fields.get("remark") or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=now, update_by=user_id, update_time=now, @@ -486,6 +576,7 @@ def create_presign_order( tenant_no=tenant_no, user_id=user_id, sample_type_ids=sample_type_ids or [], + create_dept_id=create_dept_id, ) for raw in transfers or []: if isinstance(raw, dict): @@ -495,10 +586,14 @@ def create_presign_order( tenant_no=tenant_no, user_id=user_id, fields=raw, + create_dept_id=create_dept_id, commit=False, ) - db.commit() - db.refresh(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj @@ -510,6 +605,8 @@ def update_presign_order( user_id: Optional[str], fields: Dict[str, Any], sample_type_ids: Optional[List[str]] = None, + create_dept_id: str = "", + commit: bool = True, ) -> BizPresignOrder: obj = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no) if (obj.order_status or "") == "converted": @@ -581,9 +678,13 @@ def update_presign_order( tenant_no=tenant_no, user_id=user_id, sample_type_ids=sample_type_ids, + create_dept_id=create_dept_id, ) - db.commit() - db.refresh(obj) + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj @@ -608,6 +709,7 @@ def add_transfer( tenant_no: str, user_id: Optional[str], fields: Dict[str, Any], + create_dept_id: str = "", commit: bool = True, ) -> BizPresignTransfer: _get_order_or_raise(db, order_id=presign_order_id, tenant_no=tenant_no) @@ -636,7 +738,7 @@ def add_transfer( order_num=0, remark=(fields.get("remark") or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=now, update_by=user_id, update_time=now, @@ -905,6 +1007,7 @@ def list_presign_transfers_for_finance( (order.sales_user_id if order else "") or "", (order.region_manager_user_id if order else "") or "", ], + tenant_no, ) item["collectorUserName"] = name_map.get( (order.collector_user_id if order else "") or "", "" @@ -932,12 +1035,23 @@ def convert_to_protocol( order_id: str, tenant_no: str, user_id: Optional[str], + create_dept_id: str = "", + commit: bool = True, ) -> Dict[str, Any]: """转正式协议:创建协议主单 + 当事人/采集信息 + 样本类型行;意向金仅关联预签订单 ID,不拷贝。""" 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) + 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( 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) - protocol = cp_svc.create_collection_protocol( - db, - protocol_no=order.protocol_no or "", - protocol_status="dsx", - register_date=order.order_date, - collector_user_id=staff["collector_user_id"], - source_presign_order_id=order.id, - payment_model_version=2, - protocol_total_amount=None, - remark=order.remark, - tenant_no=tenant_no, - user_id=user_id, - ) - party_fields: Dict[str, Any] = { - "mother_name": order.mother_name, - "expected_delivery_date": order.expected_delivery_date, - } - cp_svc.upsert_party_customer( - db, protocol.id, tenant_no, user_id, **party_fields - ) - collection_fields: Dict[str, Any] = { - "region_id": order.region_id, - "hospital_id": order.hospital_id, - "sales_user_id": staff["sales_user_id"], - "region_manager_user_id": staff["region_manager_user_id"], - "sample_collect_time": order.sample_collect_time, - } - cp_svc.upsert_collection_info( - db, protocol.id, tenant_no, user_id, **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) + try: + protocol = cp_svc.create_collection_protocol( + db, + protocol_no=order.protocol_no or "", + protocol_status="dsx", + register_date=order.order_date, + collector_user_id=staff["collector_user_id"], + source_presign_order_id=order.id, + payment_model_version=2, + protocol_total_amount=None, + remark=order.remark, + tenant_no=tenant_no, + user_id=user_id, + create_dept_id=create_dept_id, + commit=False, + ) + party_fields: Dict[str, Any] = { + "mother_name": order.mother_name, + "expected_delivery_date": order.expected_delivery_date, + } + cp_svc.upsert_party_customer( + db, + protocol.id, + tenant_no, + user_id, + create_dept_id=create_dept_id, + commit=False, + **party_fields, + ) + collection_fields: Dict[str, Any] = { + "region_id": order.region_id, + "hospital_id": order.hospital_id, + "sales_user_id": staff["sales_user_id"], + "region_manager_user_id": staff["region_manager_user_id"], + "sample_collect_time": order.sample_collect_time, + } + cp_svc.upsert_collection_info( + db, + 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.converted_protocol_id = protocol.id - if not (order.protocol_no or "").strip(): - order.protocol_no = protocol.protocol_no - order.update_by = user_id - order.update_time = _now() - db.add(order) - db.commit() - db.refresh(order) + order.order_status = "converted" + order.converted_protocol_id = protocol.id + if not (order.protocol_no or "").strip(): + order.protocol_no = protocol.protocol_no + order.update_by = user_id + order.update_time = _now() + db.add(order) + if commit: + 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 { "presignOrder": order_to_dict(db, order, tenant_no, with_children=True), "protocolId": protocol.id, diff --git a/backend/app/crud/protocol_sample_payment.py b/backend/app/crud/protocol_sample_payment.py index 478b7dbf..9b48fde4 100644 --- a/backend/app/crud/protocol_sample_payment.py +++ b/backend/app/crud/protocol_sample_payment.py @@ -722,6 +722,7 @@ def add_transfer( transfer_type: Optional[str] = None, transfer_attachments: Optional[str] = None, remark: Optional[str] = None, + create_dept_id: str = "", commit: bool = True, ) -> BizProtocolPaymentTransfer: sch = _get_schedule_or_raise(db, schedule_id=schedule_id, tenant_no=tenant_no) @@ -748,7 +749,7 @@ def add_transfer( order_num=0, remark=(remark or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=(create_dept_id or "").strip(), create_time=now, update_by=user_id, update_time=now, diff --git a/backend/app/crud/research_donation.py b/backend/app/crud/research_donation.py index d722c3e4..95d9ca3e 100644 --- a/backend/app/crud/research_donation.py +++ b/backend/app/crud/research_donation.py @@ -982,6 +982,9 @@ def create_donation( data: dict, tenant_no: str, user_id: Optional[str] = None, + create_dept_id: str = "", + commit: bool = True, + sync_customers: bool = True, ) -> BizResearchDonation: """创建科研捐赠单(含 party / collection / samples + transports)。""" raw = _normalize_fields(data) @@ -1016,7 +1019,7 @@ def create_donation( order_num=0, remark=(raw.get("remark") or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=now, update_by=user_id, update_time=now, @@ -1031,19 +1034,34 @@ def create_donation( from app.services.party_customer_sync import sync_party_customers 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 "") - _create_party(db, donation_id, tenant_no, user_id, party_raw, now) + if sync_customers: + 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 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_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 sample_freight_svc.apply_freight_on_protocol_save( @@ -1055,7 +1073,11 @@ def create_donation( saved_samples=new_samples, input_samples=samples_raw, ) - db.commit() + if commit: + db.commit() + db.refresh(obj) + else: + db.flush() return obj @@ -1071,6 +1093,9 @@ def update_donation( data: dict, tenant_no: str, user_id: Optional[str] = None, + create_dept_id: str = "", + commit: bool = True, + sync_customers: bool = True, ) -> BizResearchDonation: """更新科研捐赠单:主表字段 + 子资源 upsert(软删旧 + 创建新)。""" obj = ( @@ -1131,14 +1156,28 @@ def update_donation( _soft_delete_party(db, donation_id, tenant_no, user_id) party_raw = _normalize_fields(raw["party"] or {}) - party_raw = sync_party_customers(db, party_raw, tenant_no=tenant_no, user_id=user_id or "") - _create_party(db, donation_id, tenant_no, user_id, party_raw, now) + if sync_customers: + 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 if "collection" in raw: _soft_delete_collection(db, donation_id, tenant_no, user_id) 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 if "samples" in raw: @@ -1151,7 +1190,10 @@ def update_donation( _soft_delete_samples(db, donation_id, tenant_no, user_id) _soft_delete_transports(db, donation_id, tenant_no, user_id) 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 = { (s.sample_code or "").strip(): s.id for s in new_samples @@ -1168,8 +1210,6 @@ def update_donation( samples_raw = None new_samples = None - db.commit() - db.refresh(obj) if new_samples is not None and samples_raw is not None: from app.crud import sample_freight as sample_freight_svc @@ -1182,7 +1222,11 @@ def update_donation( saved_samples=new_samples, input_samples=samples_raw, ) + if commit: db.commit() + db.refresh(obj) + else: + db.flush() return obj @@ -1995,6 +2039,7 @@ def _create_party( user_id: Optional[str], raw: dict, now: datetime, + create_dept_id: str = "", ) -> BizResearchDonationParty: obj = BizResearchDonationParty( id=generate_uuid(), @@ -2003,7 +2048,7 @@ def _create_party( order_num=0, remark=(raw.get("remark") or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=now, update_by=user_id, update_time=now, @@ -2049,6 +2094,7 @@ def _create_collection( user_id: Optional[str], raw: dict, now: datetime, + create_dept_id: str = "", ) -> BizResearchDonationCollection: obj = BizResearchDonationCollection( id=generate_uuid(), @@ -2057,7 +2103,7 @@ def _create_collection( order_num=0, remark=(raw.get("remark") or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=now, update_by=user_id, update_time=now, @@ -2149,6 +2195,7 @@ def _create_samples( user_id: Optional[str], samples: list, now: datetime, + create_dept_id: str = "", ) -> List[BizResearchDonationSample]: validate_sample_codes_not_used( db, @@ -2178,7 +2225,7 @@ def _create_samples( order_num=idx, remark=(raw.get("remark") or "").strip() or None, create_by=user_id, - create_dept_id="", + create_dept_id=create_dept_id, create_time=now, update_by=user_id, update_time=now, diff --git a/backend/app/main.py b/backend/app/main.py index 61c43762..1c4505e8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from app.core.database import SessionLocal from app.core.api_request_log_middleware import ApiRequestLogMiddleware from app.core.api_crypto_middleware import ApiCryptoMiddleware 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 @@ -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(mini_auth_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) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 912ada1d..d8778a0e 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -2,7 +2,7 @@ from datetime import datetime 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 @@ -1060,6 +1060,13 @@ class BizCollectionProtocol(Base): """采集协议单 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") protocol_no_id = Column(String(64), nullable=True, comment="采集协议号表ID") diff --git a/backend/app/services/market_field_scope.py b/backend/app/services/market_field_scope.py new file mode 100644 index 00000000..8ba39801 --- /dev/null +++ b/backend/app/services/market_field_scope.py @@ -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, + ) diff --git a/backend/app/services/market_freight.py b/backend/app/services/market_freight.py new file mode 100644 index 00000000..260e6a66 --- /dev/null +++ b/backend/app/services/market_freight.py @@ -0,0 +1,1088 @@ +"""移动市场运费批次:按现场范围过滤明细并执行原子写入。""" +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import HTTPException +from sqlalchemy import and_, func, literal, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, aliased + +from app.core.utils import generate_uuid +from app.models.models import ( + BizCellSampleType, + BizCollectionProtocol, + BizCollectionRegionStaff, + BizProtocolCollectionInfo, + BizProtocolPartyCustomer, + BizProtocolSample, + BizSampleFreight, + BizSampleFreightItem, + SysDept, + SysDictData, + SysPost, + SysPostRole, + SysRole, + SysRoleDataScopeDetail, + SysUser, + SysUserPost, + SysUserRole, +) +from app.models.research_donation import ( + BizResearchDonation, + BizResearchDonationCollection, + BizResearchDonationParty, + BizResearchDonationSample, +) +from app.services.market_field_scope import ( + MarketFieldContext, + apply_cell_protocol_scope, + apply_donation_scope, +) +from app.services.sample_transport_mode import DICT_TYPE, resolve_transport_mode_value +from app.services.web_auth_roles import ( + _map_role_name_to_web, + resolve_web_role_for_user, +) + +CELL = 'cell_storage' +DONATION = 'research_donation' +SOURCES = (CELL, DONATION) + + +def _current_tenant_user_info( + db: Session, ctx: MarketFieldContext, user_info: dict +) -> dict: + """用当前租户有效角色覆盖不可信的令牌 role 快照。""" + if not isinstance(user_info, dict): + raise PermissionError('用户身份与现场范围上下文不一致') + user_id = str(user_info.get('user_id') or '').strip() + tenant_no = str(user_info.get('tenant_no') or '').strip() + if user_id != ctx.user_id or tenant_no != ctx.tenant_no: + raise PermissionError('用户身份与现场范围上下文不一致') + current = dict(user_info) + current['role'] = resolve_web_role_for_user( + db, ctx.user_id, tenant_no=ctx.tenant_no + ) + return current + + +def _latest_cell_party_id(ctx: MarketFieldContext): + latest = aliased(BizProtocolPartyCustomer) + return ( + select(latest.id) + .where( + latest.collection_protocol_id == BizCollectionProtocol.id, + latest.tenant_no == ctx.tenant_no, + latest.del_flag == 0, + ) + .order_by(latest.create_time.desc(), latest.id.desc()) + .limit(1) + .correlate(BizCollectionProtocol) + .scalar_subquery() + ) + + +def _latest_donation_party_id(ctx: MarketFieldContext): + latest = aliased(BizResearchDonationParty) + return ( + select(latest.id) + .where( + latest.research_donation_id == BizResearchDonation.id, + latest.tenant_no == ctx.tenant_no, + latest.del_flag == 0, + ) + .order_by(latest.create_time.desc(), latest.id.desc()) + .limit(1) + .correlate(BizResearchDonation) + .scalar_subquery() + ) + + +def _amount(value: Any) -> Decimal: + try: + result = Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError) as exc: + raise ValueError('运费格式无效') from exc + if not result.is_finite() or result < 0: + raise ValueError('运费格式无效') + return result.quantize(Decimal('0.01')) + + +def _fmt_dt(value: Optional[datetime]) -> str: + return value.strftime('%Y-%m-%d %H:%M:%S') if value else '' + + +def _normalize_items(items: List[Dict[str, Any]]) -> List[Tuple[str, str]]: + if not items: + raise ValueError('至少选择一个样本') + result: List[Tuple[str, str]] = [] + seen = set() + for item in items: + source = str(item.get('sampleSource') or item.get('sample_source') or '').strip() + sample_id = str(item.get('sampleId') or item.get('sample_id') or '').strip() + if source not in SOURCES or not sample_id: + raise ValueError('关联样本须包含有效的 sampleSource 与 sampleId') + key = (source, sample_id) + if key in seen: + raise ValueError('关联样本不可重复') + seen.add(key) + result.append(key) + return result + + +def _cell_item_query(db: Session, ctx: MarketFieldContext, user_info: dict): + query = ( + db.query( + BizSampleFreightItem, + BizProtocolSample, + BizCollectionProtocol, + BizProtocolPartyCustomer, + BizCellSampleType, + ) + .join( + BizProtocolSample, + and_( + BizProtocolSample.id == BizSampleFreightItem.sample_id, + BizProtocolSample.tenant_no == ctx.tenant_no, + BizProtocolSample.del_flag == 0, + ), + ) + .join( + BizCollectionProtocol, + and_( + BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id, + BizCollectionProtocol.tenant_no == ctx.tenant_no, + BizCollectionProtocol.del_flag == 0, + ), + ) + .outerjoin( + BizProtocolPartyCustomer, + and_( + BizProtocolPartyCustomer.id == _latest_cell_party_id(ctx), + BizProtocolPartyCustomer.tenant_no == ctx.tenant_no, + BizProtocolPartyCustomer.del_flag == 0, + ), + ) + .outerjoin( + BizCellSampleType, + and_( + BizCellSampleType.id == BizProtocolSample.sample_type_id, + BizCellSampleType.tenant_no == ctx.tenant_no, + BizCellSampleType.del_flag == 0, + ), + ) + .filter( + BizSampleFreightItem.tenant_no == ctx.tenant_no, + BizSampleFreightItem.del_flag == 0, + BizSampleFreightItem.sample_source == CELL, + ) + ) + return apply_cell_protocol_scope(query, db, ctx, user_info=user_info) + + +def _donation_item_query(db: Session, ctx: MarketFieldContext, user_info: dict): + query = ( + db.query( + BizSampleFreightItem, + BizResearchDonationSample, + BizResearchDonation, + BizResearchDonationParty, + BizCellSampleType, + ) + .join( + BizResearchDonationSample, + and_( + BizResearchDonationSample.id == BizSampleFreightItem.sample_id, + BizResearchDonationSample.tenant_no == ctx.tenant_no, + BizResearchDonationSample.del_flag == 0, + ), + ) + .join( + BizResearchDonation, + and_( + BizResearchDonation.id == BizResearchDonationSample.research_donation_id, + BizResearchDonation.tenant_no == ctx.tenant_no, + BizResearchDonation.del_flag == 0, + ), + ) + .outerjoin( + BizResearchDonationParty, + and_( + BizResearchDonationParty.id == _latest_donation_party_id(ctx), + BizResearchDonationParty.tenant_no == ctx.tenant_no, + BizResearchDonationParty.del_flag == 0, + ), + ) + .outerjoin( + BizCellSampleType, + and_( + BizCellSampleType.id == BizResearchDonationSample.sample_type_id, + BizCellSampleType.tenant_no == ctx.tenant_no, + BizCellSampleType.del_flag == 0, + ), + ) + .filter( + BizSampleFreightItem.tenant_no == ctx.tenant_no, + BizSampleFreightItem.del_flag == 0, + BizSampleFreightItem.sample_source == DONATION, + ) + ) + return apply_donation_scope(query, db, ctx, user_info=user_info) + + +def _visible_item_query(db: Session, source: str, ctx: MarketFieldContext, user_info: dict): + return _cell_item_query(db, ctx, user_info) if source == CELL else _donation_item_query(db, ctx, user_info) + + +def _visible_exists(db: Session, source: str, ctx: MarketFieldContext, user_info: dict, *, keyword: str = ''): + query = _visible_item_query(db, source, ctx, user_info).filter( + BizSampleFreightItem.freight_id == BizSampleFreight.id + ) + if keyword: + like = f'%{keyword}%' + if source == CELL: + query = query.filter(or_(BizProtocolSample.sample_code.like(like), BizCollectionProtocol.protocol_no.like(like), BizProtocolPartyCustomer.mother_name.like(like))) + else: + query = query.filter(or_(BizResearchDonationSample.sample_code.like(like), BizResearchDonation.donation_no.like(like), BizResearchDonationParty.mother_name.like(like))) + return query.with_entities(BizSampleFreightItem.id).exists() + + +def _visible_rows(db: Session, freight_ids: List[str], ctx: MarketFieldContext, user_info: dict): + rows = [] + if not freight_ids: + return rows + for source in SOURCES: + rows.extend( + _visible_item_query(db, source, ctx, user_info) + .filter(BizSampleFreightItem.freight_id.in_(freight_ids)) + .order_by(BizSampleFreightItem.create_time.asc(), BizSampleFreightItem.id.asc()) + .all() + ) + rows.sort(key=lambda row: (row[0].create_time or datetime.min, row[0].id)) + # 业务库历史上可能存在多条有效客户方信息;明细身份始终以关联行 ID 为准。 + unique = {} + for row in rows: + unique.setdefault(row[0].id, row) + return list(unique.values()) + + +def _item_dict(row) -> Dict[str, Any]: + item, sample, parent, party, sample_type = row + source = item.sample_source + return { + 'id': item.id, + 'freightId': item.freight_id, + 'sampleSource': source, + 'sampleId': sample.id, + 'sampleCode': sample.sample_code or '', + 'protocolId': parent.id, + 'protocolNo': (parent.protocol_no if source == CELL else parent.donation_no) or '', + 'sampleTypeName': sample_type.type_name if sample_type else '', + 'motherName': party.mother_name if party else '', + 'newbornName': party.newborn_name if party else '', + } + + +def _label_map(db: Session, tenant_no: str, values: List[str]) -> Dict[str, str]: + wanted = list({value for value in values if value}) + if not wanted: + return {} + rows = db.query(SysDictData.dict_value, SysDictData.dict_label).filter( + SysDictData.tenant_no == tenant_no, + SysDictData.dict_type == DICT_TYPE, + SysDictData.dict_value.in_(wanted), + SysDictData.del_flag == 0, + ).all() + return {value: label or value for value, label in rows} + + +def _batch_dict(obj: BizSampleFreight, count: int, label: str) -> Dict[str, Any]: + return { + 'id': obj.id, + 'freightNo': obj.freight_no or '', + 'transportMode': obj.transport_mode or '', + 'transportModeLabel': label or obj.transport_mode or '', + 'freightAmount': str((obj.freight_amount or Decimal('0')).quantize(Decimal('0.01'))), + 'visibleItemCount': int(count), + 'remark': obj.remark or '', + 'createTime': _fmt_dt(obj.create_time), + 'updateTime': _fmt_dt(obj.update_time), + } + + +def list_freights( + db: Session, *, ctx: MarketFieldContext, user_info: dict, page: int = 1, + page_size: int = 10, keyword: Optional[str] = None, + transport_mode: Optional[str] = None, sample_source: Optional[str] = None, + include_items: bool = False, +) -> Tuple[List[Dict[str, Any]], int]: + user_info = _current_tenant_user_info(db, ctx, user_info) + page, page_size = max(1, int(page)), max(1, min(100, int(page_size))) + source = (sample_source or '').strip() + if source and source not in SOURCES: + raise ValueError('sampleSource 无效') + q = db.query(BizSampleFreight).filter( + BizSampleFreight.tenant_no == ctx.tenant_no, + BizSampleFreight.del_flag == 0, + ) + visible_sources = (source,) if source else SOURCES + visible_conditions = [_visible_exists(db, src, ctx, user_info) for src in visible_sources] + q = q.filter(or_(*visible_conditions)) + if transport_mode: + q = q.filter(BizSampleFreight.transport_mode == transport_mode.strip()) + text = (keyword or '').strip() + if text: + like = f'%{text}%' + item_conditions = [_visible_exists(db, src, ctx, user_info, keyword=text) for src in visible_sources] + q = q.filter(or_(BizSampleFreight.freight_no.like(like), BizSampleFreight.transport_mode.like(like), *item_conditions)) + total = q.count() + batches = q.order_by(BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc()).offset((page - 1) * page_size).limit(page_size).all() + ids = [row.id for row in batches] + visible_rows = _visible_rows(db, ids, ctx, user_info) + by_batch: Dict[str, list] = {} + for row in visible_rows: + by_batch.setdefault(row[0].freight_id, []).append(row) + labels = _label_map(db, ctx.tenant_no, [row.transport_mode for row in batches]) + result = [] + for batch in batches: + rows = by_batch.get(batch.id, []) + data = _batch_dict(batch, len(rows), labels.get(batch.transport_mode or '', '')) + if include_items: + data['items'] = [_item_dict(row) for row in rows] + result.append(data) + return result, total + + +def get_freight_detail(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict) -> Dict[str, Any]: + user_info = _current_tenant_user_info(db, ctx, user_info) + obj = db.query(BizSampleFreight).filter( + BizSampleFreight.id == freight_id, + BizSampleFreight.tenant_no == ctx.tenant_no, + BizSampleFreight.del_flag == 0, + ).first() + rows = _visible_rows(db, [freight_id], ctx, user_info) if obj else [] + if not obj or not rows: + raise HTTPException(status_code=404, detail='数据不存在或无权访问') + labels = _label_map(db, ctx.tenant_no, [obj.transport_mode]) + data = _batch_dict(obj, len(rows), labels.get(obj.transport_mode or '', '')) + data['items'] = [_item_dict(row) for row in rows] + visible_item_ids = {row[0].id for row in rows} + has_outside_scope_item = ( + db.query(BizSampleFreightItem.id) + .filter( + BizSampleFreightItem.freight_id == freight_id, + BizSampleFreightItem.tenant_no == ctx.tenant_no, + BizSampleFreightItem.del_flag == 0, + BizSampleFreightItem.id.notin_(visible_item_ids), + ) + .first() + is not None + ) + data['allowedActions'] = ( + ['link', 'unlink'] + if has_outside_scope_item + else ['edit', 'link', 'unlink'] + ) + return data + + +def _option_query(db: Session, source: str, ctx: MarketFieldContext, user_info: dict, keyword: str): + if source == CELL: + q = db.query( + literal(CELL).label('source'), BizProtocolSample.id.label('sample_id'), + BizProtocolSample.sample_code.label('sample_code'), BizCollectionProtocol.id.label('protocol_id'), + BizCollectionProtocol.protocol_no.label('protocol_no'), BizProtocolPartyCustomer.mother_name.label('customer_name'), + BizProtocolSample.create_time.label('create_time'), + ).join(BizCollectionProtocol, and_(BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id, BizCollectionProtocol.tenant_no == ctx.tenant_no, BizCollectionProtocol.del_flag == 0)).outerjoin(BizProtocolPartyCustomer, and_(BizProtocolPartyCustomer.id == _latest_cell_party_id(ctx), BizProtocolPartyCustomer.tenant_no == ctx.tenant_no, BizProtocolPartyCustomer.del_flag == 0)).filter(BizProtocolSample.tenant_no == ctx.tenant_no, BizProtocolSample.del_flag == 0) + q = apply_cell_protocol_scope(q, db, ctx, user_info=user_info) + if keyword: + like = f'%{keyword}%' + q = q.filter(or_(BizProtocolSample.sample_code.like(like), BizCollectionProtocol.protocol_no.like(like), BizProtocolPartyCustomer.mother_name.like(like))) + return q.distinct() + q = db.query( + literal(DONATION).label('source'), BizResearchDonationSample.id.label('sample_id'), + BizResearchDonationSample.sample_code.label('sample_code'), BizResearchDonation.id.label('protocol_id'), + BizResearchDonation.donation_no.label('protocol_no'), BizResearchDonationParty.mother_name.label('customer_name'), + BizResearchDonationSample.create_time.label('create_time'), + ).join(BizResearchDonation, and_(BizResearchDonation.id == BizResearchDonationSample.research_donation_id, BizResearchDonation.tenant_no == ctx.tenant_no, BizResearchDonation.del_flag == 0)).outerjoin(BizResearchDonationParty, and_(BizResearchDonationParty.id == _latest_donation_party_id(ctx), BizResearchDonationParty.tenant_no == ctx.tenant_no, BizResearchDonationParty.del_flag == 0)).filter(BizResearchDonationSample.tenant_no == ctx.tenant_no, BizResearchDonationSample.del_flag == 0) + q = apply_donation_scope(q, db, ctx, user_info=user_info) + if keyword: + like = f'%{keyword}%' + q = q.filter(or_(BizResearchDonationSample.sample_code.like(like), BizResearchDonation.donation_no.like(like), BizResearchDonationParty.mother_name.like(like))) + return q.distinct() + + +def list_sample_options(db: Session, *, ctx: MarketFieldContext, user_info: dict, page: int = 1, page_size: int = 20, keyword: Optional[str] = None, sample_source: Optional[str] = None): + user_info = _current_tenant_user_info(db, ctx, user_info) + page, page_size = max(1, int(page)), max(1, min(100, int(page_size))) + source = (sample_source or '').strip() + if source and source not in SOURCES: + raise ValueError('sampleSource 无效') + sources = (source,) if source else SOURCES + queries = [_option_query(db, src, ctx, user_info, (keyword or '').strip()) for src in sources] + combined = queries[0] if len(queries) == 1 else queries[0].union_all(*queries[1:]) + sub = combined.subquery() + total = db.query(func.count()).select_from(sub).scalar() or 0 + rows = db.query(sub).order_by(sub.c.create_time.desc(), sub.c.source.desc(), sub.c.sample_id.desc()).offset((page - 1) * page_size).limit(page_size).all() + keys = [(row.source, row.sample_id) for row in rows] + links = {} + if keys: + clauses = [and_(BizSampleFreightItem.sample_source == src, BizSampleFreightItem.sample_id == sid) for src, sid in keys] + for item in db.query(BizSampleFreightItem).filter(BizSampleFreightItem.tenant_no == ctx.tenant_no, BizSampleFreightItem.del_flag == 0, or_(*clauses)).all(): + links[(item.sample_source, item.sample_id)] = item.freight_id + return [{ + 'sampleSource': row.source, 'sampleId': row.sample_id, + 'sampleCode': row.sample_code or '', 'protocolId': row.protocol_id, + 'protocolNo': row.protocol_no or '', 'customerName': row.customer_name or '', + 'createTime': _fmt_dt(row.create_time), 'freightId': links.get((row.source, row.sample_id), ''), + } for row in rows], int(total) + + +def _sample_parent_lock_query( + db: Session, + source: str, + sample_ids: List[str], + ctx: MarketFieldContext, +): + ids = sorted(set(sample_ids)) + if source == CELL: + query = ( + db.query(BizProtocolSample, BizCollectionProtocol) + .join( + BizCollectionProtocol, + and_( + BizCollectionProtocol.id + == BizProtocolSample.collection_protocol_id, + BizCollectionProtocol.tenant_no == ctx.tenant_no, + BizCollectionProtocol.del_flag == 0, + ), + ) + .filter( + BizProtocolSample.id.in_(ids), + BizProtocolSample.tenant_no == ctx.tenant_no, + BizProtocolSample.del_flag == 0, + ) + ) + return query.order_by( + BizProtocolSample.id.asc(), BizCollectionProtocol.id.asc() + ).populate_existing().with_for_update() + query = ( + db.query(BizResearchDonationSample, BizResearchDonation) + .join( + BizResearchDonation, + and_( + BizResearchDonation.id + == BizResearchDonationSample.research_donation_id, + BizResearchDonation.tenant_no == ctx.tenant_no, + BizResearchDonation.del_flag == 0, + ), + ) + .filter( + BizResearchDonationSample.id.in_(ids), + BizResearchDonationSample.tenant_no == ctx.tenant_no, + BizResearchDonationSample.del_flag == 0, + ) + ) + return query.order_by( + BizResearchDonationSample.id.asc(), BizResearchDonation.id.asc() + ).populate_existing().with_for_update() + + +def _collection_lock_query( + db: Session, + source: str, + parent_ids: List[str], + ctx: MarketFieldContext, +): + ids = sorted(set(parent_ids)) + if source == CELL: + return ( + db.query(BizProtocolCollectionInfo) + .filter( + BizProtocolCollectionInfo.collection_protocol_id.in_(ids), + BizProtocolCollectionInfo.tenant_no == ctx.tenant_no, + BizProtocolCollectionInfo.del_flag == 0, + ) + .order_by( + BizProtocolCollectionInfo.collection_protocol_id.asc(), + BizProtocolCollectionInfo.id.asc(), + ) + .populate_existing() + .with_for_update() + ) + return ( + db.query(BizResearchDonationCollection) + .filter( + BizResearchDonationCollection.research_donation_id.in_(ids), + BizResearchDonationCollection.tenant_no == ctx.tenant_no, + BizResearchDonationCollection.del_flag == 0, + ) + .order_by( + BizResearchDonationCollection.research_donation_id.asc(), + BizResearchDonationCollection.id.asc(), + ) + .populate_existing() + .with_for_update() + ) + + +def _leader_lock_query(db: Session, ctx: MarketFieldContext): + """锁定经理当前有效的地区负责人关系,供字段范围直接计算。""" + return ( + db.query(BizCollectionRegionStaff) + .filter( + BizCollectionRegionStaff.tenant_no == ctx.tenant_no, + BizCollectionRegionStaff.del_flag == 0, + BizCollectionRegionStaff.user_id == ctx.user_id, + BizCollectionRegionStaff.is_leader == 1, + ) + .order_by( + BizCollectionRegionStaff.region_id.asc(), + BizCollectionRegionStaff.id.asc(), + ) + .populate_existing() + .with_for_update() + ) + + +def _current_field_visible_parent_ids( + source: str, + collection_rows: List[Any], + leader_rows: List[BizCollectionRegionStaff], + ctx: MarketFieldContext, +) -> set[str]: + """只使用已加锁的采集分配/负责人当前值计算现场字段范围。""" + parent_attr = ( + 'collection_protocol_id' + if source == CELL + else 'research_donation_id' + ) + leader_regions = {row.region_id for row in leader_rows} + visible: set[str] = set() + for row in collection_rows: + if ctx.scope_kind == 'specialist': + allowed = row.sales_user_id == ctx.user_id + else: + allowed = ( + row.region_id in leader_regions + or row.region_manager_user_id == ctx.user_id + ) + if allowed: + visible.add(getattr(row, parent_attr)) + return visible + + +def _current_data_scope_parent_ids( + db: Session, + parents: List[Any], + ctx: MarketFieldContext, + user_info: dict, +) -> set[str]: + """锁定数据权限依赖并按锁后当前值计算多角色并集范围。""" + if not isinstance(user_info, dict) or str( + user_info.get('user_id') or '' + ).strip() != ctx.user_id: + raise PermissionError('用户身份与现场范围上下文不一致') + parent_ids = {parent.id for parent in parents} + + current_users = ( + db.query(SysUser) + .filter( + SysUser.id == ctx.user_id, + SysUser.tenant_no == ctx.tenant_no, + SysUser.del_flag == 0, + ) + .order_by(SysUser.id.asc()) + .populate_existing() + .with_for_update() + .all() + ) + if not current_users: + return set() + current_user = current_users[0] + + direct_links = ( + db.query(SysUserRole) + .filter( + SysUserRole.user_id == ctx.user_id, + SysUserRole.tenant_no == ctx.tenant_no, + SysUserRole.del_flag == 0, + ) + .order_by(SysUserRole.role_id.asc(), SysUserRole.id.asc()) + .populate_existing() + .with_for_update() + .all() + ) + user_posts = ( + db.query(SysUserPost) + .filter( + SysUserPost.user_id == ctx.user_id, + SysUserPost.tenant_no == ctx.tenant_no, + SysUserPost.del_flag == 0, + ) + .order_by(SysUserPost.post_id.asc(), SysUserPost.id.asc()) + .populate_existing() + .with_for_update() + .all() + ) + post_ids = sorted({row.post_id for row in user_posts}) + posts = [] + if post_ids: + posts = ( + db.query(SysPost) + .filter( + SysPost.id.in_(post_ids), + SysPost.tenant_no == ctx.tenant_no, + SysPost.del_flag == 0, + SysPost.status == '1', + ) + .order_by(SysPost.id.asc()) + .populate_existing() + .with_for_update() + .all() + ) + enabled_post_ids = sorted({row.id for row in posts}) + post_role_links = [] + if enabled_post_ids: + post_role_links = ( + db.query(SysPostRole) + .filter( + SysPostRole.post_id.in_(enabled_post_ids), + SysPostRole.tenant_no == ctx.tenant_no, + SysPostRole.del_flag == 0, + ) + .order_by( + SysPostRole.post_id.asc(), + SysPostRole.role_id.asc(), + SysPostRole.id.asc(), + ) + .populate_existing() + .with_for_update() + .all() + ) + role_ids = sorted( + {row.role_id for row in direct_links} + | {row.role_id for row in post_role_links} + ) + roles = [] + if role_ids: + roles = ( + db.query(SysRole) + .filter( + SysRole.id.in_(role_ids), + SysRole.tenant_no == ctx.tenant_no, + SysRole.del_flag == 0, + SysRole.status == '1', + ) + .order_by(SysRole.id.asc()) + .populate_existing() + .with_for_update() + .all() + ) + if not roles: + return set() + if any( + _map_role_name_to_web(role.role_name) == 'super_admin' + for role in roles + ): + return parent_ids + if any((role.data_scope or '1') == '1' for role in roles): + return parent_ids + + active_role_ids = sorted({role.id for role in roles}) + details = ( + db.query(SysRoleDataScopeDetail) + .filter( + SysRoleDataScopeDetail.role_id.in_(active_role_ids), + SysRoleDataScopeDetail.tenant_no == ctx.tenant_no, + SysRoleDataScopeDetail.del_flag == 0, + ) + .order_by( + SysRoleDataScopeDetail.role_id.asc(), + SysRoleDataScopeDetail.scope_type.asc(), + SysRoleDataScopeDetail.scope_value.asc(), + SysRoleDataScopeDetail.id.asc(), + ) + .populate_existing() + .with_for_update() + .all() + ) + details_by_role: Dict[str, List[SysRoleDataScopeDetail]] = {} + for detail in details: + details_by_role.setdefault(detail.role_id, []).append(detail) + + dept_ids: set[str] = set() + user_ids: set[str] = set() + current_dept_id = (current_user.dept_id or '').strip() + needs_descendants = any( + (role.data_scope or '1') == '3' for role in roles + ) + dept_rows = [] + if needs_descendants and current_dept_id: + dept_rows = ( + db.query(SysDept) + .filter( + SysDept.tenant_no == ctx.tenant_no, + SysDept.del_flag == 0, + ) + .order_by(SysDept.id.asc()) + .populate_existing() + .with_for_update() + .all() + ) + descendants = {current_dept_id} if current_dept_id else set() + if dept_rows and current_dept_id: + pending = [current_dept_id] + while pending: + parent_id = pending.pop(0) + for dept in dept_rows: + if dept.parent_id == parent_id and dept.id not in descendants: + descendants.add(dept.id) + pending.append(dept.id) + + for role in roles: + scope = role.data_scope or '1' + if scope == '2' and current_dept_id: + dept_ids.add(current_dept_id) + elif scope == '3': + dept_ids.update(descendants) + elif scope == '4': + user_ids.add(ctx.user_id) + elif scope == '5': + for detail in details_by_role.get(role.id, []): + value = (detail.scope_value or '').strip() + if not value: + continue + if detail.scope_type == 'dept': + dept_ids.add(value) + elif detail.scope_type == 'user': + user_ids.add(value) + + return { + parent.id + for parent in parents + if ( + (parent.create_dept_id in dept_ids if dept_ids else False) + or (parent.create_by in user_ids if user_ids else False) + ) + } + + +def _active_item_lock_query( + db: Session, + ctx: MarketFieldContext, + *, + freight_id: Optional[str] = None, + keys: Optional[List[Tuple[str, str]]] = None, +): + conditions = [] + if freight_id: + conditions.append(BizSampleFreightItem.freight_id == freight_id) + for source, sample_id in sorted(set(keys or [])): + conditions.append( + and_( + BizSampleFreightItem.sample_source == source, + BizSampleFreightItem.sample_id == sample_id, + ) + ) + query = db.query(BizSampleFreightItem).filter( + BizSampleFreightItem.tenant_no == ctx.tenant_no, + BizSampleFreightItem.del_flag == 0, + ) + if conditions: + query = query.filter(or_(*conditions)) + else: + query = query.filter(False) + return query.order_by( + BizSampleFreightItem.sample_source.asc(), + BizSampleFreightItem.sample_id.asc(), + BizSampleFreightItem.id.asc(), + ).populate_existing().with_for_update() + + +def _lock_batch_header( + db: Session, freight_id: str, ctx: MarketFieldContext +) -> BizSampleFreight: + obj = ( + db.query(BizSampleFreight) + .filter( + BizSampleFreight.id == freight_id, + BizSampleFreight.tenant_no == ctx.tenant_no, + BizSampleFreight.del_flag == 0, + ) + .populate_existing() + .with_for_update() + .first() + ) + if not obj: + raise HTTPException(status_code=404, detail='数据不存在或无权访问') + return obj + + +def _discover_batch_keys( + db: Session, freight_id: str, ctx: MarketFieldContext +) -> List[Tuple[str, str]]: + """批次头已锁后发现参与者;最终关联锁会再次验证该快照。""" + rows = ( + db.query( + BizSampleFreightItem.sample_source, + BizSampleFreightItem.sample_id, + ) + .filter( + BizSampleFreightItem.freight_id == freight_id, + BizSampleFreightItem.tenant_no == ctx.tenant_no, + BizSampleFreightItem.del_flag == 0, + ) + .order_by( + BizSampleFreightItem.sample_source.asc(), + BizSampleFreightItem.sample_id.asc(), + BizSampleFreightItem.id.asc(), + ) + .all() + ) + return [(source, sample_id) for source, sample_id in rows] + + +def _lock_sample_graph( + db: Session, + keys: List[Tuple[str, str]], + ctx: MarketFieldContext, + user_info: dict, +) -> Dict[Tuple[str, str], Tuple[Any, Any]]: + """按来源和 ID 锁样本/父单/采集分配,再用锁定当前读重算范围。""" + normalized = sorted(set(keys)) + locked_rows: Dict[str, List[Tuple[Any, Any]]] = {} + locked_collections: Dict[str, List[Any]] = {} + for source in SOURCES: + ids = [sample_id for item_source, sample_id in normalized if item_source == source] + if not ids: + continue + rows = _sample_parent_lock_query(db, source, ids, ctx).all() + locked_rows[source] = rows + parent_ids = [parent.id for _, parent in rows] + locked_collections[source] = _collection_lock_query( + db, source, parent_ids, ctx + ).all() + + leader_rows = ( + _leader_lock_query(db, ctx).all() + if ctx.scope_kind == 'manager' + else [] + ) + all_parents = [ + parent + for rows in locked_rows.values() + for _, parent in rows + ] + data_scope_ids = _current_data_scope_parent_ids( + db, all_parents, ctx, user_info + ) + visible: Dict[Tuple[str, str], Tuple[Any, Any]] = {} + for source, rows in locked_rows.items(): + field_scope_ids = _current_field_visible_parent_ids( + source, locked_collections[source], leader_rows, ctx + ) + for sample, parent in rows: + if parent.id in field_scope_ids and parent.id in data_scope_ids: + visible[(source, sample.id)] = (sample, parent) + return visible + + +def _lock_active_items( + db: Session, + ctx: MarketFieldContext, + *, + freight_id: Optional[str] = None, + keys: Optional[List[Tuple[str, str]]] = None, + expected_batch_keys: Optional[List[Tuple[str, str]]] = None, +) -> List[BizSampleFreightItem]: + rows = _active_item_lock_query( + db, ctx, freight_id=freight_id, keys=keys + ).all() + if freight_id is not None and expected_batch_keys is not None: + current = sorted( + (row.sample_source, row.sample_id) + for row in rows + if row.freight_id == freight_id + ) + if current != sorted(expected_batch_keys): + raise PermissionError('批次明细已发生变化,请重试') + return rows + + +def _next_freight_no(db: Session, tenant_no: str, now: datetime, offset: int = 0) -> str: + prefix = f"YF{now.strftime('%Y%m%d')}" + latest = db.query(BizSampleFreight.freight_no).filter(BizSampleFreight.tenant_no == tenant_no, BizSampleFreight.freight_no.like(f'{prefix}%')).order_by(BizSampleFreight.freight_no.desc()).with_for_update().first() + seq = int(str(latest[0])[-3:]) + 1 if latest and str(latest[0])[-3:].isdigit() else 1 + seq += offset + if seq > 999: + raise ValueError('当日运费批次号已达上限(999)') + return f'{prefix}{seq:03d}' + + +def create_freight(db: Session, *, ctx: MarketFieldContext, user_info: dict, transport_mode: str, freight_amount: Any, items: List[Dict[str, Any]]): + keys = _normalize_items(items) + try: + samples = _lock_sample_graph(db, keys, ctx, user_info) + if len(samples) != len(set(keys)): + raise HTTPException(status_code=404, detail='样本不存在或无权操作') + conflicts = _lock_active_items(db, ctx, keys=keys) + if conflicts: + raise ValueError('样本已关联其他运费批次') + mode = resolve_transport_mode_value(db, tenant_no=ctx.tenant_no, raw=transport_mode, user_id=ctx.user_id) + amount = _amount(freight_amount) + now = datetime.now() + obj = None + for attempt in range(3): + candidate = BizSampleFreight(id=generate_uuid(), freight_no=_next_freight_no(db, ctx.tenant_no, now, attempt), transport_mode=mode, freight_amount=amount, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0) + try: + with db.begin_nested(): + db.add(candidate) + db.flush() + obj = candidate + break + except IntegrityError: + if attempt == 2: + raise ValueError('运费批次号生成冲突,请重试') + assert obj is not None + for source, sample_id in keys: + sample, parent = samples[(source, sample_id)] + db.add(BizSampleFreightItem(id=generate_uuid(), freight_id=obj.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0)) + db.flush() + db.commit() + return get_freight_detail(db, obj.id, ctx=ctx, user_info=user_info) + except IntegrityError as exc: + db.rollback() + raise ValueError('样本已关联运费批次,不允许移动') from exc + except Exception: + db.rollback() + raise + + +def update_freight(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, transport_mode: Optional[str] = None, freight_amount: Any = None): + try: + obj = _lock_batch_header(db, freight_id, ctx) + expected_keys = _discover_batch_keys(db, freight_id, ctx) + visible = _lock_sample_graph(db, expected_keys, ctx, user_info) + active = _lock_active_items( + db, + ctx, + freight_id=freight_id, + expected_batch_keys=expected_keys, + ) + if not active or not visible: + raise HTTPException(status_code=404, detail='数据不存在或无权访问') + if any( + (row.sample_source, row.sample_id) not in visible for row in active + ): + raise PermissionError('仅当批次全部有效样本均在当前范围内时可修改批次信息') + if transport_mode not in (None, ''): + obj.transport_mode = resolve_transport_mode_value(db, tenant_no=ctx.tenant_no, raw=str(transport_mode), user_id=ctx.user_id) + if freight_amount not in (None, ''): + obj.freight_amount = _amount(freight_amount) + obj.update_by, obj.update_time = ctx.user_id, datetime.now() + db.flush() + db.commit() + return get_freight_detail(db, freight_id, ctx=ctx, user_info=user_info) + except Exception: + db.rollback() + raise + + +def link_samples(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, items: List[Dict[str, Any]]): + keys = _normalize_items(items) + try: + obj = _lock_batch_header(db, freight_id, ctx) + expected_keys = _discover_batch_keys(db, freight_id, ctx) + samples = _lock_sample_graph( + db, sorted(set(expected_keys + keys)), ctx, user_info + ) + locked_items = _lock_active_items( + db, + ctx, + freight_id=freight_id, + keys=keys, + expected_batch_keys=expected_keys, + ) + current_batch = [ + row for row in locked_items if row.freight_id == freight_id + ] + if not any( + (row.sample_source, row.sample_id) in samples + for row in current_batch + ): + raise HTTPException(status_code=404, detail='数据不存在或无权访问') + if any(key not in samples for key in keys): + raise HTTPException(status_code=404, detail='样本不存在或无权操作') + requested = set(keys) + conflicts = [ + row + for row in locked_items + if (row.sample_source, row.sample_id) in requested + ] + if conflicts: + raise ValueError('样本已关联运费批次,不允许移动') + now = datetime.now() + for source, sample_id in keys: + sample, parent = samples[(source, sample_id)] + db.add(BizSampleFreightItem(id=generate_uuid(), freight_id=obj.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0)) + db.flush() + db.commit() + return get_freight_detail(db, freight_id, ctx=ctx, user_info=user_info) + except IntegrityError as exc: + db.rollback() + raise ValueError('样本已关联运费批次,不允许移动') from exc + except Exception: + db.rollback() + raise + + +def unlink_samples(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, items: List[Dict[str, Any]]): + keys = _normalize_items(items) + try: + obj = _lock_batch_header(db, freight_id, ctx) + expected_keys = _discover_batch_keys(db, freight_id, ctx) + visible = _lock_sample_graph(db, expected_keys, ctx, user_info) + active = _lock_active_items( + db, + ctx, + freight_id=freight_id, + expected_batch_keys=expected_keys, + ) + if not any( + (row.sample_source, row.sample_id) in visible for row in active + ): + raise HTTPException(status_code=404, detail='数据不存在或无权访问') + requested = set(keys) + matched = [ + row + for row in active + if (row.sample_source, row.sample_id) in visible + and (row.sample_source, row.sample_id) in requested + ] + if len(matched) != len(keys): + raise ValueError('关联明细不存在或无权操作') + now = datetime.now() + for row in matched: + row.del_flag, row.update_by, row.update_time = 1, ctx.user_id, now + db.flush() + matched_ids = {row.id for row in matched} + remaining = [row for row in active if row.id not in matched_ids] + remaining_visible = [ + row + for row in remaining + if (row.sample_source, row.sample_id) in visible + ] + if not remaining_visible: + if not remaining: + obj.del_flag, obj.update_by, obj.update_time = 1, ctx.user_id, now + db.commit() + return {'id': freight_id, 'visibleItemCount': 0, 'disappeared': True} + db.commit() + return get_freight_detail(db, freight_id, ctx=ctx, user_info=user_info) + except Exception: + db.rollback() + raise diff --git a/backend/app/services/market_payment.py b/backend/app/services/market_payment.py new file mode 100644 index 00000000..c3f201d4 --- /dev/null +++ b/backend/app/services/market_payment.py @@ -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 diff --git a/backend/app/services/market_protocol.py b/backend/app/services/market_protocol.py new file mode 100644 index 00000000..aa7c1844 --- /dev/null +++ b/backend/app/services/market_protocol.py @@ -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 diff --git a/backend/app/services/market_sample.py b/backend/app/services/market_sample.py new file mode 100644 index 00000000..7be49f0e --- /dev/null +++ b/backend/app/services/market_sample.py @@ -0,0 +1,1100 @@ +"""市场移动端统一样本查询与轻量办理。""" +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from fastapi import HTTPException +from sqlalchemy import and_, literal, or_ +from sqlalchemy.orm import Session + +from app.models.models import ( + BizCellSampleType, + BizCollectionProtocol, + BizProtocolCollectionInfo, + BizCollectionRegionStaff, + BizProtocolFullmoonTodo, + BizProtocolPartyCustomer, + BizProtocolSample, + BizProtocolStorageCert, + BizQcWorkOrder, + BizSampleReception, + BizStorageReleaseReview, + SysUser, +) +from app.models.research_donation import ( + BizResearchDonation, + BizResearchDonationCollection, + BizResearchDonationParty, + BizResearchDonationSample, +) +from app.services.market_field_scope import ( + MarketFieldContext, + apply_cell_protocol_scope, + apply_donation_scope, + leader_region_ids_query, +) +from app.services.market_support import validate_attachment_refs +from app.services.sample_info_progress import ( + apply_handle_payload, + insurance_handled, + storage_cert_handled, +) + +CELL_SOURCE = 'cell_storage' +RESEARCH_SOURCE = 'research_donation' +VALID_SOURCES = (CELL_SOURCE, RESEARCH_SOURCE) + + +def _fmt_dt(value: Optional[datetime]) -> str: + return value.strftime('%Y-%m-%d %H:%M:%S') if value else '' + + +def _parse_dt(value: Any) -> Optional[datetime]: + if value in (None, ''): + return None + if isinstance(value, datetime): + return value + text = str(value).strip() + for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S'): + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + raise ValueError('办理时间格式无效') + + +def _status_meta(code: str, label: str) -> Dict[str, str]: + normalized = (code or '').strip() + if normalized in ( + 'ycj', + 'collected', + 'js', + 'complete', + 'tg', + 'yrk', + 'handled', + ): + tag_type = 'success' + elif normalized in ('jcz', 'incomplete', 'processing'): + tag_type = 'warning' + elif normalized in ('yzf', 'voided', 'bj', 'btg', 'rejected'): + tag_type = 'danger' + else: + tag_type = 'info' + return { + 'code': normalized, + 'label': label, + 'statusTone': tag_type, + 'tagType': tag_type, + } + + +def _sample_status(status: str) -> Dict[str, str]: + status = (status or '').strip() + label = { + 'ycj': '已采集', + 'jcz': '检测中', + 'yrk': '已入库', + 'yzf': '已作废', + }.get(status, '未采集' if not status else status) + return _status_meta(status or 'none', label) + + +def _collection_progress(status: str) -> Dict[str, str]: + status = (status or '').strip() + if status == 'yzf': + return _status_meta('voided', '已作废') + if status in ('ycj', 'jcz', 'yrk'): + return _status_meta('collected', '已采集') + return _status_meta('uncollected', '未采集') + + +def _apply_joined_collection_scope(query, model, db: Session, ctx: MarketFieldContext): + if ctx.scope_kind == 'specialist': + return query.filter(model.sales_user_id == ctx.user_id) + return query.filter( + or_( + model.region_id.in_(leader_region_ids_query(db, ctx)), + model.region_manager_user_id == ctx.user_id, + ) + ) + + +def _stable_id(source: str, sample_code: str, row_id: str) -> str: + code = (sample_code or '').strip() + return f'{source}:{code}' if code else f'{source}:@id:{row_id}' + + +def _parse_stable_id(sample_id: str) -> Tuple[str, str, bool]: + source, separator, token = (sample_id or '').partition(':') + token = token.strip() + if not separator or source not in VALID_SOURCES or not token: + raise HTTPException(status_code=404, detail='数据不存在或无权访问') + if token.startswith('@id:'): + row_id = token[4:].strip() + if not row_id: + raise HTTPException(status_code=404, detail='数据不存在或无权访问') + return source, row_id, True + return source, token, False + + +def _materialization_order(query, source: str): + if source == CELL_SOURCE: + return query.order_by( + BizProtocolCollectionInfo.create_time.desc(), + BizProtocolCollectionInfo.id.desc(), + BizProtocolPartyCustomer.create_time.desc(), + BizProtocolPartyCustomer.id.desc(), + ) + return query.order_by( + BizResearchDonationCollection.create_time.desc(), + BizResearchDonationCollection.id.desc(), + BizResearchDonationParty.create_time.desc(), + BizResearchDonationParty.id.desc(), + ) + + +def _cell_query(db: Session, ctx: MarketFieldContext, user_info: dict): + query = ( + db.query( + BizProtocolSample, + BizCollectionProtocol, + BizProtocolCollectionInfo, + BizProtocolPartyCustomer, + ) + .join( + BizCollectionProtocol, + and_( + BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id, + BizCollectionProtocol.tenant_no == ctx.tenant_no, + BizCollectionProtocol.del_flag == 0, + ), + ) + .join( + BizProtocolCollectionInfo, + and_( + BizProtocolCollectionInfo.collection_protocol_id == BizCollectionProtocol.id, + BizProtocolCollectionInfo.tenant_no == ctx.tenant_no, + BizProtocolCollectionInfo.del_flag == 0, + ), + ) + .outerjoin( + BizProtocolPartyCustomer, + and_( + BizProtocolPartyCustomer.collection_protocol_id == BizCollectionProtocol.id, + BizProtocolPartyCustomer.tenant_no == ctx.tenant_no, + BizProtocolPartyCustomer.del_flag == 0, + ), + ) + .filter( + BizProtocolSample.tenant_no == ctx.tenant_no, + BizProtocolSample.del_flag == 0, + ) + ) + query = apply_cell_protocol_scope(query, db, ctx, user_info=user_info) + return _apply_joined_collection_scope( + query, BizProtocolCollectionInfo, db, ctx + ) + + +def _research_query(db: Session, ctx: MarketFieldContext, user_info: dict): + query = ( + db.query( + BizResearchDonationSample, + BizResearchDonation, + BizResearchDonationCollection, + BizResearchDonationParty, + ) + .join( + BizResearchDonation, + and_( + BizResearchDonation.id == BizResearchDonationSample.research_donation_id, + BizResearchDonation.tenant_no == ctx.tenant_no, + BizResearchDonation.del_flag == 0, + ), + ) + .join( + BizResearchDonationCollection, + and_( + BizResearchDonationCollection.research_donation_id == BizResearchDonation.id, + BizResearchDonationCollection.tenant_no == ctx.tenant_no, + BizResearchDonationCollection.del_flag == 0, + ), + ) + .outerjoin( + BizResearchDonationParty, + and_( + BizResearchDonationParty.research_donation_id == BizResearchDonation.id, + BizResearchDonationParty.tenant_no == ctx.tenant_no, + BizResearchDonationParty.del_flag == 0, + ), + ) + .filter( + BizResearchDonationSample.tenant_no == ctx.tenant_no, + BizResearchDonationSample.del_flag == 0, + ) + ) + query = apply_donation_scope(query, db, ctx, user_info=user_info) + return _apply_joined_collection_scope( + query, BizResearchDonationCollection, db, ctx + ) + + +def _apply_filters(query, source: str, keyword: Optional[str], status: Optional[str]): + if source == CELL_SOURCE: + sample = BizProtocolSample + parent = BizCollectionProtocol + party = BizProtocolPartyCustomer + else: + sample = BizResearchDonationSample + parent = BizResearchDonation + party = BizResearchDonationParty + text = (keyword or '').strip() + if text: + like = f'%{text}%' + number_col = ( + BizCollectionProtocol.protocol_no + if source == CELL_SOURCE + else BizResearchDonation.donation_no + ) + query = query.filter( + or_(sample.sample_code.like(like), number_col.like(like), party.mother_name.like(like)) + ) + normalized_status = (status or '').strip() + if normalized_status and normalized_status not in ('all', 'pending', 'processing', 'completed'): + query = query.filter(sample.sample_status == normalized_status) + elif normalized_status == 'pending': + query = query.filter(or_(sample.sample_status.is_(None), sample.sample_status == '')) + elif normalized_status == 'processing': + query = query.filter(sample.sample_status.in_(('ycj', 'jcz'))) + elif normalized_status == 'completed': + query = query.filter(sample.sample_status == 'yrk') + return query + + +def _identity_query(query, source: str): + sample = BizProtocolSample if source == CELL_SOURCE else BizResearchDonationSample + return query.with_entities( + literal(source).label('source'), + sample.id.label('row_id'), + sample.create_time.label('create_time'), + ).distinct() + + +def _load_rows( + db: Session, + identities: Iterable[Any], + ctx: MarketFieldContext, + user_info: dict, +) -> List[Dict[str, Any]]: + ordered = [(row.source, row.row_id) for row in identities] + cell_ids = [row_id for source, row_id in ordered if source == CELL_SOURCE] + research_ids = [row_id for source, row_id in ordered if source == RESEARCH_SOURCE] + rows_by_key: Dict[Tuple[str, str], Dict[str, Any]] = {} + if cell_ids: + query = _cell_query(db, ctx, user_info).filter( + BizProtocolSample.id.in_(cell_ids) + ) + for sample, parent, collection, party in _materialization_order( + query, CELL_SOURCE + ).all(): + rows_by_key.setdefault((CELL_SOURCE, sample.id), _raw_item( + CELL_SOURCE, sample, parent, collection, party + )) + if research_ids: + query = _research_query(db, ctx, user_info).filter( + BizResearchDonationSample.id.in_(research_ids) + ) + for sample, parent, collection, party in _materialization_order( + query, RESEARCH_SOURCE + ).all(): + rows_by_key.setdefault((RESEARCH_SOURCE, sample.id), _raw_item( + RESEARCH_SOURCE, sample, parent, collection, party + )) + return [rows_by_key[key] for key in ordered if key in rows_by_key] + + +def _raw_item(source: str, sample, parent, collection, party) -> Dict[str, Any]: + number = parent.protocol_no if source == CELL_SOURCE else parent.donation_no + return { + 'id': _stable_id(source, sample.sample_code or '', sample.id), + 'source': source, + 'sourceLabel': '细胞存储' if source == CELL_SOURCE else '科研捐赠', + 'sampleRowId': sample.id, + 'parentId': parent.id, + 'protocolNo': number or '', + 'sampleCode': sample.sample_code or '', + 'sampleTypeId': sample.sample_type_id or '', + 'sampleTypeName': '', + 'sampleStatus': sample.sample_status or '', + 'customerName': party.mother_name if party else '', + 'regionId': collection.region_id or '', + 'hospitalId': collection.hospital_id or '', + 'salesUserId': collection.sales_user_id or '', + 'regionManagerUserId': collection.region_manager_user_id or '', + 'sampleCollectTime': _fmt_dt(collection.sample_collect_time), + 'createTime': _fmt_dt(sample.create_time), + } + + +def _latest_maps( + db: Session, tenant_no: str, items: List[Dict[str, Any]] +) -> Tuple[dict, dict, dict]: + keys = { + (item['source'], (item.get('sampleCode') or '').strip()) + for item in items + if (item.get('sampleCode') or '').strip() + } + if not keys: + return {}, {}, {} + clauses = [ + and_( + BizSampleReception.reception_source == source, + BizSampleReception.sample_code.in_( + [code for key_source, code in keys if key_source == source] + ), + ) + for source in VALID_SOURCES + if any(key_source == source for key_source, _ in keys) + ] + receptions = ( + db.query(BizSampleReception) + .filter( + BizSampleReception.tenant_no == tenant_no, + BizSampleReception.del_flag == 0, + or_(*clauses), + ) + .order_by( + BizSampleReception.reception_time.desc(), + BizSampleReception.create_time.desc(), + BizSampleReception.id.desc(), + ) + .all() + ) + qc_clauses = [ + and_( + BizQcWorkOrder.reception_source == source, + BizQcWorkOrder.sample_code.in_( + [code for key_source, code in keys if key_source == source] + ), + ) + for source in VALID_SOURCES + if any(key_source == source for key_source, _ in keys) + ] + qc_rows = ( + db.query(BizQcWorkOrder) + .filter( + BizQcWorkOrder.tenant_no == tenant_no, + BizQcWorkOrder.del_flag == 0, + BizQcWorkOrder.trigger_source == 'reception', + or_(*qc_clauses), + ) + .order_by( + BizQcWorkOrder.update_time.desc(), + BizQcWorkOrder.create_time.desc(), + BizQcWorkOrder.id.desc(), + ) + .all() + ) + storage_clauses = [ + and_( + BizStorageReleaseReview.reception_source == source, + BizStorageReleaseReview.sample_code.in_( + [code for key_source, code in keys if key_source == source] + ), + ) + for source in VALID_SOURCES + if any(key_source == source for key_source, _ in keys) + ] + storage_rows = ( + db.query(BizStorageReleaseReview) + .filter( + BizStorageReleaseReview.tenant_no == tenant_no, + BizStorageReleaseReview.del_flag == 0, + or_(*storage_clauses), + ) + .order_by( + BizStorageReleaseReview.storage_time.desc(), + BizStorageReleaseReview.update_time.desc(), + BizStorageReleaseReview.create_time.desc(), + BizStorageReleaseReview.id.desc(), + ) + .all() + ) + reception_map = {} + qc_map = {} + storage_map = {} + for row in receptions: + reception_map.setdefault((row.reception_source, (row.sample_code or '').strip()), row) + for row in qc_rows: + qc_map.setdefault((row.reception_source, (row.sample_code or '').strip()), row) + for row in storage_rows: + storage_map.setdefault((row.reception_source, (row.sample_code or '').strip()), row) + return reception_map, qc_map, storage_map + + +def _handle_maps(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> Tuple[dict, dict]: + parent_ids = { + item['parentId'] for item in items if item['source'] == CELL_SOURCE + } + if not parent_ids: + return {}, {} + cert_rows = ( + db.query(BizProtocolStorageCert) + .filter( + BizProtocolStorageCert.tenant_no == tenant_no, + BizProtocolStorageCert.del_flag == 0, + BizProtocolStorageCert.collection_protocol_id.in_(parent_ids), + ) + .order_by( + BizProtocolStorageCert.update_time.desc(), + BizProtocolStorageCert.create_time.desc(), + BizProtocolStorageCert.id.desc(), + ) + .all() + ) + todo_rows = ( + db.query(BizProtocolFullmoonTodo) + .filter( + BizProtocolFullmoonTodo.tenant_no == tenant_no, + BizProtocolFullmoonTodo.del_flag == 0, + BizProtocolFullmoonTodo.collection_protocol_id.in_(parent_ids), + ) + .order_by( + BizProtocolFullmoonTodo.update_time.desc(), + BizProtocolFullmoonTodo.create_time.desc(), + BizProtocolFullmoonTodo.id.desc(), + ) + .all() + ) + cert_map = {} + todo_map = {} + for row in cert_rows: + cert_map.setdefault(row.collection_protocol_id, row) + for row in todo_rows: + todo_map.setdefault(row.collection_protocol_id, row) + return cert_map, todo_map + + +def _handle_summary(row, *, insurance: bool = False) -> Dict[str, Any]: + if row is None: + return { + **_status_meta('pending', '未办理'), + 'handleTime': '', + 'handlerUserId': '', + 'attachments': '', + 'remark': '', + } + attachments = row.insurance_attachments if insurance else row.cert_image_urls + remark = row.handle_remark if insurance else row.remark + handled = insurance_handled(row) if insurance else storage_cert_handled(row) + return { + **_status_meta('handled' if handled else 'pending', '已办理' if handled else '未办理'), + 'handleTime': _fmt_dt(row.handle_time), + 'handlerUserId': row.handler_user_id or '', + 'attachments': attachments or '', + 'remark': remark or '', + } + + +def _enrich(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> None: + type_ids = {item['sampleTypeId'] for item in items if item['sampleTypeId']} + type_map = {} + if type_ids: + type_map = { + row.id: row.type_name or '' + for row in db.query(BizCellSampleType).filter( + BizCellSampleType.id.in_(type_ids), + BizCellSampleType.del_flag == 0, + or_(BizCellSampleType.tenant_no == tenant_no, BizCellSampleType.tenant_no == ''), + ) + } + reception_map, qc_map, storage_map = _latest_maps(db, tenant_no, items) + cert_map, todo_map = _handle_maps(db, tenant_no, items) + for item in items: + item['sampleTypeName'] = type_map.get(item['sampleTypeId'], '') + sample_status = _sample_status(item['sampleStatus']) + collection_progress = _collection_progress(item['sampleStatus']) + item.update( + statusLabel=sample_status['label'], + statusTone=sample_status['statusTone'], + tagType=sample_status['tagType'], + ) + code = (item['sampleCode'] or '').strip() + key = (item['source'], code) + reception = reception_map.get(key) if code else None + qc = qc_map.get(key) if code else None + storage = storage_map.get(key) if code else None + reception_code = (reception.accept_conclusion or '').strip() if reception else 'none' + reception_label = {'js': '已接收', 'bj': '拒收'}.get(reception_code, '未接收') + qc_code = (qc.conclusion or '').strip() if qc else 'incomplete' + if qc and qc.work_order_status == 'yzf': + qc_code = 'yzf' + qc_label = {'tg': '通过', 'btg': '不通过', 'yzf': '已作废'}.get(qc_code, '未完成') + if storage and storage.storage_status == 'yrk': + storage_code, storage_label = 'yrk', '已入库' + elif storage and storage.review_conclusion == 'btg': + storage_code, storage_label = 'rejected', '拒绝入库' + else: + storage_code, storage_label = 'none', '未入库' + item['progress'] = { + 'collection': collection_progress, + 'reception': { + **_status_meta(reception_code, reception_label), + 'recordId': reception.id if reception else '', + 'time': _fmt_dt(reception.reception_time) if reception else '', + }, + 'qc': { + **_status_meta(qc_code, qc_label), + 'recordId': qc.id if qc else '', + 'workOrderNo': qc.work_order_no if qc else '', + }, + 'storage': { + **_status_meta(storage_code, storage_label), + 'recordId': storage.id if storage else '', + 'time': _fmt_dt(storage.storage_time) if storage else '', + }, + } + if item['source'] == CELL_SOURCE: + item['storageCert'] = _handle_summary(cert_map.get(item['parentId'])) + item['insurance'] = _handle_summary( + todo_map.get(item['parentId']), insurance=True + ) + + +def list_samples( + db: Session, + *, + ctx: MarketFieldContext, + user_info: dict, + page: int = 1, + page_size: int = 10, + keyword: Optional[str] = None, + status: Optional[str] = None, + source: Optional[str] = None, +) -> Tuple[List[Dict[str, Any]], int]: + page = max(1, int(page or 1)) + page_size = min(max(1, int(page_size or 10)), 100) + identities = [] + if source in (None, '', CELL_SOURCE): + cell = _apply_filters(_cell_query(db, ctx, user_info), CELL_SOURCE, keyword, status) + identities.append(_identity_query(cell, CELL_SOURCE)) + if source in (None, '', RESEARCH_SOURCE): + research = _apply_filters( + _research_query(db, ctx, user_info), RESEARCH_SOURCE, keyword, status + ) + identities.append(_identity_query(research, RESEARCH_SOURCE)) + if not identities: + return [], 0 + combined = identities[0].union_all(*identities[1:]).subquery() + total = db.query(combined).count() + page_rows = ( + db.query(combined) + .order_by(combined.c.create_time.desc(), combined.c.row_id.desc(), combined.c.source.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + items = _load_rows(db, page_rows, ctx, user_info) + _enrich(db, ctx.tenant_no, items) + return items, total + + +def _cell_sample_lookup_query( + db: Session, + *, + token: str, + by_row_id: bool, + ctx: MarketFieldContext, + user_info: dict, + for_update: bool = False, +): + query = _cell_query(db, ctx, user_info) + if by_row_id: + query = query.filter( + BizProtocolSample.id == token, + or_( + BizProtocolSample.sample_code.is_(None), + BizProtocolSample.sample_code == '', + ), + ) + else: + query = query.filter(BizProtocolSample.sample_code == token) + query = query.order_by( + BizProtocolSample.create_time.desc(), + BizProtocolSample.id.desc(), + BizProtocolCollectionInfo.create_time.desc(), + BizProtocolCollectionInfo.id.desc(), + BizProtocolPartyCustomer.create_time.desc(), + BizProtocolPartyCustomer.id.desc(), + ) + if for_update: + query = query.populate_existing().with_for_update() + return query + + +def _find_sample( + db: Session, + sample_id: str, + ctx: MarketFieldContext, + user_info: dict, + *, + for_update: bool = False, +) -> Dict[str, Any]: + source, token, by_row_id = _parse_stable_id(sample_id) + if source == CELL_SOURCE: + row = _cell_sample_lookup_query( + db, + token=token, + by_row_id=by_row_id, + ctx=ctx, + user_info=user_info, + for_update=for_update, + ).first() + else: + query = _research_query(db, ctx, user_info) + if by_row_id: + query = query.filter( + BizResearchDonationSample.id == token, + or_( + BizResearchDonationSample.sample_code.is_(None), + BizResearchDonationSample.sample_code == '', + ), + ) + else: + query = query.filter(BizResearchDonationSample.sample_code == token) + query = query.order_by( + BizResearchDonationSample.create_time.desc(), + BizResearchDonationSample.id.desc(), + BizResearchDonationCollection.create_time.desc(), + BizResearchDonationCollection.id.desc(), + BizResearchDonationParty.create_time.desc(), + BizResearchDonationParty.id.desc(), + ) + if for_update: + query = query.populate_existing().with_for_update() + row = query.first() + if row is None: + raise HTTPException(status_code=404, detail='数据不存在或无权访问') + return _raw_item(source, *row) + + +def get_sample_detail( + db: Session, + *, + sample_id: str, + ctx: MarketFieldContext, + user_info: dict, +) -> Dict[str, Any]: + item = _find_sample(db, sample_id, ctx, user_info) + _enrich(db, ctx.tenant_no, [item]) + progress = item.pop('progress') + storage_cert = item.pop('storageCert', None) + insurance = item.pop('insurance', None) + sections = [ + { + 'key': 'basic', + 'title': '基本信息', + 'fields': [ + {'label': '业务来源', 'value': item['sourceLabel']}, + {'label': '协议编号', 'value': item['protocolNo']}, + {'label': '样本编码', 'value': item['sampleCode']}, + {'label': '样本类型', 'value': item['sampleTypeName']}, + ], + }, + { + 'key': 'progress', + 'title': '办理进度', + 'fields': [ + {'label': '采集', 'value': progress['collection']['label']}, + {'label': '接收', 'value': progress['reception']['label']}, + {'label': '质检', 'value': progress['qc']['label']}, + {'label': '入库', 'value': progress['storage']['label']}, + ], + }, + ] + if item['source'] == CELL_SOURCE: + sections.append( + { + 'key': 'market-handles', + 'title': '市场办理', + 'storageCert': storage_cert, + 'insurance': insurance, + } + ) + actions = [] + if item['source'] == CELL_SOURCE and item['sampleCode']: + actions = [ + {'key': 'storageCert', 'label': '办理储存证'}, + {'key': 'insurance', 'label': '办理保险'}, + ] + return {'id': item['id'], 'card': item, 'progress': progress, 'sections': sections, 'actions': actions} + + +def _eligible_region_staff_query( + db: Session, tenant_no: str, region_id: str +): + return ( + db.query(SysUser) + .join( + BizCollectionRegionStaff, + BizCollectionRegionStaff.user_id == SysUser.id, + ) + .filter( + BizCollectionRegionStaff.region_id == region_id, + BizCollectionRegionStaff.tenant_no == tenant_no, + BizCollectionRegionStaff.del_flag == 0, + SysUser.tenant_no == tenant_no, + SysUser.status == '1', + SysUser.del_flag == 0, + ) + ) + + +def get_sample_form_options( + db: Session, + *, + sample_id: str, + ctx: MarketFieldContext, + user_info: dict, +) -> Dict[str, List[Dict[str, str]]]: + item = _find_sample(db, sample_id, ctx, user_info) + if item['source'] != CELL_SOURCE or not item['sampleCode']: + raise ValueError('该样本不支持办理人员选择') + users = ( + _eligible_region_staff_query(db, ctx.tenant_no, item['regionId']) + .distinct() + .order_by(SysUser.nick_name.asc(), SysUser.id.asc()) + .all() + ) + return { + 'staff': [ + {'value': str(user.id), 'label': str(user.nick_name or '')} + for user in users + ] + } + + +def _validate_handler( + db: Session, + tenant_no: str, + region_id: str, + handler_user_id: str, +) -> SysUser: + user = ( + _eligible_region_staff_query(db, tenant_no, region_id) + .filter(SysUser.id == handler_user_id) + .first() + ) + if user is None: + raise ValueError('办理人员无效') + return user + + +def _handle_identity_changed(row, normalized: Dict[str, Any]) -> bool: + """全量表单会回传原办理人/时间;只有语义变化时重新校验资格。""" + return ( + str(normalized['handler_user_id'] or '').strip() + != str(row.handler_user_id or '').strip() + or normalized['handle_time'] != row.handle_time + ) + + +def _cell_parent_lock_query( + db: Session, parent_id: str, ctx: MarketFieldContext +): + return ( + db.query(BizCollectionProtocol) + .populate_existing() + .filter( + BizCollectionProtocol.id == parent_id, + BizCollectionProtocol.tenant_no == ctx.tenant_no, + BizCollectionProtocol.del_flag == 0, + ) + .with_for_update() + ) + + +def _lock_cell_parent( + db: Session, parent_id: str, ctx: MarketFieldContext +) -> BizCollectionProtocol: + parent = _cell_parent_lock_query(db, parent_id, ctx).first() + if parent is None: + raise HTTPException(status_code=404, detail='数据不存在或无权访问') + return parent + + +def _handle_child_current_query( + db: Session, + model, + parent_id: str, + ctx: MarketFieldContext, +): + return ( + db.query(model) + .populate_existing() + .filter( + model.collection_protocol_id == parent_id, + model.tenant_no == ctx.tenant_no, + model.del_flag == 0, + ) + .order_by( + model.update_time.desc(), + model.create_time.desc(), + model.id.desc(), + ) + .with_for_update() + ) + + +def _normalize_partial_handle( + row, + fields: Dict[str, Any], + *, + insurance: bool, + handle_time: Optional[datetime], + handler_user_id: str, + attachments: str, + remark: str, +) -> Dict[str, Any]: + if 'handleTime' in fields: + return apply_handle_payload( + handle_time=handle_time, + handler_user_id=handler_user_id, + attachments=attachments, + remark=remark, + ) + if 'handlerUserId' in fields: + if not handler_user_id: + raise ValueError('办理人员不能为空') + handled = ( + insurance_handled(row) if insurance else storage_cert_handled(row) + ) + if not handled: + raise ValueError('记录尚未办理,不能单独设置办理人员') + return { + 'handle_time': row.handle_time, + 'handler_user_id': handler_user_id, + 'attachments': attachments, + 'remark': remark, + 'make_or_todo_status': '1', + 'legacy_date': '', + } + return { + 'handle_time': row.handle_time, + 'handler_user_id': row.handler_user_id, + 'attachments': attachments, + 'remark': remark, + 'make_or_todo_status': '0', + 'legacy_date': '', + } + + +def _write_handle( + db: Session, + *, + sample_id: str, + fields: Dict[str, Any], + ctx: MarketFieldContext, + user_info: dict, + kind: str, +) -> Dict[str, Any]: + source, _, by_row_id = _parse_stable_id(sample_id) + if source != CELL_SOURCE or by_row_id: + raise ValueError('该样本不支持此办理操作') + item = _find_sample(db, sample_id, ctx, user_info) + if not item['sampleCode']: + raise ValueError('该样本不支持此办理操作') + initial_parent_id = item['parentId'] + try: + _lock_cell_parent(db, initial_parent_id, ctx) + item = _find_sample( + db, + sample_id, + ctx, + user_info, + for_update=True, + ) + if item['parentId'] != initial_parent_id: + raise HTTPException( + status_code=404, detail='数据不存在或无权访问' + ) + except Exception: + db.rollback() + raise + now = datetime.now() + create_dept_id = str( + user_info.get('dept_id') or user_info.get('deptId') or user_info.get('region_id') or '' + ) + try: + if kind == 'storage_cert': + row = _handle_child_current_query( + db, BizProtocolStorageCert, item['parentId'], ctx + ).first() + creating_handle = row is None + if row is None: + row = BizProtocolStorageCert( + id=str(uuid.uuid4()), + collection_protocol_id=item['parentId'], + tenant_no=ctx.tenant_no, + create_by=ctx.user_id, + create_dept_id=create_dept_id, + create_time=now, + del_flag=0, + ) + db.add(row) + handle_time = ( + _parse_dt(fields.get('handleTime')) + if 'handleTime' in fields + else row.handle_time + ) + handler_user_id = ( + str(fields.get('handlerUserId') or '').strip() + if 'handlerUserId' in fields + else str(row.handler_user_id or '').strip() + ) + attachments = ( + str(fields.get('attachments') or '').strip() + if 'attachments' in fields + else str(row.cert_image_urls or '').strip() + ) + if 'attachments' in fields: + attachments = validate_attachment_refs( + db, + attachments, + module='sample', + ctx=ctx, + creating=creating_handle, + existing_refs=row.cert_image_urls, + business_id=row.id, + ) + remark = ( + str(fields.get('remark') or '').strip() + if 'remark' in fields + else str(row.remark or '').strip() + ) + normalized = _normalize_partial_handle( + row, + fields, + insurance=False, + handle_time=handle_time, + handler_user_id=handler_user_id, + attachments=attachments, + remark=remark, + ) + should_validate_handler = creating_handle or _handle_identity_changed( + row, normalized + ) + handler = ( + _validate_handler( + db, + ctx.tenant_no, + item['regionId'], + normalized['handler_user_id'], + ) + if normalized['handler_user_id'] and should_validate_handler + else None + ) + row.handle_time = normalized['handle_time'] + row.handler_user_id = normalized['handler_user_id'] + row.cert_image_urls = normalized['attachments'] + row.remark = normalized['remark'] + if 'handleTime' in fields: + row.make_status = normalized['make_or_todo_status'] + row.make_date = normalized['legacy_date'] or None + else: + row = _handle_child_current_query( + db, BizProtocolFullmoonTodo, item['parentId'], ctx + ).first() + creating_handle = row is None + if row is None: + row = BizProtocolFullmoonTodo( + id=str(uuid.uuid4()), + collection_protocol_id=item['parentId'], + tenant_no=ctx.tenant_no, + create_by=ctx.user_id, + create_dept_id=create_dept_id, + create_time=now, + del_flag=0, + ) + db.add(row) + handle_time = ( + _parse_dt(fields.get('handleTime')) + if 'handleTime' in fields + else row.handle_time + ) + handler_user_id = ( + str(fields.get('handlerUserId') or '').strip() + if 'handlerUserId' in fields + else str(row.handler_user_id or '').strip() + ) + attachments = ( + str(fields.get('attachments') or '').strip() + if 'attachments' in fields + else str(row.insurance_attachments or '').strip() + ) + if 'attachments' in fields: + attachments = validate_attachment_refs( + db, + attachments, + module='sample', + ctx=ctx, + creating=creating_handle, + existing_refs=row.insurance_attachments, + business_id=row.id, + ) + remark = ( + str(fields.get('remark') or '').strip() + if 'remark' in fields + else str(row.handle_remark or '').strip() + ) + normalized = _normalize_partial_handle( + row, + fields, + insurance=True, + handle_time=handle_time, + handler_user_id=handler_user_id, + attachments=attachments, + remark=remark, + ) + should_validate_handler = creating_handle or _handle_identity_changed( + row, normalized + ) + handler = ( + _validate_handler( + db, + ctx.tenant_no, + item['regionId'], + normalized['handler_user_id'], + ) + if normalized['handler_user_id'] and should_validate_handler + else None + ) + row.handle_time = normalized['handle_time'] + row.handler_user_id = normalized['handler_user_id'] + row.insurance_attachments = normalized['attachments'] + row.handle_remark = normalized['remark'] + if 'handleTime' in fields: + row.todo_status = normalized['make_or_todo_status'] + row.handle_date = normalized['legacy_date'] or None + row.update_by = ctx.user_id + row.update_time = now + db.flush() + db.commit() + db.refresh(row) + except Exception: + db.rollback() + raise + data = _handle_summary(row, insurance=kind == 'insurance') + data['handlerName'] = handler.nick_name if handler else '' + return data + + +def update_storage_cert(db: Session, **kwargs) -> Dict[str, Any]: + return _write_handle(db, kind='storage_cert', **kwargs) + + +def update_insurance(db: Session, **kwargs) -> Dict[str, Any]: + return _write_handle(db, kind='insurance', **kwargs) diff --git a/backend/app/services/web_auth_roles.py b/backend/app/services/web_auth_roles.py index fb5232ba..3338f670 100644 --- a/backend/app/services/web_auth_roles.py +++ b/backend/app/services/web_auth_roles.py @@ -50,11 +50,13 @@ def _dedupe_role_id_rows(rows) -> List[str]: 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)。""" if not user_id: return [] - tenant = settings.DEFAULT_TENANT_NO + tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip() rows = ( db.query(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) -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)。""" if not user_id: return [] - tenant = settings.DEFAULT_TENANT_NO + tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip() rows = ( db.query(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) -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:直接绑定 ∪ 启用职位关联的启用角色(去重保序)。""" if not user_id: return [] out: List[str] = [] seen: Set[str] = set() - for rid in get_direct_enabled_role_ids(db, user_id) + get_post_inherited_role_ids( - db, user_id + for rid in get_direct_enabled_role_ids( + db, user_id, tenant_no=tenant_no + ) + get_post_inherited_role_ids( + db, user_id, tenant_no=tenant_no ): if rid not in seen: seen.add(rid) @@ -282,12 +290,14 @@ def list_user_ids_with_any_effective_role_ids( 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 返回启用角色名称(保序、去重)。""" ordered = list(dict.fromkeys([str(r).strip() for r in (role_ids or []) if r])) if not ordered: return [] - tenant = settings.DEFAULT_TENANT_NO + tenant = (tenant_no or settings.DEFAULT_TENANT_NO).strip() rows = ( db.query(SysRole.id, SysRole.role_name) .filter( @@ -313,9 +323,15 @@ def get_role_names_by_ids(db: Session, role_ids: List[str]) -> List[str]: 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 列表(含职位继承)。""" - 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: @@ -329,9 +345,11 @@ def get_user_role_names(db: Session, user_id: str) -> list: 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)。""" - names = _active_role_name_rows(db, user_id) + names = _active_role_name_rows(db, user_id, tenant_no=tenant_no) if not names: return NO_ROLE_WEB_ROLE 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" -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)。 注:不过滤 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: 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: return set() - tenant = settings.DEFAULT_TENANT_NO try: q = ( db.query(SysMenu.perms, SysMenu.id) diff --git a/backend/tests/test_market_field_scope.py b/backend/tests/test_market_field_scope.py new file mode 100644 index 00000000..a7254f96 --- /dev/null +++ b/backend/tests/test_market_field_scope.py @@ -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, + ) diff --git a/backend/tests/test_market_freight_scope.py b/backend/tests/test_market_freight_scope.py new file mode 100644 index 00000000..9415c448 --- /dev/null +++ b/backend/tests/test_market_freight_scope.py @@ -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 diff --git a/backend/tests/test_market_mobile_menu_sql.py b/backend/tests/test_market_mobile_menu_sql.py new file mode 100644 index 00000000..0ebfe92b --- /dev/null +++ b/backend/tests/test_market_mobile_menu_sql.py @@ -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()) diff --git a/backend/tests/test_market_mobile_permissions.py b/backend/tests/test_market_mobile_permissions.py new file mode 100644 index 00000000..8bb341c4 --- /dev/null +++ b/backend/tests/test_market_mobile_permissions.py @@ -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 diff --git a/backend/tests/test_market_payment_api.py b/backend/tests/test_market_payment_api.py new file mode 100644 index 00000000..d9be7822 --- /dev/null +++ b/backend/tests/test_market_payment_api.py @@ -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 diff --git a/backend/tests/test_market_payment_indexes_sql.py b/backend/tests/test_market_payment_indexes_sql.py new file mode 100644 index 00000000..f699c63f --- /dev/null +++ b/backend/tests/test_market_payment_indexes_sql.py @@ -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 diff --git a/backend/tests/test_market_protocol_api.py b/backend/tests/test_market_protocol_api.py new file mode 100644 index 00000000..095ceec7 --- /dev/null +++ b/backend/tests/test_market_protocol_api.py @@ -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 diff --git a/backend/tests/test_market_sample_api.py b/backend/tests/test_market_sample_api.py new file mode 100644 index 00000000..1d458536 --- /dev/null +++ b/backend/tests/test_market_sample_api.py @@ -0,0 +1,1683 @@ +from datetime import datetime, timedelta + +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, + BizCollectionProtocol, + BizCollectionRegionStaff, + BizProtocolCollectionInfo, + BizProtocolFullmoonTodo, + BizProtocolPartyCustomer, + BizProtocolSample, + BizProtocolStorageCert, + BizQcWorkOrder, + BizSampleReception, + BizStorageReleaseReview, + SysPost, + SysPostRole, + SysRole, + SysRoleDataScopeDetail, + SysUser, + SysUserPost, + SysUserRole, +) +from app.models.research_donation import ( + BizResearchDonation, + BizResearchDonationCollection, + BizResearchDonationParty, + BizResearchDonationSample, +) + + +TABLES = [ + BizAttachment.__table__, + BizCollectionProtocol.__table__, + BizProtocolCollectionInfo.__table__, + BizProtocolPartyCustomer.__table__, + BizProtocolSample.__table__, + BizResearchDonation.__table__, + BizResearchDonationCollection.__table__, + BizResearchDonationParty.__table__, + BizResearchDonationSample.__table__, + BizCellSampleType.__table__, + BizSampleReception.__table__, + BizQcWorkOrder.__table__, + BizStorageReleaseReview.__table__, + BizProtocolStorageCert.__table__, + BizProtocolFullmoonTodo.__table__, + BizCollectionRegionStaff.__table__, + SysUser.__table__, + SysRole.__table__, + SysRoleDataScopeDetail.__table__, + SysUserRole.__table__, + SysPost.__table__, + SysPostRole.__table__, + SysUserPost.__table__, +] + + +def _session(): + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(engine, tables=TABLES) + db = sessionmaker(bind=engine)() + for index, name in enumerate( + ( + 'a.jpg', + 'b.jpg', + 'cert-new.jpg', + 'cert-old.jpg', + 'i.jpg', + 'insurance-new.jpg', + 'insurance-old.jpg', + 'keep.jpg', + 'keep-insurance.jpg', + 'old.jpg', + ) + ): + db.add( + BizAttachment( + id=f'att-sample-{index}', + business_type='market:specialist:sample:staging', + original_name=name, + file_ext='jpg', + file_url=name, + file_size=10, + file_type='image/jpeg', + tenant_no='tenant-a', + create_by='u1', + create_dept_id='', + del_flag=0, + ) + ) + db.commit() + return db + + +def _ctx(kind='specialist', user_id='u1'): + from app.services.market_field_scope import MarketFieldContext + + return MarketFieldContext('tenant-a', user_id, kind) + + +def _user(user_id='u1'): + return { + 'user_id': user_id, + 'tenant_no': 'tenant-a', + 'role': 'super_admin', + 'region_id': 'dept-a', + } + + +def _assign_region_staff(db, user_id: str, key: str, region_id: str = 'r1'): + db.add( + BizCollectionRegionStaff( + id=f'staff-{key}', + region_id=region_id, + user_id=user_id, + is_leader=0, + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + + +def test_sample_handle_rejects_external_attachment_reference(): + from app.services.market_sample import update_storage_cert + + db = _session() + _, _, sample = _cell_sample(db, 'attachment-reject') + with pytest.raises(ValueError, match='附件'): + update_storage_cert( + db, + sample_id=f"cell_storage:{sample.sample_code}", + fields={'attachments': 'https://evil.test/a.jpg'}, + ctx=_ctx(), + user_info=_user(), + ) + + +def _cell_sample( + db, + key, + *, + sample_code=None, + sales='u1', + manager='m1', + region='r1', + tenant='tenant-a', + deleted=0, + created=None, + row_id=None, +): + created = created or datetime(2026, 10, 1, 8, 0) + code = f'C-{key}' if sample_code is None else sample_code + protocol = BizCollectionProtocol( + id=f'cp-{key}', + protocol_no=f'CP-{key}', + protocol_status='sxz', + tenant_no=tenant, + create_dept_id='dept-a', + create_time=created, + del_flag=deleted, + ) + collection = BizProtocolCollectionInfo( + id=f'cc-{key}', + collection_protocol_id=protocol.id, + region_id=region, + sales_user_id=sales, + region_manager_user_id=manager, + sample_collect_time=created, + tenant_no=tenant, + create_dept_id='dept-a', + del_flag=deleted, + ) + party = BizProtocolPartyCustomer( + id=f'pc-{key}', + collection_protocol_id=protocol.id, + mother_name=f'客户-{key}', + tenant_no=tenant, + create_dept_id='dept-a', + del_flag=deleted, + ) + sample = BizProtocolSample( + id=row_id or f'cs-{key}', + collection_protocol_id=protocol.id, + sample_code=code, + sample_status='ycj', + tenant_no=tenant, + create_dept_id='dept-a', + create_time=created, + del_flag=deleted, + ) + db.add_all([protocol, collection, party, sample]) + db.commit() + return protocol, collection, sample + + +def _research_sample( + db, + key, + *, + sample_code=None, + sales='u1', + manager='m1', + region='r1', + tenant='tenant-a', + deleted=0, + created=None, +): + created = created or datetime(2026, 10, 1, 8, 0) + code = f'R-{key}' if sample_code is None else sample_code + donation = BizResearchDonation( + id=f'rd-{key}', + donation_no=f'RD-{key}', + donation_status='sxz', + tenant_no=tenant, + create_dept_id='dept-a', + create_time=created, + del_flag=deleted, + ) + collection = BizResearchDonationCollection( + id=f'rc-{key}', + research_donation_id=donation.id, + region_id=region, + sales_user_id=sales, + region_manager_user_id=manager, + sample_collect_time=created, + tenant_no=tenant, + create_dept_id='dept-a', + del_flag=deleted, + ) + party = BizResearchDonationParty( + id=f'rp-{key}', + research_donation_id=donation.id, + mother_name=f'科研客户-{key}', + tenant_no=tenant, + create_dept_id='dept-a', + del_flag=deleted, + ) + sample = BizResearchDonationSample( + id=f'rs-{key}', + research_donation_id=donation.id, + sample_code=code, + sample_status='jcz', + tenant_no=tenant, + create_dept_id='dept-a', + create_time=created, + del_flag=deleted, + ) + db.add_all([donation, collection, party, sample]) + db.commit() + return donation, collection, sample + + +def _downstream(db, key, code, source, *, tenant='tenant-a', when=None, conclusion='tg'): + when = when or datetime(2026, 10, 2, 8, 0) + db.add_all( + [ + BizSampleReception( + id=f'rec-{key}', + protocol_sample_id='stale-row-id', + collection_protocol_id='stale-parent-id', + reception_time=when, + accept_conclusion='js', + reception_source=source, + sample_code=code, + tenant_no=tenant, + create_dept_id='dept-a', + create_time=when, + del_flag=0, + ), + BizQcWorkOrder( + id=f'qc-{key}', + work_order_no=f'QC-{key}', + sample_code=code, + reception_source=source, + trigger_source='reception', + work_order_status='ywc', + conclusion=conclusion, + tenant_no=tenant, + create_dept_id='dept-a', + create_time=when, + update_time=when, + del_flag=0, + ), + BizStorageReleaseReview( + id=f'st-{key}', + sample_code=code, + reception_source=source, + storage_status='yrk', + storage_time=when, + tenant_no=tenant, + create_dept_id='dept-a', + create_time=when, + del_flag=0, + ), + ] + ) + db.commit() + + +def test_sample_list_resolves_parent_scope_and_uses_global_stable_paging(): + from app.services.market_sample import list_samples + + db = _session() + base = datetime(2026, 10, 10, 8, 0) + _cell_sample(db, 'c-old', created=base) + _research_sample(db, 'r-new', created=base + timedelta(hours=3)) + _cell_sample(db, 'c-mid', created=base + timedelta(hours=2)) + _research_sample(db, 'r-mid', created=base + timedelta(hours=1)) + _cell_sample(db, 'other-user', sales='u2', created=base + timedelta(hours=4)) + _cell_sample(db, 'cross-tenant', tenant='tenant-b', created=base + timedelta(hours=5)) + _research_sample(db, 'deleted', deleted=1, created=base + timedelta(hours=6)) + + page1, total = list_samples(db, ctx=_ctx(), user_info=_user(), page=1, page_size=2) + page2, total2 = list_samples(db, ctx=_ctx(), user_info=_user(), page=2, page_size=2) + + assert total == total2 == 4 + assert [item['sampleCode'] for item in page1] == ['R-r-new', 'C-c-mid'] + assert [item['sampleCode'] for item in page2] == ['R-r-mid', 'C-c-old'] + assert page1[0]['id'] == 'research_donation:R-r-new' + assert page1[1]['id'] == 'cell_storage:C-c-mid' + assert all('statusTone' in item and 'tagType' in item for item in page1) + + +def test_manager_and_dual_role_use_the_fixed_route_field_scope(): + from app.services.market_sample import list_samples + + db = _session() + db.add( + BizCollectionRegionStaff( + id='staff-m1', + region_id='r-managed', + user_id='m1', + is_leader=1, + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + db.commit() + _cell_sample(db, 'managed', sales='u2', manager='other', region='r-managed') + _cell_sample(db, 'assigned', sales='u2', manager='m1', region='r-other') + _cell_sample(db, 'hidden', sales='m1', manager='other', region='r-other') + + manager_items, _ = list_samples(db, ctx=_ctx('manager', 'm1'), user_info=_user('m1')) + specialist_items, _ = list_samples( + db, ctx=_ctx('specialist', 'm1'), user_info=_user('m1') + ) + + assert {item['sampleCode'] for item in manager_items} == {'C-managed', 'C-assigned'} + assert {item['sampleCode'] for item in specialist_items} == {'C-hidden'} + + +def test_detail_matches_downstream_only_by_tenant_code_and_source_and_latest(): + from app.services.market_sample import get_sample_detail + + db = _session() + _cell_sample(db, 'same', sample_code='SAME', row_id='rebuilt-row-id') + _research_sample(db, 'same', sample_code='SAME') + old = datetime(2026, 10, 1, 8, 0) + new = datetime(2026, 10, 2, 8, 0) + _downstream(db, 'cell-old', 'SAME', 'cell_storage', when=old, conclusion='btg') + _downstream(db, 'cell-new', 'SAME', 'cell_storage', when=new, conclusion='tg') + _downstream(db, 'research', 'SAME', 'research_donation', when=new, conclusion='btg') + _downstream(db, 'cross', 'SAME', 'cell_storage', tenant='tenant-b', when=new + timedelta(days=1), conclusion='btg') + + cell = get_sample_detail( + db, sample_id='cell_storage:SAME', ctx=_ctx(), user_info=_user() + ) + research = get_sample_detail( + db, sample_id='research_donation:SAME', ctx=_ctx(), user_info=_user() + ) + + assert cell['card']['sampleRowId'] == 'rebuilt-row-id' + assert cell['progress']['qc']['code'] == 'tg' + assert research['progress']['qc']['code'] == 'btg' + assert cell['progress']['reception']['recordId'] == 'rec-cell-new' + assert cell['progress']['storage']['recordId'] == 'st-cell-new' + assert cell['sections'] + + +def test_detail_idor_and_malformed_ids_are_not_exposed(): + from app.services.market_sample import get_sample_detail + + db = _session() + _cell_sample(db, 'other', sales='u2') + _cell_sample(db, 'cross', tenant='tenant-b') + + for sample_id in ('cell_storage:C-other', 'cell_storage:C-cross', 'bad', 'evil:C-other'): + with pytest.raises(HTTPException) as exc: + get_sample_detail(db, sample_id=sample_id, ctx=_ctx(), user_info=_user()) + assert exc.value.status_code == 404 + + +def test_empty_sample_code_has_basic_read_only_detail_without_downstream(): + from app.services.market_sample import get_sample_detail, list_samples + + db = _session() + _cell_sample(db, 'blank', sample_code='') + _downstream(db, 'unrelated', 'C-other', 'cell_storage') + + items, total = list_samples(db, ctx=_ctx(), user_info=_user()) + assert total == 1 + assert items[0]['id'] == 'cell_storage:@id:cs-blank' + detail = get_sample_detail(db, sample_id=items[0]['id'], ctx=_ctx(), user_info=_user()) + assert detail['card']['sampleCode'] == '' + assert detail['actions'] == [] + assert detail['progress']['reception']['code'] == 'none' + + +def test_cell_lightweight_storage_cert_and_insurance_writes_validate_handler(): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + protocol, _, _ = _cell_sample(db, 'write') + db.add_all( + [ + SysUser( + id='handler', + user_no='handler', + nick_name='办理人', + email='', + phone_no='', + status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizCollectionRegionStaff( + id='staff-handler-write', + region_id='r1', + user_id='handler', + is_leader=0, + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + SysUser( + id='disabled', + user_no='disabled', + nick_name='停用人', + email='', + phone_no='', + status='0', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + ] + ) + db.commit() + when = '2026-10-11 09:30:00' + cert = update_storage_cert( + db, + sample_id='cell_storage:C-write', + fields={ + 'handleTime': when, + 'handlerUserId': 'handler', + 'attachments': 'a.jpg,b.jpg', + 'remark': '证书备注', + }, + ctx=_ctx(), + user_info=_user(), + ) + insurance = update_insurance( + db, + sample_id='cell_storage:C-write', + fields={ + 'handleTime': when, + 'handlerUserId': 'handler', + 'attachments': 'i.jpg', + 'remark': '保险备注', + }, + ctx=_ctx(), + user_info=_user(), + ) + + cert_row = db.query(BizProtocolStorageCert).filter_by(collection_protocol_id=protocol.id).one() + todo_row = db.query(BizProtocolFullmoonTodo).filter_by(collection_protocol_id=protocol.id).one() + assert cert['handlerName'] == insurance['handlerName'] == '办理人' + assert cert_row.cert_image_urls == 'a.jpg,b.jpg' + assert cert_row.remark == '证书备注' + assert todo_row.insurance_attachments == 'i.jpg' + assert todo_row.handle_remark == '保险备注' + + with pytest.raises(ValueError, match='办理人员'): + update_storage_cert( + db, + sample_id='cell_storage:C-write', + fields={'handlerUserId': 'disabled'}, + ctx=_ctx(), + user_info=_user(), + ) + + +def test_sample_form_options_return_only_enabled_staff_in_scoped_document_region(): + from app.services.market_sample import get_sample_form_options + + db = _session() + _cell_sample(db, 'handler-options', region='r1') + db.add_all( + [ + SysUser(id='eligible', user_no='eligible', nick_name='可办理', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='other-region', user_no='other-region', nick_name='其他地区', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='disabled-staff', user_no='disabled-staff', nick_name='停用人员', email='', phone_no='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='cross-tenant-staff', user_no='cross-tenant-staff', nick_name='跨租户', email='', phone_no='', status='1', tenant_no='tenant-b', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='region-eligible', region_id='r1', user_id='eligible', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='region-other', region_id='r2', user_id='other-region', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='region-disabled', region_id='r1', user_id='disabled-staff', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='region-cross', region_id='r1', user_id='cross-tenant-staff', is_leader=0, tenant_no='tenant-b', create_dept_id='', del_flag=0), + ] + ) + db.commit() + + result = get_sample_form_options( + db, + sample_id='cell_storage:C-handler-options', + ctx=_ctx(), + user_info=_user(), + ) + + assert result == {'staff': [{'value': 'eligible', 'label': '可办理'}]} + + +def test_sample_handler_must_be_enabled_staff_in_current_document_region(): + from app.services.market_sample import update_storage_cert + + db = _session() + _cell_sample(db, 'handler-region', region='r1') + db.add_all( + [ + SysUser(id='wrong-region', user_no='wrong-region', nick_name='错区人员', email='', phone_no='', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='wrong-region-link', region_id='r2', user_id='wrong-region', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0), + ] + ) + db.commit() + + with pytest.raises(ValueError, match='办理人员'): + update_storage_cert( + db, + sample_id='cell_storage:C-handler-region', + fields={'handleTime': '2026-10-11 09:30:00', 'handlerUserId': 'wrong-region'}, + ctx=_ctx(), + user_info=_user(), + ) + + assert db.query(BizProtocolStorageCert).count() == 0 + + +def test_research_and_empty_code_samples_are_read_only_and_scope_is_rechecked(): + from app.services.market_sample import update_storage_cert + + db = _session() + _research_sample(db, 'readonly') + _, collection, _ = _cell_sample(db, 'moved') + _cell_sample(db, 'blank', sample_code='') + + for sample_id in ('research_donation:R-readonly', 'cell_storage:@id:cs-blank'): + with pytest.raises(ValueError, match='不支持'): + update_storage_cert( + db, + sample_id=sample_id, + fields={'remark': 'x'}, + ctx=_ctx(), + user_info=_user(), + ) + + collection.sales_user_id = 'u2' + db.commit() + with pytest.raises(HTTPException) as exc: + update_storage_cert( + db, + sample_id='cell_storage:C-moved', + fields={'remark': 'x'}, + ctx=_ctx(), + user_info=_user(), + ) + assert exc.value.status_code == 404 + + +def test_lightweight_dtos_forbid_status_qc_production_lab_and_sample_fields(): + from app.schemas.market_mobile import ( + MarketSampleInsuranceBody, + MarketSampleStorageCertBody, + ) + + allowed = { + 'handleTime': '2026-10-11 09:30:00', + 'handlerUserId': 'u1', + 'attachments': 'a.jpg', + 'remark': 'ok', + } + MarketSampleStorageCertBody.model_validate(allowed) + MarketSampleInsuranceBody.model_validate(allowed) + + for forbidden in ( + 'sampleCode', + 'sampleStatus', + 'storageStatus', + 'qcConclusion', + 'productionResult', + 'labStorageCode', + 'makeStatus', + 'todoStatus', + 'tenantNo', + ): + with pytest.raises(ValidationError): + MarketSampleStorageCertBody.model_validate({**allowed, forbidden: 'bad'}) + + +def test_write_rolls_back_when_flush_fails(monkeypatch): + from app.services.market_sample import update_storage_cert + + db = _session() + _cell_sample(db, 'rollback') + original_flush = db.flush + + def fail_flush(*args, **kwargs): + original_flush(*args, **kwargs) + raise RuntimeError('injected failure') + + monkeypatch.setattr(db, 'flush', fail_flush) + with pytest.raises(RuntimeError, match='injected failure'): + update_storage_cert( + db, + sample_id='cell_storage:C-rollback', + fields={'remark': 'must rollback'}, + ctx=_ctx(), + user_info=_user(), + ) + monkeypatch.setattr(db, 'flush', original_flush) + assert db.query(BizProtocolStorageCert).count() == 0 + + +def test_list_batch_enrichment_has_bounded_query_count(): + from app.services.market_sample import list_samples + + db = _session() + for index in range(8): + source = 'cell_storage' if index % 2 == 0 else 'research_donation' + if source == 'cell_storage': + _cell_sample(db, f'q{index}') + code = f'C-q{index}' + else: + _research_sample(db, f'q{index}') + code = f'R-q{index}' + _downstream(db, f'q{index}', code, source) + + statements = [] + + def count_statement(*args): + statements.append(args[2]) + + event.listen(db.bind, 'before_cursor_execute', count_statement) + try: + items, total = list_samples(db, ctx=_ctx(), user_info=_user(), page_size=20) + finally: + event.remove(db.bind, 'before_cursor_execute', count_statement) + + assert total == len(items) == 8 + assert len(statements) <= 16 + + +def test_market_sample_routes_are_fixed_and_have_no_lab_transitions(): + from app.api.market_mobile import router + + paths = {route.path for route in router.routes} + for kind in ('specialist', 'manager'): + base = f'/mini/market/{kind}/samples' + assert base in paths + assert f'{base}/{{sample_id}}' in paths + assert f'{base}/{{sample_id}}/storage-cert' in paths + assert f'{base}/{{sample_id}}/insurance' in paths + assert not any( + path.startswith(base) and any(word in path for word in ('receive', '/qc', 'production', '/storage-in')) + for path in paths + ) + + +def test_global_identity_query_deduplicates_legacy_duplicate_child_rows(): + from app.services.market_sample import list_samples + + db = _session() + protocol, _, _ = _cell_sample(db, 'duplicate-child') + db.add( + BizProtocolCollectionInfo( + id='cc-duplicate-child-legacy', + collection_protocol_id=protocol.id, + region_id='r1', + sales_user_id='u1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + db.commit() + + items, total = list_samples(db, ctx=_ctx(), user_info=_user()) + + assert total == len(items) == 1 + + +def test_partial_handle_update_preserves_omitted_lightweight_fields(): + from app.services.market_sample import update_storage_cert + + db = _session() + _cell_sample(db, 'partial') + db.add( + SysUser( + id='handler', + user_no='handler', + nick_name='办理人', + email='', + phone_no='', + status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + _assign_region_staff(db, 'handler', 'partial') + db.commit() + update_storage_cert( + db, + sample_id='cell_storage:C-partial', + fields={ + 'handleTime': '2026-10-11 09:30:00', + 'handlerUserId': 'handler', + 'attachments': 'old.jpg', + 'remark': '旧备注', + }, + ctx=_ctx(), + user_info=_user(), + ) + + update_storage_cert( + db, + sample_id='cell_storage:C-partial', + fields={'remark': '新备注'}, + ctx=_ctx(), + user_info=_user(), + ) + row = db.query(BizProtocolStorageCert).one() + assert _fmt(row.handle_time) == '2026-10-11 09:30:00' + assert row.handler_user_id == 'handler' + assert row.cert_image_urls == 'old.jpg' + assert row.remark == '新备注' + + +def _fmt(value): + return value.strftime('%Y-%m-%d %H:%M:%S') if value else '' + + +def test_materialized_collection_row_itself_must_be_inside_fixed_scope(): + from app.services.market_sample import list_samples + + db = _session() + protocol, visible, _ = _cell_sample(db, 'mixed-collection', region='visible-region') + db.add( + BizProtocolCollectionInfo( + id='cc-mixed-hidden', + collection_protocol_id=protocol.id, + region_id='hidden-region', + sales_user_id='u2', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + db.commit() + + items, total = list_samples(db, ctx=_ctx(), user_info=_user()) + + assert total == 1 + assert items[0]['regionId'] == visible.region_id + + +def test_progress_uses_normalized_codes_and_explicit_semantic_tones(): + from app.services.market_sample import get_sample_detail + + db = _session() + _cell_sample(db, 'tone') + detail = get_sample_detail( + db, sample_id='cell_storage:C-tone', ctx=_ctx(), user_info=_user() + ) + + assert detail['progress']['collection'] == { + 'code': 'collected', + 'label': '已采集', + 'statusTone': 'success', + 'tagType': 'success', + } + assert detail['progress']['reception']['statusTone'] == 'info' + assert detail['progress']['qc']['statusTone'] == 'warning' + + +def test_legacy_storage_cert_and_insurance_statuses_are_read_as_handled(): + from app.services.market_sample import get_sample_detail + + db = _session() + protocol, _, _ = _cell_sample(db, 'legacy-handled') + protocol_alt, _, _ = _cell_sample(db, 'legacy-handled-alt') + db.add_all( + [ + BizProtocolStorageCert( + id='cert-legacy', + collection_protocol_id=protocol.id, + make_status='0', + make_date='20261001', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolFullmoonTodo( + id='todo-legacy', + collection_protocol_id=protocol.id, + todo_status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolStorageCert( + id='cert-legacy-status', + collection_protocol_id=protocol_alt.id, + make_status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolFullmoonTodo( + id='todo-legacy-date', + collection_protocol_id=protocol_alt.id, + todo_status='0', + handle_date='20261002', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + ] + ) + db.commit() + + detail = get_sample_detail( + db, + sample_id='cell_storage:C-legacy-handled', + ctx=_ctx(), + user_info=_user(), + ) + + section = next(item for item in detail['sections'] if item['key'] == 'market-handles') + assert section['storageCert']['code'] == 'handled' + assert section['storageCert']['statusTone'] == 'success' + assert section['insurance']['code'] == 'handled' + assert section['insurance']['statusTone'] == 'success' + alt = get_sample_detail( + db, + sample_id='cell_storage:C-legacy-handled-alt', + ctx=_ctx(), + user_info=_user(), + ) + alt_section = next( + item for item in alt['sections'] if item['key'] == 'market-handles' + ) + assert alt_section['storageCert']['code'] == 'handled' + assert alt_section['insurance']['code'] == 'handled' + + +def test_setting_handle_time_synchronizes_legacy_status_and_date_for_both_actions(): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + _cell_sample(db, 'sync-set') + db.add( + SysUser( + id='handler', + user_no='handler', + nick_name='办理人', + email='', + phone_no='', + status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + _assign_region_staff(db, 'handler', 'sync-set') + db.commit() + fields = { + 'handleTime': '2026-10-11 09:30:00', + 'handlerUserId': 'handler', + 'attachments': 'keep.jpg', + 'remark': '保留备注', + } + + update_storage_cert( + db, + sample_id='cell_storage:C-sync-set', + fields=fields, + ctx=_ctx(), + user_info=_user(), + ) + update_insurance( + db, + sample_id='cell_storage:C-sync-set', + fields={**fields, 'attachments': 'keep-insurance.jpg'}, + ctx=_ctx(), + user_info=_user(), + ) + + cert = db.query(BizProtocolStorageCert).one() + todo = db.query(BizProtocolFullmoonTodo).one() + assert (cert.make_status, cert.make_date) == ('1', '20261011') + assert (todo.todo_status, todo.handle_date) == ('1', '20261011') + + +def test_clearing_handle_time_clears_handler_and_legacy_status_but_preserves_content(): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + _cell_sample(db, 'sync-clear') + db.add( + SysUser( + id='handler', + user_no='handler', + nick_name='办理人', + email='', + phone_no='', + status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + _assign_region_staff(db, 'handler', 'sync-clear') + db.commit() + initial = { + 'handleTime': '2026-10-11 09:30:00', + 'handlerUserId': 'handler', + 'attachments': 'keep.jpg', + 'remark': '保留备注', + } + for updater, attachment in ( + (update_storage_cert, 'keep.jpg'), + (update_insurance, 'keep-insurance.jpg'), + ): + updater( + db, + sample_id='cell_storage:C-sync-clear', + fields={**initial, 'attachments': attachment}, + ctx=_ctx(), + user_info=_user(), + ) + updater( + db, + sample_id='cell_storage:C-sync-clear', + fields={'handleTime': None, 'handlerUserId': 'handler'}, + ctx=_ctx(), + user_info=_user(), + ) + + cert = db.query(BizProtocolStorageCert).one() + todo = db.query(BizProtocolFullmoonTodo).one() + assert (cert.handle_time, cert.handler_user_id, cert.make_status, cert.make_date) == ( + None, + None, + '0', + None, + ) + assert (todo.handle_time, todo.handler_user_id, todo.todo_status, todo.handle_date) == ( + None, + None, + '0', + None, + ) + assert (cert.cert_image_urls, cert.remark) == ('keep.jpg', '保留备注') + assert (todo.insurance_attachments, todo.handle_remark) == ( + 'keep-insurance.jpg', + '保留备注', + ) + + +def test_partial_insurance_update_preserves_omitted_lightweight_fields(): + from app.services.market_sample import update_insurance + + db = _session() + _cell_sample(db, 'partial-insurance') + db.add( + SysUser( + id='handler', + user_no='handler', + nick_name='办理人', + email='', + phone_no='', + status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + _assign_region_staff(db, 'handler', 'partial-insurance') + db.commit() + update_insurance( + db, + sample_id='cell_storage:C-partial-insurance', + fields={ + 'handleTime': '2026-10-11 09:30:00', + 'handlerUserId': 'handler', + 'attachments': 'old.jpg', + 'remark': '旧备注', + }, + ctx=_ctx(), + user_info=_user(), + ) + + update_insurance( + db, + sample_id='cell_storage:C-partial-insurance', + fields={'remark': '新备注'}, + ctx=_ctx(), + user_info=_user(), + ) + row = db.query(BizProtocolFullmoonTodo).one() + assert _fmt(row.handle_time) == '2026-10-11 09:30:00' + assert row.handler_user_id == 'handler' + assert row.insurance_attachments == 'old.jpg' + assert row.handle_remark == '新备注' + + +def test_partial_update_preserves_legacy_handled_fields_when_time_is_omitted(): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + protocol, _, _ = _cell_sample(db, 'partial-legacy') + db.add_all( + [ + SysUser( + id='handler', + user_no='handler', + nick_name='办理人', + email='', + phone_no='', + status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolStorageCert( + id='cert-partial-legacy', + collection_protocol_id=protocol.id, + make_status='1', + make_date='20261001', + handler_user_id='handler', + cert_image_urls='cert-old.jpg', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolFullmoonTodo( + id='todo-partial-legacy', + collection_protocol_id=protocol.id, + todo_status='1', + handle_date='20261002', + handler_user_id='handler', + insurance_attachments='insurance-old.jpg', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + ] + ) + db.commit() + + update_storage_cert( + db, + sample_id='cell_storage:C-partial-legacy', + fields={'remark': '证书新备注'}, + ctx=_ctx(), + user_info=_user(), + ) + update_insurance( + db, + sample_id='cell_storage:C-partial-legacy', + fields={'remark': '保险新备注'}, + ctx=_ctx(), + user_info=_user(), + ) + + cert = db.query(BizProtocolStorageCert).one() + todo = db.query(BizProtocolFullmoonTodo).one() + assert (cert.make_status, cert.make_date, cert.handler_user_id) == ( + '1', + '20261001', + 'handler', + ) + assert cert.cert_image_urls == 'cert-old.jpg' + assert (todo.todo_status, todo.handle_date, todo.handler_user_id) == ( + '1', + '20261002', + 'handler', + ) + assert todo.insurance_attachments == 'insurance-old.jpg' + + +def test_handler_only_update_changes_legacy_handled_records_without_clearing_status(): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + protocol, _, _ = _cell_sample(db, 'handler-only-legacy') + db.add_all( + [ + SysUser( + id='new-handler', + user_no='new-handler', + nick_name='新办理人', + email='', + phone_no='', + status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolStorageCert( + id='cert-handler-only', + collection_protocol_id=protocol.id, + make_status='1', + make_date='20261001', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolFullmoonTodo( + id='todo-handler-only', + collection_protocol_id=protocol.id, + todo_status='0', + handle_date='20261002', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + ] + ) + _assign_region_staff(db, 'new-handler', 'handler-only-legacy') + db.commit() + + for updater in (update_storage_cert, update_insurance): + result = updater( + db, + sample_id='cell_storage:C-handler-only-legacy', + fields={'handlerUserId': 'new-handler'}, + ctx=_ctx(), + user_info=_user(), + ) + assert result['handlerName'] == '新办理人' + + cert = db.query(BizProtocolStorageCert).one() + todo = db.query(BizProtocolFullmoonTodo).one() + assert (cert.handler_user_id, cert.make_status, cert.make_date) == ( + 'new-handler', + '1', + '20261001', + ) + assert (todo.handler_user_id, todo.todo_status, todo.handle_date) == ( + 'new-handler', + '0', + '20261002', + ) + + +def test_handler_only_update_rejects_truly_unhandled_cert_and_insurance(): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + protocol, _, _ = _cell_sample(db, 'handler-only-unhandled') + db.add_all( + [ + SysUser( + id='handler', + user_no='handler', + nick_name='办理人', + email='', + phone_no='', + status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolStorageCert( + id='cert-unhandled', + collection_protocol_id=protocol.id, + make_status='0', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolFullmoonTodo( + id='todo-unhandled', + collection_protocol_id=protocol.id, + todo_status='0', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + ] + ) + db.commit() + + for updater in (update_storage_cert, update_insurance): + with pytest.raises(ValueError, match='未办理'): + updater( + db, + sample_id='cell_storage:C-handler-only-unhandled', + fields={'handlerUserId': 'handler'}, + ctx=_ctx(), + user_info=_user(), + ) + + +def test_historical_disabled_handler_does_not_block_content_only_updates(): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + protocol, _, _ = _cell_sample(db, 'disabled-history') + db.add_all( + [ + SysUser( + id='disabled-history-handler', + user_no='disabled-history-handler', + nick_name='历史办理人', + email='', + phone_no='', + status='0', + tenant_no='tenant-a', + create_dept_id='', + del_flag=1, + ), + BizProtocolStorageCert( + id='cert-disabled-history', + collection_protocol_id=protocol.id, + handle_time=datetime(2026, 10, 1, 8, 0), + handler_user_id='disabled-history-handler', + make_status='1', + make_date='20261001', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + BizProtocolFullmoonTodo( + id='todo-disabled-history', + collection_protocol_id=protocol.id, + handle_time=datetime(2026, 10, 2, 8, 0), + handler_user_id='disabled-history-handler', + todo_status='1', + handle_date='20261002', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ), + ] + ) + db.commit() + + update_storage_cert( + db, + sample_id='cell_storage:C-disabled-history', + fields={'attachments': 'cert-new.jpg', 'remark': '证书新备注'}, + ctx=_ctx(), + user_info=_user(), + ) + update_insurance( + db, + sample_id='cell_storage:C-disabled-history', + fields={'attachments': 'insurance-new.jpg', 'remark': '保险新备注'}, + ctx=_ctx(), + user_info=_user(), + ) + + cert = db.query(BizProtocolStorageCert).one() + todo = db.query(BizProtocolFullmoonTodo).one() + assert cert.handler_user_id == todo.handler_user_id == 'disabled-history-handler' + assert (cert.cert_image_urls, cert.remark) == ('cert-new.jpg', '证书新备注') + assert (todo.insurance_attachments, todo.handle_remark) == ( + 'insurance-new.jpg', + '保险新备注', + ) + + +@pytest.mark.parametrize('handler_state', ['disabled', 'deleted', 'out-of-region']) +@pytest.mark.parametrize('kind', ['storage-cert', 'insurance']) +def test_full_unchanged_historical_handler_payload_allows_content_update( + handler_state, kind +): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + protocol, _, _ = _cell_sample(db, f'historical-full-{handler_state}-{kind}') + handler_id = f'historical-{handler_state}-{kind}' + handle_time = datetime(2026, 10, 1, 8, 0) + user = SysUser( + id=handler_id, + user_no=handler_id, + nick_name='历史办理人', + email='', + phone_no='', + status='0' if handler_state == 'disabled' else '1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=1 if handler_state == 'deleted' else 0, + ) + staff = BizCollectionRegionStaff( + id=f'staff-{handler_id}', + region_id='r2' if handler_state == 'out-of-region' else 'r1', + user_id=handler_id, + is_leader=0, + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + db.add_all([user, staff]) + if kind == 'storage-cert': + db.add( + BizProtocolStorageCert( + id=f'cert-{handler_id}', + collection_protocol_id=protocol.id, + handle_time=handle_time, + handler_user_id=handler_id, + cert_image_urls='cert-old.jpg', + make_status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + updater = update_storage_cert + attachments = 'cert-new.jpg' + else: + db.add( + BizProtocolFullmoonTodo( + id=f'todo-{handler_id}', + collection_protocol_id=protocol.id, + handle_time=handle_time, + handler_user_id=handler_id, + insurance_attachments='insurance-old.jpg', + todo_status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + updater = update_insurance + attachments = 'insurance-new.jpg' + db.commit() + + result = updater( + db, + sample_id=f'cell_storage:C-historical-full-{handler_state}-{kind}', + fields={ + 'handleTime': '2026-10-01 08:00:00', + 'handlerUserId': handler_id, + 'attachments': attachments, + 'remark': '只改内容', + }, + ctx=_ctx(), + user_info=_user(), + ) + + assert result['handlerUserId'] == handler_id + assert result['handleTime'] == '2026-10-01 08:00:00' + assert result['attachments'] == attachments + assert result['remark'] == '只改内容' + + +@pytest.mark.parametrize('kind', ['storage-cert', 'insurance']) +def test_changed_handle_time_revalidates_historical_ineligible_handler(kind): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + protocol, _, _ = _cell_sample(db, f'historical-time-change-{kind}') + handler_id = f'disabled-time-change-{kind}' + db.add( + SysUser( + id=handler_id, + user_no=handler_id, + nick_name='停用历史办理人', + email='', + phone_no='', + status='0', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + ) + if kind == 'storage-cert': + row = BizProtocolStorageCert( + id=f'cert-{handler_id}', + collection_protocol_id=protocol.id, + handle_time=datetime(2026, 10, 1, 8, 0), + handler_user_id=handler_id, + make_status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + updater = update_storage_cert + else: + row = BizProtocolFullmoonTodo( + id=f'todo-{handler_id}', + collection_protocol_id=protocol.id, + handle_time=datetime(2026, 10, 1, 8, 0), + handler_user_id=handler_id, + todo_status='1', + tenant_no='tenant-a', + create_dept_id='', + del_flag=0, + ) + updater = update_insurance + db.add(row) + db.commit() + + with pytest.raises(ValueError, match='办理人员'): + updater( + db, + sample_id=f'cell_storage:C-historical-time-change-{kind}', + fields={ + 'handleTime': '2026-10-02 08:00:00', + 'handlerUserId': handler_id, + 'remark': '不得写入', + }, + ctx=_ctx(), + user_info=_user(), + ) + + db.expire_all() + assert row.handle_time == datetime(2026, 10, 1, 8, 0) + + +def test_first_handle_parent_lock_compiles_to_mysql_for_update(): + from sqlalchemy.dialects import mysql + + from app.services.market_sample import _cell_parent_lock_query + + db = _session() + query = _cell_parent_lock_query(db, 'cp-lock', _ctx()) + sql = str( + query.statement.compile( + dialect=mysql.dialect(), compile_kwargs={'literal_binds': True} + ) + ) + + assert 'FOR UPDATE' in sql + assert "biz_collection_protocol.id = 'cp-lock'" in sql + assert "biz_collection_protocol.tenant_no = 'tenant-a'" in sql + assert 'biz_collection_protocol.del_flag = 0' in sql + + +def test_repeated_first_handle_requests_reuse_single_child_row(): + from app.services.market_sample import update_insurance, update_storage_cert + + db = _session() + _cell_sample(db, 'single-child') + for updater in (update_storage_cert, update_insurance): + updater( + db, + sample_id='cell_storage:C-single-child', + fields={'remark': 'first'}, + ctx=_ctx(), + user_info=_user(), + ) + updater( + db, + sample_id='cell_storage:C-single-child', + fields={'remark': 'second'}, + ctx=_ctx(), + user_info=_user(), + ) + + assert db.query(BizProtocolStorageCert).count() == 1 + assert db.query(BizProtocolFullmoonTodo).count() == 1 + assert db.query(BizProtocolStorageCert).one().remark == 'second' + assert db.query(BizProtocolFullmoonTodo).one().handle_remark == 'second' + + +def test_voided_progress_uses_danger_tone(): + from app.services.market_sample import get_sample_detail + + db = _session() + _, _, sample = _cell_sample(db, 'voided-tone') + sample.sample_status = 'yzf' + db.commit() + + detail = get_sample_detail( + db, + sample_id='cell_storage:C-voided-tone', + ctx=_ctx(), + user_info=_user(), + ) + + assert detail['progress']['collection']['code'] == 'voided' + assert detail['progress']['collection']['statusTone'] == 'danger' + assert detail['progress']['collection']['tagType'] == 'danger' + + +def test_source_qualified_whitespace_token_returns_uniform_404(): + from app.services.market_sample import get_sample_detail + + db = _session() + for sample_id in ('cell_storage: ', 'research_donation:\t'): + with pytest.raises(HTTPException) as exc: + get_sample_detail( + db, sample_id=sample_id, ctx=_ctx(), user_info=_user() + ) + assert exc.value.status_code == 404 + assert exc.value.detail == '数据不存在或无权访问' + + +def test_duplicate_active_children_use_same_newest_fields_in_list_and_detail(): + from app.services.market_sample import get_sample_detail, list_samples + + db = _session() + protocol, collection, _ = _cell_sample(db, 'deterministic') + collection.create_time = datetime(2026, 10, 1, 8, 0) + original_party = db.query(BizProtocolPartyCustomer).filter_by( + collection_protocol_id=protocol.id + ).one() + original_party.create_time = datetime(2026, 10, 1, 8, 0) + db.add_all( + [ + BizProtocolCollectionInfo( + id='cc-deterministic-new', + collection_protocol_id=protocol.id, + region_id='newest-region', + sales_user_id='u1', + tenant_no='tenant-a', + create_dept_id='', + create_time=datetime(2026, 10, 2, 8, 0), + del_flag=0, + ), + BizProtocolPartyCustomer( + id='pc-deterministic-new', + collection_protocol_id=protocol.id, + mother_name='最新客户', + tenant_no='tenant-a', + create_dept_id='', + create_time=datetime(2026, 10, 2, 8, 0), + del_flag=0, + ), + ] + ) + db.commit() + + items, total = list_samples(db, ctx=_ctx(), user_info=_user()) + detail = get_sample_detail( + db, + sample_id='cell_storage:C-deterministic', + ctx=_ctx(), + user_info=_user(), + ) + + assert total == 1 + assert items[0]['regionId'] == detail['card']['regionId'] == 'newest-region' + assert items[0]['customerName'] == detail['card']['customerName'] == '最新客户' + + +def test_write_reasserts_current_scope_after_parent_lock(monkeypatch): + from app.services import market_sample + + db = _session() + _, collection, _ = _cell_sample(db, 'scope-race') + original_find = market_sample._find_sample + calls = [] + + def move_out_of_scope_after_initial_read(*args, **kwargs): + calls.append(bool(kwargs.get('for_update'))) + item = original_find(*args, **kwargs) + if len(calls) == 1: + collection.sales_user_id = 'u2' + db.commit() + return item + + monkeypatch.setattr( + market_sample, '_find_sample', move_out_of_scope_after_initial_read + ) + + with pytest.raises(HTTPException) as exc: + market_sample.update_storage_cert( + db, + sample_id='cell_storage:C-scope-race', + fields={'remark': 'must not write'}, + ctx=_ctx(), + user_info=_user(), + ) + + assert exc.value.status_code == 404 + assert calls == [False, True] + assert not db.in_transaction() + assert db.query(BizProtocolStorageCert).count() == 0 + + +def test_current_sample_and_child_reads_compile_as_mysql_locking_reads(): + from sqlalchemy.dialects import mysql + + from app.services.market_sample import ( + _cell_sample_lookup_query, + _handle_child_current_query, + ) + + db = _session() + sample_query = _cell_sample_lookup_query( + db, + token='C-locking', + by_row_id=False, + ctx=_ctx(), + user_info=_user(), + for_update=True, + ) + cert_query = _handle_child_current_query( + db, BizProtocolStorageCert, 'cp-locking', _ctx() + ) + todo_query = _handle_child_current_query( + db, BizProtocolFullmoonTodo, 'cp-locking', _ctx() + ) + + def mysql_sql(query): + return str( + query.statement.compile( + dialect=mysql.dialect(), compile_kwargs={'literal_binds': True} + ) + ) + + sample_sql = mysql_sql(sample_query) + cert_sql = mysql_sql(cert_query) + todo_sql = mysql_sql(todo_query) + assert 'FOR UPDATE' in sample_sql + assert 'FOR UPDATE' in cert_sql + assert 'FOR UPDATE' in todo_sql + assert 'ORDER BY biz_protocol_storage_cert.update_time DESC' in cert_sql + assert 'ORDER BY biz_protocol_fullmoon_todo.update_time DESC' in todo_sql + + +def test_write_lock_order_is_parent_then_current_sample_then_child(monkeypatch): + from app.services import market_sample + + db = _session() + _cell_sample(db, 'lock-order') + events = [] + original_parent_lock = market_sample._lock_cell_parent + original_find = market_sample._find_sample + original_child_query = market_sample._handle_child_current_query + + def record_parent(*args, **kwargs): + events.append('parent') + return original_parent_lock(*args, **kwargs) + + def record_find(*args, **kwargs): + events.append('sample-current' if kwargs.get('for_update') else 'sample-initial') + return original_find(*args, **kwargs) + + def record_child(*args, **kwargs): + events.append('child') + return original_child_query(*args, **kwargs) + + monkeypatch.setattr(market_sample, '_lock_cell_parent', record_parent) + monkeypatch.setattr(market_sample, '_find_sample', record_find) + monkeypatch.setattr( + market_sample, '_handle_child_current_query', record_child + ) + + market_sample.update_storage_cert( + db, + sample_id='cell_storage:C-lock-order', + fields={'remark': 'locked'}, + ctx=_ctx(), + user_info=_user(), + ) + + assert events == ['sample-initial', 'parent', 'sample-current', 'child'] diff --git a/backend/tests/test_market_sample_indexes_sql.py b/backend/tests/test_market_sample_indexes_sql.py new file mode 100644 index 00000000..2e85d071 --- /dev/null +++ b/backend/tests/test_market_sample_indexes_sql.py @@ -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 diff --git a/docs/sql/migrations/2026-10-10-market-field-scope-indexes.sql b/docs/sql/migrations/2026-10-10-market-field-scope-indexes.sql new file mode 100644 index 00000000..ead6a651 --- /dev/null +++ b/docs/sql/migrations/2026-10-10-market-field-scope-indexes.sql @@ -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; diff --git a/docs/sql/migrations/2026-10-10-market-mobile-menus.sql b/docs/sql/migrations/2026-10-10-market-mobile-menus.sql new file mode 100644 index 00000000..ce506701 --- /dev/null +++ b/docs/sql/migrations/2026-10-10-market-mobile-menus.sql @@ -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); diff --git a/docs/sql/migrations/2026-10-10-presign-convert-unique.sql b/docs/sql/migrations/2026-10-10-presign-convert-unique.sql new file mode 100644 index 00000000..fc2c356d --- /dev/null +++ b/docs/sql/migrations/2026-10-10-presign-convert-unique.sql @@ -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 ; diff --git a/docs/sql/migrations/2026-10-11-market-freight-indexes.sql b/docs/sql/migrations/2026-10-11-market-freight-indexes.sql new file mode 100644 index 00000000..05f9f67e --- /dev/null +++ b/docs/sql/migrations/2026-10-11-market-freight-indexes.sql @@ -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 ; diff --git a/docs/sql/migrations/2026-10-11-market-payment-indexes.sql b/docs/sql/migrations/2026-10-11-market-payment-indexes.sql new file mode 100644 index 00000000..f94c37b1 --- /dev/null +++ b/docs/sql/migrations/2026-10-11-market-payment-indexes.sql @@ -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; diff --git a/docs/sql/migrations/2026-10-11-market-sample-indexes.sql b/docs/sql/migrations/2026-10-11-market-sample-indexes.sql new file mode 100644 index 00000000..2fd647fa --- /dev/null +++ b/docs/sql/migrations/2026-10-11-market-sample-indexes.sql @@ -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;