Просмотр исходного кода

feat: switch protocol view cert/insurance handle panels to sample level.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 недель назад
Родитель
Сommit
efbf8f6ac9

+ 2 - 0
backend/app/api/__init__.py

@@ -43,6 +43,7 @@ from app.api.mini_menu import router as mini_menu_router
 from app.api.market_mobile import router as market_mobile_router
 from app.api.sample_storage_cert_qc import router as sample_storage_cert_qc_router
 from app.api.customer_service_dict import router as customer_service_dict_router
+from app.api.sample_cert_insurance import router as sample_cert_insurance_router
 
 __all__ = [
     "public_router",
@@ -89,4 +90,5 @@ __all__ = [
     "market_mobile_router",
     "sample_storage_cert_qc_router",
     "customer_service_dict_router",
+    "sample_cert_insurance_router",
 ]

+ 210 - 0
backend/app/api/sample_cert_insurance.py

@@ -0,0 +1,210 @@
+"""样本级储存证 / 保险办理 API"""
+from typing import Optional
+
+from fastapi import APIRouter, Depends
+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_any_menu_code
+from app.core.response import error_response, success_response
+from app.core.utils import parse_datetime
+from app.crud import sample_insurance as insurance_crud
+from app.crud import sample_storage_cert as cert_crud
+from app.models.models import BizProtocolSample, SysUser
+
+router = APIRouter(prefix="/web/admin", tags=["样本储存证保险"])
+
+_MENU = require_any_menu_code("cell-storage:sample-info:list")
+
+
+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
+
+
+def _get_current_user_id(user: dict) -> str:
+    user_id = user.get("user_id")
+    if not user_id:
+        raise PermissionError("用户身份信息缺失")
+    return str(user_id)
+
+
+def _resolve_protocol_id(db: Session, tenant_no: str, sample_code: str) -> Optional[str]:
+    row = (
+        db.query(BizProtocolSample)
+        .filter(
+            BizProtocolSample.tenant_no == tenant_no,
+            BizProtocolSample.sample_code == sample_code,
+            BizProtocolSample.del_flag == 0,
+        )
+        .first()
+    )
+    return (row.collection_protocol_id or "").strip() or None if row else None
+
+
+class SampleHandleBody(BaseModel):
+    model_config = ConfigDict(populate_by_name=True)
+
+    handle_time: Optional[str] = Field(None, alias="handleTime")
+    handler_user_id: Optional[str] = Field(None, alias="handlerUserId")
+    cert_image_urls: Optional[str] = Field(None, alias="certImageUrls")
+    insurance_attachments: Optional[str] = Field(None, alias="insuranceAttachments")
+    remark: Optional[str] = None
+    handle_remark: Optional[str] = Field(None, alias="handleRemark")
+    mail_cert_time: Optional[str] = Field(None, alias="mailCertTime")
+    policy_send_time: Optional[str] = Field(None, alias="policySendTime")
+    collection_protocol_id: Optional[str] = Field(None, alias="collectionProtocolId")
+
+
+@router.get("/sample-storage-certs/{sample_code}")
+async def get_sample_storage_cert(
+    sample_code: str,
+    db: Session = Depends(get_db),
+    user: dict = Depends(_MENU),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        code = (sample_code or "").strip()
+        row = cert_crud.get_by_sample_code(db, tenant_no, code)
+        if row is None:
+            return success_response(
+                {
+                    "sampleCode": code,
+                    "storageCertHandled": False,
+                    "handleTime": "",
+                    "handlerUserId": "",
+                    "certImageUrls": "",
+                    "mailCertTime": "",
+                    "remark": "",
+                }
+            )
+        return success_response(cert_crud.row_to_dict(row))
+    except PermissionError as e:
+        return error_response(403, str(e))
+    except Exception as e:
+        return error_response(400, str(e))
+
+
+@router.put("/sample-storage-certs/{sample_code}")
+async def put_sample_storage_cert(
+    sample_code: str,
+    body: SampleHandleBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(_MENU),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        code = (sample_code or "").strip()
+        pid = (body.collection_protocol_id or "").strip() or _resolve_protocol_id(
+            db, tenant_no, code
+        )
+        ht = parse_datetime(body.handle_time) if body.handle_time else None
+        mail_raw = body.mail_cert_time
+        update_mail = mail_raw is not None
+        mail_t = parse_datetime(mail_raw) if mail_raw and str(mail_raw).strip() else None
+        row = cert_crud.upsert_handle(
+            db,
+            tenant_no=tenant_no,
+            sample_code=code,
+            collection_protocol_id=pid,
+            user_id=user_id,
+            handle_time=ht,
+            handler_user_id=body.handler_user_id,
+            cert_image_urls=body.cert_image_urls or "",
+            remark=body.remark or "",
+            mail_cert_time=mail_t,
+            update_mail_cert_time=update_mail,
+        )
+        db.commit()
+        return success_response(cert_crud.row_to_dict(row), message="保存成功")
+    except PermissionError as e:
+        db.rollback()
+        return error_response(403, str(e))
+    except ValueError as e:
+        db.rollback()
+        return error_response(400, str(e))
+    except Exception as e:
+        db.rollback()
+        return error_response(400, str(e))
+
+
+@router.get("/sample-insurances/{sample_code}")
+async def get_sample_insurance(
+    sample_code: str,
+    db: Session = Depends(get_db),
+    user: dict = Depends(_MENU),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        code = (sample_code or "").strip()
+        row = insurance_crud.get_by_sample_code(db, tenant_no, code)
+        if row is None:
+            return success_response(
+                {
+                    "sampleCode": code,
+                    "insuranceHandled": False,
+                    "handleTime": "",
+                    "handlerUserId": "",
+                    "insuranceAttachments": "",
+                    "policySendTime": "",
+                    "handleRemark": "",
+                }
+            )
+        return success_response(insurance_crud.row_to_dict(row))
+    except PermissionError as e:
+        return error_response(403, str(e))
+    except Exception as e:
+        return error_response(400, str(e))
+
+
+@router.put("/sample-insurances/{sample_code}")
+async def put_sample_insurance(
+    sample_code: str,
+    body: SampleHandleBody,
+    db: Session = Depends(get_db),
+    user: dict = Depends(_MENU),
+):
+    try:
+        tenant_no = _get_tenant_no(db, user)
+        user_id = _get_current_user_id(user)
+        code = (sample_code or "").strip()
+        pid = (body.collection_protocol_id or "").strip() or _resolve_protocol_id(
+            db, tenant_no, code
+        )
+        ht = parse_datetime(body.handle_time) if body.handle_time else None
+        policy_raw = body.policy_send_time
+        update_policy = policy_raw is not None
+        policy_t = (
+            parse_datetime(policy_raw) if policy_raw and str(policy_raw).strip() else None
+        )
+        row = insurance_crud.upsert_handle(
+            db,
+            tenant_no=tenant_no,
+            sample_code=code,
+            collection_protocol_id=pid,
+            user_id=user_id,
+            handle_time=ht,
+            handler_user_id=body.handler_user_id,
+            insurance_attachments=body.insurance_attachments or "",
+            handle_remark=body.handle_remark or body.remark or "",
+            policy_send_time=policy_t,
+            update_policy_send_time=update_policy,
+        )
+        db.commit()
+        return success_response(insurance_crud.row_to_dict(row), message="保存成功")
+    except PermissionError as e:
+        db.rollback()
+        return error_response(403, str(e))
+    except ValueError as e:
+        db.rollback()
+        return error_response(400, str(e))
+    except Exception as e:
+        db.rollback()
+        return error_response(400, str(e))

+ 2 - 1
backend/app/main.py

@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
 from app.core.api_request_log_middleware import ApiRequestLogMiddleware
 from app.core.api_crypto_middleware import ApiCryptoMiddleware
 from app.models import models  # noqa: F401
-from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router
+from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router
 
 
 @asynccontextmanager
@@ -155,6 +155,7 @@ app.include_router(mini_menu_router, prefix=settings.API_V1_PREFIX)
 app.include_router(market_mobile_router, prefix=settings.API_V1_PREFIX)
 app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX)
 app.include_router(customer_service_dict_router, prefix=settings.API_V1_PREFIX)
+app.include_router(sample_cert_insurance_router, prefix=settings.API_V1_PREFIX)
 
 
 def _mask_secret(value: str) -> str:

+ 42 - 0
web-admin/src/api/sample-info-handle.ts

@@ -55,3 +55,45 @@ export function fetchUpdateFullmoonHandle(
 ) {
   return request.put({ url: `/web/admin/fullmoon-todos/${protocolId}/handle`, data })
 }
+
+export function fetchSampleStorageCert(sampleCode: string) {
+  return request.get({ url: `/web/admin/sample-storage-certs/${encodeURIComponent(sampleCode)}` })
+}
+
+export function fetchUpdateSampleStorageCert(
+  sampleCode: string,
+  data: {
+    handleTime?: string | null
+    handlerUserId?: string | null
+    certImageUrls?: string
+    remark?: string
+    mailCertTime?: string | null
+    collectionProtocolId?: string
+  }
+) {
+  return request.put({
+    url: `/web/admin/sample-storage-certs/${encodeURIComponent(sampleCode)}`,
+    data
+  })
+}
+
+export function fetchSampleInsurance(sampleCode: string) {
+  return request.get({ url: `/web/admin/sample-insurances/${encodeURIComponent(sampleCode)}` })
+}
+
+export function fetchUpdateSampleInsurance(
+  sampleCode: string,
+  data: {
+    handleTime?: string | null
+    handlerUserId?: string | null
+    insuranceAttachments?: string
+    handleRemark?: string
+    policySendTime?: string | null
+    collectionProtocolId?: string
+  }
+) {
+  return request.put({
+    url: `/web/admin/sample-insurances/${encodeURIComponent(sampleCode)}`,
+    data
+  })
+}

+ 95 - 13
web-admin/src/components/business/protocol-handle-panel/index.vue

@@ -1,5 +1,6 @@
 <template>
   <div class="protocol-handle-panel">
+    <p v-if="sampleCode" class="handle-sample-hint">当前样本:{{ sampleCode }}</p>
     <el-form label-position="top" class="biz-dialog-form" @submit.prevent>
       <div class="form-grid-2">
         <el-form-item label="办理时间" required>
@@ -31,6 +32,26 @@
             />
           </el-select>
         </el-form-item>
+        <el-form-item v-if="mode === 'storage-cert'" label="邮寄储存证时间">
+          <el-date-picker
+            v-model="form.mailCertTime"
+            type="datetime"
+            placeholder="可选"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            style="width: 100%"
+            clearable
+          />
+        </el-form-item>
+        <el-form-item v-if="mode === 'insurance'" label="保单发送时间">
+          <el-date-picker
+            v-model="form.policySendTime"
+            type="datetime"
+            placeholder="可选"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            style="width: 100%"
+            clearable
+          />
+        </el-form-item>
         <el-form-item :label="attachmentLabel" class="form-col-span-2">
           <div class="attachment-upload">
             <div class="attachment-list">
@@ -78,7 +99,11 @@
   import { fetchUserList } from '@/api/admin'
   import { fetchUploadAttachment } from '@/api/business'
   import {
+    fetchSampleInsurance,
+    fetchSampleStorageCert,
     fetchUpdateFullmoonHandle,
+    fetchUpdateSampleInsurance,
+    fetchUpdateSampleStorageCert,
     fetchUpdateStorageCertHandle
   } from '@/api/sample-info-handle'
   import { useUserStore } from '@/store/modules/user'
@@ -88,6 +113,8 @@
   const props = defineProps<{
     mode: 'storage-cert' | 'insurance'
     protocolId: string
+    /** 有值时按样本级读写(客户服务管理入口) */
+    sampleCode?: string
     initial?: Record<string, unknown> | null
   }>()
 
@@ -101,13 +128,17 @@
   const form = reactive({
     handleTime: '' as string,
     handlerUserId: '' as string,
-    remark: ''
+    remark: '',
+    mailCertTime: '',
+    policySendTime: ''
   })
 
   const attachmentLabel = computed(() =>
     props.mode === 'storage-cert' ? '储存证附件' : '保险附件'
   )
 
+  const useSampleLevel = computed(() => Boolean((props.sampleCode || '').trim()))
+
   function urlsToFiles(raw: string) {
     return String(raw || '')
       .split(',')
@@ -120,32 +151,55 @@
       }))
   }
 
