diff --git a/backend/app/api/market_mobile.py b/backend/app/api/market_mobile.py new file mode 100644 index 00000000..ffa977dc --- /dev/null +++ b/backend/app/api/market_mobile.py @@ -0,0 +1,1017 @@ +"""市场专员/地区经理移动端专用 API。""" +from __future__ import annotations + +from typing import Literal, Optional + +from fastapi import APIRouter, Depends, File, Query, UploadFile +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.mobile_menu_guard import ( + require_market_menu_code, + user_has_tenant_menu_perm, +) +from app.core.response import error_response, success_response +from app.schemas.market_mobile import ( + MarketFreightCreateBody, + MarketFreightItemsBody, + MarketFreightUpdateBody, + MarketPaymentTransferBody, + MarketPaymentTransferCreateBody, + MarketPresignOrderBody, + MarketPresignTransferBody, + MarketProtocolBody, + MarketSampleInsuranceBody, + MarketSampleStorageCertBody, +) +from app.services import ( + market_freight, + market_payment, + market_presign, + market_protocol, + market_sample, + market_support, +) +from app.services.market_field_scope import MarketFieldContext, ScopeKind +from app.services.web_auth_roles import resolve_web_role_for_user + +router = APIRouter(tags=["移动市场"]) + + +def _context(user: dict, scope_kind: ScopeKind) -> MarketFieldContext: + return MarketFieldContext( + tenant_no=str(user.get("tenant_no") or ""), + user_id=str(user.get("user_id") or ""), + scope_kind=scope_kind, + ) + + +def _has_menu(db: Session, user: dict, permission: str) -> bool: + tenant_no = str(user.get('tenant_no') or '').strip() + user_id = str(user.get('user_id') or '').strip() + return resolve_web_role_for_user( + db, user_id, tenant_no=tenant_no + ) == 'super_admin' or user_has_tenant_menu_perm( + db, + user_id=user_id, + tenant_no=tenant_no, + perm_code=permission, + ) + + +def _register_presign_routes(scope_kind: ScopeKind) -> None: + prefix = f"/mini/market/{scope_kind}/presign-orders" + permission = f"mini:market:{scope_kind}:presign" + permission_guard = require_market_menu_code(permission) + + @router.get(prefix) + async def list_orders( + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + order_status: Optional[str] = Query(None, alias="orderStatus"), + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + ctx = _context(user, scope_kind) + items, total = market_presign.list_presign_orders( + db, + ctx=ctx, + user_info=user, + page=page, + page_size=page_size, + keyword=keyword, + order_status=order_status, + ) + items = [ + market_support.decorate_allowed_actions( + 'presign', item, scope_kind=scope_kind + ) + for item in items + ] + return success_response( + { + "list": items, + "total": total, + "page": page, + "pageSize": page_size, + "allowedActions": market_support.allowed_actions( + 'presign-list', status='', scope_kind=scope_kind + ), + } + ) + + @router.get(f"{prefix}/form-options") + async def presign_form_options( + region_id: Optional[str] = Query(None, alias="regionId"), + business_id: Optional[str] = Query(None, alias="businessId"), + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_support.get_form_options( + db, + module="presign", + ctx=_context(user, scope_kind), + user_info=user, + region_id=region_id, + business_id=business_id, + ) + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.get(f"{prefix}/{{order_id}}") + async def get_order( + order_id: str, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + return success_response( + market_support.decorate_allowed_actions( + 'presign', + market_presign.get_presign_order( + db, + order_id=order_id, + ctx=_context(user, scope_kind), + user_info=user, + ), + scope_kind=scope_kind, + detail=True, + ) + ) + + @router.post(prefix) + async def create_order( + body: MarketPresignOrderBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_presign.create_presign_order( + db, + body=body, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message="创建成功") + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.put(f"{prefix}/{{order_id}}") + async def update_order( + order_id: str, + body: MarketPresignOrderBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_presign.update_presign_order( + db, + order_id=order_id, + body=body, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message="保存成功") + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.delete(f"{prefix}/{{order_id}}") + async def delete_order( + order_id: str, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + market_presign.delete_presign_order( + db, + order_id=order_id, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(message="删除成功") + except PermissionError as exc: + return error_response(403, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + + @router.post(f"{prefix}/{{order_id}}/transfers") + async def create_transfer( + order_id: str, + body: MarketPresignTransferBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_presign.create_presign_transfer( + db, + order_id=order_id, + body=body, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message="创建成功") + except PermissionError as exc: + return error_response(403, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + + transfer_prefix = f"/mini/market/{scope_kind}/presign-transfers" + + @router.put(f"{transfer_prefix}/{{transfer_id}}") + async def update_transfer( + transfer_id: str, + body: MarketPresignTransferBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_presign.update_presign_transfer( + db, + transfer_id=transfer_id, + body=body, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message="保存成功") + except PermissionError as exc: + return error_response(403, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + + @router.delete(f"{transfer_prefix}/{{transfer_id}}") + async def delete_transfer( + transfer_id: str, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + market_presign.delete_presign_transfer( + db, + transfer_id=transfer_id, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(message="删除成功") + except PermissionError as exc: + return error_response(403, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + + @router.post(f"{transfer_prefix}/{{transfer_id}}/submit") + async def submit_transfer( + transfer_id: str, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_presign.submit_presign_transfer( + db, + transfer_id=transfer_id, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message="已提交财务确认") + except PermissionError as exc: + return error_response(403, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + + @router.post(f"{transfer_prefix}/{{transfer_id}}/withdraw") + async def withdraw_transfer( + transfer_id: str, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_presign.withdraw_presign_transfer( + db, + transfer_id=transfer_id, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message="已撤回") + except PermissionError as exc: + return error_response(403, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + + @router.post(f"{prefix}/{{order_id}}/convert") + async def convert_order( + order_id: str, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_presign.convert_presign_order( + db, + order_id=order_id, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message="已转正式协议") + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + +_register_presign_routes("specialist") +_register_presign_routes("manager") + + +def _register_protocol_routes(scope_kind: ScopeKind) -> None: + prefix = f'/mini/market/{scope_kind}/protocols' + permission = f'mini:market:{scope_kind}:protocol' + permission_guard = require_market_menu_code(permission) + + @router.get(prefix) + async def list_protocols( + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100, alias='pageSize'), + keyword: Optional[str] = None, + source: Optional[Literal['cell_storage', 'research_donation']] = None, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + items, total = market_protocol.list_protocols( + db, + ctx=_context(user, scope_kind), + user_info=user, + page=page, + page_size=page_size, + keyword=keyword, + source=source, + ) + items = [ + market_support.decorate_allowed_actions( + 'protocol', item, scope_kind=scope_kind + ) + for item in items + ] + return success_response( + { + 'list': items, + 'total': total, + 'page': page, + 'pageSize': page_size, + 'allowedActions': market_support.allowed_actions( + 'protocol-list', status='', scope_kind=scope_kind + ), + } + ) + + @router.get(f'{prefix}/form-options') + async def protocol_form_options( + region_id: Optional[str] = Query(None, alias='regionId'), + business_id: Optional[str] = Query(None, alias='businessId'), + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_support.get_form_options( + db, + module='protocol', + ctx=_context(user, scope_kind), + user_info=user, + region_id=region_id, + business_id=business_id, + ) + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.get(f'{prefix}/{{protocol_id}}') + async def get_protocol( + protocol_id: str, + source: Optional[ + Literal['cell_storage', 'research_donation'] + ] = Query(None), + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + return success_response( + market_support.decorate_allowed_actions( + 'protocol', + market_protocol.get_protocol( + db, + source=source, + protocol_id=protocol_id, + ctx=_context(user, scope_kind), + user_info=user, + ), + scope_kind=scope_kind, + ) + ) + + @router.post(prefix) + async def create_protocol( + body: MarketProtocolBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_protocol.create_protocol( + db, + body=body, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message='创建成功') + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.put(f'{prefix}/{{protocol_id}}') + async def update_protocol( + protocol_id: str, + body: MarketProtocolBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + data = market_protocol.update_protocol( + db, + source=body.source, + protocol_id=protocol_id, + body=body, + ctx=_context(user, scope_kind), + user_info=user, + ) + return success_response(data, message='保存成功') + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + +_register_protocol_routes('specialist') +_register_protocol_routes('manager') + + +def _register_payment_routes(scope_kind: ScopeKind) -> None: + prefix = f'/mini/market/{scope_kind}' + chase_guard = require_market_menu_code( + f'mini:market:{scope_kind}:payment-chase' + ) + payment_guard = require_market_menu_code( + f'mini:market:{scope_kind}:payment' + ) + + @router.get(f'{prefix}/payment-chase') + async def list_payment_chase( + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100, alias='pageSize'), + keyword: Optional[str] = None, + chase_type: Optional[Literal['overdue', 'upcoming']] = Query( + None, alias='chaseType' + ), + pay_mode: Optional[str] = Query(None, alias='payMode'), + sales_user_id: Optional[str] = Query(None, alias='salesUserId'), + user: dict = Depends(chase_guard), + db: Session = Depends(get_db), + ): + items, total = market_payment.list_payment_chase( + db, + ctx=_context(user, scope_kind), + user_info=user, + page=page, + page_size=page_size, + keyword=keyword, + chase_type=chase_type, + pay_mode=pay_mode, + sales_user_id=sales_user_id, + ) + payment_allowed = _has_menu( + db, user, f'mini:market:{scope_kind}:payment' + ) + items = [ + market_support.decorate_allowed_actions( + 'payment-chase', + item, + scope_kind=scope_kind, + has_payment_permission=payment_allowed, + ) + for item in items + ] + return success_response( + {'list': items, 'total': total, 'page': page, 'pageSize': page_size} + ) + + @router.get(f'{prefix}/payments') + async def list_payments( + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100, alias='pageSize'), + keyword: Optional[str] = None, + pay_mode: Optional[str] = Query(None, alias='payMode'), + user: dict = Depends(payment_guard), + db: Session = Depends(get_db), + ): + items, total = market_payment.list_payments( + db, + ctx=_context(user, scope_kind), + user_info=user, + page=page, + page_size=page_size, + keyword=keyword, + pay_mode=pay_mode, + ) + items = [ + market_support.decorate_allowed_actions( + 'payment', item, scope_kind=scope_kind + ) + for item in items + ] + return success_response( + {'list': items, 'total': total, 'page': page, 'pageSize': page_size} + ) + + @router.get(f'{prefix}/payments/form-options') + async def payment_form_options( + user: dict = Depends(payment_guard), + db: Session = Depends(get_db), + ): + return success_response( + market_support.get_form_options( + db, + module='payment', + ctx=_context(user, scope_kind), + user_info=user, + ) + ) + + @router.get(f'{prefix}/payments/{{payment_id}}') + async def get_payment( + payment_id: str, + user: dict = Depends(payment_guard), + db: Session = Depends(get_db), + ): + return success_response( + market_support.decorate_allowed_actions( + 'payment', + market_payment.get_payment_detail( + db, + payment_id=payment_id, + ctx=_context(user, scope_kind), + user_info=user, + ), + scope_kind=scope_kind, + ) + ) + + @router.post(f'{prefix}/payment-transfers') + async def create_payment_transfer( + body: MarketPaymentTransferCreateBody, + user: dict = Depends(payment_guard), + db: Session = Depends(get_db), + ): + try: + fields = body.model_dump(by_alias=True, exclude_unset=True) + schedule_id = fields.pop('scheduleId') + return success_response( + market_payment.create_transfer( + db, + schedule_id=schedule_id, + fields=fields, + ctx=_context(user, scope_kind), + user_info=user, + ), + message='创建成功', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.put(f'{prefix}/payment-transfers/{{transfer_id}}') + async def update_payment_transfer( + transfer_id: str, + body: MarketPaymentTransferBody, + user: dict = Depends(payment_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_payment.update_transfer( + db, + transfer_id=transfer_id, + fields=body.model_dump(by_alias=True, exclude_unset=True), + ctx=_context(user, scope_kind), + user_info=user, + ), + message='保存成功', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.post(f'{prefix}/payment-transfers/{{transfer_id}}/submit') + async def submit_payment_transfer( + transfer_id: str, + user: dict = Depends(payment_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_payment.submit_transfer( + db, + transfer_id=transfer_id, + ctx=_context(user, scope_kind), + user_info=user, + ), + message='已提交财务确认', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.post(f'{prefix}/payment-transfers/{{transfer_id}}/withdraw') + async def withdraw_payment_transfer( + transfer_id: str, + user: dict = Depends(payment_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_payment.withdraw_transfer( + db, + transfer_id=transfer_id, + ctx=_context(user, scope_kind), + user_info=user, + ), + message='已撤回', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + +_register_payment_routes('specialist') +_register_payment_routes('manager') + + +def _register_sample_routes(scope_kind: ScopeKind) -> None: + prefix = f'/mini/market/{scope_kind}/samples' + permission_guard = require_market_menu_code( + f'mini:market:{scope_kind}:sample' + ) + + @router.get(prefix) + async def list_samples( + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100, alias='pageSize'), + keyword: Optional[str] = None, + status: Optional[str] = None, + source: Optional[Literal['cell_storage', 'research_donation']] = None, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + items, total = market_sample.list_samples( + db, + ctx=_context(user, scope_kind), + user_info=user, + page=page, + page_size=page_size, + keyword=keyword, + status=status, + source=source, + ) + items = [ + market_support.decorate_allowed_actions( + 'sample', item, scope_kind=scope_kind + ) + for item in items + ] + return success_response( + {'list': items, 'total': total, 'page': page, 'pageSize': page_size} + ) + + @router.get(f'{prefix}/form-options') + async def sample_form_options( + sample_id: str = Query(..., alias='sampleId'), + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_sample.get_sample_form_options( + db, + sample_id=sample_id, + ctx=_context(user, scope_kind), + user_info=user, + ) + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.get(f'{prefix}/{{sample_id}}') + async def get_sample( + sample_id: str, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + return success_response( + market_support.decorate_allowed_actions( + 'sample', + market_sample.get_sample_detail( + db, + sample_id=sample_id, + ctx=_context(user, scope_kind), + user_info=user, + ), + scope_kind=scope_kind, + ) + ) + + @router.put(f'{prefix}/{{sample_id}}/storage-cert') + async def update_storage_cert( + sample_id: str, + body: MarketSampleStorageCertBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_sample.update_storage_cert( + db, + sample_id=sample_id, + fields=body.model_dump(by_alias=True, exclude_unset=True), + ctx=_context(user, scope_kind), + user_info=user, + ), + message='保存成功', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.put(f'{prefix}/{{sample_id}}/insurance') + async def update_insurance( + sample_id: str, + body: MarketSampleInsuranceBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_sample.update_insurance( + db, + sample_id=sample_id, + fields=body.model_dump(by_alias=True, exclude_unset=True), + ctx=_context(user, scope_kind), + user_info=user, + ), + message='保存成功', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + +_register_sample_routes('specialist') +_register_sample_routes('manager') + + +def _register_freight_routes(scope_kind: ScopeKind) -> None: + prefix = f'/mini/market/{scope_kind}/freights' + permission_guard = require_market_menu_code( + f'mini:market:{scope_kind}:freight' + ) + + @router.get(prefix) + async def list_freights( + page: int = Query(1, ge=1), + page_size: int = Query(10, ge=1, le=100, alias='pageSize'), + keyword: Optional[str] = None, + transport_mode: Optional[str] = Query(None, alias='transportMode'), + sample_source: Optional[ + Literal['cell_storage', 'research_donation'] + ] = Query(None, alias='sampleSource'), + include_items: bool = Query(False, alias='includeItems'), + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + items, total = market_freight.list_freights( + db, + ctx=_context(user, scope_kind), + user_info=user, + page=page, + page_size=page_size, + keyword=keyword, + transport_mode=transport_mode, + sample_source=sample_source, + include_items=include_items, + ) + items = [ + market_support.decorate_allowed_actions( + 'freight', item, scope_kind=scope_kind + ) + for item in items + ] + return success_response( + { + 'list': items, + 'total': total, + 'page': page, + 'pageSize': page_size, + 'allowedActions': market_support.allowed_actions( + 'freight-list', status='', scope_kind=scope_kind + ), + } + ) + + @router.get(f'{prefix}/sample-options') + async def list_freight_sample_options( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100, alias='pageSize'), + keyword: Optional[str] = None, + sample_source: Optional[ + Literal['cell_storage', 'research_donation'] + ] = Query(None, alias='sampleSource'), + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + items, total = market_freight.list_sample_options( + db, + ctx=_context(user, scope_kind), + user_info=user, + page=page, + page_size=page_size, + keyword=keyword, + sample_source=sample_source, + ) + return success_response( + {'list': items, 'total': total, 'page': page, 'pageSize': page_size} + ) + + @router.get(f'{prefix}/form-options') + async def freight_form_options( + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + return success_response( + market_support.get_form_options( + db, + module='freight', + ctx=_context(user, scope_kind), + user_info=user, + ) + ) + + @router.get(f'{prefix}/{{freight_id}}') + async def get_freight( + freight_id: str, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + return success_response( + market_support.decorate_allowed_actions( + 'freight', + market_freight.get_freight_detail( + db, + freight_id, + ctx=_context(user, scope_kind), + user_info=user, + ), + scope_kind=scope_kind, + detail=True, + ) + ) + + @router.post(prefix) + async def create_freight( + body: MarketFreightCreateBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_freight.create_freight( + db, + ctx=_context(user, scope_kind), + user_info=user, + transport_mode=body.transport_mode, + freight_amount=body.freight_amount, + items=[ + item.model_dump(by_alias=True) for item in body.items + ], + ), + message='创建成功', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.put(f'{prefix}/{{freight_id}}') + async def update_freight( + freight_id: str, + body: MarketFreightUpdateBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_freight.update_freight( + db, + freight_id, + ctx=_context(user, scope_kind), + user_info=user, + transport_mode=body.transport_mode, + freight_amount=body.freight_amount, + ), + message='保存成功', + ) + except PermissionError as exc: + return error_response(403, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + + @router.post(f'{prefix}/{{freight_id}}/link') + async def link_freight_samples( + freight_id: str, + body: MarketFreightItemsBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_freight.link_samples( + db, + freight_id, + ctx=_context(user, scope_kind), + user_info=user, + items=[ + item.model_dump(by_alias=True) for item in body.items + ], + ), + message='关联成功', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + @router.post(f'{prefix}/{{freight_id}}/unlink') + async def unlink_freight_samples( + freight_id: str, + body: MarketFreightItemsBody, + user: dict = Depends(permission_guard), + db: Session = Depends(get_db), + ): + try: + return success_response( + market_freight.unlink_samples( + db, + freight_id, + ctx=_context(user, scope_kind), + user_info=user, + items=[ + item.model_dump(by_alias=True) for item in body.items + ], + ), + message='已取消关联', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + + +_register_freight_routes('specialist') +_register_freight_routes('manager') + + +def _register_attachment_routes(scope_kind: ScopeKind) -> None: + for module in ('presign', 'protocol', 'payment', 'sample'): + permission_guard = require_market_menu_code( + f'mini:market:{scope_kind}:{module}' + ) + path = f'/mini/market/{scope_kind}/{module}/attachments' + + def build_upload_endpoint(bound_module, bound_guard): + async def upload_market_attachment( + file: UploadFile = File(...), + user: dict = Depends(bound_guard), + db: Session = Depends(get_db), + ): + try: + max_bytes = ( + market_support.PRESIGN_MAX_UPLOAD_BYTES + if bound_module == 'presign' + else market_support.MAX_UPLOAD_BYTES + ) + content = await file.read(max_bytes + 1) + return success_response( + market_support.save_market_attachment( + db, + content=content, + filename=file.filename or '', + content_type=file.content_type or '', + module=bound_module, + ctx=_context(user, scope_kind), + user_info=user, + ), + message='上传成功', + ) + except (PermissionError, ValueError) as exc: + return error_response(400, str(exc)) + finally: + await file.close() + + return upload_market_attachment + + router.add_api_route( + path, + build_upload_endpoint(module, permission_guard), + methods=['POST'], + name=f'upload_market_{scope_kind}_{module}_attachment', + ) + +_register_attachment_routes('specialist') +_register_attachment_routes('manager') diff --git a/backend/app/services/market_support.py b/backend/app/services/market_support.py new file mode 100644 index 00000000..1bfb4de5 --- /dev/null +++ b/backend/app/services/market_support.py @@ -0,0 +1,729 @@ +"""移动市场表单选项、附件暂存与动作白名单。 + +该模块是移动市场写接口的安全边界:客户端不能选择任意字典、目录或附件 URL。 +""" +from __future__ import annotations + +import os +import re +import uuid +import zipfile +from io import BytesIO +from typing import Any, Dict, Iterable, List, Optional + +from fastapi import HTTPException +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.storage import storage +from app.models.models import ( + BizAttachment, + BizCellSampleType, + BizCollectionHospital, + BizProtocolCollectionInfo, + BizCollectionRegion, + BizCollectionRegionStaff, + BizItemUnit, + SysDictData, + SysUser, +) +from app.models.research_donation import BizResearchDonationCollection +from app.services.market_field_scope import ( + MarketFieldContext, + assert_cell_protocol_access, + assert_donation_access, + assert_presign_access, +) + +FORM_MODULES = frozenset({'presign', 'protocol', 'payment', 'freight'}) +UPLOAD_MODULES = frozenset({'presign', 'protocol', 'payment', 'sample'}) +DICT_BY_MODULE = { + 'payment': ('zzfs', 'transferTypes'), + 'freight': ('sample_transport_mode', 'transportModes'), +} +MAX_UPLOAD_BYTES = 10 * 1024 * 1024 +PRESIGN_MAX_UPLOAD_BYTES = 20 * 1024 * 1024 +MAX_ATTACHMENT_REFS = 20 +MAX_ATTACHMENT_REFS_LENGTH = 4000 +MAX_ATTACHMENT_REF_LENGTH = 1000 +PAYMENT_TERMINAL_PROTOCOL_STATUSES = frozenset({'yzf', 'sdfq', 'zky'}) +PAYMENT_WRITABLE_SCHEDULE_STATUS = 'open' +HEIC_BRANDS = frozenset( + {b'heic', b'heix', b'hevc', b'hevx', b'heim', b'heis'} +) +HEIF_BRANDS = HEIC_BRANDS | frozenset({b'mif1', b'msf1'}) +ALLOWED_MIME_BY_EXT = { + 'jpg': {'image/jpeg'}, + 'jpeg': {'image/jpeg'}, + 'png': {'image/png'}, + 'webp': {'image/webp'}, + 'heic': {'image/heic', 'image/heif', 'application/octet-stream'}, + 'heif': {'image/heic', 'image/heif', 'application/octet-stream'}, + 'pdf': {'application/pdf'}, + 'doc': {'application/msword', 'application/octet-stream'}, + 'docx': { + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/zip', + 'application/octet-stream', + }, + 'xls': {'application/vnd.ms-excel', 'application/octet-stream'}, + 'xlsx': { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/zip', + 'application/octet-stream', + }, +} + + +def _option(value: Any, label: Any) -> Dict[str, str]: + return {'value': str(value or ''), 'label': str(label or '')} + + +def _verify_identity(ctx: MarketFieldContext, user_info: dict) -> None: + if not isinstance(user_info, dict): + raise PermissionError('用户身份与现场范围上下文不一致') + if ( + str(user_info.get('user_id') or '').strip() != ctx.user_id + or str(user_info.get('tenant_no') or '').strip() != ctx.tenant_no + ): + raise PermissionError('用户身份与现场范围上下文不一致') + + +def authorized_region_ids(db: Session, ctx: MarketFieldContext) -> List[str]: + query = db.query(BizCollectionRegionStaff.region_id).filter( + BizCollectionRegionStaff.tenant_no == ctx.tenant_no, + BizCollectionRegionStaff.del_flag == 0, + BizCollectionRegionStaff.user_id == ctx.user_id, + ) + if ctx.scope_kind == 'manager': + query = query.filter(BizCollectionRegionStaff.is_leader == 1) + return [str(row[0]) for row in query.distinct().all() if row and row[0]] + + +def _dict_options(db: Session, tenant_no: str, dict_type: str) -> List[dict]: + rows = ( + db.query(SysDictData) + .filter( + SysDictData.tenant_no == tenant_no, + SysDictData.del_flag == 0, + SysDictData.dict_type == dict_type, + ) + .order_by(SysDictData.order_num.asc(), SysDictData.dict_label.asc()) + .all() + ) + return [_option(row.dict_value, row.dict_label) for row in rows] + + +def _business_region_id( + db: Session, + *, + module: str, + business_id: str, + ctx: MarketFieldContext, + user_info: dict, +) -> Optional[str]: + """仅从当前已在 scope 内的单据补充地区,不把 businessId 当越权入口。""" + record_id = str(business_id or '').strip() + if not record_id: + return None + if module == 'presign': + return str( + assert_presign_access( + db, record_id, ctx, user_info=user_info + ).region_id + or '' + ).strip() or None + if module != 'protocol': + return None + try: + assert_cell_protocol_access(db, record_id, ctx, user_info=user_info) + collection = ( + db.query(BizProtocolCollectionInfo) + .filter( + BizProtocolCollectionInfo.collection_protocol_id == record_id, + BizProtocolCollectionInfo.tenant_no == ctx.tenant_no, + BizProtocolCollectionInfo.del_flag == 0, + ) + .order_by( + BizProtocolCollectionInfo.create_time.desc(), + BizProtocolCollectionInfo.id.desc(), + ) + .first() + ) + except HTTPException: + assert_donation_access(db, record_id, ctx, user_info=user_info) + collection = ( + db.query(BizResearchDonationCollection) + .filter( + BizResearchDonationCollection.research_donation_id == record_id, + BizResearchDonationCollection.tenant_no == ctx.tenant_no, + BizResearchDonationCollection.del_flag == 0, + ) + .order_by( + BizResearchDonationCollection.create_time.desc(), + BizResearchDonationCollection.id.desc(), + ) + .first() + ) + return str(collection.region_id or '').strip() if collection else None + + +def get_form_options( + db: Session, + *, + module: str, + ctx: MarketFieldContext, + user_info: dict, + region_id: Optional[str] = None, + business_id: Optional[str] = None, +) -> Dict[str, List[dict]]: + """返回移动表单最小选项集;不会返回联系方式或证件等 PII。""" + _verify_identity(ctx, user_info) + normalized_module = str(module or '').strip() + if normalized_module not in FORM_MODULES: + raise ValueError('不支持的表单选项模块') + if normalized_module in DICT_BY_MODULE: + dict_type, key = DICT_BY_MODULE[normalized_module] + return {key: _dict_options(db, ctx.tenant_no, dict_type)} + + region_ids = authorized_region_ids(db, ctx) + document_region = _business_region_id( + db, + module=normalized_module, + business_id=str(business_id or ''), + ctx=ctx, + user_info=user_info, + ) + if document_region and document_region not in region_ids: + region_ids.append(document_region) + selected_region = str(region_id or '').strip() + if selected_region and selected_region not in region_ids: + raise PermissionError('所选地区不在当前用户权限范围内') + hospital_regions = [selected_region] if selected_region else region_ids + regions = [] + if region_ids: + regions = ( + db.query(BizCollectionRegion) + .filter( + BizCollectionRegion.id.in_(region_ids), + BizCollectionRegion.tenant_no == ctx.tenant_no, + BizCollectionRegion.del_flag == 0, + ) + .order_by(BizCollectionRegion.order_num.asc(), BizCollectionRegion.region_name.asc()) + .all() + ) + hospitals = [] + staff_rows = [] + if hospital_regions: + hospitals = ( + db.query(BizCollectionHospital) + .filter( + BizCollectionHospital.region_id.in_(hospital_regions), + BizCollectionHospital.tenant_no == ctx.tenant_no, + BizCollectionHospital.del_flag == 0, + ) + .order_by(BizCollectionHospital.order_num.asc(), BizCollectionHospital.hospital_name.asc()) + .all() + ) + staff_rows = ( + db.query(SysUser, BizCollectionRegionStaff) + .join( + BizCollectionRegionStaff, + BizCollectionRegionStaff.user_id == SysUser.id, + ) + .filter( + BizCollectionRegionStaff.region_id.in_(hospital_regions), + BizCollectionRegionStaff.tenant_no == ctx.tenant_no, + BizCollectionRegionStaff.del_flag == 0, + SysUser.tenant_no == ctx.tenant_no, + SysUser.del_flag == 0, + SysUser.status == '1', + ) + .order_by( + BizCollectionRegionStaff.is_leader.desc(), + SysUser.nick_name.asc(), + SysUser.id.asc(), + ) + .all() + ) + sample_types = ( + db.query(BizCellSampleType) + .filter( + BizCellSampleType.tenant_no == ctx.tenant_no, + BizCellSampleType.del_flag == 0, + ) + .order_by(BizCellSampleType.order_num.asc(), BizCellSampleType.type_name.asc()) + .all() + ) + units = ( + db.query(BizItemUnit) + .filter(BizItemUnit.tenant_no == ctx.tenant_no, BizItemUnit.del_flag == 0) + .order_by(BizItemUnit.order_num.asc(), BizItemUnit.unit_name.asc()) + .all() + ) + staff = [] + seen_staff_ids = set() + for user, binding in staff_rows: + if user.id in seen_staff_ids: + continue + seen_staff_ids.add(user.id) + staff.append( + { + **_option(user.id, user.nick_name), + 'isLeader': bool(binding.is_leader), + } + ) + result = { + 'regions': [_option(row.id, row.region_name) for row in regions], + 'hospitals': [_option(row.id, row.hospital_name) for row in hospitals], + 'staff': staff, + 'sampleTypes': [_option(row.id, row.type_name) for row in sample_types], + 'units': [_option(row.id, row.unit_name) for row in units], + } + if normalized_module == 'presign': + result.update( + { + 'payModes': [ + {'label': '趸交', 'value': 'dj'}, + {'label': '分期', 'value': 'fq'}, + ], + 'orderStatuses': [ + {'label': '草稿', 'value': 'draft'}, + {'label': '进行中', 'value': 'active'}, + {'label': '已转正式', 'value': 'converted'}, + {'label': '已作废', 'value': 'void'}, + ], + 'transferTypes': _dict_options(db, ctx.tenant_no, 'zzfs'), + } + ) + return result + + +def _validate_upload( + content: bytes, + filename: str, + content_type: str, + *, + max_bytes: int = MAX_UPLOAD_BYTES, +) -> tuple[str, str]: + name = str(filename or '').strip() + if not name or len(name) > 200 or re.search(r'[\x00-\x1f\x7f]', name): + raise ValueError('文件名无效') + if '/' in name or '\\' in name: + raise ValueError('文件名无效') + if not content: + raise ValueError('上传文件不能为空') + if len(content) > max_bytes: + raise ValueError(f'上传文件不能超过 {max_bytes // (1024 * 1024)}MiB') + ext = os.path.splitext(name)[1].lower().lstrip('.') + mime = str(content_type or '').lower().split(';', 1)[0].strip() + if ext not in ALLOWED_MIME_BY_EXT or mime not in ALLOWED_MIME_BY_EXT[ext]: + raise ValueError('不支持的文件类型') + signatures = { + 'jpg': (b'\xff\xd8\xff',), + 'jpeg': (b'\xff\xd8\xff',), + 'png': (b'\x89PNG\r\n\x1a\n',), + 'webp': (b'RIFF',), + 'pdf': (b'%PDF-',), + 'doc': (b'\xd0\xcf\x11\xe0',), + 'xls': (b'\xd0\xcf\x11\xe0',), + 'docx': (b'PK\x03\x04',), + 'xlsx': (b'PK\x03\x04',), + } + if ext not in {'heic', 'heif'} and not any( + content.startswith(prefix) for prefix in signatures[ext] + ): + raise ValueError('文件内容与扩展名不匹配') + if ext == 'webp' and (len(content) < 12 or content[8:12] != b'WEBP'): + raise ValueError('文件内容与扩展名不匹配') + if ext in {'heic', 'heif'}: + if len(content) < 16 or content[4:8] != b'ftyp': + raise ValueError('文件内容与扩展名不匹配') + box_size = int.from_bytes(content[:4], byteorder='big', signed=False) + if ( + box_size < 16 + or box_size > len(content) + or (box_size - 16) % 4 != 0 + ): + raise ValueError('文件内容与扩展名不匹配') + brands = {content[8:12]} + brands.update( + content[offset : offset + 4] + for offset in range(16, box_size, 4) + ) + allowed_brands = HEIC_BRANDS if ext == 'heic' else HEIF_BRANDS + if not brands.intersection(allowed_brands): + raise ValueError('文件内容与扩展名不匹配') + if ext in {'docx', 'xlsx'}: + try: + with zipfile.ZipFile(BytesIO(content)) as archive: + names = set(archive.namelist()) + except (OSError, zipfile.BadZipFile): + raise ValueError('文件内容与扩展名不匹配') + required_prefix = 'word/' if ext == 'docx' else 'xl/' + if '[Content_Types].xml' not in names or not any( + name.startswith(required_prefix) for name in names + ): + raise ValueError('文件内容与扩展名不匹配') + return ext, mime + + +def _relative_file_url(full_url: str) -> str: + base_url = (settings.MINIO_BASE_URL or '').rstrip('/') + if base_url and full_url.startswith(base_url + '/'): + return full_url[len(base_url) + 1 :] + return full_url + + +def _file_size_display(size: int) -> str: + if size < 1024: + return f'{size} B' + if size < 1024 * 1024: + return f'{size / 1024:.1f} KB' + return f'{size / (1024 * 1024):.1f} MB' + + +def save_market_attachment( + db: Session, + *, + content: bytes, + filename: str, + content_type: str, + module: str, + ctx: MarketFieldContext, + user_info: dict, + storage_backend=storage, +) -> Dict[str, Any]: + _verify_identity(ctx, user_info) + normalized_module = str(module or '').strip() + if normalized_module not in UPLOAD_MODULES: + raise ValueError('不支持的附件模块') + ext, mime = _validate_upload( + content, + filename, + content_type, + max_bytes=( + PRESIGN_MAX_UPLOAD_BYTES + if normalized_module == 'presign' + else MAX_UPLOAD_BYTES + ), + ) + folder = f'market/{ctx.scope_kind}/{normalized_module}' + full_url = storage_backend.upload_file(content, filename, folder) + file_url = _relative_file_url(full_url) + row = BizAttachment( + id=uuid.uuid4().hex, + business_type=f'market:{ctx.scope_kind}:{normalized_module}:staging', + business_id=None, + original_name=filename, + file_ext=ext, + file_url=file_url, + file_size=len(content), + file_type=mime, + tenant_no=ctx.tenant_no, + create_by=ctx.user_id, + create_dept_id=str( + user_info.get('dept_id') + or user_info.get('deptId') + or user_info.get('region_id') + or '' + ).strip(), + del_flag=0, + ) + committed = False + try: + db.add(row) + db.flush() + db.refresh(row) + result = { + 'id': row.id, + 'fileName': row.original_name, + 'fileUrl': row.file_url, + 'fileSize': row.file_size, + 'fileSizeDisplay': _file_size_display(row.file_size or 0), + 'fileType': row.file_type, + } + db.commit() + committed = True + except Exception: + if not committed: + db.rollback() + try: + storage_backend.delete_file(full_url) + except Exception: + pass + raise + return result + + +def _split_refs(refs: Any) -> List[str]: + if refs is None: + return [] + if isinstance(refs, str): + if len(refs) > MAX_ATTACHMENT_REFS_LENGTH: + raise ValueError('附件引用总长度超出限制') + raw: Iterable[Any] = refs.split(',') + elif isinstance(refs, (list, tuple, set)): + raw = refs + else: + raise ValueError('附件引用格式无效') + raw = list(raw) + if len(raw) > MAX_ATTACHMENT_REFS: + raise ValueError('附件数量不能超过 20 个') + values = [] + total_length = 0 + for item in raw: + if isinstance(item, dict): + item = item.get('id') or item.get('fileUrl') or item.get('url') + value = str(item or '').strip() + if len(value) > MAX_ATTACHMENT_REF_LENGTH: + raise ValueError('附件引用长度无效') + total_length += len(value) + if total_length + max(0, len(raw) - 1) > MAX_ATTACHMENT_REFS_LENGTH: + raise ValueError('附件引用总长度超出限制') + if value and value not in values: + values.append(value) + return values + + +def _attachment_candidates(value: str) -> List[str]: + candidates = [value] + base = (settings.MINIO_BASE_URL or '').rstrip('/') + if base and value.startswith(base + '/'): + candidates.append(value[len(base) + 1 :]) + return list(dict.fromkeys(candidates)) + + +def validate_attachment_refs( + db: Session, + refs: Any, + *, + module: str, + ctx: MarketFieldContext, + creating: bool, + existing_refs: Any = None, + business_id: Optional[str] = None, +) -> str: + """批量锁定并解析附件;有 business_id 时原子消费当前角色暂存记录。""" + normalized_module = str(module or '').strip() + if normalized_module not in UPLOAD_MODULES: + raise ValueError('不支持的附件模块') + values = _split_refs(refs) + if not values: + return '' + existing = set(_split_refs(existing_refs)) if not creating else set() + new_values = [value for value in values if value not in existing] + if not new_values: + return ','.join(values) + normalized_business_id = str(business_id or '').strip() + candidates_by_value = { + value: _attachment_candidates(value) for value in new_values + } + all_candidates = { + candidate + for candidates in candidates_by_value.values() + for candidate in candidates + } + rows = ( + db.query(BizAttachment) + .filter( + BizAttachment.tenant_no == ctx.tenant_no, + BizAttachment.del_flag == 0, + or_( + BizAttachment.id.in_(new_values), + BizAttachment.file_url.in_(all_candidates), + ), + ) + .with_for_update() + .all() + ) + by_id = {str(row.id): row for row in rows} + by_url = {str(row.file_url): row for row in rows} + expected_staging = f'market:{ctx.scope_kind}:{normalized_module}:staging' + expected_bound = f'market:{ctx.scope_kind}:{normalized_module}:bound' + resolved = [] + seen_row_ids = set() + for value in values: + if value in existing: + if value not in resolved: + resolved.append(value) + continue + row = by_id.get(value) + if row is None: + row = next( + ( + by_url.get(candidate) + for candidate in candidates_by_value[value] + if by_url.get(candidate) + ), + None, + ) + if row is None: + raise ValueError('附件不存在或无权引用') + if row.id in seen_row_ids: + continue + seen_row_ids.add(row.id) + is_current_staging = ( + row.business_type == expected_staging + and row.create_by == ctx.user_id + and not row.business_id + ) + is_same_binding = ( + bool(normalized_business_id) + and row.business_type == expected_bound + and str(row.business_id or '') == normalized_business_id + ) + if not (is_current_staging or is_same_binding): + raise ValueError('附件不存在、已被绑定或无权引用') + if normalized_business_id and is_current_staging: + row.business_type = expected_bound + row.business_id = normalized_business_id + db.add(row) + resolved.append(str(row.file_url)) + if normalized_business_id: + db.flush() + return ','.join(resolved) + + +def allowed_actions( + module: str, + *, + status: str, + scope_kind: str = 'specialist', + source: str = '', + has_sample_code: bool = False, + has_payment_permission: bool = False, +) -> List[str]: + """纯函数动作白名单;路由仍必须执行对应权限与状态校验。""" + module, state = str(module or '').strip(), str(status or '').strip().lower() + if module in {'presign-list', 'protocol-list', 'freight-list'}: + return ['create'] + if module == 'presign': + if state == 'converted': + return ['edit'] + if state == 'void': + return ['edit', 'delete'] + return ['edit', 'convert', 'delete'] + if module == 'presign-transfer': + if state == 'dfj': + return ['edit', 'delete', 'submit'] + if state == 'jfqrz': + return ['withdraw'] + return [] + if module == 'protocol': + return ['edit'] if state in {'', 'draft', 'dsx', 'dqr', 'sxz', 'ygq'} else [] + if module == 'payment': + return [] if state in PAYMENT_TERMINAL_PROTOCOL_STATUSES else ['register-transfer'] + if module == 'payment-transfer': + if state == 'dfj': + return ['edit-transfer', 'submit-finance'] + if state == 'jfqrz': + return ['withdraw-transfer'] + return [] + if module == 'payment-chase': + return ['register-transfer'] if has_payment_permission else [] + if module == 'sample': + if source == 'cell_storage' and has_sample_code: + return ['storage-cert', 'insurance'] + return [] + if module == 'freight': + return ['edit'] if scope_kind == 'manager' else [] + return [] + + +def decorate_allowed_actions( + module: str, + item: Dict[str, Any], + *, + scope_kind: str, + has_payment_permission: bool = False, + detail: bool = False, +) -> Dict[str, Any]: + """为移动列表/详情 DTO 增加动作白名单,不把动作判断交给客户端。""" + data = dict(item) + basic = data.get('basic') if isinstance(data.get('basic'), dict) else {} + status = str( + data.get('orderStatus') + or data.get('protocolStatus') + or data.get('donationStatus') + or data.get('status') + or basic.get('protocolStatus') + or basic.get('donationStatus') + or basic.get('status') + or '' + ) + if module == 'sample': + card = data.get('card') if isinstance(data.get('card'), dict) else data + source = str(card.get('source') or data.get('source') or '') + sample_code = str(card.get('sampleCode') or data.get('sampleCode') or '') + data['allowedActions'] = allowed_actions( + module, + status=status, + scope_kind=scope_kind, + source=source, + has_sample_code=bool(sample_code.strip()), + ) + return data + if module == 'payment': + parent_writable = status.lower() not in PAYMENT_TERMINAL_PROTOCOL_STATUSES + schedules = [] + has_writable_schedule = False + for schedule in data.get('schedules') or []: + schedule_data = dict(schedule) + schedule_writable = ( + parent_writable + and str(schedule_data.get('scheduleStatus') or '').lower() + == PAYMENT_WRITABLE_SCHEDULE_STATUS + ) + schedule_data['allowedActions'] = ( + ['register-transfer'] if schedule_writable else [] + ) + has_writable_schedule = has_writable_schedule or schedule_writable + transfers = [] + for transfer in schedule_data.get('transfers') or []: + transfer_data = dict(transfer) + transfer_data['allowedActions'] = ( + allowed_actions( + 'payment-transfer', + status=str(transfer_data.get('payStatus') or ''), + ) + if schedule_writable + else [] + ) + transfers.append(transfer_data) + schedule_data['transfers'] = transfers + schedules.append(schedule_data) + data['schedules'] = schedules + data['allowedActions'] = ( + allowed_actions(module, status=status) + if parent_writable and has_writable_schedule + else [] + ) + return data + if module == 'presign' and detail: + transfers = [] + for transfer in data.get('transfers') or []: + transfer_data = dict(transfer) + transfer_data['allowedActions'] = allowed_actions( + 'presign-transfer', + status=str(transfer_data.get('payStatus') or ''), + ) + transfers.append(transfer_data) + data['transfers'] = transfers + if module == 'freight' and detail: + provided = data.get('allowedActions') + safe_actions = {'edit', 'link', 'unlink'} + data['allowedActions'] = ( + [action for action in provided if action in safe_actions] + if isinstance(provided, list) + else ['link', 'unlink'] + ) + return data + data['allowedActions'] = allowed_actions( + module, + status=status, + scope_kind=scope_kind, + has_payment_permission=has_payment_permission, + ) + return data diff --git a/backend/tests/test_market_presign_api.py b/backend/tests/test_market_presign_api.py index c4bf18e0..54bf4888 100644 --- a/backend/tests/test_market_presign_api.py +++ b/backend/tests/test_market_presign_api.py @@ -2037,7 +2037,7 @@ def test_conversion_unique_index_migration_is_mysql57_idempotent(): assert "unique index uk_collection_protocol_presign_source" in normalized -def test_market_presign_routes_are_fixed_and_do_not_expose_delete_or_finance_actions(): +def test_market_presign_routes_are_fixed_and_expose_scoped_transfer_actions(): from app.api.market_mobile import router actual = { @@ -2053,13 +2053,32 @@ def test_market_presign_routes_are_fixed_and_do_not_expose_delete_or_finance_act { ("GET", base), ("POST", base), - ("GET", f"{base}/form-options"), + ("GET", f"{base}/form-options"), ("GET", f"{base}/{{order_id}}"), ("PUT", f"{base}/{{order_id}}"), + ("DELETE", f"{base}/{{order_id}}"), ("POST", f"{base}/{{order_id}}/convert"), + ("POST", f"{base}/{{order_id}}/transfers"), } ) assert actual == expected - assert all(method != "DELETE" for method, _ in actual) - assert all("finance" not in path for _, path in actual) + + transfer_actual = { + (method, route.path) + for route in router.routes + if "/presign-transfers" in route.path + for method in route.methods + } + transfer_expected = set() + for role in ("specialist", "manager"): + base = f"/mini/market/{role}/presign-transfers/{{transfer_id}}" + transfer_expected.update( + { + ("PUT", base), + ("DELETE", base), + ("POST", f"{base}/submit"), + ("POST", f"{base}/withdraw"), + } + ) + assert transfer_actual == transfer_expected diff --git a/backend/tests/test_market_support_api.py b/backend/tests/test_market_support_api.py new file mode 100644 index 00000000..ca5aa069 --- /dev/null +++ b/backend/tests/test_market_support_api.py @@ -0,0 +1,962 @@ +import asyncio +from types import SimpleNamespace + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.models.base import Base +from app.models.models import ( + BizAttachment, + BizCellSampleType, + BizCollectionHospital, + BizCollectionRegion, + BizCollectionRegionStaff, + BizItemUnit, + SysDictData, + SysUser, +) +from app.services.market_field_scope import MarketFieldContext + + +def _session(): + engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all( + engine, + tables=[ + BizAttachment.__table__, + BizCellSampleType.__table__, + BizCollectionHospital.__table__, + BizCollectionRegion.__table__, + BizCollectionRegionStaff.__table__, + BizItemUnit.__table__, + SysDictData.__table__, + SysUser.__table__, + ], + ) + return sessionmaker(bind=engine)() + + +def _ctx(scope='specialist', user_id='user-a'): + return MarketFieldContext('tenant-a', user_id, scope) + + +def _user(user_id='user-a'): + return { + 'user_id': user_id, + 'tenant_no': 'tenant-a', + 'region_id': 'dept-a', + } + + +def _seed_options(db): + db.add_all( + [ + BizCollectionRegion(id='ra', region_name='A区', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegion(id='rb', region_name='B区', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegion(id='rx', region_name='跨租户秘密区', tenant_no='tenant-b', create_dept_id='', del_flag=0), + BizCollectionHospital(id='ha', region_id='ra', hospital_name='A院', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionHospital(id='hb', region_id='rb', hospital_name='B院', tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionHospital(id='hx', region_id='rx', hospital_name='跨租户秘密院', tenant_no='tenant-b', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='rsa', region_id='ra', user_id='user-a', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='rsb', region_id='rb', user_id='user-b', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='rsa2', region_id='ra', user_id='staff-a', is_leader=0, tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='rsa3', region_id='ra', user_id='leader-a', is_leader=1, tenant_no='tenant-a', create_dept_id='', del_flag=0), + BizCollectionRegionStaff(id='rsx', region_id='rx', user_id='cross-user', is_leader=0, tenant_no='tenant-b', create_dept_id='', del_flag=0), + SysUser(id='user-a', user_no='ua', nick_name='本人', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='user-b', user_no='ub', nick_name='B经理', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='staff-a', user_no='sa', nick_name='A员工', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='leader-a', user_no='la', nick_name='A负责人', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='disabled', user_no='off', nick_name='停用秘密', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysUser(id='cross-user', user_no='xx', nick_name='跨租户秘密用户', status='1', tenant_no='tenant-b', 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), + BizCellSampleType(id='type-x', type_code='X', type_name='跨租户秘密样本', tenant_no='tenant-b', create_dept_id='', del_flag=0), + BizItemUnit(id='unit-a', unit_code='ml', unit_name='毫升', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysDictData(id='dict-pay', dict_label='微信', dict_value='wx', dict_type='zzfs', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysDictData(id='dict-freight', dict_label='专线车', dict_value='zxc', dict_type='sample_transport_mode', tenant_no='tenant-a', create_dept_id='', del_flag=0), + SysDictData(id='dict-secret', dict_label='不应泄漏', dict_value='secret', dict_type='unrelated', tenant_no='tenant-a', create_dept_id='', del_flag=0), + ] + ) + db.commit() + + +def test_specialist_form_options_are_region_scoped_and_minimal(monkeypatch): + from app.services.market_support import get_form_options + + db = _session() + _seed_options(db) + + out = get_form_options(db, module='presign', ctx=_ctx(), user_info=_user()) + + assert out['regions'] == [{'value': 'ra', 'label': 'A区'}] + assert out['hospitals'] == [{'value': 'ha', 'label': 'A院'}] + assert out['staff'] == [ + {'value': 'leader-a', 'label': 'A负责人', 'isLeader': True}, + {'value': 'staff-a', 'label': 'A员工', 'isLeader': False}, + {'value': 'user-a', 'label': '本人', 'isLeader': False}, + ] + assert out['sampleTypes'] == [{'value': 'type-a', 'label': '样本A'}] + assert out['units'] == [{'value': 'unit-a', 'label': '毫升'}] + assert out['payModes'] == [ + {'label': '趸交', 'value': 'dj'}, + {'label': '分期', 'value': 'fq'}, + ] + assert out['orderStatuses'] == [ + {'label': '草稿', 'value': 'draft'}, + {'label': '进行中', 'value': 'active'}, + {'label': '已转正式', 'value': 'converted'}, + {'label': '已作废', 'value': 'void'}, + ] + assert out['transferTypes'] == [{'value': 'wx', 'label': '微信'}] + assert '跨租户' not in str(out) + + monkeypatch.setattr( + 'app.services.market_support._business_region_id', + lambda *args, **kwargs: 'rb', + ) + supplemented = get_form_options( + db, + module='presign', + ctx=_ctx(), + user_info=_user(), + business_id='own-document', + ) + assert supplemented['regions'] == [ + {'value': 'ra', 'label': 'A区'}, + {'value': 'rb', 'label': 'B区'}, + ] + + +def test_manager_form_options_only_include_led_regions_and_requested_region_must_be_authorized(): + from app.services.market_support import get_form_options + + db = _session() + _seed_options(db) + + out = get_form_options( + db, + module='protocol', + ctx=_ctx('manager', 'user-b'), + user_info=_user('user-b'), + region_id='rb', + ) + assert out['regions'] == [{'value': 'rb', 'label': 'B区'}] + assert out['hospitals'] == [{'value': 'hb', 'label': 'B院'}] + + with pytest.raises(PermissionError, match='地区'): + get_form_options( + db, + module='protocol', + ctx=_ctx('manager', 'user-b'), + user_info=_user('user-b'), + region_id='ra', + ) + + +def test_payment_and_freight_options_use_fixed_dictionary_allowlist(): + from app.services.market_support import get_form_options + + db = _session() + _seed_options(db) + payment = get_form_options(db, module='payment', ctx=_ctx(), user_info=_user()) + freight = get_form_options(db, module='freight', ctx=_ctx(), user_info=_user()) + + assert payment == {'transferTypes': [{'value': 'wx', 'label': '微信'}]} + assert freight == {'transportModes': [{'value': 'zxc', 'label': '专线车'}]} + assert '不应泄漏' not in str(payment) + str(freight) + + +class _Storage: + def __init__(self): + self.uploaded = [] + self.deleted = [] + + def upload_file(self, content, filename, folder): + self.uploaded.append((content, filename, folder)) + return f'https://files.test/{folder}/generated.pdf' + + def delete_file(self, path): + self.deleted.append(path) + return True + + +def test_upload_uses_fixed_folder_and_records_current_tenant_and_uploader(): + from app.services.market_support import save_market_attachment + + db = _session() + backend = _Storage() + result = save_market_attachment( + db, + content=b'%PDF-1.7 safe', + filename='proof.pdf', + content_type='application/pdf', + module='payment', + ctx=_ctx(), + user_info=_user(), + storage_backend=backend, + ) + + assert backend.uploaded[0][2] == 'market/specialist/payment' + row = db.query(BizAttachment).one() + assert (row.tenant_no, row.create_by, row.create_dept_id) == ('tenant-a', 'user-a', 'dept-a') + assert row.business_type == 'market:specialist:payment:staging' + assert result['id'] == row.id + assert result['fileUrl'] == row.file_url + + +def test_presign_upload_allows_20_mib_while_other_modules_remain_at_10_mib(): + from app.services.market_support import save_market_attachment + + content = b'%PDF-' + b'a' * (10 * 1024 * 1024) + backend = _Storage() + + result = save_market_attachment( + _session(), + content=content, + filename='presign-proof.pdf', + content_type='application/pdf', + module='presign', + ctx=_ctx(), + user_info=_user(), + storage_backend=backend, + ) + + assert result['fileSize'] == len(content) + with pytest.raises(ValueError, match='10MiB'): + save_market_attachment( + _session(), + content=content, + filename='payment-proof.pdf', + content_type='application/pdf', + module='payment', + ctx=_ctx(), + user_info=_user(), + storage_backend=_Storage(), + ) + + +@pytest.mark.parametrize( + ('content', 'filename', 'content_type'), + [ + (b'', 'empty.pdf', 'application/pdf'), + (b'', 'bad.svg', 'image/svg+xml'), + (b'MZ', 'bad.exe', 'application/octet-stream'), + (b'not an image', 'fake.jpg', 'image/jpeg'), + (b'%PDF-1.7', 'fake.jpg', 'image/jpeg'), + (b'\x00\x00\x00\x18junk-ftyp-heic-data', 'fake.heic', 'image/heic'), + (b'\x00\x00\x00\x18ftypzzzz\x00\x00\x00\x00zzzz', 'fake.heic', 'image/heic'), + (b'\x00\x00\x00\xffftypheic\x00\x00\x00\x00', 'fake.heic', 'image/heic'), + (b'\x00\x00\x00\x0cftypheic', 'short.heic', 'image/heic'), + (b'\x00\x00\x00\x0fftypheicabc', 'short.heic', 'image/heic'), + (b'\x00\x00\x00\x15ftypheic\x00\x00\x00\x00mif1x', 'misaligned.heic', 'image/heic'), + (b'\x00\x00\x00\x10ftypmif1\x00\x00\x00\x00', 'generic-only.heic', 'image/heic'), + (b'PK\x03\x04not-a-docx', 'fake.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'), + (b'a' * (10 * 1024 * 1024 + 1), 'huge.pdf', 'application/pdf'), + ], +) +def test_upload_rejects_empty_oversized_or_unsafe_files(content, filename, content_type): + from app.services.market_support import save_market_attachment + + db = _session() + backend = _Storage() + with pytest.raises(ValueError): + save_market_attachment( + db, + content=content, + filename=filename, + content_type=content_type, + module='payment', + ctx=_ctx(), + user_info=_user(), + storage_backend=backend, + ) + assert backend.uploaded == [] + + +def test_upload_deletes_storage_object_when_database_commit_fails(monkeypatch): + from app.services.market_support import save_market_attachment + + db = _session() + backend = _Storage() + monkeypatch.setattr(db, 'commit', lambda: (_ for _ in ()).throw(RuntimeError('db down'))) + + with pytest.raises(RuntimeError, match='db down'): + save_market_attachment( + db, + content=b'%PDF-1.7 safe', + filename='proof.pdf', + content_type='application/pdf', + module='protocol', + ctx=_ctx(), + user_info=_user(), + storage_backend=backend, + ) + assert backend.deleted == ['https://files.test/market/specialist/protocol/generated.pdf'] + + +def test_upload_never_refreshes_after_commit_or_deletes_committed_object(monkeypatch): + from app.services.market_support import save_market_attachment + + db = _session() + backend = _Storage() + committed = False + original_commit = db.commit + original_refresh = db.refresh + + def tracked_commit(): + nonlocal committed + original_commit() + committed = True + + def refresh_only_before_commit(row): + if committed: + raise RuntimeError('refresh after commit is unsafe') + return original_refresh(row) + + monkeypatch.setattr(db, 'commit', tracked_commit) + monkeypatch.setattr(db, 'refresh', refresh_only_before_commit) + + result = save_market_attachment( + db, + content=b'%PDF-1.7 safe', + filename='proof.pdf', + content_type='application/pdf', + module='protocol', + ctx=_ctx(), + user_info=_user(), + storage_backend=backend, + ) + + assert result['id'] + assert backend.deleted == [] + + +def test_upload_accepts_well_formed_heif_ftyp_box_with_allowed_brand(): + from app.services.market_support import save_market_attachment + + db = _session() + backend = _Storage() + content = b'\x00\x00\x00\x14ftypheic\x00\x00\x00\x00mif1' + + result = save_market_attachment( + db, + content=content, + filename='photo.heic', + content_type='image/heic', + module='sample', + ctx=_ctx(), + user_info=_user(), + storage_backend=backend, + ) + + assert result['fileType'] == 'image/heic' + assert backend.uploaded[0][0] == content + + +def test_heif_extension_accepts_generic_heif_brand(): + from app.services.market_support import save_market_attachment + + db = _session() + backend = _Storage() + content = b'\x00\x00\x00\x10ftypmif1\x00\x00\x00\x00' + + result = save_market_attachment( + db, + content=content, + filename='photo.heif', + content_type='image/heif', + module='sample', + ctx=_ctx(), + user_info=_user(), + storage_backend=backend, + ) + + assert result['fileType'] == 'image/heif' + + +def _attachment(**overrides): + values = dict( + id='att-a', + business_type='market:specialist:payment:staging', + business_id=None, + original_name='proof.pdf', + file_ext='pdf', + file_url='market/specialist/payment/a.pdf', + file_size=12, + file_type='application/pdf', + tenant_no='tenant-a', + create_by='user-a', + create_dept_id='', + del_flag=0, + ) + values.update(overrides) + return BizAttachment(**values) + + +def test_create_attachment_refs_require_current_uploader_staging_exact_match(): + from app.services.market_support import validate_attachment_refs + + db = _session() + db.add_all( + [ + _attachment(), + _attachment(id='att-other', file_url='market/specialist/payment/other.pdf', create_by='other'), + _attachment(id='att-cross', file_url='market/specialist/payment/cross.pdf', tenant_no='tenant-b'), + ] + ) + db.commit() + + assert validate_attachment_refs( + db, + 'att-a,market/specialist/payment/a.pdf', + module='payment', + ctx=_ctx(), + creating=True, + ) == 'market/specialist/payment/a.pdf' + for invalid in ('att-other', 'att-cross', 'https://evil.test/a.pdf', 'a.pdf'): + with pytest.raises(ValueError, match='附件'): + validate_attachment_refs(db, invalid, module='payment', ctx=_ctx(), creating=True) + + +def test_staging_attachment_must_match_current_scope_role_exactly(): + from app.services.market_support import validate_attachment_refs + + db = _session() + db.add( + _attachment( + id='manager-staging', + business_type='market:manager:payment:staging', + file_url='market/manager/payment/proof.pdf', + ) + ) + db.commit() + + with pytest.raises(ValueError, match='附件'): + validate_attachment_refs( + db, + 'manager-staging', + module='payment', + ctx=_ctx('specialist'), + creating=True, + business_id='pay-a', + ) + + +def test_attachment_refs_bind_once_to_exact_scope_module_and_business(): + from app.services.market_support import validate_attachment_refs + + db = _session() + row = _attachment() + db.add(row) + db.commit() + + result = validate_attachment_refs( + db, + 'att-a,market/specialist/payment/a.pdf', + module='payment', + ctx=_ctx(), + creating=True, + business_id='transfer-a', + ) + + assert result == 'market/specialist/payment/a.pdf' + assert (row.business_type, row.business_id) == ( + 'market:specialist:payment:bound', + 'transfer-a', + ) + assert validate_attachment_refs( + db, + 'att-a', + module='payment', + ctx=_ctx(), + creating=False, + business_id='transfer-a', + ) == 'market/specialist/payment/a.pdf' + with pytest.raises(ValueError, match='附件'): + validate_attachment_refs( + db, + 'att-a', + module='payment', + ctx=_ctx(), + creating=False, + business_id='transfer-b', + ) + with pytest.raises(ValueError, match='附件'): + validate_attachment_refs( + db, + 'att-a', + module='payment', + ctx=_ctx('manager'), + creating=False, + business_id='transfer-a', + ) + + +def test_attachment_ref_resolution_uses_one_batched_query_and_deduplicates_aliases(): + from sqlalchemy import event + + from app.services.market_support import validate_attachment_refs + + db = _session() + db.add_all( + [ + _attachment(id='att-1', file_url='market/specialist/payment/1.pdf'), + _attachment(id='att-2', file_url='market/specialist/payment/2.pdf'), + ] + ) + db.commit() + statements = [] + + def capture(_conn, _cursor, statement, _parameters, _context, _many): + if 'biz_attachment' in statement.lower() and statement.lstrip().lower().startswith('select'): + statements.append(statement) + + event.listen(db.get_bind(), 'before_cursor_execute', capture) + try: + result = validate_attachment_refs( + db, + 'att-1,market/specialist/payment/1.pdf,att-2', + module='payment', + ctx=_ctx(), + creating=True, + business_id='transfer-batch', + ) + finally: + event.remove(db.get_bind(), 'before_cursor_execute', capture) + + assert result == 'market/specialist/payment/1.pdf,market/specialist/payment/2.pdf' + assert len(statements) == 1 + + +@pytest.mark.parametrize( + 'refs', + [','.join(f'att-{index}' for index in range(21)), 'x' * 4001], +) +def test_attachment_refs_reject_excessive_count_or_total_length(refs): + from app.services.market_support import validate_attachment_refs + + with pytest.raises(ValueError, match='附件'): + validate_attachment_refs( + _session(), + refs, + module='payment', + ctx=_ctx(), + creating=True, + business_id='transfer-limit', + ) + + +def test_all_mobile_attachment_schemas_cap_total_reference_length(): + from pydantic import ValidationError + + from app.schemas.market_mobile import ( + MarketPaymentTransferBody, + MarketPresignTransferBody, + MarketProtocolAttachmentsBody, + MarketSampleStorageCertBody, + ) + + too_long = 'x' * 4001 + payloads = ( + (MarketPresignTransferBody, {'transferAttachments': too_long}), + (MarketProtocolAttachmentsBody, {'protocolAttachments': too_long}), + (MarketPaymentTransferBody, {'transferAttachments': too_long}), + (MarketSampleStorageCertBody, {'attachments': too_long}), + ) + for schema, payload in payloads: + with pytest.raises(ValidationError): + schema.model_validate(payload) + + +def test_update_attachment_refs_allow_exact_existing_refs_and_current_user_new_only(): + from app.services.market_support import validate_attachment_refs + + db = _session() + db.add_all( + [ + _attachment( + id='existing', + file_url='market/specialist/payment/existing.pdf', + create_by='legacy', + business_type='market:specialist:payment:bound', + business_id='transfer-existing', + ), + _attachment(id='new', file_url='market/specialist/payment/new.pdf'), + ] + ) + db.commit() + + out = validate_attachment_refs( + db, + 'existing,new', + module='payment', + ctx=_ctx(), + creating=False, + existing_refs='market/specialist/payment/existing.pdf', + business_id='transfer-existing', + ) + assert out == 'market/specialist/payment/existing.pdf,market/specialist/payment/new.pdf' + + +@pytest.mark.parametrize('metadata_kind', ['missing', 'legacy-unbound', 'other-role-bound']) +def test_update_may_retain_exact_server_supplied_existing_url_without_current_metadata( + metadata_kind, +): + from app.services.market_support import validate_attachment_refs + + db = _session() + existing_url = 'legacy/payment/existing.pdf' + if metadata_kind == 'legacy-unbound': + db.add( + _attachment( + id='legacy-unbound', + file_url=existing_url, + business_type='protocol', + create_by='legacy-user', + ) + ) + elif metadata_kind == 'other-role-bound': + db.add( + _attachment( + id='specialist-bound', + file_url=existing_url, + business_type='market:specialist:payment:bound', + business_id='transfer-existing', + create_by='specialist-user', + ) + ) + db.commit() + + assert validate_attachment_refs( + db, + existing_url, + module='payment', + ctx=_ctx('manager'), + creating=False, + existing_refs=existing_url, + business_id='transfer-existing', + ) == existing_url + + +def test_existing_ref_exception_allows_removal_but_not_arbitrary_url_smuggling(): + from app.services.market_support import validate_attachment_refs + + db = _session() + assert validate_attachment_refs( + db, + '', + module='payment', + ctx=_ctx(), + creating=False, + existing_refs='legacy/payment/existing.pdf', + business_id='transfer-existing', + ) == '' + + with pytest.raises(ValueError, match='附件'): + validate_attachment_refs( + db, + 'https://evil.test/smuggled.pdf', + module='payment', + ctx=_ctx(), + creating=False, + existing_refs='legacy/payment/existing.pdf', + business_id='transfer-existing', + ) + + +def test_allowed_actions_are_state_scope_and_permission_aware(): + from app.services.market_support import allowed_actions + + assert allowed_actions('presign', status='active') == ['edit', 'convert', 'delete'] + assert allowed_actions('presign', status='void') == ['edit', 'delete'] + assert allowed_actions('presign', status='converted') == ['edit'] + assert allowed_actions('presign-transfer', status='dfj') == ['edit', 'delete', 'submit'] + assert allowed_actions('presign-transfer', status='jfqrz') == ['withdraw'] + assert allowed_actions('presign-transfer', status='yjf') == [] + assert allowed_actions('protocol', status='dsx') == ['edit'] + assert allowed_actions('protocol', status='yzz') == [] + assert allowed_actions('payment-transfer', status='dfj') == ['edit-transfer', 'submit-finance'] + assert allowed_actions('payment-transfer', status='jfqrz') == ['withdraw-transfer'] + assert allowed_actions('payment-chase', status='', has_payment_permission=False) == [] + assert allowed_actions('payment-chase', status='', has_payment_permission=True) == ['register-transfer'] + assert allowed_actions('sample', status='', source='research_donation', has_sample_code=True) == [] + assert allowed_actions('sample', status='', source='cell_storage', has_sample_code=True) == ['storage-cert', 'insurance'] + assert allowed_actions('freight', status='', scope_kind='specialist') == [] + assert allowed_actions('freight', status='', scope_kind='manager') == ['edit'] + assert allowed_actions('presign-list', status='') == ['create'] + assert allowed_actions('protocol-list', status='') == ['create'] + assert allowed_actions('freight-list', status='', scope_kind='specialist') == ['create'] + + +def test_presign_actions_include_delete_and_nested_transfer_actions(): + from app.services.market_support import decorate_allowed_actions + + active = decorate_allowed_actions( + 'presign', + { + 'orderStatus': 'active', + 'transfers': [ + {'id': 't1', 'payStatus': 'dfj'}, + {'id': 't2', 'payStatus': 'jfqrz'}, + {'id': 't3', 'payStatus': 'yjf'}, + ], + }, + scope_kind='specialist', + detail=True, + ) + + assert active['allowedActions'] == ['edit', 'convert', 'delete'] + assert [row['allowedActions'] for row in active['transfers']] == [ + ['edit', 'delete', 'submit'], + ['withdraw'], + [], + ] + + +def test_decorate_allowed_actions_covers_nested_payment_transfers(): + from app.services.market_support import decorate_allowed_actions + + detail = { + 'id': 'pay-a', + 'schedules': [ + { + 'id': 'schedule-a', + 'scheduleStatus': 'open', + 'transfers': [ + {'id': 'draft', 'payStatus': 'dfj'}, + {'id': 'submitted', 'payStatus': 'jfqrz'}, + {'id': 'done', 'payStatus': 'yjf'}, + ], + } + ], + } + out = decorate_allowed_actions('payment', detail, scope_kind='specialist') + + assert out['allowedActions'] == ['register-transfer'] + assert out['schedules'][0]['allowedActions'] == ['register-transfer'] + assert [row['allowedActions'] for row in out['schedules'][0]['transfers']] == [ + ['edit-transfer', 'submit-finance'], + ['withdraw-transfer'], + [], + ] + + +@pytest.mark.parametrize('protocol_status', ['yzf', 'sdfq', 'zky']) +def test_payment_terminal_protocol_removes_all_nested_write_actions(protocol_status): + from app.services.market_support import decorate_allowed_actions + + detail = { + 'id': 'pay-terminal', + 'protocolStatus': protocol_status, + 'schedules': [ + { + 'id': 'schedule-open', + 'scheduleStatus': 'open', + 'transfers': [ + {'id': 'draft', 'payStatus': 'dfj'}, + {'id': 'submitted', 'payStatus': 'jfqrz'}, + ], + } + ], + } + + out = decorate_allowed_actions('payment', detail, scope_kind='specialist') + + assert out['allowedActions'] == [] + assert out['schedules'][0]['allowedActions'] == [] + assert [row['allowedActions'] for row in out['schedules'][0]['transfers']] == [[], []] + + +@pytest.mark.parametrize('schedule_status', ['cleared', 'void']) +def test_non_open_payment_schedule_removes_schedule_and_transfer_actions(schedule_status): + from app.services.market_support import decorate_allowed_actions + + detail = { + 'id': 'pay-closed', + 'protocolStatus': 'sxz', + 'schedules': [ + { + 'id': 'schedule-closed', + 'scheduleStatus': schedule_status, + 'transfers': [{'id': 'draft', 'payStatus': 'dfj'}], + } + ], + } + + out = decorate_allowed_actions('payment', detail, scope_kind='specialist') + + assert out['allowedActions'] == [] + assert out['schedules'][0]['allowedActions'] == [] + assert out['schedules'][0]['transfers'][0]['allowedActions'] == [] + + +def test_freight_detail_exposes_only_scope_safe_link_actions(): + from app.services.market_support import decorate_allowed_actions + + specialist = decorate_allowed_actions( + 'freight', + {'id': 'f1', 'allowedActions': ['edit', 'link', 'unlink']}, + scope_kind='specialist', + detail=True, + ) + manager = decorate_allowed_actions( + 'freight', + {'id': 'f1', 'allowedActions': ['link', 'unlink']}, + scope_kind='manager', + detail=True, + ) + assert specialist['allowedActions'] == ['edit', 'link', 'unlink'] + assert manager['allowedActions'] == ['link', 'unlink'] + + +def test_protocol_detail_action_reads_nested_basic_status(): + from app.services.market_support import decorate_allowed_actions + + active = decorate_allowed_actions( + 'protocol', {'basic': {'protocolStatus': 'dsx'}}, scope_kind='specialist' + ) + terminal = decorate_allowed_actions( + 'protocol', {'basic': {'protocolStatus': 'yzf'}}, scope_kind='specialist' + ) + assert active['allowedActions'] == ['edit'] + assert terminal['allowedActions'] == [] + + +def test_market_support_routes_are_fixed_and_registered_before_dynamic_details(): + from app.api.market_mobile import router + + paths = [route.path for route in router.routes] + for scope in ('specialist', 'manager'): + for path in ( + f'/mini/market/{scope}/presign-orders/form-options', + f'/mini/market/{scope}/protocols/form-options', + f'/mini/market/{scope}/payments/form-options', + f'/mini/market/{scope}/samples/form-options', + f'/mini/market/{scope}/freights/form-options', + ): + assert path in paths + for module in ('presign', 'protocol', 'payment', 'sample'): + upload_path = f'/mini/market/{scope}/{module}/attachments' + assert upload_path in paths + upload_route = next(route for route in router.routes if route.path == upload_path) + assert [param.name for param in upload_route.dependant.query_params] == [] + assert paths.index(f'/mini/market/{scope}/presign-orders/form-options') < paths.index( + f'/mini/market/{scope}/presign-orders/{{order_id}}' + ) + assert paths.index(f'/mini/market/{scope}/protocols/form-options') < paths.index( + f'/mini/market/{scope}/protocols/{{protocol_id}}' + ) + assert paths.index(f'/mini/market/{scope}/samples/form-options') < paths.index( + f'/mini/market/{scope}/samples/{{sample_id}}' + ) + + +def test_presign_write_routes_use_fixed_paths_and_methods(): + from app.api.market_mobile import router + + for scope in ('specialist', 'manager'): + expected = { + f'/mini/market/{scope}/presign-orders/{{order_id}}': {'GET', 'PUT', 'DELETE'}, + f'/mini/market/{scope}/presign-orders/{{order_id}}/transfers': {'POST'}, + f'/mini/market/{scope}/presign-transfers/{{transfer_id}}': {'PUT', 'DELETE'}, + f'/mini/market/{scope}/presign-transfers/{{transfer_id}}/submit': {'POST'}, + f'/mini/market/{scope}/presign-transfers/{{transfer_id}}/withdraw': {'POST'}, + } + for path, methods in expected.items(): + registered = { + method + for route in router.routes + if route.path == path + for method in route.methods + } + assert registered == methods + + +def test_presign_write_routes_map_permission_and_validation_errors(monkeypatch): + from app.api import market_mobile + from app.schemas.market_mobile import MarketPresignTransferBody + + delete_route = next( + route + for route in market_mobile.router.routes + if route.path == '/mini/market/specialist/presign-orders/{order_id}' + and route.methods == {'DELETE'} + ) + create_transfer_route = next( + route + for route in market_mobile.router.routes + if route.path + == '/mini/market/specialist/presign-orders/{order_id}/transfers' + ) + monkeypatch.setattr( + market_mobile.market_presign, + 'delete_presign_order', + lambda *args, **kwargs: (_ for _ in ()).throw(PermissionError('forbidden')), + ) + permission_result = asyncio.run( + delete_route.endpoint(order_id='o1', user=_user(), db=SimpleNamespace()) + ) + assert permission_result['code'] == 403 + + monkeypatch.setattr( + market_mobile.market_presign, + 'create_presign_transfer', + lambda *args, **kwargs: (_ for _ in ()).throw(ValueError('invalid')), + ) + validation_result = asyncio.run( + create_transfer_route.endpoint( + order_id='o1', + body=MarketPresignTransferBody(transferAmount=100), + user=_user(), + db=SimpleNamespace(), + ) + ) + assert validation_result['code'] == 400 + + +def test_attachment_routes_read_only_their_module_upload_limit(monkeypatch): + from app.api import market_mobile + + class TrackingUpload: + filename = 'proof.pdf' + content_type = 'application/pdf' + + def __init__(self): + self.read_size = None + + async def read(self, size): + self.read_size = size + return b'%PDF-safe' + + async def close(self): + return None + + monkeypatch.setattr( + market_mobile.market_support, + 'save_market_attachment', + lambda *args, **kwargs: {'id': 'attachment'}, + ) + routes = { + route.path: route + for route in market_mobile.router.routes + if route.path in { + '/mini/market/specialist/presign/attachments', + '/mini/market/specialist/payment/attachments', + } + } + presign_file = TrackingUpload() + payment_file = TrackingUpload() + + asyncio.run( + routes['/mini/market/specialist/presign/attachments'].endpoint( + file=presign_file, user=_user(), db=SimpleNamespace() + ) + ) + asyncio.run( + routes['/mini/market/specialist/payment/attachments'].endpoint( + file=payment_file, user=_user(), db=SimpleNamespace() + ) + ) + + assert presign_file.read_size == 20 * 1024 * 1024 + 1 + assert payment_file.read_size == 10 * 1024 * 1024 + 1