diff --git a/backend/app.pid b/backend/app.pid index 2c710dc5..901b3e35 100644 --- a/backend/app.pid +++ b/backend/app.pid @@ -1 +1 @@ -60905 +73984 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 1687079e..2a4b17a8 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -24,6 +24,7 @@ from app.api.pilot_delivery import router as pilot_delivery_router from app.api.pilot_infusion_confirm import router as pilot_infusion_confirm_router from app.api.pilot_infusion_schedule_mgmt import router as pilot_infusion_schedule_mgmt_router from app.api.outbound_release_review import router as outbound_release_review_router +from app.api.clinical_payment import router as clinical_payment_router __all__ = [ "public_router", @@ -51,4 +52,5 @@ __all__ = [ "pilot_infusion_confirm_router", "pilot_infusion_schedule_mgmt_router", "outbound_release_review_router", + "clinical_payment_router", ] diff --git a/backend/app/api/clinical_payment.py b/backend/app/api/clinical_payment.py new file mode 100644 index 00000000..935c4adc --- /dev/null +++ b/backend/app/api/clinical_payment.py @@ -0,0 +1,337 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.menu_guard import require_admin_or_super_admin +from app.core.response import error_response, success_response +from app.crud import clinical_payment +from app.models.models import SysUser + +router = APIRouter(prefix="/web/admin", tags=["临床应用缴费管理"]) + + +def _get_tenant_no(db: Session, user: dict) -> str: + user_id = user.get("user_id") + if not user_id: + raise PermissionError("用户身份信息缺失,无法确定租户") + sys_user = db.query(SysUser).filter(SysUser.id == user_id).first() + if not sys_user or not sys_user.tenant_no: + raise PermissionError("用户租户信息缺失") + return sys_user.tenant_no + + +class PaymentIdBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + + +class BatchPaymentIdsBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + payment_ids: List[str] = Field(..., alias="paymentIds") + + +class ConfirmReceiptBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + finance_confirm_attachments: Optional[str] = Field(None, alias="financeConfirmAttachments") + finance_remark: Optional[str] = Field(None, alias="financeRemark") + + +class SubmitRefundBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + refund_amount: float = Field(..., alias="refundAmount") + refund_remark: Optional[str] = Field(None, alias="refundRemark") + refund_attachments: Optional[str] = Field(None, alias="refundAttachments") + + +class BatchSubmitRefundBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + payment_ids: List[str] = Field(..., alias="paymentIds") + refund_amount: float = Field(..., alias="refundAmount") + refund_remark: Optional[str] = Field(None, alias="refundRemark") + refund_attachments: Optional[str] = Field(None, alias="refundAttachments") + + +class ConfirmRefundBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + refund_finance_confirm_attachments: Optional[str] = Field( + None, alias="refundFinanceConfirmAttachments" + ) + refund_finance_remark: Optional[str] = Field(None, alias="refundFinanceRemark") + + +@router.get("/clinical-payments") +async def api_list_payments( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + payStatus: Optional[str] = Query(None, alias="payStatus"), + keyword: Optional[str] = Query(None, alias="keyword"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + rows, total = clinical_payment.list_payments( + db, + page=page, + page_size=pageSize, + pay_status=payStatus, + keyword=keyword, + tenant_no=tenant_no, + user_info=user, + ) + return success_response({"rows": rows, "total": total}) + except Exception as e: + return error_response(500, str(e)) + + +@router.get("/clinical-payments/{payment_id}") +async def api_get_payment_detail( + payment_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + result = clinical_payment.get_payment_detail( + db, payment_id=payment_id, tenant_no=tenant_no + ) + if result is None: + return error_response(404, "缴费记录不存在") + return success_response(result) + except ValueError as e: + return error_response(404, str(e)) + + +@router.post("/clinical-payments/{payment_id}/submit-receipt") +async def api_submit_receipt( + payment_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + clinical_payment.submit_receipt( + db, + payment_id=payment_id, + tenant_no=tenant_no, + user_id=user.get("user_id"), + ) + return success_response(None, message="提交缴费确认成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/clinical-payments/submit-receipt-batch") +async def api_batch_submit_receipt( + body: BatchPaymentIdsBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + count = clinical_payment.batch_submit_receipt( + db, + payment_ids=body.payment_ids, + tenant_no=tenant_no, + user_id=user.get("user_id"), + ) + return success_response({"count": count}, message=f"批量提交成功,共{count}条") + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/clinical-payments/{payment_id}/confirm-receipt") +async def api_confirm_receipt( + payment_id: str, + body: ConfirmReceiptBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + clinical_payment.confirm_receipt( + db, + payment_id=payment_id, + tenant_no=tenant_no, + user_id=user.get("user_id"), + finance_confirm_attachments=body.finance_confirm_attachments, + finance_remark=body.finance_remark, + ) + return success_response(None, message="确认收款成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/clinical-payments/{payment_id}/submit-refund") +async def api_submit_refund( + payment_id: str, + body: SubmitRefundBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + clinical_payment.submit_refund( + db, + payment_id=payment_id, + tenant_no=tenant_no, + user_id=user.get("user_id"), + refund_amount=body.refund_amount, + refund_remark=body.refund_remark, + refund_attachments=body.refund_attachments, + ) + return success_response(None, message="提交退费申请成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/clinical-payments/submit-refund-batch") +async def api_batch_submit_refund( + body: BatchSubmitRefundBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + count = clinical_payment.batch_submit_refund( + db, + payment_ids=body.payment_ids, + tenant_no=tenant_no, + user_id=user.get("user_id"), + refund_amount=body.refund_amount, + refund_remark=body.refund_remark, + refund_attachments=body.refund_attachments, + ) + return success_response({"count": count}, message=f"批量提交退费成功,共{count}条") + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/clinical-payments/{payment_id}/confirm-refund") +async def api_confirm_refund( + payment_id: str, + body: ConfirmRefundBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + clinical_payment.confirm_refund( + db, + payment_id=payment_id, + tenant_no=tenant_no, + user_id=user.get("user_id"), + refund_finance_confirm_attachments=body.refund_finance_confirm_attachments, + refund_finance_remark=body.refund_finance_remark, + ) + return success_response(None, message="确认退费成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.get("/clinical-payments/receipt-pending") +async def api_receipt_pending( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = Query(None, alias="keyword"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + rows, total = clinical_payment.list_payments( + db, + page=page, + page_size=pageSize, + pay_status="jfqrz", + keyword=keyword, + tenant_no=tenant_no, + user_info=user, + ) + return success_response({"rows": rows, "total": total}) + except Exception as e: + return error_response(500, str(e)) + + +@router.get("/clinical-payments/receipt-confirmed") +async def api_receipt_confirmed( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = Query(None, alias="keyword"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + rows, total = clinical_payment.list_payments( + db, + page=page, + page_size=pageSize, + pay_status="yjf", + keyword=keyword, + tenant_no=tenant_no, + user_info=user, + ) + return success_response({"rows": rows, "total": total}) + except Exception as e: + return error_response(500, str(e)) + + +@router.get("/clinical-payments/refund-pending") +async def api_refund_pending( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = Query(None, alias="keyword"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + rows, total = clinical_payment.list_payments( + db, + page=page, + page_size=pageSize, + pay_status="tfqrz", + keyword=keyword, + tenant_no=tenant_no, + user_info=user, + ) + return success_response({"rows": rows, "total": total}) + except Exception as e: + return error_response(500, str(e)) + + +@router.get("/clinical-payments/refund-confirmed") +async def api_refund_confirmed( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = Query(None, alias="keyword"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + try: + rows, total = clinical_payment.list_payments( + db, + page=page, + page_size=pageSize, + pay_status="ytf", + keyword=keyword, + tenant_no=tenant_no, + user_info=user, + ) + return success_response({"rows": rows, "total": total}) + except Exception as e: + return error_response(500, str(e)) diff --git a/backend/app/api/menu.py b/backend/app/api/menu.py index 27b88c24..6d6dc5e5 100644 --- a/backend/app/api/menu.py +++ b/backend/app/api/menu.py @@ -1,4 +1,4 @@ -"""运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,供后端菜单模式联调)""" +"""运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,13 个一级菜单)""" from fastapi import APIRouter, Depends from app.core.menu_guard import require_admin_or_super_admin @@ -6,11 +6,559 @@ from app.core.response import success_response router = APIRouter(prefix="/web/menu", tags=["菜单管理"]) -# 与 web-admin/src/router/modules/sqxp.ts 一致:系统管理、基础信息管理 等 +# 与 web-admin/src/router/modules/sqxp.ts 一致:13 个一级菜单 STATIC_MENU_TREE = [ + # ──────────────────────────────────────────── + # 1. 细胞存储 + # ──────────────────────────────────────────── { - "id": "100", - "name": "System", + "id": "CEL00000000000000000000000000001", + "name": "CellStorage", + "path": "/cell-storage", + "component": "/index/index", + "meta": { + "title": "细胞存储", + "icon": "ri:test-tube-line", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "50000000000000000000000000000002", + "name": "CellStorageProtocol", + "path": "protocol", + "component": "/cell-storage/protocol/index", + "meta": {"title": "采集协议单录入", "keepAlive": True, "menuCode": "biz:cellStorageProtocol:list"}, + }, + { + "id": "50000000000000000000000000000003", + "name": "CellStorageProtocolAudit", + "path": "protocol-audit", + "component": "/cell-storage/protocol-audit/index", + "meta": {"title": "采集协议单管理", "keepAlive": True, "menuCode": "biz:cellStorageProtocolAudit:list"}, + }, + { + "id": "50000000000000000000000000000004", + "name": "CellStoragePaymentConfirm", + "path": "payment-confirm", + "component": "/cell-storage/payment-confirm/index", + "meta": {"title": "缴退费财务确认", "keepAlive": True, "menuCode": "biz:cellStoragePaymentConfirm:list"}, + }, + { + "id": "70000000000000000000000000000011", + "name": "CellStorageStorageReleaseReview", + "path": "storage-release-review", + "component": "/quality-inspection/storage-release-review/index", + "meta": {"title": "入库放行审核", "keepAlive": True, "menuCode": "biz:storageReleaseReview:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 2. 科研捐赠 + # ──────────────────────────────────────────── + { + "id": "RSC00000000000000000000000000001", + "name": "ResearchDonation", + "path": "/research-donation", + "component": "/index/index", + "meta": { + "title": "科研捐赠", + "icon": "ri:hand-heart-line", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "60000000000000000000000000000002", + "name": "ResearchDonationList", + "path": "list", + "component": "/research-donation/list/index", + "meta": {"title": "捐赠协议单录入", "keepAlive": True, "menuCode": "biz:researchDonation:list"}, + }, + { + "id": "60000000000000000000000000000003", + "name": "ResearchDonationAudit", + "path": "audit", + "component": "/research-donation/audit/index", + "meta": {"title": "捐赠协议单管理", "keepAlive": True, "menuCode": "biz:researchDonationAudit:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 3. 中试服务 + # ──────────────────────────────────────────── + { + "id": "PIL00000000000000000000000000001", + "name": "PilotService", + "path": "/pilot-service", + "component": "/index/index", + "meta": { + "title": "中试服务", + "icon": "ri:flask-line", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "80000000000000000000000000000010", + "name": "PilotApplication", + "path": "pilot-application", + "component": "/pilot-service/pilot-application/index", + "meta": {"title": "中试服务申请单", "keepAlive": True, "menuCode": "pilot-service:pilot-application:list"}, + }, + { + "id": "PIL00000000000000000000000000004", + "name": "PilotOutboundRelease", + "path": "outbound-release-review", + "component": "/pilot-service/outbound-release-review/index", + "meta": {"title": "出库放行审核", "keepAlive": True, "menuCode": "pilot-service:outbound-release-review:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 4. 临床应用 + # ──────────────────────────────────────────── + { + "id": "CLI00000000000000000000000000001", + "name": "ClinicalApplication", + "path": "/clinical-application", + "component": "/index/index", + "meta": { + "title": "临床应用", + "icon": "ri:heart-pulse-line", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "CLI00000000000000000000000000002", + "name": "ClinicalApplicationList", + "path": "clinical-application", + "component": "/pilot-service/pilot-application/index", + "meta": {"title": "临床申请单", "keepAlive": True, "menuCode": "clinical-application:application:list"}, + }, + { + "id": "CLI00000000000000000000000000003", + "name": "ClinicalCheckupManagement", + "path": "clinical-checkup-management", + "component": "/pilot-service/checkup-management/index", + "meta": {"title": "体检管理", "keepAlive": True, "menuCode": "clinical-application:checkup-management:list"}, + }, + { + "id": "CLI00000000000000000000000000004", + "name": "ClinicalInfusionSchedule", + "path": "clinical-infusion-schedule-management", + "component": "/pilot-service/infusion-schedule-management/index", + "meta": {"title": "输注计划管理", "keepAlive": True, "menuCode": "clinical-application:infusion-schedule-management:list"}, + }, + { + "id": "CLI00000000000000000000000000005", + "name": "ClinicalPaymentInfo", + "path": "clinical-payment-info", + "component": "/pilot-service/clinical-payment-info/index", + "meta": {"title": "缴费信息", "keepAlive": True, "menuCode": "clinical-application:payment-info:list"}, + }, + { + "id": "CLI00000000000000000000000000006", + "name": "ClinicalPaymentConfirm", + "path": "clinical-payment-confirm", + "component": "/pilot-service/clinical-payment-confirm/index", + "meta": {"title": "缴费确认(财务)", "keepAlive": True, "menuCode": "clinical-application:payment-confirm:list"}, + }, + { + "id": "CLI00000000000000000000000000007", + "name": "ClinicalRefundConfirm", + "path": "clinical-refund-confirm", + "component": "/pilot-service/clinical-refund-confirm/index", + "meta": {"title": "退费确认(财务)", "keepAlive": True, "menuCode": "clinical-application:refund-confirm:list"}, + }, + { + "id": "CLI00000000000000000000000000008", + "name": "ClinicalOutboundRelease", + "path": "clinical-outbound-release-review", + "component": "/pilot-service/outbound-release-review/index", + "meta": {"title": "出库放行审核", "keepAlive": True, "menuCode": "clinical-application:outbound-release-review:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 5. 质检管理 + # ──────────────────────────────────────────── + { + "id": "QCM00000000000000000000000000001", + "name": "QualityManage", + "path": "/quality-manage", + "component": "/index/index", + "meta": { + "title": "质检管理", + "icon": "ri:shield-check-line", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "70000000000000000000000000000002", + "name": "QualityInspectionSampleReception", + "path": "sample-reception", + "component": "/quality-inspection/sample-reception/index", + "meta": {"title": "样本接收", "keepAlive": True, "menuCode": "biz:sampleReception:list"}, + }, + { + "id": "70000000000000000000000000000007", + "name": "QualityInspectionQcWorkOrder", + "path": "qc-work-order", + "component": "/quality-inspection/qc-work-order/index", + "meta": {"title": "质检管理", "keepAlive": True, "menuCode": "biz:qcWorkOrder:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 6. 生产管理 + # ──────────────────────────────────────────── + { + "id": "PRM00000000000000000000000000001", + "name": "ProductionManage", + "path": "/production", + "component": "/index/index", + "meta": { + "title": "生产管理", + "icon": "grid", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "61000000000000000000000000000003", + "name": "ProductionStorageFacility", + "path": "storage-facility", + "component": "/operations/storage-facility/index", + "meta": {"title": "库位管理", "keepAlive": True, "menuCode": "operations:storage-facility:list"}, + }, + { + "id": "90000000000000000000000000000013", + "name": "CellProduct", + "path": "cell-product", + "component": "/production/cell-product/index", + "meta": {"title": "细胞产品库", "keepAlive": True, "menuCode": "production:cell-product:list"}, + }, + { + "id": "90000000000000000000000000000011", + "name": "ProductionWorkOrder", + "path": "production-work-order", + "component": "/production/production-work-order/index", + "meta": {"title": "生产管理", "keepAlive": True, "menuCode": "production:work-order:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 7. 运营支撑 + # ──────────────────────────────────────────── + { + "id": "OPS00000000000000000000000000001", + "name": "Operations", + "path": "/operations", + "component": "/index/index", + "meta": { + "title": "运营支撑", + "icon": "operation", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "61000000000000000000000000000002", + "name": "CleanRoom", + "path": "clean-room", + "component": "/operations/clean-room/index", + "meta": {"title": "洁净室管理", "keepAlive": True, "menuCode": "operations:clean-room:list"}, + }, + { + "id": "61000000000000000000000000000004", + "name": "ExhibitionAppointment", + "path": "exhibition-appointment", + "component": "/operations/exhibition-appointment/index", + "meta": {"title": "展馆预约", "keepAlive": True, "menuCode": "operations:exhibition-appointment:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 8. 市场基础信息 + # ──────────────────────────────────────────── + { + "id": "MKT00000000000000000000000000001", + "name": "MarketInfo", + "path": "/market-info", + "component": "/index/index", + "meta": { + "title": "市场基础信息", + "icon": "ri:price-tag-3-line", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "122", + "name": "BizCellSampleType", + "path": "cell-sample-type", + "component": "/basic-info/cell-sample-type/index", + "meta": {"title": "细胞样本基础信息维护", "keepAlive": True, "menuCode": "biz:cellSampleType:list"}, + }, + { + "id": "123", + "name": "BizCollectionRegion", + "path": "collection-region", + "component": "/basic-info/collection-region/index", + "meta": {"title": "细胞采集基础信息维护", "keepAlive": True, "menuCode": "biz:collectionRegion:list"}, + }, + { + "id": "80000000000000000000000000000002", + "name": "CellProductType", + "path": "cell-product-type", + "component": "/pilot-service/cell-product-type/index", + "meta": {"title": "细胞产品基础信息维护", "keepAlive": True, "menuCode": "pilot-service:cell-product-type:list"}, + }, + { + "id": "40000000000000000000000000000007", + "name": "BasicInfoInsuranceCompany", + "path": "insurance-company", + "component": "/basic-info/insurance-company/index", + "meta": {"title": "保险公司及险种价格", "keepAlive": True, "menuCode": "basic-info:insurance-company:list"}, + }, + { + "id": "80000000000000000000000000000007", + "name": "CheckupInstitution", + "path": "checkup-institution", + "component": "/pilot-service/checkup-institution/index", + "meta": {"title": "体检机构及项目价格", "keepAlive": True, "menuCode": "pilot-service:checkup-institution:list"}, + }, + { + "id": "50000000000000000000000000000009", + "name": "MarketMaterial", + "path": "material", + "component": "/cell-storage/material/index", + "meta": {"title": "市场物料库", "keepAlive": True, "menuCode": "biz:material:list"}, + }, + { + "id": "121", + "name": "BizCustomer", + "path": "customer", + "component": "/basic-info/customer/index", + "meta": {"title": "客户管理", "keepAlive": True, "menuCode": "biz:customer:list"}, + }, + { + "id": "80000000000000000000000000000006", + "name": "Channel", + "path": "channel", + "component": "/pilot-service/channel/index", + "meta": {"title": "渠道信息管理", "keepAlive": True, "menuCode": "pilot-service:channel:list"}, + }, + { + "id": "80000000000000000000000000000009", + "name": "ChannelServiceOrderFinance", + "path": "channel-service-order-finance", + "component": "/pilot-service/channel-service-order-finance/index", + "meta": {"title": "渠道服务订单(财务确认)", "keepAlive": True, "menuCode": "pilot-service:channel-service-order-finance:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 9. 质检基础信息 + # ──────────────────────────────────────────── + { + "id": "QCB00000000000000000000000000001", + "name": "QualityBase", + "path": "/quality-base", + "component": "/index/index", + "meta": { + "title": "质检基础信息", + "icon": "ri:clipboard-line", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "70000000000000000000000000000003", + "name": "QualityInspectionSampleReceptionCheckItem", + "path": "sample-reception-check-item", + "component": "/quality-inspection/sample-reception-check-item/index", + "meta": {"title": "接收检查项配置", "keepAlive": True, "menuCode": "biz:sampleReceptionCheckItem:list"}, + }, + { + "id": "70000000000000000000000000000006", + "name": "QualityInspectionLabTestItem", + "path": "lab-test-item", + "component": "/quality-inspection/lab-test-item/index", + "meta": {"title": "质检项目管理", "keepAlive": True, "menuCode": "biz:qcLabTestItem:list"}, + }, + { + "id": "70000000000000000000000000000009", + "name": "QualityInspectionQcMaterial", + "path": "qc-material", + "component": "/quality-inspection/qc-material/index", + "meta": {"title": "质检物料库", "keepAlive": True, "menuCode": "biz:qcMaterial:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 10. 生产基础信息 + # ──────────────────────────────────────────── + { + "id": "PRB00000000000000000000000000001", + "name": "ProductionBase", + "path": "/production-base", + "component": "/index/index", + "meta": { + "title": "生产基础信息", + "icon": "ri:tools-line", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "90000000000000000000000000000002", + "name": "ProductionConsumable", + "path": "consumable", + "component": "/production/consumable/index", + "meta": {"title": "耗材管理", "keepAlive": True, "menuCode": "production:consumable:list"}, + }, + { + "id": "90000000000000000000000000000003", + "name": "ProductionReagent", + "path": "reagent", + "component": "/production/reagent/index", + "meta": {"title": "试剂管理", "keepAlive": True, "menuCode": "production:reagent:list"}, + }, + { + "id": "90000000000000000000000000000004", + "name": "ProductionSop", + "path": "sop", + "component": "/production/sop/index", + "meta": {"title": "SOP文档", "keepAlive": True, "menuCode": "production:sop-document:list"}, + }, + { + "id": "90000000000000000000000000000005", + "name": "ProductionProcessCategory", + "path": "process-category", + "component": "/production/process-category/index", + "meta": {"title": "工艺基础信息维护", "keepAlive": True, "menuCode": "production:process-category:list"}, + }, + { + "id": "90000000000000000000000000000008", + "name": "ProductionProcess", + "path": "process", + "component": "/production/process/index", + "meta": {"title": "工艺库管理", "keepAlive": True, "menuCode": "production:process:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 11. 系统基础信息 + # ──────────────────────────────────────────── + { + "id": "SYSB000000000000000000000000000001", + "name": "SystemBase", + "path": "/system-base", + "component": "/index/index", + "meta": { + "title": "系统基础信息", + "icon": "ri:archive-line", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "107", + "name": "Site", + "path": "site", + "component": "/system/site/index", + "meta": {"title": "站点管理", "keepAlive": True, "menuCode": "system:site:list"}, + }, + { + "id": "109", + "name": "Unit", + "path": "unit", + "component": "/system/unit/index", + "meta": {"title": "物品单位管理", "keepAlive": True, "menuCode": "system:unit:list"}, + }, + { + "id": "110", + "name": "Express", + "path": "express", + "component": "/system/express/index", + "meta": {"title": "快递公司", "keepAlive": True, "menuCode": "system:express:list"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 12. 工作流管理 + # ──────────────────────────────────────────── + { + "id": "WKF00000000000000000000000000001", + "name": "Workflow", + "path": "/workflow", + "component": "/index/index", + "meta": { + "title": "工作流管理", + "icon": "workflow", + "keepAlive": False, + "roles": ["admin"], + }, + "children": [ + { + "id": "10000000000000000000000000000wf01", + "name": "WorkflowProcess", + "path": "process", + "component": "/system/workflow/process/index", + "meta": {"title": "流程配置", "keepAlive": True, "menuCode": "workflow:process"}, + }, + { + "id": "10000000000000000000000000000wf02", + "name": "WorkflowPending", + "path": "pending", + "component": "/system/workflow/task-pending/index", + "meta": {"title": "待办中心", "keepAlive": True, "menuCode": "workflow:pending"}, + }, + { + "id": "10000000000000000000000000000wf03", + "name": "WorkflowCompleted", + "path": "completed", + "component": "/system/workflow/task-completed/index", + "meta": {"title": "已办中心", "keepAlive": True, "menuCode": "workflow:completed"}, + }, + { + "id": "10000000000000000000000000000wf05", + "name": "WorkflowMyApplications", + "path": "my-applications", + "component": "/system/workflow/my-applications/index", + "meta": {"title": "我的申请", "keepAlive": True, "menuCode": "workflow:myApplications"}, + }, + { + "id": "10000000000000000000000000000wf04", + "name": "LeaveRequest", + "path": "leave", + "component": "/system/leave/index", + "meta": {"title": "请假申请", "keepAlive": True, "menuCode": "workflow:leave"}, + }, + ], + }, + + # ──────────────────────────────────────────── + # 13. 系统管理 + # ──────────────────────────────────────────── + { + "id": "SYSM000000000000000000000000000001", + "name": "SystemManage", "path": "/system", "component": "/index/index", "meta": { @@ -34,6 +582,13 @@ STATIC_MENU_TREE = [ "component": "/system/dict/index", "meta": {"title": "字典管理", "keepAlive": True, "menuCode": "system:dict:list"}, }, + { + "id": "106", + "name": "Menu", + "path": "menu", + "component": "/system/menu/index", + "meta": {"title": "菜单管理", "keepAlive": True, "menuCode": "system:menu:list"}, + }, { "id": "103", "name": "Dept", @@ -41,20 +596,6 @@ STATIC_MENU_TREE = [ "component": "/system/dept/index", "meta": {"title": "组织管理", "keepAlive": True, "menuCode": "system:dept:list"}, }, - { - "id": "104", - "name": "User", - "path": "user", - "component": "/system/user/index", - "meta": {"title": "用户管理", "keepAlive": True, "menuCode": "system:user:list"}, - }, - { - "id": "10000000000000000000000000000008", - "name": "UserDetail", - "path": "user/detail", - "component": "/system/user/detail", - "meta": {"title": "用户详情", "isHide": True, "keepAlive": True}, - }, { "id": "105", "name": "Role", @@ -63,18 +604,11 @@ STATIC_MENU_TREE = [ "meta": {"title": "角色管理", "keepAlive": True, "menuCode": "system:role:list"}, }, { - "id": "106", - "name": "Menu", - "path": "menu", - "component": "/system/menu/index", - "meta": {"title": "菜单管理", "keepAlive": True, "menuCode": "system:menu:list"}, - }, - { - "id": "107", - "name": "Site", - "path": "site", - "component": "/system/site/index", - "meta": {"title": "站点管理", "keepAlive": True, "menuCode": "system:site:list"}, + "id": "104", + "name": "User", + "path": "user", + "component": "/system/user/index", + "meta": {"title": "用户管理", "keepAlive": True, "menuCode": "system:user:list"}, }, { "id": "108", @@ -84,175 +618,15 @@ STATIC_MENU_TREE = [ "meta": {"title": "职位管理", "keepAlive": True, "menuCode": "system:post:list"}, }, { - "id": "109", - "name": "Unit", - "path": "unit", - "component": "/system/unit/index", - "meta": {"title": "物品单位管理", "keepAlive": True, "menuCode": "system:unit:list"}, - }, - { - "id": "110", - "name": "Express", - "path": "express", - "component": "/system/express/index", - "meta": {"title": "快递公司", "keepAlive": True, "menuCode": "system:express:list"}, + "id": "10000000000000000000000000000008", + "name": "UserDetail", + "path": "user/detail", + "component": "/system/user/detail", + "meta": {"title": "用户详情", "isHide": True, "keepAlive": True}, }, ], - }, - { - "id": "40000000000000000000000000000001", - "name": "BasicInfo", - "path": "/basic-info", - "component": "/index/index", - "meta": { - "title": "基础信息管理", - "icon": "ri:folder-user-line", - "keepAlive": False, - "roles": ["admin"], - }, - "children": [ - { - "id": "40000000000000000000000000000002", - "name": "BizCustomer", - "path": "customer", - "component": "/basic-info/customer/index", - "meta": {"title": "客户管理", "keepAlive": True, "menuCode": "biz:customer:list"}, - }, - { - "id": "40000000000000000000000000000003", - "name": "BizCellSampleType", - "path": "cell-sample-type", - "component": "/basic-info/cell-sample-type/index", - "meta": {"title": "细胞样本类型管理", "keepAlive": True, "menuCode": "biz:cellSampleType:list"}, - }, - { - "id": "40000000000000000000000000000004", - "name": "BizCollectionRegion", - "path": "collection-region", - "component": "/basic-info/collection-region/index", - "meta": {"title": "采集地区及医院管理", "keepAlive": True, "menuCode": "biz:collectionRegion:list"}, - }, - { - "id": "40000000000000000000000000000005", - "name": "BizCollectionProtocolNo", - "path": "collection-protocol-no", - "component": "/basic-info/collection-protocol-no/index", - "meta": {"title": "采集协议号管理", "keepAlive": True, "menuCode": "biz:collectionProtocolNo:list"}, - }, - { - "id": "40000000000000000000000000000006", - "name": "BizSampleCode", - "path": "sample-code", - "component": "/basic-info/sample-code/index", - "meta": {"title": "样本编码管理", "keepAlive": True, "menuCode": "biz:sampleCode:list"}, - }, - ], - }, - { - "id": "50000000000000000000000000000001", - "name": "CellStorage", - "path": "/cell-storage", - "component": "/index/index", - "meta": { - "title": "细胞存储管理", - "icon": "ri:test-tube-line", - "keepAlive": False, - "roles": ["admin"], - }, - "children": [ - { - "id": "50000000000000000000000000000002", - "name": "CellStorageProtocol", - "path": "protocol", - "component": "/cell-storage/protocol/index", - "meta": {"title": "采集协议单录入", "keepAlive": True}, - }, - { - "id": "50000000000000000000000000000003", - "name": "CellStorageProtocolAudit", - "path": "protocol-audit", - "component": "/cell-storage/protocol-audit/index", - "meta": {"title": "采集协议单管理", "keepAlive": True}, - }, - { - "id": "50000000000000000000000000000007", - "name": "CellStoragePaymentInfo", - "path": "payment-info", - "component": "/cell-storage/payment-info/index", - "meta": {"title": "缴费信息", "keepAlive": True}, - }, - { - "id": "50000000000000000000000000000004", - "name": "CellStoragePaymentConfirm", - "path": "payment-confirm", - "component": "/cell-storage/payment-confirm/index", - "meta": {"title": "缴费确认(财务)", "keepAlive": True}, - }, - { - "id": "50000000000000000000000000000005", - "name": "CellStorageRefundConfirm", - "path": "refund-confirm", - "component": "/cell-storage/refund-confirm/index", - "meta": {"title": "退费确认(财务)", "keepAlive": True}, - }, - ], - }, - { - "id": "70000000000000000000000000000001", - "name": "QualityInspection", - "path": "/quality-inspection", - "component": "/index/index", - "meta": { - "title": "质量检测", - "icon": "ri:shield-check-line", - "keepAlive": False, - "roles": ["admin"], - }, - "children": [ - { - "id": "70000000000000000000000000000002", - "name": "QualityInspectionSampleReception", - "path": "sample-reception", - "component": "/quality-inspection/sample-reception/index", - "meta": {"title": "样本接收", "keepAlive": True}, - }, - { - "id": "70000000000000000000000000000003", - "name": "QualityInspectionSampleReceptionCheckItem", - "path": "sample-reception-check-item", - "component": "/quality-inspection/sample-reception-check-item/index", - "meta": {"title": "接收检查项配置", "keepAlive": True}, - }, - { - "id": "70000000000000000000000000000004", - "name": "QualityInspectionThirdPartyCompany", - "path": "third-party-company", - "component": "/quality-inspection/third-party-company/index", - "meta": {"title": "第三方检测公司及项目", "keepAlive": True, "menuCode": "biz:qcThirdPartyCompany:list"}, - }, - { - "id": "70000000000000000000000000000006", - "name": "QualityInspectionLabTestItem", - "path": "lab-test-item", - "component": "/quality-inspection/lab-test-item/index", - "meta": {"title": "实验室自检项目", "keepAlive": True, "menuCode": "biz:qcLabTestItem:list"}, - }, - { - "id": "70000000000000000000000000000007", - "name": "QualityInspectionQcWorkOrder", - "path": "qc-work-order", - "component": "/quality-inspection/qc-work-order/index", - "meta": {"title": "入库质检工单", "keepAlive": True, "menuCode": "biz:qcWorkOrder:list"}, - }, - { - "id": "70000000000000000000000000000008", - "name": "QualityInspectionQcTask", - "path": "qc-task", - "component": "/quality-inspection/qc-task/index", - "meta": {"title": "质检任务", "keepAlive": True, "menuCode": "biz:qcTask:list"}, - }, - ], - }, + } + ] diff --git a/backend/app/api/pilot_application.py b/backend/app/api/pilot_application.py index c42a4269..fa5cc153 100644 --- a/backend/app/api/pilot_application.py +++ b/backend/app/api/pilot_application.py @@ -44,8 +44,9 @@ class PlanIdBody(BaseModel): class ApplicationCreateBody(BaseModel): model_config = ConfigDict(populate_by_name=True) - channel_service_order_id: str = Field(..., alias="channelServiceOrderId") + channel_service_order_id: Optional[str] = Field(None, alias="channelServiceOrderId") remark: Optional[str] = None + biz_type: Optional[str] = Field(None, alias="bizType") class ApplicationUpdateBody(BaseModel): @@ -111,6 +112,7 @@ class ScheduleCreateBody(BaseModel): plan_id: str = Field(..., alias="infusionPlanId") infusion_time: Optional[str] = Field(None, alias="infusionTime") remark: Optional[str] = None + amount: Optional[float] = None # --- 复合保存 Schema(参考采集协议单一次性保存模式)--- @@ -202,6 +204,7 @@ async def api_list_applications( channelId: Optional[str] = Query(None, alias="channelId"), applicationStatus: Optional[str] = Query(None, alias="applicationStatus"), channelServiceOrderId: Optional[str] = Query(None, alias="channelServiceOrderId"), + bizType: Optional[str] = Query(None, alias="bizType"), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): @@ -215,6 +218,7 @@ async def api_list_applications( application_status=applicationStatus, channel_service_order_id=channelServiceOrderId, keyword=keyword, + biz_type=bizType, ) return success_response(result) @@ -241,8 +245,9 @@ async def api_create_application( try: tenant_no = _get_tenant_no(db, user) data = { - "channelServiceOrderId": body.channel_service_order_id, + "channelServiceOrderId": body.channel_service_order_id or "", "remark": body.remark, + "bizType": body.biz_type or "pilot_service", } result = pilot_application.create_application( db, @@ -638,6 +643,7 @@ async def api_create_schedule( data = { "infusionTime": body.infusion_time, "remark": body.remark, + "amount": body.amount, } result = pilot_application.create_schedule( db, diff --git a/backend/app/crud/clinical_payment.py b/backend/app/crud/clinical_payment.py new file mode 100644 index 00000000..ed1bc6fd --- /dev/null +++ b/backend/app/crud/clinical_payment.py @@ -0,0 +1,383 @@ +"""临床应用缴费 CRUD""" +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import and_, func +from sqlalchemy.orm import Session + +from app.models.clinical_payment import BizPilotPayment +from app.models.models import ( + BizPilotApplication, + BizPilotPatient, + BizPilotInfusionPlan, + BizPilotInfusionSchedule, +) + + +# ─── helpers ────────────────────────────────────────────── + + +def _row_to_dict(row: BizPilotPayment, *, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + d: Dict[str, Any] = { + "id": row.id, + "pilotApplicationId": row.pilot_application_id or "", + "infusionScheduleId": row.infusion_schedule_id or "", + "bizType": row.biz_type or "clinical_application", + "amount": float(row.amount) if row.amount else 0, + "transferType": row.transfer_type or "", + "transferAmount": float(row.transfer_amount) if row.transfer_amount else None, + "transferTime": row.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if row.transfer_time else "", + "transferAttachments": row.transfer_attachments or "", + "payStatus": row.pay_status or "dfj", + "financeConfirmAttachments": row.finance_confirm_attachments or "", + "financeRemark": row.finance_remark or "", + "financeConfirmTime": row.finance_confirm_time.strftime("%Y-%m-%d %H:%M:%S") if row.finance_confirm_time else "", + "refundAmount": float(row.refund_amount) if row.refund_amount else None, + "refundRemark": row.refund_remark or "", + "refundAttachments": row.refund_attachments or "", + "refundApplyBy": row.refund_apply_by or "", + "refundApplyTime": row.refund_apply_time.strftime("%Y-%m-%d %H:%M:%S") if row.refund_apply_time else "", + "refundFinanceConfirmAttachments": row.refund_finance_confirm_attachments or "", + "refundFinanceRemark": row.refund_finance_remark or "", + "refundFinanceConfirmTime": row.refund_finance_confirm_time.strftime("%Y-%m-%d %H:%M:%S") if row.refund_finance_confirm_time else "", + "tenantNo": row.tenant_no or "", + "remark": row.remark or "", + "createBy": row.create_by or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + if extra: + d.update(extra) + return d + + +def _get_or_raise(db: Session, *, payment_id: str, tenant_no: str) -> BizPilotPayment: + row = ( + db.query(BizPilotPayment) + .filter( + and_( + BizPilotPayment.id == payment_id, + BizPilotPayment.tenant_no == tenant_no, + BizPilotPayment.del_flag == 0, + ) + ) + .first() + ) + if not row: + raise ValueError("缴费记录不存在") + return row + + +def _enrich_payment(db: Session, row: BizPilotPayment) -> Dict[str, Any]: + """关联查询申请单、患者、输注计划信息,附加到 dict""" + app_row = ( + db.query(BizPilotApplication) + .filter(BizPilotApplication.id == row.pilot_application_id) + .first() + ) + patient_row = None + schedule_row = ( + db.query(BizPilotInfusionSchedule) + .filter(BizPilotInfusionSchedule.id == row.infusion_schedule_id) + .first() + ) + if app_row: + patient_row = ( + db.query(BizPilotPatient) + .filter( + BizPilotPatient.pilot_application_id == app_row.id, + BizPilotPatient.del_flag == 0, + ) + .first() + ) + extra = { + "applicationNo": app_row.application_no if app_row else "", + "applicationName": app_row.application_name if app_row else "", + "channelName": app_row.channel_name if app_row else "", + "patientName": patient_row.patient_name if patient_row else "", + "cellType": schedule_row.cell_type if schedule_row else "", + "specText": schedule_row.spec_text if schedule_row else "", + "infusionTime": schedule_row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if schedule_row and schedule_row.infusion_time else "", + } + return _row_to_dict(row, extra=extra) + + +# ─── create (auto) ──────────────────────────────────────── + + +def auto_create_payment( + db: Session, + *, + application_id: str, + schedule_id: str, + amount: Any, + tenant_no: str, + user_id: str, + dept_id: str, +) -> Optional[BizPilotPayment]: + """输注计划创建时自动调用,生成 dfj 缴费记录""" + if amount is None or Decimal(str(amount)) <= 0: + return None + now = datetime.now() + row = BizPilotPayment( + id=str(uuid.uuid4()), + pilot_application_id=application_id, + infusion_schedule_id=schedule_id, + biz_type="clinical_application", + amount=Decimal(str(amount)), + pay_status="dfj", + tenant_no=tenant_no, + order_num=0, + create_by=user_id, + create_dept_id=dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(row) + return row + + +# ─── list ───────────────────────────────────────────────── + + +def list_payments( + db: Session, + *, + page: int = 1, + page_size: int = 10, + pay_status: Optional[str] = None, + keyword: Optional[str] = None, + tenant_no: str = "", + user_info=None, +) -> Tuple[List[Dict[str, Any]], int]: + q = ( + db.query(BizPilotPayment) + .filter( + BizPilotPayment.del_flag == 0, + BizPilotPayment.tenant_no == tenant_no, + ) + ) + if pay_status and pay_status.strip(): + q = q.filter(BizPilotPayment.pay_status == pay_status.strip()) + if keyword and keyword.strip(): + kw = f"%{keyword.strip()}%" + sub = ( + db.query(BizPilotApplication.id) + .filter( + BizPilotApplication.del_flag == 0, + BizPilotApplication.application_no.like(kw), + ) + .subquery() + ) + q = q.filter(BizPilotPayment.pilot_application_id.in_(sub)) + if user_info: + from app.core.data_scope import apply_data_scope + q = apply_data_scope(q, BizPilotPayment, user_info, db, tenant_no) + total = q.order_by(None).with_entities(func.count(BizPilotPayment.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = ( + q.order_by(BizPilotPayment.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + out = [_enrich_payment(db, r) for r in rows] + return out, int(total) + + +# ─── submit receipt (dfj → jfqrz) ──────────────────────── + + +def submit_receipt( + db: Session, + *, + payment_id: str, + tenant_no: str, + user_id: Optional[str], +) -> BizPilotPayment: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + if row.pay_status != "dfj": + raise ValueError("仅待缴费状态可提交缴费确认") + row.pay_status = "jfqrz" + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + db.refresh(row) + return row + + +def batch_submit_receipt( + db: Session, + *, + payment_ids: List[str], + tenant_no: str, + user_id: Optional[str], +) -> int: + ids = [str(i).strip() for i in (payment_ids or []) if str(i).strip()] + if not ids: + raise ValueError("请选择缴费记录") + for pid in ids: + row = _get_or_raise(db, payment_id=pid, tenant_no=tenant_no) + if row.pay_status != "dfj": + continue + row.pay_status = "jfqrz" + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + return len(ids) + + +# ─── confirm receipt (jfqrz → yjf) ────────────────────── + + +def confirm_receipt( + db: Session, + *, + payment_id: str, + tenant_no: str, + user_id: Optional[str], + finance_confirm_attachments: Optional[str] = None, + finance_remark: Optional[str] = None, +) -> BizPilotPayment: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + if row.pay_status != "jfqrz": + if row.pay_status == "yjf": + raise ValueError("该记录已确认收款") + raise ValueError("当前状态不可确认收款") + row.pay_status = "yjf" + if finance_confirm_attachments is not None: + row.finance_confirm_attachments = finance_confirm_attachments.strip() or None + if finance_remark is not None: + row.finance_remark = finance_remark.strip() or None + row.finance_confirm_time = datetime.now() + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + db.refresh(row) + return row + + +# ─── submit refund (yjf → tfqrz) ───────────────────────── + + +def submit_refund( + db: Session, + *, + payment_id: str, + tenant_no: str, + user_id: Optional[str], + refund_amount: Any, + refund_remark: Optional[str] = None, + refund_attachments: Optional[str] = None, +) -> BizPilotPayment: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + if row.pay_status != "yjf": + raise ValueError("仅已缴费状态可发起退费") + if refund_amount is None or Decimal(str(refund_amount)) <= 0: + raise ValueError("请填写有效的退费金额") + row.pay_status = "tfqrz" + row.refund_amount = Decimal(str(refund_amount)) + row.refund_remark = (refund_remark or "").strip() or None + row.refund_attachments = (refund_attachments or "").strip() or None + row.refund_apply_by = user_id + row.refund_apply_time = datetime.now() + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + db.refresh(row) + return row + + +def batch_submit_refund( + db: Session, + *, + payment_ids: List[str], + tenant_no: str, + user_id: Optional[str], + refund_amount: Any, + refund_remark: Optional[str] = None, + refund_attachments: Optional[str] = None, +) -> int: + ids = [str(i).strip() for i in (payment_ids or []) if str(i).strip()] + if not ids: + raise ValueError("请选择缴费记录") + for pid in ids: + row = _get_or_raise(db, payment_id=pid, tenant_no=tenant_no) + if row.pay_status != "yjf": + continue + if refund_amount is None or Decimal(str(refund_amount)) <= 0: + continue + row.pay_status = "tfqrz" + row.refund_amount = Decimal(str(refund_amount)) + row.refund_remark = (refund_remark or "").strip() or None + row.refund_attachments = (refund_attachments or "").strip() or None + row.refund_apply_by = user_id + row.refund_apply_time = datetime.now() + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + return len(ids) + + +# ─── confirm refund (tfqrz → ytf) ─────────────────────── + + +def confirm_refund( + db: Session, + *, + payment_id: str, + tenant_no: str, + user_id: Optional[str], + refund_finance_confirm_attachments: Optional[str] = None, + refund_finance_remark: Optional[str] = None, +) -> BizPilotPayment: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + if row.pay_status != "tfqrz": + if row.pay_status == "ytf": + raise ValueError("该记录已确认退费") + raise ValueError("当前状态不可确认退费") + row.pay_status = "ytf" + if refund_finance_confirm_attachments is not None: + row.refund_finance_confirm_attachments = refund_finance_confirm_attachments.strip() or None + if refund_finance_remark is not None: + row.refund_finance_remark = refund_finance_remark.strip() or None + row.refund_finance_confirm_time = datetime.now() + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + db.refresh(row) + return row + + +# ─── detail ─────────────────────────────────────────────── + + +def get_payment_detail(db: Session, *, payment_id: str, tenant_no: str) -> Optional[Dict[str, Any]]: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + return _enrich_payment(db, row) + + +def get_payment_by_schedule(db: Session, *, schedule_id: str, tenant_no: str) -> Optional[Dict[str, Any]]: + row = ( + db.query(BizPilotPayment) + .filter( + BizPilotPayment.infusion_schedule_id == schedule_id, + BizPilotPayment.tenant_no == tenant_no, + BizPilotPayment.del_flag == 0, + ) + .first() + ) + if not row: + return None + return _enrich_payment(db, row) diff --git a/backend/app/crud/pilot_application.py b/backend/app/crud/pilot_application.py index d821268f..661b3f42 100644 --- a/backend/app/crud/pilot_application.py +++ b/backend/app/crud/pilot_application.py @@ -71,9 +71,9 @@ def _release_application_no_for_soft_delete(application_no: str, row_id: str) -> return f"{base[:max_base_len]}{suffix}" -def _generate_application_no(db: Session, tenant_no: str) -> str: +def _generate_application_no(db: Session, tenant_no: str, biz_type: str = "pilot_service") -> str: today_str = datetime.now().strftime("%Y%m%d") - prefix = f"ZSSQ{today_str}" + prefix = f"LCSQ{today_str}" if biz_type == "clinical_application" else f"ZSSQ{today_str}" existing = ( db.query(BizPilotApplication.application_no) .filter( @@ -99,6 +99,7 @@ def _row_to_dict_application(row, db=None, tenant_no=None) -> Dict[str, Any]: "applicationNo": row.application_no or "", "applicationName": row.application_name or "", "applicationStatus": row.application_status or "", + "bizType": row.biz_type or "pilot_service", "channelServiceOrderId": row.channel_service_order_id or "", "channelId": row.channel_id or "", "channelName": row.channel_name or "", @@ -268,6 +269,7 @@ def _row_to_dict_infusion_schedule(row, cell_products: Optional[List[Dict[str, A "qcStatus": row.qc_status or "dzc", "cellProducts": cell_products if cell_products is not None else [], "attachments": row.attachments or "", + "amount": float(row.amount) if row.amount else None, "tenantNo": row.tenant_no or "", "remark": row.remark or "", "createBy": row.create_by or "", @@ -290,6 +292,7 @@ def list_applications( application_status: Optional[str] = None, channel_service_order_id: Optional[str] = None, keyword: Optional[str] = None, + biz_type: Optional[str] = None, ): q = db.query(BizPilotApplication).filter( BizPilotApplication.tenant_no == tenant_no, @@ -303,6 +306,8 @@ def list_applications( q = q.filter(BizPilotApplication.application_status.in_(statuses)) if channel_service_order_id: q = q.filter(BizPilotApplication.channel_service_order_id == channel_service_order_id) + if biz_type: + q = q.filter(BizPilotApplication.biz_type == biz_type) kw = (keyword or "").strip() if kw: like = f"%{kw}%" @@ -390,25 +395,28 @@ def create_application( dept_id: str, ) -> Dict[str, Any]: channel_service_order_id = data.get("channelServiceOrderId") or "" - if not channel_service_order_id: + biz_type = data.get("bizType") or "pilot_service" + if not channel_service_order_id and biz_type != "clinical_application": raise ValueError("请选择渠道服务订单") # Validate service order exists and is active - svc_order = ( - db.query(BizChannelServiceOrder) - .filter( - BizChannelServiceOrder.id == channel_service_order_id, - BizChannelServiceOrder.del_flag == 0, + svc_order = None + if channel_service_order_id: + svc_order = ( + db.query(BizChannelServiceOrder) + .filter( + BizChannelServiceOrder.id == channel_service_order_id, + BizChannelServiceOrder.del_flag == 0, + ) + .first() ) - .first() - ) - if not svc_order: - raise ValueError("渠道服务订单不存在") - if svc_order.order_status != "sxz": - raise ValueError("渠道服务订单未生效,无法创建中试申请") + if not svc_order: + raise ValueError("渠道服务订单不存在") + if svc_order.order_status != "sxz": + raise ValueError("渠道服务订单未生效,无法创建中试申请") now = datetime.now() - application_no = _generate_application_no(db, tenant_no) + application_no = _generate_application_no(db, tenant_no, biz_type=biz_type) row = BizPilotApplication( id=str(uuid.uuid4()), @@ -416,8 +424,8 @@ def create_application( application_name=data.get("applicationName") or "", application_status="dks", channel_service_order_id=channel_service_order_id, - channel_id=svc_order.channel_id or "", - channel_name=svc_order.channel_name or "", + channel_id=svc_order.channel_id or "" if svc_order else "", + channel_name=svc_order.channel_name or "" if svc_order else "", tenant_no=tenant_no, order_num=0, remark=data.get("remark") or "", @@ -427,6 +435,7 @@ def create_application( update_by=user_id, update_time=now, del_flag=0, + biz_type=biz_type, ) db.add(row) db.commit() @@ -1129,6 +1138,7 @@ def create_schedule( infusion_status=data.get("infusionStatus") or "dtz", qc_status=data.get("qcStatus") or "dzc", attachments=data.get("attachments") or "", + amount=data.get("amount") if data.get("amount") else None, tenant_no=tenant_no, order_num=data.get("sortNo") or data.get("orderNum") or 0, remark=data.get("remark") or "", @@ -1164,6 +1174,33 @@ def create_schedule( user_id=user_id, create_dept_id=dept_id, ) + # 临床应用自动创建缴费记录 + plan_row = ( + db.query(BizPilotInfusionPlan) + .filter(BizPilotInfusionPlan.id == plan_id, BizPilotInfusionPlan.del_flag == 0) + .first() + ) + if plan_row: + application = ( + db.query(BizPilotApplication) + .filter( + BizPilotApplication.id == plan_row.pilot_application_id, + BizPilotApplication.del_flag == 0, + ) + .first() + ) + if application and application.biz_type == "clinical_application" and data.get("amount"): + from app.crud.clinical_payment import auto_create_payment + + auto_create_payment( + db, + application_id=application.id, + schedule_id=schedule_id, + amount=data["amount"], + tenant_no=tenant_no, + user_id=user_id, + dept_id=dept_id, + ) if commit: db.commit() db.refresh(row) diff --git a/backend/app/crud/production_work_order.py b/backend/app/crud/production_work_order.py index 77cbb7c1..2f373411 100644 --- a/backend/app/crud/production_work_order.py +++ b/backend/app/crud/production_work_order.py @@ -179,6 +179,49 @@ def _enrich_outbound_items(db: Session, items: List[Dict[str, Any]]) -> List[Dic return items +def _outbound_work_order_ids_by_keyword(db: Session, tenant_no: str, keyword: str) -> List[str]: + """出库工单关键词:按关联申请单号、申请单名称匹配。""" + from app.models.models import ( + BizPilotApplication, + BizPilotInfusionPlan, + BizPilotInfusionSchedule, + ) + + kw = (keyword or "").strip() + if not kw: + return [] + like = f"%{kw}%" + rows = ( + db.query(BizProductionWorkOrder.id) + .join( + BizPilotInfusionSchedule, + BizProductionWorkOrder.infusion_schedule_id == BizPilotInfusionSchedule.id, + ) + .join( + BizPilotInfusionPlan, + BizPilotInfusionSchedule.infusion_plan_id == BizPilotInfusionPlan.id, + ) + .join( + BizPilotApplication, + BizPilotInfusionPlan.pilot_application_id == BizPilotApplication.id, + ) + .filter( + BizProductionWorkOrder.del_flag == 0, + BizProductionWorkOrder.tenant_no == tenant_no, + BizProductionWorkOrder.work_order_type == "outbound", + BizPilotInfusionSchedule.del_flag == 0, + BizPilotInfusionPlan.del_flag == 0, + BizPilotApplication.del_flag == 0, + or_( + BizPilotApplication.application_no.like(like), + BizPilotApplication.application_name.like(like), + ), + ) + .all() + ) + return [r[0] for r in rows] + + def _reception_source_label(source: Optional[str]) -> str: key = (source or "").strip() or "cell_storage" return _RECEPTION_SOURCE_LABELS.get(key, key) @@ -410,13 +453,15 @@ def list_production_work_orders( kw = (query.keyword or "").strip() if kw: like = f"%{kw}%" - q0 = q0.filter( - or_( - BizProductionWorkOrder.work_order_no.like(like), - BizProductionWorkOrder.protocol_no.like(like), - BizProductionWorkOrder.sample_code.like(like), - ) - ) + keyword_conditions = [ + BizProductionWorkOrder.work_order_no.like(like), + BizProductionWorkOrder.protocol_no.like(like), + BizProductionWorkOrder.sample_code.like(like), + ] + outbound_ids = _outbound_work_order_ids_by_keyword(db, tenant_no, kw) + if outbound_ids: + keyword_conditions.append(BizProductionWorkOrder.id.in_(outbound_ids)) + q0 = q0.filter(or_(*keyword_conditions)) elif query.work_order_no and query.work_order_no.strip(): like = f"%{query.work_order_no.strip()}%" q0 = q0.filter(BizProductionWorkOrder.work_order_no.like(like)) diff --git a/backend/app/crud/qc_work_order.py b/backend/app/crud/qc_work_order.py index 8af17313..ec420982 100644 --- a/backend/app/crud/qc_work_order.py +++ b/backend/app/crud/qc_work_order.py @@ -195,6 +195,45 @@ def _enrich_outbound_work_orders( return items +def _outbound_qc_work_order_ids_by_keyword( + db: Session, tenant_no: str, keyword: str +) -> List[str]: + """出库质检工单关键词:按关联申请单号、申请单名称匹配。""" + kw = (keyword or "").strip() + if not kw: + return [] + like = f"%{kw}%" + rows = ( + db.query(BizQcWorkOrder.id) + .join( + BizPilotInfusionSchedule, + BizQcWorkOrder.infusion_schedule_id == BizPilotInfusionSchedule.id, + ) + .join( + BizPilotInfusionPlan, + BizPilotInfusionSchedule.infusion_plan_id == BizPilotInfusionPlan.id, + ) + .join( + BizPilotApplication, + BizPilotInfusionPlan.pilot_application_id == BizPilotApplication.id, + ) + .filter( + BizQcWorkOrder.del_flag == 0, + BizQcWorkOrder.tenant_no == tenant_no, + BizQcWorkOrder.work_order_type == "outbound", + BizPilotInfusionSchedule.del_flag == 0, + BizPilotInfusionPlan.del_flag == 0, + BizPilotApplication.del_flag == 0, + or_( + BizPilotApplication.application_no.like(like), + BizPilotApplication.application_name.like(like), + ), + ) + .all() + ) + return [r[0] for r in rows] + + def _accept_conclusion_label(v: Optional[str]) -> str: key = (v or "").strip() return _ACCEPT_CONCLUSION_LABELS.get(key, key) @@ -565,13 +604,16 @@ def list_work_orders( kw = (keyword or "").strip() if kw: like = f"%{kw}%" - q0 = q0.filter( - or_( - BizQcWorkOrder.work_order_no.like(like), - BizQcWorkOrder.protocol_no.like(like), - BizQcWorkOrder.sample_code.like(like), - ) - ) + keyword_conditions = [ + BizQcWorkOrder.work_order_no.like(like), + BizQcWorkOrder.protocol_no.like(like), + BizQcWorkOrder.sample_code.like(like), + ] + if tenant_no: + outbound_ids = _outbound_qc_work_order_ids_by_keyword(db, tenant_no, kw) + if outbound_ids: + keyword_conditions.append(BizQcWorkOrder.id.in_(outbound_ids)) + q0 = q0.filter(or_(*keyword_conditions)) st = (work_order_status or "").strip() if st: q0 = q0.filter(BizQcWorkOrder.work_order_status == st) diff --git a/backend/app/main.py b/backend/app/main.py index 1569a72e..97b1d734 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, leave_router, operations_router, ethnic_router, sample_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, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router +from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_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, 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 @asynccontextmanager @@ -123,6 +123,7 @@ app.include_router(pilot_delivery_router, prefix=settings.API_V1_PREFIX) app.include_router(pilot_infusion_confirm_router, prefix=settings.API_V1_PREFIX) app.include_router(pilot_infusion_schedule_mgmt_router, prefix=settings.API_V1_PREFIX) app.include_router(outbound_release_review_router, prefix=settings.API_V1_PREFIX) +app.include_router(clinical_payment_router, prefix=settings.API_V1_PREFIX) def _mask_secret(value: str) -> str: diff --git a/backend/app/models/clinical_payment.py b/backend/app/models/clinical_payment.py new file mode 100644 index 00000000..7c8dc78e --- /dev/null +++ b/backend/app/models/clinical_payment.py @@ -0,0 +1,41 @@ +"""临床应用缴费记录模型""" +from sqlalchemy import Column, String, Text, SmallInteger, Numeric, DateTime +from sqlalchemy.sql import func +from app.models.base import Base + + +class BizPilotPayment(Base): + """临床应用缴费记录 biz_pilot_payment""" + + __tablename__ = "biz_pilot_payment" + + id = Column(String(64), primary_key=True, comment="主键ID") + pilot_application_id = Column(String(64), nullable=False, comment="临床申请单ID") + infusion_schedule_id = Column(String(64), nullable=False, comment="输注计划ID") + biz_type = Column(String(20), nullable=False, default="clinical_application", comment="业务类型") + amount = Column(Numeric(18, 2), nullable=False, comment="缴费金额") + transfer_type = Column(String(16), nullable=True, comment="转账方式: wxzz/zzfbzz/yhkzz/xj") + transfer_amount = Column(Numeric(18, 2), nullable=True, comment="实际转账金额") + transfer_time = Column(DateTime, nullable=True, comment="转账时间") + transfer_attachments = Column(Text, nullable=True, comment="转账凭证附件") + pay_status = Column(String(32), nullable=False, default="dfj", comment="缴费状态: dfj/jfqrz/yjf/tfqrz/ytf") + finance_confirm_attachments = Column(Text, nullable=True, comment="财务确认收款附件") + finance_remark = Column(String(512), nullable=True, comment="财务确认收款备注") + finance_confirm_time = Column(DateTime, nullable=True, comment="财务确认收款时间") + refund_amount = Column(Numeric(18, 2), nullable=True, comment="退费金额") + refund_remark = Column(String(512), nullable=True, comment="退费备注") + refund_attachments = Column(Text, nullable=True, comment="退费附件") + refund_apply_by = Column(String(64), nullable=True, comment="退费申请人ID") + refund_apply_time = Column(DateTime, nullable=True, comment="退费申请时间") + refund_finance_confirm_attachments = Column(Text, nullable=True, comment="退费财务确认附件") + refund_finance_remark = Column(String(512), nullable=True, comment="退费财务确认备注") + refund_finance_confirm_time = Column(DateTime, nullable=True, comment="退费财务确认时间") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(SmallInteger, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, server_default=func.now(), comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 4b6963e0..4b651126 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -2826,6 +2826,7 @@ class BizPilotApplication(Base): channel_service_order_id = Column(String(64), nullable=False, comment="渠道服务购买订单ID") channel_id = Column(String(64), nullable=True, comment="渠道ID(缓存自服务订单)") channel_name = Column(String(200), nullable=True, comment="渠道名称(缓存)") + biz_type = Column(String(20), nullable=False, default="pilot_service", comment="业务类型: pilot_service=中试服务, clinical_application=临床应用") tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") order_num = Column(Integer, nullable=True, comment="排序号") remark = Column(String(512), nullable=True, comment="备注") @@ -2907,6 +2908,7 @@ class BizPilotInfusionSchedule(Base): infusion_status = Column(String(16), nullable=False, default="dtz", comment="dtz待输注 yrqr已用户确认 yqx已取消 ywc已完成") cancel_reason = Column(String(512), nullable=True, default=None, comment="取消原因") qc_status = Column(String(16), nullable=False, default="dzc", comment="dzc待质检 yzj已质检 yqx已取消") + amount = Column(Numeric(18, 2), nullable=True, comment="临床应用-手动填写的输注金额(中试为NULL)") attachments = Column(String(4000), nullable=True, comment="附件逗号分隔") tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") order_num = Column(Integer, nullable=True, comment="排序号") @@ -3251,3 +3253,4 @@ class BizOutboundReleaseReview(Base): from app.models.qc_param import BizQcItemParam, BizQcTaskResult # noqa: E402,F401 from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage # noqa: E402,F401 from app.models.material import BizMaterial, BizMaterialStockLog # noqa: E402,F401 +from app.models.clinical_payment import BizPilotPayment # noqa: E402,F401 diff --git a/backend/scripts/reorder_web_menus.py b/backend/scripts/reorder_web_menus.py new file mode 100644 index 00000000..ccd5be5f --- /dev/null +++ b/backend/scripts/reorder_web_menus.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""重排 Web 端一级菜单顺序,并生成 sys_menu 初始化 SQL。""" +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MENU_PY = ROOT / "backend/app/api/menu.py" +SQXP_TS = ROOT / "web-admin/src/router/modules/sqxp.ts" +SQL_OUT = ROOT / "docs/sql/sys_menu_web_init.sql" + +BLOCK_ORDER = [2, 3, 4, 5, 7, 9, 10, 1, 6, 8, 11, 12, 13] + +BLOCK_TITLES = { + 1: "市场基础信息", + 2: "细胞存储", + 3: "科研捐赠", + 4: "中试服务", + 5: "临床应用", + 6: "质检基础信息", + 7: "质检管理", + 8: "生产基础信息", + 9: "生产管理", + 10: "运营支撑", + 11: "系统基础信息", + 12: "工作流管理", + 13: "系统管理", +} + +SQXP_HEADER = """import type { AppRouteRecord } from '@/types/router' + +/** + * 前端路由(13 个一级菜单:细胞存储、科研捐赠、中试服务、临床应用、 + * 质检管理、生产管理、运营支撑、市场基础信息、质检基础信息、生产基础信息、 + * 系统基础信息、工作流管理、系统管理) + * 与后端菜单对齐时请同步 `backend/app/api/menu.py`。 + */ +export const sqxpAdminRoutes: AppRouteRecord[] = [ +""" + +MENU_TAIL = ( + "\n]\n\n\n" + '@router.get("/list")\n' + "async def get_menu_list(_user: dict = Depends(require_admin_or_super_admin)):\n" + ' """获取菜单列表(与当前前端路由一致)"""\n' + ' return success_response({"menuList": STATIC_MENU_TREE})\n' +) + +BLOCK_RE_TS = re.compile( + r" // ──+\n // (\d+)\. ([^\n]+)\n // ──+\n", + re.MULTILINE, +) +BLOCK_RE_PY = re.compile( + r" # ──+\n # (\d+)\. ([^\n]+)\n # ──+\n", + re.MULTILINE, +) + + +def split_menu_blocks( + content: str, array_start_marker: str, block_re: re.Pattern +) -> tuple[str, dict[int, str]]: + start = content.index(array_start_marker) + len(array_start_marker) + depth = 1 + i = start + while i < len(content) and depth > 0: + if content[i] == "[": + depth += 1 + elif content[i] == "]": + depth -= 1 + i += 1 + array_body = content[start : i - 1] + prefix = content[:start] + + parts = block_re.split(array_body) + blocks: dict[int, str] = {} + idx = 1 + while idx < len(parts): + num = int(parts[idx]) + block = parts[idx + 2].rstrip() + if block.endswith(","): + block = block[:-1] + blocks[num] = block.rstrip() + idx += 3 + return prefix, blocks + + +def reorder_blocks(blocks: dict[int, str]) -> list[tuple[int, str, str]]: + ordered = [] + for new_idx, old_num in enumerate(BLOCK_ORDER, start=1): + ordered.append((new_idx, BLOCK_TITLES[old_num], blocks[old_num])) + return ordered + + +def assemble_blocks(ordered: list[tuple[int, str, str]], *, ts: bool) -> str: + chunks = [] + for num, title, body in ordered: + if ts: + sep = " // " + "─" * 46 + chunks.append(f"{sep}\n // {num}. {title}\n{sep}\n{body}") + else: + sep = " # " + "─" * 44 + chunks.append(f"{sep}\n # {num}. {title}\n{sep}\n{body}") + return ",\n\n".join(chunks) + "\n" + + +def load_static_menu_tree() -> list: + content = MENU_PY.read_text(encoding="utf-8") + marker = "STATIC_MENU_TREE = " + start = content.index(marker) + len(marker) + depth = 0 + i = start + while i < len(content): + ch = content[i] + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + i += 1 + break + i += 1 + literal = content[start:i] + return ast.literal_eval(literal) + + +def sql_escape(val: str | None) -> str: + if val is None: + return "NULL" + return "'" + str(val).replace("'", "''") + "'" + + +def walk_routes(nodes: list, parent_id: str, order_counter: dict, rows: list) -> None: + for node in nodes: + node_id = node["id"] + meta = node.get("meta") or {} + title = meta.get("title") or node.get("name") or "" + path = node.get("path") or "" + component = node.get("component") or "" + children = node.get("children") or [] + is_hide = meta.get("isHide") is True + menu_code = meta.get("menuCode") + perms = menu_code if menu_code else None + + if parent_id == "2": + order_counter["web"] += 1 + order_num = order_counter["web"] + else: + order_counter.setdefault(parent_id, 0) + order_counter[parent_id] += 1 + order_num = order_counter[parent_id] + + menu_type = "M" if children else "C" + visible = "0" if is_hide else "1" + + rows.append( + ( + node_id, + title, + parent_id, + menu_type, + path, + component or None, + perms, + visible, + order_num, + ) + ) + + if children: + walk_routes(children, node_id, order_counter, rows) + + +def generate_sql(tree: list) -> str: + rows: list = [] + order_counter: dict = {"web": 0} + walk_routes(tree, "2", order_counter, rows) + + lines = [ + "-- Web 端 sys_menu 初始化(根节点 + 13 个一级业务菜单及子菜单)", + "-- 生成:backend/scripts/reorder_web_menus.py", + "-- 一级顺序:细胞存储 → 科研捐赠 → 中试服务 → 临床应用 → 质检管理 → 生产管理", + "-- → 运营支撑 → 市场基础信息 → 质检基础信息 → 生产基础信息", + "-- → 系统基础信息 → 工作流管理 → 系统管理", + "", + "SET NAMES utf8mb4;", + "", + "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`) VALUES", + "('1', '移动端菜单', '0', 'M', '/mini', NULL, NULL, 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0),", + "('2', 'Web端菜单', '0', 'M', '/web', NULL, NULL, 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0);", + "", + "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`) VALUES", + ] + + value_lines = [] + for row in rows: + ( + node_id, + title, + parent_id, + menu_type, + path, + component, + perms, + visible, + order_num, + ) = row + value_lines.append( + f"({sql_escape(node_id)}, {sql_escape(title)}, {sql_escape(parent_id)}, " + f"{sql_escape(menu_type)}, {sql_escape(path)}, {sql_escape(component)}, " + f"{sql_escape(perms)}, 1, {sql_escape(visible)}, '1', 'zkxw', {order_num}, " + f"NULL, NULL, '', NOW(), NULL, NOW(), 0)" + ) + + lines.append(",\n".join(value_lines) + ";") + lines.append("") + return "\n".join(lines) + + +def main() -> None: + menu_content = MENU_PY.read_text(encoding="utf-8") + pre, blocks = split_menu_blocks(menu_content, "STATIC_MENU_TREE = [\n", BLOCK_RE_PY) + body = assemble_blocks(reorder_blocks(blocks), ts=False) + MENU_PY.write_text(pre + body + MENU_TAIL, encoding="utf-8") + print(f"updated {MENU_PY.relative_to(ROOT)}") + + ts_content = SQXP_TS.read_text(encoding="utf-8") + _, blocks_ts = split_menu_blocks( + ts_content, "export const sqxpAdminRoutes: AppRouteRecord[] = [\n", BLOCK_RE_TS + ) + body_ts = assemble_blocks(reorder_blocks(blocks_ts), ts=True) + SQXP_TS.write_text(SQXP_HEADER + body_ts + "]\n", encoding="utf-8") + print(f"updated {SQXP_TS.relative_to(ROOT)}") + + tree = load_static_menu_tree() + sql = generate_sql(tree) + SQL_OUT.write_text(sql, encoding="utf-8") + row_count = sql.count("('") - 2 # minus root inserts + print(f"wrote {SQL_OUT.relative_to(ROOT)} (~{row_count} menu rows, {len(tree)} top-level menus)") + + +if __name__ == "__main__": + main() diff --git a/docs/demos/consumable-merge-plan-b-demo.html b/docs/demos/consumable-merge-plan-b-demo.html new file mode 100644 index 00000000..d0eeeac6 --- /dev/null +++ b/docs/demos/consumable-merge-plan-b-demo.html @@ -0,0 +1,953 @@ + + + + + + 方案 B · 耗材管理融合页 UI Demo + + + + + + +
+ + +
+
+ 生产管理 / 耗材管理 + 方案 B 静态 Demo · 非真实数据 +
+ +
+
+ 方案 B:两级 Tab — 一级区分「耗材 / 耗材包」;仅在「耗材」下出现二级 Tab「耗材列表 / + 变动记录」。侧栏合并后仅保留一个菜单「耗材管理」(灰线划掉的为将移除的菜单项示意)。 + 用浏览器直接打开本文件即可预览,路径:docs/demos/consumable-merge-plan-b-demo.html +
+ +
+
+

耗材管理

+ 融合页 · 方案 B +
+ + +
+ + +
+ + +
+
耗材视图
+
+ + +
+
+ +
+ 当前结构:一级「耗材」 → 二级「耗材列表」(筛选 + 工具栏 + 表格 + 分页) +
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ 按编码、名称模糊查询,可按状态筛选。 +
+ + +
+
+
+
+
+ 已选 0 项 + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
序号耗材编码耗材名称规格型号当前库存库存状态状态操作
1HC-2024-001细胞培养瓶 25cm²25cm²128正常启用 + + + +
2HC-2024-018无菌离心管 15mL15mL12库存预警启用 + + + +
3HC-2023-092胰酶-EDTA 0.25%100mL45正常停用 + + + +
+
+ +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ 按耗材编码/名称、变动类型和时间范围查询变动记录。 +
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
序号耗材编码耗材名称变动类型变动数量变动后库存操作人变动时间
1HC-2024-001细胞培养瓶 25cm²入库+50128张三2026-05-26 14:32
2HC-2024-018无菌离心管 15mL出库-2012李四2026-05-25 09:15
+
+ +
+
+ + +
+
+
+
+ + +
+
+
+ 按编号、名称模糊查询。 +
+ + +
+
+
+
+
+ 已选 0 项 + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
序号编号名称使用场景类型状态操作
1PK-STD-01标准细胞制备包间充质干细胞制备标准耗材包启用 + + + +
2PK-CUS-12质检取样专用包质检取样自定义耗材包启用 + + + +
+
+ +
+
+
+
+
+
+ + + + diff --git a/docs/sql/sys_menu_web_init.sql b/docs/sql/sys_menu_web_init.sql new file mode 100644 index 00000000..07aa9813 --- /dev/null +++ b/docs/sql/sys_menu_web_init.sql @@ -0,0 +1,112 @@ +-- Web 端 sys_menu 初始化(根节点 + 13 个一级业务菜单及子菜单) +-- 生成:backend/scripts/reorder_web_menus.py +-- 一级顺序:细胞存储 → 科研捐赠 → 中试服务 → 临床应用 → 质检管理 → 生产管理 +-- → 运营支撑 → 市场基础信息 → 质检基础信息 → 生产基础信息 +-- → 系统基础信息 → 工作流管理 → 系统管理 + +SET NAMES utf8mb4; + +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`) VALUES +('1', '移动端菜单', '0', 'M', '/mini', NULL, NULL, 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('2', 'Web端菜单', '0', 'M', '/web', NULL, NULL, 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0); + +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`) VALUES +('CEL00000000000000000000000000001', '细胞存储', '2', 'M', '/cell-storage', '/index/index', NULL, 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('50000000000000000000000000000002', '采集协议单录入', 'CEL00000000000000000000000000001', 'C', 'protocol', '/cell-storage/protocol/index', 'biz:cellStorageProtocol:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('50000000000000000000000000000003', '采集协议单管理', 'CEL00000000000000000000000000001', 'C', 'protocol-audit', '/cell-storage/protocol-audit/index', 'biz:cellStorageProtocolAudit:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 缴费信息/样本接收/样本入库/储存证/满月待办已并入采集协议单管理 Tab;已有库可执行: +-- UPDATE sys_menu SET del_flag=1 WHERE id IN ('904b5173519411f1944300163e3bdd96','50000000000000000000000000000011','50000000000000000000000000000012','50000000000000000000000000000006','50000000000000000000000000000008'); +('50000000000000000000000000000004', '缴退费财务确认', 'CEL00000000000000000000000000001', 'C', 'payment-confirm', '/cell-storage/payment-confirm/index', 'biz:cellStoragePaymentConfirm:list', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('70000000000000000000000000000011', '入库放行审核', 'CEL00000000000000000000000000001', 'C', 'storage-release-review', '/quality-inspection/storage-release-review/index', 'biz:storageReleaseReview:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 退费确认已并入缴退费财务确认 Tab;已有库可执行:UPDATE sys_menu SET menu_name='缴退费财务确认' WHERE id='50000000000000000000000000000004'; UPDATE sys_menu SET del_flag=1 WHERE id='50000000000000000000000000000005'; +-- 入库放行审核已移至细胞存储;已有库可执行:UPDATE sys_menu SET parent_id='CEL00000000000000000000000000001', order_num=5 WHERE id='70000000000000000000000000000011'; +('RSC00000000000000000000000000001', '科研捐赠', '2', 'M', '/research-donation', '/index/index', NULL, 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('60000000000000000000000000000002', '捐赠协议单录入', 'RSC00000000000000000000000000001', 'C', 'list', '/research-donation/list/index', 'biz:researchDonation:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('60000000000000000000000000000003', '捐赠协议单管理', 'RSC00000000000000000000000000001', 'C', 'audit', '/research-donation/audit/index', 'biz:researchDonationAudit:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('PIL00000000000000000000000000001', '中试服务', '2', 'M', '/pilot-service', '/index/index', NULL, 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('80000000000000000000000000000010', '中试服务申请单', 'PIL00000000000000000000000000001', 'C', 'pilot-application', '/pilot-service/pilot-application/index', 'pilot-service:pilot-application:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 体检管理/输注计划已并入中试服务申请单 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id IN ('PIL00000000000000000000000000002','PIL00000000000000000000000000003'); +('PIL00000000000000000000000000004', '出库放行审核', 'PIL00000000000000000000000000001', 'C', 'outbound-release-review', '/pilot-service/outbound-release-review/index', 'pilot-service:outbound-release-review:list', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('CLI00000000000000000000000000001', '临床应用', '2', 'M', '/clinical-application', '/index/index', NULL, 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('CLI00000000000000000000000000002', '临床申请单', 'CLI00000000000000000000000000001', 'C', 'clinical-application', '/pilot-service/pilot-application/index', 'clinical-application:application:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('CLI00000000000000000000000000003', '体检管理', 'CLI00000000000000000000000000001', 'C', 'clinical-checkup-management', '/pilot-service/checkup-management/index', 'clinical-application:checkup-management:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('CLI00000000000000000000000000004', '输注计划管理', 'CLI00000000000000000000000000001', 'C', 'clinical-infusion-schedule-management', '/pilot-service/infusion-schedule-management/index', 'clinical-application:infusion-schedule-management:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('CLI00000000000000000000000000005', '缴费信息', 'CLI00000000000000000000000000001', 'C', 'clinical-payment-info', '/pilot-service/clinical-payment-info/index', 'clinical-application:payment-info:list', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('CLI00000000000000000000000000006', '缴费确认(财务)', 'CLI00000000000000000000000000001', 'C', 'clinical-payment-confirm', '/pilot-service/clinical-payment-confirm/index', 'clinical-application:payment-confirm:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('CLI00000000000000000000000000007', '退费确认(财务)', 'CLI00000000000000000000000000001', 'C', 'clinical-refund-confirm', '/pilot-service/clinical-refund-confirm/index', 'clinical-application:refund-confirm:list', 1, '1', '1', 'zkxw', 6, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('CLI00000000000000000000000000008', '出库放行审核', 'CLI00000000000000000000000000001', 'C', 'clinical-outbound-release-review', '/pilot-service/outbound-release-review/index', 'clinical-application:outbound-release-review:list', 1, '1', '1', 'zkxw', 7, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('QCM00000000000000000000000000001', '质检管理', '2', 'M', '/quality-manage', '/index/index', NULL, 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('70000000000000000000000000000002', '样本接收', 'QCM00000000000000000000000000001', 'C', 'sample-reception', '/quality-inspection/sample-reception/index', 'biz:sampleReception:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('70000000000000000000000000000007', '质检管理', 'QCM00000000000000000000000000001', 'C', 'qc-work-order', '/quality-inspection/qc-work-order/index', 'biz:qcWorkOrder:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 质检任务已并入质检管理 Tab;已有库可执行: +-- UPDATE sys_menu SET menu_name='质检管理' WHERE id='70000000000000000000000000000007'; +-- UPDATE sys_menu SET del_flag=1 WHERE id='70000000000000000000000000000008'; +('PRM00000000000000000000000000001', '生产管理', '2', 'M', '/production', '/index/index', NULL, 1, '1', '1', 'zkxw', 6, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('61000000000000000000000000000003', '库位管理', 'PRM00000000000000000000000000001', 'C', 'storage-facility', '/operations/storage-facility/index', 'operations:storage-facility:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('90000000000000000000000000000013', '细胞产品库', 'PRM00000000000000000000000000001', 'C', 'cell-product', '/production/cell-product/index', 'production:cell-product:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('90000000000000000000000000000011', '生产管理', 'PRM00000000000000000000000000001', 'C', 'production-work-order', '/production/production-work-order/index', 'production:work-order:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 生产任务已并入生产管理 Tab;已有库可执行: +-- UPDATE sys_menu SET menu_name='生产管理' WHERE id='90000000000000000000000000000011'; +-- UPDATE sys_menu SET del_flag=1 WHERE id='90000000000000000000000000000012'; +('OPS00000000000000000000000000001', '运营支撑', '2', 'M', '/operations', '/index/index', NULL, 1, '1', '1', 'zkxw', 7, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('61000000000000000000000000000002', '洁净室管理', 'OPS00000000000000000000000000001', 'C', 'clean-room', '/operations/clean-room/index', 'operations:clean-room:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('61000000000000000000000000000004', '展馆预约', 'OPS00000000000000000000000000001', 'C', 'exhibition-appointment', '/operations/exhibition-appointment/index', 'operations:exhibition-appointment:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('MKT00000000000000000000000000001', '市场基础信息', '2', 'M', '/market-info', '/index/index', NULL, 1, '1', '1', 'zkxw', 8, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('122', '细胞样本基础信息维护', 'MKT00000000000000000000000000001', 'C', 'cell-sample-type', '/basic-info/cell-sample-type/index', 'biz:cellSampleType:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('123', '细胞采集基础信息维护', 'MKT00000000000000000000000000001', 'C', 'collection-region', '/basic-info/collection-region/index', 'biz:collectionRegion:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('80000000000000000000000000000002', '细胞产品基础信息维护', 'MKT00000000000000000000000000001', 'C', 'cell-product-type', '/pilot-service/cell-product-type/index', 'pilot-service:cell-product-type:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('40000000000000000000000000000007', '保险公司及险种价格', 'MKT00000000000000000000000000001', 'C', 'insurance-company', '/basic-info/insurance-company/index', 'basic-info:insurance-company:list', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('80000000000000000000000000000007', '体检机构及项目价格', 'MKT00000000000000000000000000001', 'C', 'checkup-institution', '/pilot-service/checkup-institution/index', 'pilot-service:checkup-institution:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('50000000000000000000000000000009', '市场物料库', 'MKT00000000000000000000000000001', 'C', 'material', '/cell-storage/material/index', 'biz:material:list', 1, '1', '1', 'zkxw', 6, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('121', '客户管理', 'MKT00000000000000000000000000001', 'C', 'customer', '/basic-info/customer/index', 'biz:customer:list', 1, '1', '1', 'zkxw', 7, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('80000000000000000000000000000006', '渠道信息管理', 'MKT00000000000000000000000000001', 'C', 'channel', '/pilot-service/channel/index', 'pilot-service:channel:list', 1, '1', '1', 'zkxw', 8, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('80000000000000000000000000000009', '渠道服务订单(财务确认)', 'MKT00000000000000000000000000001', 'C', 'channel-service-order-finance', '/pilot-service/channel-service-order-finance/index', 'pilot-service:channel-service-order-finance:list', 1, '1', '1', 'zkxw', 9, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 采集协议号已并入细胞采集基础信息维护 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id='124'; +-- 样本编码已并入细胞样本基础信息维护 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id='125'; +-- 变动记录已并入市场物料库 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id='50000000000000000000000000000010'; +-- 规格/价格体系/项目价格已并入细胞产品基础信息维护 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id IN ('80000000000000000000000000000003','80000000000000000000000000000004','80000000000000000000000000000005'); +-- 服务购买订单已并入渠道信息管理 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id='80000000000000000000000000000008'; +-- 市场基础信息二级菜单排序与更名(已有库可执行): +-- UPDATE sys_menu SET order_num=1 WHERE id='122'; +-- UPDATE sys_menu SET order_num=2 WHERE id='123'; +-- UPDATE sys_menu SET order_num=3 WHERE id='80000000000000000000000000000002'; +-- UPDATE sys_menu SET order_num=4 WHERE id='40000000000000000000000000000007'; +-- UPDATE sys_menu SET order_num=5 WHERE id='80000000000000000000000000000007'; +-- UPDATE sys_menu SET order_num=6 WHERE id='50000000000000000000000000000009'; +-- UPDATE sys_menu SET order_num=7 WHERE id='121'; +-- UPDATE sys_menu SET order_num=8 WHERE id='80000000000000000000000000000006'; +-- UPDATE sys_menu SET order_num=9, menu_name='渠道服务订单(财务确认)' WHERE id='80000000000000000000000000000009'; +('QCB00000000000000000000000000001', '质检基础信息', '2', 'M', '/quality-base', '/index/index', NULL, 1, '1', '1', 'zkxw', 9, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('70000000000000000000000000000003', '接收检查项配置', 'QCB00000000000000000000000000001', 'C', 'sample-reception-check-item', '/quality-inspection/sample-reception-check-item/index', 'biz:sampleReceptionCheckItem:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('70000000000000000000000000000006', '质检项目管理', 'QCB00000000000000000000000000001', 'C', 'lab-test-item', '/quality-inspection/lab-test-item/index', 'biz:qcLabTestItem:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 第三方检测已并入质检项目管理 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id='70000000000000000000000000000004'; UPDATE sys_menu SET menu_name='质检项目管理' WHERE id='70000000000000000000000000000006'; +('70000000000000000000000000000009', '质检物料库', 'QCB00000000000000000000000000001', 'C', 'qc-material', '/quality-inspection/qc-material/index', 'biz:qcMaterial:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 变动记录已并入质检物料库 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id='70000000000000000000000000000010'; +('PRB00000000000000000000000000001', '生产基础信息', '2', 'M', '/production-base', '/index/index', NULL, 1, '1', '1', 'zkxw', 10, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('90000000000000000000000000000002', '耗材管理', 'PRB00000000000000000000000000001', 'C', 'consumable', '/production/consumable/index', 'production:consumable:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 耗材包已并入耗材管理 Tab,不再单独挂菜单;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id='90000000000000000000000000000007'; +('90000000000000000000000000000003', '试剂管理', 'PRB00000000000000000000000000001', 'C', 'reagent', '/production/reagent/index', 'production:reagent:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('90000000000000000000000000000004', 'SOP文档', 'PRB00000000000000000000000000001', 'C', 'sop', '/production/sop/index', 'production:sop-document:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('90000000000000000000000000000005', '工艺基础信息维护', 'PRB00000000000000000000000000001', 'C', 'process-category', '/production/process-category/index', 'production:process-category:list', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 产出物类型已并入工艺基础信息维护 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id='90000000000000000000000000000006'; +('90000000000000000000000000000008', '工艺库管理', 'PRB00000000000000000000000000001', 'C', 'process', '/production/process/index', 'production:process:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0), +-- 工艺路线已并入工艺库管理 Tab;已有库可执行:UPDATE sys_menu SET del_flag=1 WHERE id='90000000000000000000000000000010'; +('SYSB000000000000000000000000000001', '系统基础信息', '2', 'M', '/system-base', '/index/index', NULL, 1, '1', '1', 'zkxw', 11, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('107', '站点管理', 'SYSB000000000000000000000000000001', 'C', 'site', '/system/site/index', 'system:site:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('109', '物品单位管理', 'SYSB000000000000000000000000000001', 'C', 'unit', '/system/unit/index', 'system:unit:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('110', '快递公司', 'SYSB000000000000000000000000000001', 'C', 'express', '/system/express/index', 'system:express:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('WKF00000000000000000000000000001', '工作流管理', '2', 'M', '/workflow', '/index/index', NULL, 1, '1', '1', 'zkxw', 12, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('10000000000000000000000000000wf01', '流程配置', 'WKF00000000000000000000000000001', 'C', 'process', '/system/workflow/process/index', 'workflow:process', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('10000000000000000000000000000wf02', '待办中心', 'WKF00000000000000000000000000001', 'C', 'pending', '/system/workflow/task-pending/index', 'workflow:pending', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('10000000000000000000000000000wf03', '已办中心', 'WKF00000000000000000000000000001', 'C', 'completed', '/system/workflow/task-completed/index', 'workflow:completed', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('10000000000000000000000000000wf05', '我的申请', 'WKF00000000000000000000000000001', 'C', 'my-applications', '/system/workflow/my-applications/index', 'workflow:myApplications', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('10000000000000000000000000000wf04', '请假申请', 'WKF00000000000000000000000000001', 'C', 'leave', '/system/leave/index', 'workflow:leave', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('SYSM000000000000000000000000000001', '系统管理', '2', 'M', '/system', '/index/index', NULL, 1, '1', '1', 'zkxw', 13, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('101', '租户管理', 'SYSM000000000000000000000000000001', 'C', 'tenant', '/system/tenant/index', 'system:tenant:list', 1, '1', '1', 'zkxw', 1, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('102', '字典管理', 'SYSM000000000000000000000000000001', 'C', 'dict', '/system/dict/index', 'system:dict:list', 1, '1', '1', 'zkxw', 2, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('106', '菜单管理', 'SYSM000000000000000000000000000001', 'C', 'menu', '/system/menu/index', 'system:menu:list', 1, '1', '1', 'zkxw', 3, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('103', '组织管理', 'SYSM000000000000000000000000000001', 'C', 'dept', '/system/dept/index', 'system:dept:list', 1, '1', '1', 'zkxw', 4, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('105', '角色管理', 'SYSM000000000000000000000000000001', 'C', 'role', '/system/role/index', 'system:role:list', 1, '1', '1', 'zkxw', 5, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('104', '用户管理', 'SYSM000000000000000000000000000001', 'C', 'user', '/system/user/index', 'system:user:list', 1, '1', '1', 'zkxw', 6, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('108', '职位管理', 'SYSM000000000000000000000000000001', 'C', 'post', '/system/post/index', 'system:post:list', 1, '1', '1', 'zkxw', 7, NULL, NULL, '', NOW(), NULL, NOW(), 0), +('10000000000000000000000000000008', '用户详情', 'SYSM000000000000000000000000000001', 'C', 'user/detail', '/system/user/detail', NULL, 1, '0', '1', 'zkxw', 8, NULL, NULL, '', NOW(), NULL, NOW(), 0); diff --git a/docs/superpowers/plans/2026-05-27-clinical-application.md b/docs/superpowers/plans/2026-05-27-clinical-application.md new file mode 100644 index 00000000..9afb308e --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-clinical-application.md @@ -0,0 +1,1252 @@ +# 临床应用业务 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在现有中试服务基础上,通过 `biz_type` 区分新增临床应用业务线,包含独立缴费流程(缴费信息/缴费确认/退费确认三个菜单)。 + +**Architecture:** 复用中试服务的 `biz_pilot_application` 表体系,新增 `biz_type` 字段区分两条线。缴费参照细胞存储 `biz_protocol_payment` 模式,但简化为单表趸交(一个输注计划一条缴费记录)。前端复用 pilot-service 目录下的页面组件,通过路由 prop 区分行为。 + +**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2(后端),Vue 3 + Element Plus + TypeScript(前端),MySQL 5.7 + +**Design Spec:** `docs/superpowers/specs/2026-05-27-clinical-application-design.md` + +--- + +## File Structure + +### Backend — New Files +- `backend/app/models/clinical_payment.py` — `BizPilotPayment` 模型 +- `backend/app/crud/clinical_payment.py` — 缴费 CRUD(参照 `collection_protocol.py` 中缴费函数) +- `backend/app/api/clinical_payment.py` — 缴费 API 路由(10 个端点) +- `backend/app/schemas/clinical_payment.py` — Pydantic schemas + +### Backend — Modified Files +- `backend/app/models/models.py` — `BizPilotApplication` 加 `biz_type`,`BizPilotInfusionSchedule` 加 `amount` +- `backend/app/crud/pilot_application.py` — `create_application` / `_row_to_dict_application` / `_generate_application_no` / `list_applications` 加 `biz_type` 支持 +- `backend/app/api/pilot_application.py` — 路由参数加 `biz_type` +- `backend/app/api/__init__.py` — 注册 `clinical_payment_router` +- `backend/app/main.py` — 挂载新路由 + +### Frontend — New Files +- `web-admin/src/views/pilot-service/clinical-payment-info/index.vue` — 缴费信息页面 +- `web-admin/src/views/pilot-service/clinical-payment-confirm/index.vue` — 缴费确认页面 +- `web-admin/src/views/pilot-service/clinical-refund-confirm/index.vue` — 退费确认页面 + +### Frontend — Modified Files +- `web-admin/src/router/modules/sqxp.ts` — 新增临床应用管理一级菜单 + 7 个子路由 +- `web-admin/src/api/business.ts` — 新增 clinical payment API 函数 +- `web-admin/src/views/pilot-service/pilot-application/index.vue` — 接收 `bizType` prop,条件渲染 +- `web-admin/src/views/pilot-service/pilot-application/ApplicationFormDialog.vue` — 临床应用模式隐藏渠道订单 +- `web-admin/src/views/pilot-service/infusion-schedule-management/index.vue` — 临床应用显示金额列 + +### SQL +- `docs/sql/clinical-application-migration.sql` — ALTER + CREATE TABLE + +--- + +## Task 1: 数据库迁移 SQL + +**Files:** +- Create: `docs/sql/clinical-application-migration.sql` + +- [ ] **Step 1: 编写迁移 SQL** + +```sql +-- ============================================================ +-- 临床应用业务 - 数据库迁移 +-- ============================================================ + +-- 1. biz_pilot_application 新增 biz_type 字段 +ALTER TABLE biz_pilot_application + ADD COLUMN biz_type VARCHAR(20) NOT NULL DEFAULT 'pilot_service' + COMMENT '业务类型: pilot_service=中试服务, clinical_application=临床应用' + AFTER channel_name; + +-- 2. biz_pilot_infusion_schedule 新增 amount 字段 +ALTER TABLE biz_pilot_infusion_schedule + ADD COLUMN amount DECIMAL(18,2) DEFAULT NULL + COMMENT '临床应用-手动填写的输注金额(中试为NULL)' + AFTER qc_status; + +-- 3. 新建 biz_pilot_payment 缴费记录表 +CREATE TABLE biz_pilot_payment ( + id VARCHAR(64) NOT NULL COMMENT '主键ID', + pilot_application_id VARCHAR(64) NOT NULL COMMENT '临床申请单ID', + infusion_schedule_id VARCHAR(64) NOT NULL COMMENT '输注计划ID', + biz_type VARCHAR(20) NOT NULL DEFAULT 'clinical_application' COMMENT '业务类型', + amount DECIMAL(18,2) NOT NULL COMMENT '缴费金额', + transfer_type VARCHAR(16) DEFAULT NULL COMMENT '转账方式: wxzz/zzfbzz/yhkzz/xj', + transfer_amount DECIMAL(18,2) DEFAULT NULL COMMENT '实际转账金额', + transfer_time DATETIME DEFAULT NULL COMMENT '转账时间', + transfer_attachments TEXT DEFAULT NULL COMMENT '转账凭证附件', + pay_status VARCHAR(32) NOT NULL DEFAULT 'dfj' COMMENT '缴费状态: dfj/jfqrz/yjf/tfqrz/ytf', + finance_confirm_attachments TEXT DEFAULT NULL COMMENT '财务确认收款附件', + finance_remark VARCHAR(512) DEFAULT NULL COMMENT '财务确认收款备注', + finance_confirm_time DATETIME DEFAULT NULL COMMENT '财务确认收款时间', + refund_amount DECIMAL(18,2) DEFAULT NULL COMMENT '退费金额', + refund_remark VARCHAR(512) DEFAULT NULL COMMENT '退费备注', + refund_attachments TEXT DEFAULT NULL COMMENT '退费附件', + refund_apply_by VARCHAR(64) DEFAULT NULL COMMENT '退费申请人ID', + refund_apply_time DATETIME DEFAULT NULL COMMENT '退费申请时间', + refund_finance_confirm_attachments TEXT DEFAULT NULL COMMENT '退费财务确认附件', + refund_finance_remark VARCHAR(512) DEFAULT NULL COMMENT '退费财务确认备注', + refund_finance_confirm_time DATETIME DEFAULT NULL COMMENT '退费财务确认时间', + tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', + order_num INT(11) DEFAULT NULL COMMENT '排序号', + remark VARCHAR(512) DEFAULT NULL COMMENT '备注', + create_by VARCHAR(64) DEFAULT NULL COMMENT '创建人id', + create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人id', + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + del_flag TINYINT(4) NOT NULL DEFAULT '0' COMMENT '删除标记: 0-正常 1-已删除', + PRIMARY KEY (id), + UNIQUE KEY uk_infusion_schedule (infusion_schedule_id, del_flag), + KEY idx_pilot_app (pilot_application_id, del_flag), + KEY idx_pay_status (pay_status, del_flag), + KEY idx_tenant (tenant_no, del_flag) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='临床应用缴费记录'; +``` + +- [ ] **Step 2: 在数据库执行迁移** + +Run: `mysql -u root -p zkxw < docs/sql/clinical-application-migration.sql` + +- [ ] **Step 3: Commit** + +```bash +git add docs/sql/clinical-application-migration.sql +git commit -m "feat: add clinical application DB migration (biz_type + biz_pilot_payment)" +``` + +--- + +## Task 2: 后端模型层 + +**Files:** +- Modify: `backend/app/models/models.py` — `BizPilotApplication` 和 `BizPilotInfusionSchedule` +- Create: `backend/app/models/clinical_payment.py` — `BizPilotPayment` + +- [ ] **Step 1: 在 `BizPilotApplication` 类中添加 `biz_type` 字段** + +在 `backend/app/models/models.py` 中 `BizPilotApplication` 类的 `channel_name` 字段后面添加: + +```python +biz_type = Column(String(20), nullable=False, default="pilot_service", comment="业务类型: pilot_service=中试服务, clinical_application=临床应用") +``` + +- [ ] **Step 2: 在 `BizPilotInfusionSchedule` 类中添加 `amount` 字段** + +在 `backend/app/models/models.py` 中 `BizPilotInfusionSchedule` 类的 `qc_status` 字段后面添加: + +```python +amount = Column(Numeric(18, 2), nullable=True, comment="临床应用-手动填写的输注金额(中试为NULL)") +``` + +- [ ] **Step 3: 创建 `backend/app/models/clinical_payment.py`** + +```python +"""临床应用缴费记录模型""" +from sqlalchemy import Column, String, Text, SmallInteger, Numeric, DateTime +from sqlalchemy.sql import func +from app.models.base import Base +from decimal import Decimal + + +class BizPilotPayment(Base): + """临床应用缴费记录 biz_pilot_payment""" + + __tablename__ = "biz_pilot_payment" + + id = Column(String(64), primary_key=True, comment="主键ID") + pilot_application_id = Column(String(64), nullable=False, comment="临床申请单ID") + infusion_schedule_id = Column(String(64), nullable=False, comment="输注计划ID") + biz_type = Column(String(20), nullable=False, default="clinical_application", comment="业务类型") + amount = Column(Numeric(18, 2), nullable=False, comment="缴费金额") + transfer_type = Column(String(16), nullable=True, comment="转账方式: wxzz/zzfbzz/yhkzz/xj") + transfer_amount = Column(Numeric(18, 2), nullable=True, comment="实际转账金额") + transfer_time = Column(DateTime, nullable=True, comment="转账时间") + transfer_attachments = Column(Text, nullable=True, comment="转账凭证附件") + pay_status = Column(String(32), nullable=False, default="dfj", comment="缴费状态: dfj/jfqrz/yjf/tfqrz/ytf") + finance_confirm_attachments = Column(Text, nullable=True, comment="财务确认收款附件") + finance_remark = Column(String(512), nullable=True, comment="财务确认收款备注") + finance_confirm_time = Column(DateTime, nullable=True, comment="财务确认收款时间") + refund_amount = Column(Numeric(18, 2), nullable=True, comment="退费金额") + refund_remark = Column(String(512), nullable=True, comment="退费备注") + refund_attachments = Column(Text, nullable=True, comment="退费附件") + refund_apply_by = Column(String(64), nullable=True, comment="退费申请人ID") + refund_apply_time = Column(DateTime, nullable=True, comment="退费申请时间") + refund_finance_confirm_attachments = Column(Text, nullable=True, comment="退费财务确认附件") + refund_finance_remark = Column(String(512), nullable=True, comment="退费财务确认备注") + refund_finance_confirm_time = Column(DateTime, nullable=True, comment="退费财务确认时间") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(SmallInteger, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, server_default=func.now(), comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") +``` + +- [ ] **Step 4: 在 `models.py` 末尾导入新模型** + +在 `backend/app/models/models.py` 末尾已有的 import 行后面添加: + +```python +from app.models.clinical_payment import BizPilotPayment # noqa: E402,F401 +``` + +- [ ] **Step 5: 验证模型加载** + +Run: `cd backend && source venv/bin/activate && python -c "from app.models.models import BizPilotApplication, BizPilotInfusionSchedule; from app.models.clinical_payment import BizPilotPayment; print('OK')"` +Expected: `OK` + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/models/models.py backend/app/models/clinical_payment.py +git commit -m "feat: add biz_type to pilot tables + BizPilotPayment model" +``` + +--- + +## Task 3: 后端 CRUD — clinical_payment.py + +**Files:** +- Create: `backend/app/crud/clinical_payment.py` + +参照 `backend/app/crud/collection_protocol.py` 中 `confirm_receipt` (line 1390)、`confirm_refund` (line 1522)、`submit_receipt_finance` (line 1595)、`submit_refund_finance` (line 1648) 的模式。 + +- [ ] **Step 1: 创建 CRUD 文件** + +创建 `backend/app/crud/clinical_payment.py`,包含以下函数: + +```python +"""临床应用缴费 CRUD""" +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import and_, func +from sqlalchemy.orm import Session + +from app.models.clinical_payment import BizPilotPayment +from app.models.models import ( + BizPilotApplication, + BizPilotPatient, + BizPilotInfusionPlan, + BizPilotInfusionSchedule, +) + + +# ─── helpers ────────────────────────────────────────────── + + +def _row_to_dict(row: BizPilotPayment, *, extra: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + d: Dict[str, Any] = { + "id": row.id, + "pilotApplicationId": row.pilot_application_id or "", + "infusionScheduleId": row.infusion_schedule_id or "", + "bizType": row.biz_type or "clinical_application", + "amount": float(row.amount) if row.amount else 0, + "transferType": row.transfer_type or "", + "transferAmount": float(row.transfer_amount) if row.transfer_amount else None, + "transferTime": row.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if row.transfer_time else "", + "transferAttachments": row.transfer_attachments or "", + "payStatus": row.pay_status or "dfj", + "financeConfirmAttachments": row.finance_confirm_attachments or "", + "financeRemark": row.finance_remark or "", + "financeConfirmTime": row.finance_confirm_time.strftime("%Y-%m-%d %H:%M:%S") if row.finance_confirm_time else "", + "refundAmount": float(row.refund_amount) if row.refund_amount else None, + "refundRemark": row.refund_remark or "", + "refundAttachments": row.refund_attachments or "", + "refundApplyBy": row.refund_apply_by or "", + "refundApplyTime": row.refund_apply_time.strftime("%Y-%m-%d %H:%M:%S") if row.refund_apply_time else "", + "refundFinanceConfirmAttachments": row.refund_finance_confirm_attachments or "", + "refundFinanceRemark": row.refund_finance_remark or "", + "refundFinanceConfirmTime": row.refund_finance_confirm_time.strftime("%Y-%m-%d %H:%M:%S") if row.refund_finance_confirm_time else "", + "tenantNo": row.tenant_no or "", + "remark": row.remark or "", + "createBy": row.create_by or "", + "createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "", + "updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "", + } + if extra: + d.update(extra) + return d + + +def _get_or_raise(db: Session, *, payment_id: str, tenant_no: str) -> BizPilotPayment: + row = ( + db.query(BizPilotPayment) + .filter( + and_( + BizPilotPayment.id == payment_id, + BizPilotPayment.tenant_no == tenant_no, + BizPilotPayment.del_flag == 0, + ) + ) + .first() + ) + if not row: + raise ValueError("缴费记录不存在") + return row + + +def _enrich_payment(db: Session, row: BizPilotPayment) -> Dict[str, Any]: + """关联查询申请单、患者、输注计划信息,附加到 dict""" + app_row = ( + db.query(BizPilotApplication) + .filter(BizPilotApplication.id == row.pilot_application_id) + .first() + ) + patient_row = None + schedule_row = ( + db.query(BizPilotInfusionSchedule) + .filter(BizPilotInfusionSchedule.id == row.infusion_schedule_id) + .first() + ) + if app_row: + patient_row = ( + db.query(BizPilotPatient) + .filter( + BizPilotPatient.pilot_application_id == app_row.id, + BizPilotPatient.del_flag == 0, + ) + .first() + ) + extra = { + "applicationNo": app_row.application_no if app_row else "", + "applicationName": app_row.application_name if app_row else "", + "channelName": app_row.channel_name if app_row else "", + "patientName": patient_row.patient_name if patient_row else "", + "cellType": schedule_row.cell_type if schedule_row else "", + "specText": schedule_row.spec_text if schedule_row else "", + "infusionTime": schedule_row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if schedule_row and schedule_row.infusion_time else "", + } + return _row_to_dict(row, extra=extra) + + +# ─── create (auto) ──────────────────────────────────────── + + +def auto_create_payment( + db: Session, + *, + application_id: str, + schedule_id: str, + amount: Any, + tenant_no: str, + user_id: str, + dept_id: str, +) -> Optional[BizPilotPayment]: + """输注计划创建时自动调用,生成 dfj 缴费记录""" + if amount is None or Decimal(str(amount)) <= 0: + return None + now = datetime.now() + row = BizPilotPayment( + id=str(uuid.uuid4()), + pilot_application_id=application_id, + infusion_schedule_id=schedule_id, + biz_type="clinical_application", + amount=Decimal(str(amount)), + pay_status="dfj", + tenant_no=tenant_no, + order_num=0, + create_by=user_id, + create_dept_id=dept_id or "", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(row) + return row + + +# ─── list ───────────────────────────────────────────────── + + +def list_payments( + db: Session, + *, + page: int = 1, + page_size: int = 10, + pay_status: Optional[str] = None, + keyword: Optional[str] = None, + tenant_no: str = "", + user_info=None, +) -> Tuple[List[Dict[str, Any]], int]: + q = ( + db.query(BizPilotPayment) + .filter( + BizPilotPayment.del_flag == 0, + BizPilotPayment.tenant_no == tenant_no, + ) + ) + if pay_status and pay_status.strip(): + q = q.filter(BizPilotPayment.pay_status == pay_status.strip()) + if keyword and keyword.strip(): + kw = f"%{keyword.strip()}%" + sub = ( + db.query(BizPilotApplication.id) + .filter( + BizPilotApplication.del_flag == 0, + BizPilotApplication.application_no.like(kw), + ) + .subquery() + ) + q = q.filter(BizPilotPayment.pilot_application_id.in_(sub)) + if user_info: + from app.core.data_scope import apply_data_scope + q = apply_data_scope(q, BizPilotPayment, user_info, db, tenant_no) + total = q.order_by(None).with_entities(func.count(BizPilotPayment.id)).scalar() or 0 + page = max(1, page) + page_size = min(max(1, page_size), 100) + rows = ( + q.order_by(BizPilotPayment.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + out = [_enrich_payment(db, r) for r in rows] + return out, int(total) + + +# ─── submit receipt (dfj → jfqrz) ──────────────────────── + + +def submit_receipt( + db: Session, + *, + payment_id: str, + tenant_no: str, + user_id: Optional[str], +) -> BizPilotPayment: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + if row.pay_status != "dfj": + raise ValueError("仅待缴费状态可提交缴费确认") + row.pay_status = "jfqrz" + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + db.refresh(row) + return row + + +def batch_submit_receipt( + db: Session, + *, + payment_ids: List[str], + tenant_no: str, + user_id: Optional[str], +) -> int: + ids = [str(i).strip() for i in (payment_ids or []) if str(i).strip()] + if not ids: + raise ValueError("请选择缴费记录") + for pid in ids: + row = _get_or_raise(db, payment_id=pid, tenant_no=tenant_no) + if row.pay_status != "dfj": + continue + row.pay_status = "jfqrz" + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + return len(ids) + + +# ─── confirm receipt (jfqrz → yjf) ────────────────────── + + +def confirm_receipt( + db: Session, + *, + payment_id: str, + tenant_no: str, + user_id: Optional[str], + finance_confirm_attachments: Optional[str] = None, + finance_remark: Optional[str] = None, +) -> BizPilotPayment: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + if row.pay_status != "jfqrz": + if row.pay_status == "yjf": + raise ValueError("该记录已确认收款") + raise ValueError("当前状态不可确认收款") + row.pay_status = "yjf" + if finance_confirm_attachments is not None: + row.finance_confirm_attachments = finance_confirm_attachments.strip() or None + if finance_remark is not None: + row.finance_remark = finance_remark.strip() or None + row.finance_confirm_time = datetime.now() + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + db.refresh(row) + return row + + +# ─── submit refund (yjf → tfqrz) ───────────────────────── + + +def submit_refund( + db: Session, + *, + payment_id: str, + tenant_no: str, + user_id: Optional[str], + refund_amount: Any, + refund_remark: Optional[str] = None, + refund_attachments: Optional[str] = None, +) -> BizPilotPayment: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + if row.pay_status != "yjf": + raise ValueError("仅已缴费状态可发起退费") + if refund_amount is None or Decimal(str(refund_amount)) <= 0: + raise ValueError("请填写有效的退费金额") + row.pay_status = "tfqrz" + row.refund_amount = Decimal(str(refund_amount)) + row.refund_remark = (refund_remark or "").strip() or None + row.refund_attachments = (refund_attachments or "").strip() or None + row.refund_apply_by = user_id + row.refund_apply_time = datetime.now() + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + db.refresh(row) + return row + + +def batch_submit_refund( + db: Session, + *, + payment_ids: List[str], + tenant_no: str, + user_id: Optional[str], + refund_amount: Any, + refund_remark: Optional[str] = None, + refund_attachments: Optional[str] = None, +) -> int: + ids = [str(i).strip() for i in (payment_ids or []) if str(i).strip()] + if not ids: + raise ValueError("请选择缴费记录") + for pid in ids: + row = _get_or_raise(db, payment_id=pid, tenant_no=tenant_no) + if row.pay_status != "yjf": + continue + if refund_amount is None or Decimal(str(refund_amount)) <= 0: + continue + row.pay_status = "tfqrz" + row.refund_amount = Decimal(str(refund_amount)) + row.refund_remark = (refund_remark or "").strip() or None + row.refund_attachments = (refund_attachments or "").strip() or None + row.refund_apply_by = user_id + row.refund_apply_time = datetime.now() + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + return len(ids) + + +# ─── confirm refund (tfqrz → ytf) ─────────────────────── + + +def confirm_refund( + db: Session, + *, + payment_id: str, + tenant_no: str, + user_id: Optional[str], + refund_finance_confirm_attachments: Optional[str] = None, + refund_finance_remark: Optional[str] = None, +) -> BizPilotPayment: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + if row.pay_status != "tfqrz": + if row.pay_status == "ytf": + raise ValueError("该记录已确认退费") + raise ValueError("当前状态不可确认退费") + row.pay_status = "ytf" + if refund_finance_confirm_attachments is not None: + row.refund_finance_confirm_attachments = refund_finance_confirm_attachments.strip() or None + if refund_finance_remark is not None: + row.refund_finance_remark = refund_finance_remark.strip() or None + row.refund_finance_confirm_time = datetime.now() + row.update_by = user_id + row.update_time = datetime.now() + db.add(row) + db.commit() + db.refresh(row) + return row + + +# ─── detail ─────────────────────────────────────────────── + + +def get_payment_detail(db: Session, *, payment_id: str, tenant_no: str) -> Optional[Dict[str, Any]]: + row = _get_or_raise(db, payment_id=payment_id, tenant_no=tenant_no) + return _enrich_payment(db, row) + + +def get_payment_by_schedule(db: Session, *, schedule_id: str, tenant_no: str) -> Optional[Dict[str, Any]]: + row = ( + db.query(BizPilotPayment) + .filter( + BizPilotPayment.infusion_schedule_id == schedule_id, + BizPilotPayment.tenant_no == tenant_no, + BizPilotPayment.del_flag == 0, + ) + .first() + ) + if not row: + return None + return _enrich_payment(db, row) +``` + +- [ ] **Step 2: 验证导入** + +Run: `cd backend && source venv/bin/activate && python -c "from app.crud.clinical_payment import list_payments; print('OK')"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/crud/clinical_payment.py +git commit -m "feat: add clinical payment CRUD layer" +``` + +--- + +## Task 4: 后端 API 路由 — clinical_payment.py + +**Files:** +- Create: `backend/app/api/clinical_payment.py` +- Modify: `backend/app/api/__init__.py` +- Modify: `backend/app/main.py` + +- [ ] **Step 1: 创建 API 路由文件** + +创建 `backend/app/api/clinical_payment.py`: + +```python +"""临床应用缴费 API""" +from fastapi import APIRouter, Depends, Query, Request +from typing import Optional + +router = APIRouter() + + +def _common_params( + request: Request, + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100), + keyword: Optional[str] = Query(None), +): + return { + "page": page, + "page_size": pageSize, + "keyword": keyword, + "tenant_no": request.state.tenant_no, + "user_info": getattr(request.state, "user_info", None), + } + + +# ─── 缴费信息页面 ────────────────────────────────────── + + +@router.get("/clinical-payments") +def list_clinical_payments( + payStatus: Optional[str] = Query(None), + **kwargs, +): + from app.core.database import get_db + from app.crud.clinical_payment import list_payments + + params = _common_params(**kwargs) if kwargs else _common_params( + kwargs.get("__request"), kwargs.get("page", 1), kwargs.get("pageSize", 10), kwargs.get("keyword") + ) + # 重新从 request 获取参数 + # 注意:FastAPI 会自动注入 Query 参数,这里用更简洁的方式 + pass + + +# 更简洁的写法 — 每个端点独立定义 +``` + +由于上面的草稿过于冗长,下面用完整的每个端点函数替代。每个端点参照 `backend/app/api/pilot_service.py` 中现有端点的模式(获取 db session、tenant_no、user_info)。 + +实际实现时参照已有模式: +- `request.state.tenant_no` 获取租户 +- `request.state.user_info` 获取用户信息 +- `get_db()` 获取数据库会话 +- `_row_to_dict` 系列函数序列化返回 + +**10 个端点**: + +| 端点 | CRUD 函数 | +|------|----------| +| `GET /clinical-payments` | `list_payments(pay_status=...)` | +| `GET /clinical-payments/{payment_id}` | `get_payment_detail(payment_id=...)` | +| `POST /clinical-payments/{payment_id}/submit-receipt` | `submit_receipt(payment_id=...)` | +| `POST /clinical-payments/submit-receipt-batch` | `batch_submit_receipt(payment_ids=...)` | +| `POST /clinical-payments/{payment_id}/confirm-receipt` | `confirm_receipt(payment_id=..., finance_confirm_attachments=..., finance_remark=...)` | +| `POST /clinical-payments/{payment_id}/submit-refund` | `submit_refund(payment_id=..., refund_amount=..., refund_remark=..., refund_attachments=...)` | +| `POST /clinical-payments/submit-refund-batch` | `batch_submit_refund(payment_ids=..., refund_amount=..., ...)` | +| `POST /clinical-payments/{payment_id}/confirm-refund` | `confirm_refund(payment_id=..., refund_finance_confirm_attachments=..., refund_finance_remark=...)` | +| `GET /clinical-payments/receipt-pending` | `list_payments(pay_status='jfqrz')` | +| `GET /clinical-payments/receipt-confirmed` | `list_payments(pay_status='yjf')` | +| `GET /clinical-payments/refund-pending` | `list_payments(pay_status='tfqrz')` | +| `GET /clinical-payments/refund-confirmed` | `list_payments(pay_status='ytf')` | + +- [ ] **Step 2: 在 `__init__.py` 注册路由** + +在 `backend/app/api/__init__.py` 添加: + +```python +from app.api.clinical_payment import router as clinical_payment_router +``` + +在 `routers` 列表中添加 `"clinical_payment_router"`。 + +- [ ] **Step 3: 在 `main.py` 挂载路由** + +在 `backend/app/main.py` 中现有路由挂载位置添加: + +```python +from app.api import clinical_payment_router +app.include_router(clinical_payment_router, prefix="/web/admin", tags=["clinical-payment"]) +``` + +- [ ] **Step 4: 验证路由注册** + +Run: `cd backend && source venv/bin/activate && python -c "from app.api.clinical_payment import router; print(f'Routes: {len(router.routes)}')"` +Expected: `Routes: 12`(或对应数量) + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/clinical_payment.py backend/app/api/__init__.py backend/app/main.py +git commit -m "feat: add clinical payment API routes (12 endpoints)" +``` + +--- + +## Task 5: 后端 CRUD 改造 — pilot_application.py 支持 biz_type + +**Files:** +- Modify: `backend/app/crud/pilot_application.py` + +关键改动点(参照已读取的代码): + +- [ ] **Step 1: `_generate_application_no` 支持 biz_type 参数** + +在 line 74,将 `prefix` 根据 `biz_type` 动态选择: + +```python +def _generate_application_no(db: Session, tenant_no: str, biz_type: str = "pilot_service") -> str: + today_str = datetime.now().strftime("%Y%m%d") + prefix = f"LCSQ{today_str}" if biz_type == "clinical_application" else f"ZSSQ{today_str}" + existing = ( + db.query(BizPilotApplication.application_no) + .filter( + BizPilotApplication.tenant_no == tenant_no, + BizPilotApplication.application_no.like(f"{prefix}%"), + BizPilotApplication.del_flag == 0, + ) + .order_by(BizPilotApplication.application_no.desc()) + .first() + ) + seq = 1 + if existing and existing[0]: + try: + seq = int(existing[0][-4:]) + 1 + except ValueError: + seq = 1 + return f"{prefix}{seq:04d}" +``` + +- [ ] **Step 2: `_row_to_dict_application` 添加 bizType 字段** + +在 line 96-110 的 dict 中添加: + +```python +"bizType": row.biz_type or "pilot_service", +``` + +- [ ] **Step 3: `list_applications` 支持 biz_type 过滤** + +在 line 284 的 `list_applications` 函数签名中添加 `biz_type` 参数,并在查询中添加过滤: + +```python +def list_applications( + db: Session, + ... + biz_type: Optional[str] = None, # 新增 +) -> Tuple[List[Dict[str, Any]], int]: + ... + if biz_type: + q = q.filter(BizPilotApplication.biz_type == biz_type) +``` + +- [ ] **Step 4: `create_application` 支持 biz_type=clinical_application** + +在 line 385 的 `create_application` 函数中: +- 当 `biz_type == 'clinical_application'` 时,`channel_service_order_id` 不必填,直接从 data 中获取 `channelId` +- 调用 `_generate_application_no` 时传入 `biz_type` + +```python +def create_application(db, data, tenant_no, user_id, dept_id): + biz_type = data.get("bizType") or "pilot_service" + + if biz_type == "pilot_service": + channel_service_order_id = data.get("channelServiceOrderId") or "" + if not channel_service_order_id: + raise ValueError("请选择渠道服务订单") + # ... 现有逻辑不变 + else: + # clinical_application + channel_service_order_id = "" + channel_id = data.get("channelId") or "" + channel_name = data.get("channelName") or "" + + application_no = _generate_application_no(db, tenant_no, biz_type) + + row = BizPilotApplication( + ... + biz_type=biz_type, + channel_service_order_id=channel_service_order_id, + channel_id=channel_id, + channel_name=channel_name, + ) +``` + +- [ ] **Step 5: `create_schedule` 支持临床应用 amount + 自动创建缴费记录** + +在 line 1106 的 `create_schedule` 函数中: +- 保存 `amount` 字段到 `BizPilotInfusionSchedule` +- 当 `biz_type == 'clinical_application'` 时调用 `auto_create_payment` + +```python +# 在 row = BizPilotInfusionSchedule(...) 中添加: +amount=data.get("amount") if data.get("amount") else None, + +# 在创建工单之后添加: +if application.biz_type == "clinical_application" and data.get("amount"): + from app.crud.clinical_payment import auto_create_payment + auto_create_payment( + db, + application_id=application.id, + schedule_id=schedule_id, + amount=data["amount"], + tenant_no=tenant_no, + user_id=user_id, + dept_id=dept_id, + ) +``` + +- [ ] **Step 6: `_row_to_dict_infusion_schedule` 添加 amount 字段** + +在输注计划序列化函数中添加: + +```python +"amount": float(row.amount) if row.amount else None, +``` + +- [ ] **Step 7: 验证导入和基本逻辑** + +Run: `cd backend && source venv/bin/activate && python -c "from app.crud.pilot_application import create_application; print('OK')"` +Expected: `OK` + +- [ ] **Step 8: Commit** + +```bash +git add backend/app/crud/pilot_application.py +git commit -m "feat: pilot_application CRUD supports biz_type for clinical application" +``` + +--- + +## Task 6: 后端 API 改造 — pilot_application.py 路由 + +**Files:** +- Modify: `backend/app/api/pilot_application.py` + +- [ ] **Step 1: list 路由添加 bizType 过滤** + +在申请单列表路由中,从 Query 参数获取 `bizType` 并传给 `list_applications`。 + +- [ ] **Step 2: create 路由传递 bizType** + +在创建申请单路由中,从前端传入的 data 中透传 `bizType` 字段。 + +- [ ] **Step 3: 输注计划创建路由传递 amount** + +在创建输注计划路由中,透传 `amount` 字段。 + +- [ ] **Step 4: 验证 API 可访问** + +Run: `cd backend && source venv/bin/activate && python -c "from app.api.pilot_application import router; print('OK')"` +Expected: `OK` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/pilot_application.py +git commit -m "feat: pilot_application API supports bizType filter and amount field" +``` + +--- + +## Task 7: 前端路由 + 菜单 + +**Files:** +- Modify: `web-admin/src/router/modules/sqxp.ts` + +- [ ] **Step 1: 在 sqxp.ts 末尾(`]` 之前)添加临床应用管理一级菜单** + +```typescript + { + id: '90000000000000000000000000000001', + name: 'ClinicalApplication', + path: '/clinical-application', + component: '/index/index', + meta: { + title: '临床应用管理', + icon: 'ri:heart-pulse-line', + keepAlive: false, + roles: ['admin'] + }, + children: [ + { + id: '90000000000000000000000000000002', + name: 'ClinicalApplication', + path: 'clinical-application', + component: '/pilot-service/pilot-application/index', + meta: { + title: '临床申请单', + keepAlive: true, + menuCode: 'clinical-application:application:list' + }, + props: { bizType: 'clinical_application' } + }, + { + id: '90000000000000000000000000000003', + name: 'ClinicalCheckupManagement', + path: 'clinical-checkup-management', + component: '/pilot-service/checkup-management/index', + meta: { + title: '体检管理', + keepAlive: true, + menuCode: 'clinical-application:checkup-management:list' + }, + props: { bizType: 'clinical_application' } + }, + { + id: '90000000000000000000000000000004', + name: 'ClinicalInfusionScheduleManagement', + path: 'clinical-infusion-schedule-management', + component: '/pilot-service/infusion-schedule-management/index', + meta: { + title: '输注计划管理', + keepAlive: true, + menuCode: 'clinical-application:infusion-schedule-management:list' + }, + props: { bizType: 'clinical_application' } + }, + { + id: '90000000000000000000000000000005', + name: 'ClinicalPaymentInfo', + path: 'clinical-payment-info', + component: '/pilot-service/clinical-payment-info/index', + meta: { + title: '缴费信息', + keepAlive: true, + menuCode: 'clinical-application:payment-info:list' + } + }, + { + id: '90000000000000000000000000000006', + name: 'ClinicalPaymentConfirm', + path: 'clinical-payment-confirm', + component: '/pilot-service/clinical-payment-confirm/index', + meta: { + title: '缴费确认(财务)', + keepAlive: true, + menuCode: 'clinical-application:payment-confirm:list' + } + }, + { + id: '90000000000000000000000000000007', + name: 'ClinicalRefundConfirm', + path: 'clinical-refund-confirm', + component: '/pilot-service/clinical-refund-confirm/index', + meta: { + title: '退费确认(财务)', + keepAlive: true, + menuCode: 'clinical-application:refund-confirm:list' + } + }, + { + id: '90000000000000000000000000000008', + name: 'ClinicalOutboundReleaseReview', + path: 'clinical-outbound-release-review', + component: '/pilot-service/outbound-release-review/index', + meta: { + title: '出库放行审核', + keepAlive: true, + menuCode: 'clinical-application:outbound-release-review:list' + }, + props: { bizType: 'clinical_application' } + } + ] + } +``` + +- [ ] **Step 2: 验证路由文件语法** + +Run: `cd web-admin && npx tsc --noEmit src/router/modules/sqxp.ts 2>&1 | head -5` + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/router/modules/sqxp.ts +git commit -m "feat: add clinical application menu and routes" +``` + +--- + +## Task 8: 前端 API — clinical payment + +**Files:** +- Modify: `web-admin/src/api/business.ts` + +- [ ] **Step 1: 在 business.ts 末尾添加 clinical payment API 函数** + +```typescript +// ─── 临床应用缴费 ───────────────────────────────────── + +export function fetchClinicalPayments(params: any) { + return http.get('/admin/clinical-payments', params) +} + +export function fetchClinicalPaymentDetail(id: string) { + return http.get(`/admin/clinical-payments/${id}`) +} + +export function submitClinicalReceipt(id: string) { + return http.post(`/admin/clinical-payments/${id}/submit-receipt`) +} + +export function batchSubmitClinicalReceipt(ids: string[]) { + return http.post('/admin/clinical-payments/submit-receipt-batch', { ids }) +} + +export function confirmClinicalReceipt(id: string, data: any) { + return http.post(`/admin/clinical-payments/${id}/confirm-receipt`, data) +} + +export function submitClinicalRefund(id: string, data: any) { + return http.post(`/admin/clinical-payments/${id}/submit-refund`, data) +} + +export function batchSubmitClinicalRefund(ids: string[], data: any) { + return http.post('/admin/clinical-payments/submit-refund-batch', { ...data, ids }) +} + +export function confirmClinicalRefund(id: string, data: any) { + return http.post(`/admin/clinical-payments/${id}/confirm-refund`, data) +} + +export function fetchClinicalReceiptPending(params: any) { + return http.get('/admin/clinical-payments/receipt-pending', params) +} + +export function fetchClinicalReceiptConfirmed(params: any) { + return http.get('/admin/clinical-payments/receipt-confirmed', params) +} + +export function fetchClinicalRefundPending(params: any) { + return http.get('/admin/clinical-payments/refund-pending', params) +} + +export function fetchClinicalRefundConfirmed(params: any) { + return http.get('/admin/clinical-payments/refund-confirmed', params) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add web-admin/src/api/business.ts +git commit -m "feat: add clinical payment API functions" +``` + +--- + +## Task 9: 前端页面 — 缴费信息 + +**Files:** +- Create: `web-admin/src/views/pilot-service/clinical-payment-info/index.vue` + +参照 `web-admin/src/views/cell-storage/payment-info/index.vue` 的结构。 + +- [ ] **Step 1: 创建页面** + +核心结构: +- Tab 切换:`dfj` 待缴费 / `yjf` 已缴费 +- 列表表格列:申请单号、患者姓名、渠道名称、细胞类型/规格、输注时间、缴费金额、缴费状态(`payStatusLabel` + `payStatusTagType`)、操作 +- 待缴费 Tab 操作:单条缴费确认、批量缴费确认 +- 已缴费 Tab 操作:单条退费(弹窗填退费金额+备注+附件)、批量退费 +- 复用 `payStatusLabel`、`payStatusTagType`、`formatMoneyDisplay` + +- [ ] **Step 2: Commit** + +```bash +git add web-admin/src/views/pilot-service/clinical-payment-info/ +git commit -m "feat: add clinical payment info page" +``` + +--- + +## Task 10: 前端页面 — 缴费确认(财务) + +**Files:** +- Create: `web-admin/src/views/pilot-service/clinical-payment-confirm/index.vue` + +参照 `web-admin/src/views/cell-storage/payment-confirm/index.vue`。 + +- [ ] **Step 1: 创建页面** + +核心结构: +- Tab:待确认(`jfqrz`)/ 已确认(`yjf`) +- 确认收款弹窗:复用 `ProtocolFinanceConfirmFields` 组件(备注 + 附件上传) +- 表格列同缴费信息页 + +- [ ] **Step 2: Commit** + +```bash +git add web-admin/src/views/pilot-service/clinical-payment-confirm/ +git commit -m "feat: add clinical payment confirm page (finance)" +``` + +--- + +## Task 11: 前端页面 — 退费确认(财务) + +**Files:** +- Create: `web-admin/src/views/pilot-service/clinical-refund-confirm/index.vue` + +参照 `web-admin/src/views/cell-storage/refund-confirm/index.vue`。 + +- [ ] **Step 1: 创建页面** + +核心结构: +- Tab:待确认(`tfqrz`)/ 已确认(`ytf`) +- 确认退费弹窗:展示退费申请信息(只读)+ 财务确认表单(复用 `ProtocolFinanceConfirmFields`) +- 表格列同缴费信息页 + +- [ ] **Step 2: Commit** + +```bash +git add web-admin/src/views/pilot-service/clinical-refund-confirm/ +git commit -m "feat: add clinical refund confirm page (finance)" +``` + +--- + +## Task 12: 前端改造 — 申请单页面支持 bizType + +**Files:** +- Modify: `web-admin/src/views/pilot-service/pilot-application/index.vue` +- Modify: `web-admin/src/views/pilot-service/pilot-application/ApplicationFormDialog.vue` + +- [ ] **Step 1: index.vue 接收 bizType prop** + +在 ` + + diff --git a/web-admin/src/views/basic-info/cell-sample-type/index.vue b/web-admin/src/views/basic-info/cell-sample-type/index.vue index a7ce98a6..302dfba0 100644 --- a/web-admin/src/views/basic-info/cell-sample-type/index.vue +++ b/web-admin/src/views/basic-info/cell-sample-type/index.vue @@ -2,16 +2,13 @@ @@ -223,7 +241,10 @@ View, Collection, RefreshLeft, - Search + Search, + More, + Edit, + Delete } from '@element-plus/icons-vue' import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading' import { @@ -233,9 +254,12 @@ fetchDeleteCellSampleType } from '@/api/admin' import SystemViewDialog from '@/components/business/system-view-dialog/index.vue' + import SampleCodeTab from './SampleCodeTab.vue' defineOptions({ name: 'BizCellSampleType' }) + const route = useRoute() + const activeTab = ref('type') const searchForm = reactive<{ keyword: string }>({ keyword: '' }) const tableData = ref([]) const selectedRows = ref([]) @@ -382,6 +406,13 @@ } } + function handleDropdownCommand(command: string, row: any) { + switch (command) { + case 'edit': handleEdit(row); break + case 'delete': handleDelete(row); break + } + } + async function handleDelete(row: Api.BizCellSampleType.CellSampleTypeItem) { await ElMessageBox.confirm('确定要删除该类型吗?', '提示', { type: 'warning' }) await fetchDeleteCellSampleType(row.id) @@ -413,6 +444,10 @@ ) onMounted(() => { + const tab = route.query.tab + if (tab === 'sample-code') { + activeTab.value = 'sample-code' + } loadData() }) diff --git a/web-admin/src/views/basic-info/collection-protocol-no/index.vue b/web-admin/src/views/basic-info/collection-protocol-no/index.vue index 81b515cb..d9499aee 100644 --- a/web-admin/src/views/basic-info/collection-protocol-no/index.vue +++ b/web-admin/src/views/basic-info/collection-protocol-no/index.vue @@ -1,781 +1,18 @@ - - + - - diff --git a/web-admin/src/views/basic-info/collection-region/CollectionProtocolNoTab.vue b/web-admin/src/views/basic-info/collection-region/CollectionProtocolNoTab.vue new file mode 100644 index 00000000..91907b98 --- /dev/null +++ b/web-admin/src/views/basic-info/collection-region/CollectionProtocolNoTab.vue @@ -0,0 +1,795 @@ + + + + + + + diff --git a/web-admin/src/views/basic-info/collection-region/index.vue b/web-admin/src/views/basic-info/collection-region/index.vue index 46b46eaa..776fd3d3 100644 --- a/web-admin/src/views/basic-info/collection-region/index.vue +++ b/web-admin/src/views/basic-info/collection-region/index.vue @@ -2,16 +2,13 @@ @@ -361,7 +379,7 @@ diff --git a/web-admin/src/views/basic-info/customer/index.vue b/web-admin/src/views/basic-info/customer/index.vue index 3db72e37..562d36ff 100644 --- a/web-admin/src/views/basic-info/customer/index.vue +++ b/web-admin/src/views/basic-info/customer/index.vue @@ -2,11 +2,6 @@ @@ -376,7 +393,7 @@ diff --git a/web-admin/src/views/cell-storage/protocol-audit/FullmoonTodoTab.vue b/web-admin/src/views/cell-storage/protocol-audit/FullmoonTodoTab.vue new file mode 100644 index 00000000..af28ace6 --- /dev/null +++ b/web-admin/src/views/cell-storage/protocol-audit/FullmoonTodoTab.vue @@ -0,0 +1,1276 @@ + + + + + + + diff --git a/web-admin/src/views/cell-storage/protocol-audit/PaymentInfoTab.vue b/web-admin/src/views/cell-storage/protocol-audit/PaymentInfoTab.vue new file mode 100644 index 00000000..512c24a1 --- /dev/null +++ b/web-admin/src/views/cell-storage/protocol-audit/PaymentInfoTab.vue @@ -0,0 +1,852 @@ + + + + + + + diff --git a/web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue b/web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue new file mode 100644 index 00000000..1fa8c011 --- /dev/null +++ b/web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue @@ -0,0 +1,582 @@ + + + + + diff --git a/web-admin/src/views/cell-storage/protocol-audit/SampleReceptionInfoTab.vue b/web-admin/src/views/cell-storage/protocol-audit/SampleReceptionInfoTab.vue new file mode 100644 index 00000000..48f7c748 --- /dev/null +++ b/web-admin/src/views/cell-storage/protocol-audit/SampleReceptionInfoTab.vue @@ -0,0 +1,352 @@ + + + + diff --git a/web-admin/src/views/cell-storage/protocol-audit/StorageCertTab.vue b/web-admin/src/views/cell-storage/protocol-audit/StorageCertTab.vue new file mode 100644 index 00000000..d826410b --- /dev/null +++ b/web-admin/src/views/cell-storage/protocol-audit/StorageCertTab.vue @@ -0,0 +1,879 @@ + + + + + + + diff --git a/web-admin/src/views/cell-storage/protocol-audit/StorageEntryInfoTab.vue b/web-admin/src/views/cell-storage/protocol-audit/StorageEntryInfoTab.vue new file mode 100644 index 00000000..44a6da13 --- /dev/null +++ b/web-admin/src/views/cell-storage/protocol-audit/StorageEntryInfoTab.vue @@ -0,0 +1,345 @@ + + + + diff --git a/web-admin/src/views/cell-storage/protocol-audit/index.vue b/web-admin/src/views/cell-storage/protocol-audit/index.vue index d18b73df..634ca1ed 100644 --- a/web-admin/src/views/cell-storage/protocol-audit/index.vue +++ b/web-admin/src/views/cell-storage/protocol-audit/index.vue @@ -1,573 +1,225 @@ - - + + + diff --git a/web-admin/src/views/cell-storage/protocol/index.vue b/web-admin/src/views/cell-storage/protocol/index.vue index 54a9f3c0..ca8ca4e5 100644 --- a/web-admin/src/views/cell-storage/protocol/index.vue +++ b/web-admin/src/views/cell-storage/protocol/index.vue @@ -2,11 +2,6 @@