-  function applyInitial() {
-    const init = props.initial || {}
+  function applyInitial(init: Record<string, unknown>) {
     form.handleTime = String(init.handleTime || '')
     form.handlerUserId = String(init.handlerUserId || '')
     form.remark = String(
-      props.mode === 'storage-cert' ? init.remark || '' : init.handleRemark || ''
+      props.mode === 'storage-cert' ? init.remark || '' : init.handleRemark || init.remark || ''
     )
+    form.mailCertTime = String(init.mailCertTime || '')
+    form.policySendTime = String(init.policySendTime || '')
     const urlField = props.mode === 'storage-cert' ? 'certImageUrls' : 'insuranceAttachments'
     files.value = urlsToFiles(String(init[urlField] || ''))
-    const uid = form.handlerUserId || String(userStore.info?.userId || '')
+    const uid = form.handlerUserId || String((userStore.getUserInfo as any)?.id || '')
     if (!form.handlerUserId && !form.handleTime) {
       form.handlerUserId = uid
     }
     if (form.handlerUserId) {
-      const name = String(init.handlerUserName || userStore.info?.userName || '')
+      const name = String(
+        init.handlerUserName ||
+          (userStore.getUserInfo as any)?.realName ||
+          (userStore.getUserInfo as any)?.userName ||
+          ''
+      )
       if (name) {
-        userOptions.value = [
-          { id: form.handlerUserId, nickName: name, userName: name }
-        ]
+        userOptions.value = [{ id: form.handlerUserId, nickName: name, userName: name }]
       }
     }
   }
 
+  async function loadSampleInitial() {
+    const code = (props.sampleCode || '').trim()
+    if (!code) {
+      applyInitial(props.initial || {})
+      return
+    }
+    try {
+      const res =
+        props.mode === 'storage-cert'
+          ? ((await fetchSampleStorageCert(code)) as Record<string, unknown>)
+          : ((await fetchSampleInsurance(code)) as Record<string, unknown>)
+      applyInitial(res || {})
+    } catch {
+      applyInitial(props.initial || {})
+    }
+  }
+
   watch(
-    () => [props.protocolId, props.initial],
-    () => applyInitial(),
+    () => [props.protocolId, props.sampleCode, props.initial, props.mode],
+    () => {
+      void loadSampleInitial()
+    },
     { immediate: true, deep: true }
   )
 
@@ -182,11 +236,33 @@
       ElMessage.warning('填写办理时间时必须指定办理人员')
       return
     }
-    if (!props.protocolId) return
+    const code = (props.sampleCode || '').trim()
+    if (useSampleLevel.value && !code) return
+    if (!useSampleLevel.value && !props.protocolId) return
     saving.value = true
     try {
       const urls = files.value.map((f) => f.fileUrl).filter(Boolean).join(',')
-      if (props.mode === 'storage-cert') {
+      if (useSampleLevel.value) {
+        if (props.mode === 'storage-cert') {
+          await fetchUpdateSampleStorageCert(code, {
+            handleTime: form.handleTime || null,
+            handlerUserId: form.handleTime ? form.handlerUserId : null,
+            certImageUrls: urls,
+            remark: form.remark,
+            mailCertTime: form.mailCertTime || null,
+            collectionProtocolId: props.protocolId
+          })
+        } else {
+          await fetchUpdateSampleInsurance(code, {
+            handleTime: form.handleTime || null,
+            handlerUserId: form.handleTime ? form.handlerUserId : null,
+            insuranceAttachments: urls,
+            handleRemark: form.remark,
+            policySendTime: form.policySendTime || null,
+            collectionProtocolId: props.protocolId
+          })
+        }
+      } else if (props.mode === 'storage-cert') {
         await fetchUpdateStorageCertHandle(props.protocolId, {
           handleTime: form.handleTime || null,
           handlerUserId: form.handleTime ? form.handlerUserId : null,
@@ -210,6 +286,12 @@
 </script>
 
 <style scoped lang="scss">
+  .handle-sample-hint {
+    margin: 0 0 10px;
+    font-size: 13px;
+    color: var(--mint-text-muted, #64748b);
+  }
+
   .handle-actions {
     margin-top: 8px;
   }

+ 2 - 0
web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue

@@ -256,6 +256,7 @@
               v-if="protocolId"
               mode="storage-cert"
               :protocol-id="protocolId"
+              :sample-code="highlightSampleCode || undefined"
               :initial="storageCertInfo"
               @saved="loadDetail"
             />
@@ -270,6 +271,7 @@
               v-if="protocolId"
               mode="insurance"
               :protocol-id="protocolId"
+              :sample-code="highlightSampleCode || undefined"
               :initial="fullmoonInsuranceInfo"
               @saved="loadDetail"
             />