Jelajahi Sumber

feat: expose lineage QC API and wire viewer in outbound material dialog

Add GET /cell-products/{id}/lineage-qc, frontend client, readonly dialog,
and a 查看质检 action on outbound cell rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 bulan lalu
induk
melakukan
33c4408557

+ 13 - 0
backend/app/api/production.py

@@ -2800,6 +2800,19 @@ async def get_cell_product_chain(
     return success_response(chain)
 
 
+@router.get("/cell-products/{product_id}/lineage-qc")
+async def get_cell_product_lineage_qc(
+    product_id: str,
+    user: dict = Depends(require_admin_or_super_admin),
+    db: Session = Depends(get_db),
+):
+    tenant_no = _get_tenant_no(db, user)
+    data = cell_product_svc.get_product_lineage_qc(db, product_id, tenant_no)
+    if not data:
+        raise HTTPException(status_code=404, detail="细胞产品不存在")
+    return success_response(data)
+
+
 @router.post("/cell-products")
 async def create_cell_product(
     body: CellProductCreate,

+ 51 - 0
web-admin/src/api/cell-product.ts

@@ -76,3 +76,54 @@ export function fetchCheckCellProductCode(productCode: string, excludeId?: strin
 export function fetchCellProductChain(productId: string) {
   return request.get<any>({ url: `/web/admin/cell-products/${productId}/chain` })
 }
+
+export type CellProductLineageQcTask = {
+  id?: string
+  taskNo?: string
+  itemName?: string
+  itemCode?: string
+  taskType?: string
+  taskStatus?: string
+  taskStatusLabel?: string
+  conclusion?: string
+  conclusionLabel?: string
+  remark?: string
+  completedTime?: string
+  companyName?: string
+  results?: Array<{ paramName?: string; paramValue?: string; paramUnit?: string }>
+  attachmentFiles?: Array<{ fileName?: string; fileUrl?: string; fileType?: string }>
+}
+
+export type CellProductLineageQcWorkOrder = {
+  id?: string
+  workOrderNo?: string
+  workOrderType?: string
+  workOrderStatus?: string
+  workOrderStatusLabel?: string
+  tasks?: CellProductLineageQcTask[]
+}
+
+export type CellProductLineageQcNode = {
+  nodeType?: 'sample' | 'cell_product' | string
+  id?: string
+  label?: string
+  productName?: string
+  passage?: string | null
+  receptionSource?: string | null
+  receptionSourceLabel?: string | null
+  isCurrent?: boolean
+  workOrders?: CellProductLineageQcWorkOrder[]
+}
+
+export type CellProductLineageQc = {
+  productId?: string
+  productCode?: string
+  productName?: string
+  nodes?: CellProductLineageQcNode[]
+}
+
+export function fetchCellProductLineageQc(productId: string) {
+  return request.get<CellProductLineageQc>({
+    url: `/web/admin/cell-products/${productId}/lineage-qc`
+  })
+}

+ 264 - 0
web-admin/src/components/business/cell-product-lineage-qc-dialog/index.vue

@@ -0,0 +1,264 @@
+<!-- 细胞产品 — 来源链质检只读 -->
+<!-- eslint-disable vue/no-v-model-argument -->
+<template>
+  <el-dialog
+    :model-value="modelValue"
+    title="来源链质检"
+    width="1040px"
+    class="dialog-form-layout cell-lineage-qc-dialog"
+    align-center
+    append-to-body
+    modal-class="protocol-dialog-mask"
+    destroy-on-close
+    :close-on-click-modal="false"
+    style="backdrop-filter: none"
+    @update:model-value="emit('update:modelValue', $event)"
+    @closed="resetState"
+  >
+    <div v-loading="loading" class="lineage-qc-body">
+      <div v-if="data" class="lineage-qc-summary">
+        <span class="lineage-qc-summary__code">{{ data.productCode || '—' }}</span>
+        <span class="lineage-qc-summary__name">{{ data.productName || '—' }}</span>
+      </div>
+
+      <el-empty
+        v-if="!loading && (!data?.nodes || !data.nodes.length)"
+        description="暂无来源链数据"
+      />
+
+      <el-collapse v-else-if="data?.nodes?.length" v-model="activeNames" class="lineage-qc-collapse">
+        <el-collapse-item
+          v-for="(node, idx) in data.nodes"
+          :key="`${node.nodeType}-${node.id}-${idx}`"
+          :name="String(idx)"
+        >
+          <template #title>
+            <div class="lineage-qc-node-title">
+              <el-tag v-if="node.isCurrent" type="success" size="small">当前</el-tag>
+              <el-tag v-if="node.nodeType === 'sample'" type="info" size="small">样本</el-tag>
+              <el-tag v-else type="primary" size="small">细胞产品</el-tag>
+              <span class="lineage-qc-node-title__label">{{ node.label || '—' }}</span>
+              <span v-if="node.productName" class="lineage-qc-node-title__sub">{{
+                node.productName
+              }}</span>
+              <span v-if="node.passage" class="lineage-qc-node-title__sub"
+                >代次 {{ node.passage }}</span
+              >
+              <span v-if="node.receptionSourceLabel" class="lineage-qc-node-title__sub">{{
+                node.receptionSourceLabel
+              }}</span>
+            </div>
+          </template>
+
+          <div v-if="!(node.workOrders || []).length" class="lineage-qc-empty">暂无质检工单</div>
+          <div v-for="wo in node.workOrders || []" :key="wo.id" class="lineage-qc-wo">
+            <div class="lineage-qc-wo__head">
+              <span class="lineage-qc-wo__no">{{ wo.workOrderNo || '—' }}</span>
+              <el-tag v-if="wo.workOrderType" type="info" size="small">{{
+                workOrderTypeLabel(wo.workOrderType)
+              }}</el-tag>
+              <el-tag
+                v-if="wo.workOrderStatus"
+                :type="workOrderStatusTagType(wo.workOrderStatus)"
+                size="small"
+              >
+                {{ wo.workOrderStatusLabel || wo.workOrderStatus }}
+              </el-tag>
+            </div>
+            <QcWorkOrderTaskResultPanel :tasks="mapTasks(wo.tasks)" />
+          </div>
+        </el-collapse-item>
+      </el-collapse>
+    </div>
+
+    <template #footer>
+      <el-button class="btn-ghost" @click="emit('update:modelValue', false)">
+        <el-icon><Close /></el-icon>
+        关闭
+      </el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { ElMessage } from 'element-plus'
+  import { Close } from '@element-plus/icons-vue'
+  import {
+    fetchCellProductLineageQc,
+    type CellProductLineageQc,
+    type CellProductLineageQcTask
+  } from '@/api/cell-product'
+  import QcWorkOrderTaskResultPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderTaskResultPanel.vue'
+  import type { QcWorkOrderTaskView } from '@/views/quality-inspection/qc-work-order/composables/useQcWorkOrderTaskViewSections'
+
+  defineOptions({ name: 'CellProductLineageQcDialog' })
+
+  const props = defineProps<{
+    modelValue: boolean
+    productId: string
+  }>()
+
+  const emit = defineEmits<{
+    (e: 'update:modelValue', val: boolean): void
+  }>()
+
+  const loading = ref(false)
+  const data = ref<CellProductLineageQc | null>(null)
+  const activeNames = ref<string[]>([])
+
+  function workOrderTypeLabel(type?: string) {
+    const map: Record<string, string> = {
+      inbound: '入库',
+      temporary: '临时',
+      outbound: '出库'
+    }
+    return map[type || ''] || type || '—'
+  }
+
+  function workOrderStatusTagType(status?: string): 'success' | 'warning' | 'info' | 'danger' {
+    if (status === 'ywc') return 'success'
+    if (status === 'yzf') return 'danger'
+    if (status === 'ypz') return 'warning'
+    return 'info'
+  }
+
+  function mapTasks(tasks?: CellProductLineageQcTask[]): QcWorkOrderTaskView[] {
+    return (tasks || []).map((t) => ({
+      ...(t as any),
+      results: t.results || [],
+      attachmentFiles: t.attachmentFiles || [],
+      materialUsages: []
+    }))
+  }
+
+  async function loadData() {
+    const id = (props.productId || '').trim()
+    if (!id) return
+    loading.value = true
+    data.value = null
+    try {
+      const res = await fetchCellProductLineageQc(id)
+      data.value = res || null
+      activeNames.value = (res?.nodes || []).map((_, i) => String(i))
+    } catch {
+      ElMessage.error('加载来源链质检失败')
+      data.value = null
+      activeNames.value = []
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function resetState() {
+    data.value = null
+    activeNames.value = []
+    loading.value = false
+  }
+
+  watch(
+    () => [props.modelValue, props.productId] as const,
+    ([vis, id]) => {
+      if (vis && id) loadData()
+    },
+    { immediate: true }
+  )
+</script>
+
+<style scoped lang="scss">
+  .lineage-qc-body {
+    min-height: 160px;
+  }
+
+  .lineage-qc-summary {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 8px 16px;
+    margin-bottom: 16px;
+    padding: 12px 16px;
+    background: #f8fafc;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+  }
+
+  .lineage-qc-summary__code {
+    font-size: 14px;
+    font-weight: 600;
+    color: #1e293b;
+  }
+
+  .lineage-qc-summary__name {
+    font-size: 13px;
+    color: var(--mint-text-muted, #64748b);
+  }
+
+  .lineage-qc-collapse {
+    border: none;
+
+    :deep(.el-collapse-item__header) {
+      height: auto;
+      min-height: 48px;
+      line-height: 1.4;
+      padding: 10px 0;
+    }
+
+    :deep(.el-collapse-item__wrap) {
+      border-bottom: none;
+    }
+
+    :deep(.el-collapse-item__content) {
+      padding-bottom: 16px;
+    }
+  }
+
+  .lineage-qc-node-title {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    padding-right: 12px;
+  }
+
+  .lineage-qc-node-title__label {
+    font-size: 14px;
+    font-weight: 600;
+    color: #1e293b;
+  }
+
+  .lineage-qc-node-title__sub {
+    font-size: 12px;
+    color: var(--mint-text-muted, #64748b);
+  }
+
+  .lineage-qc-empty {
+    padding: 12px 4px;
+    font-size: 13px;
+    color: var(--mint-text-muted, #64748b);
+  }
+
+  .lineage-qc-wo {
+    margin-bottom: 12px;
+    padding: 12px 14px;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    background: #fff;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  .lineage-qc-wo__head {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 12px;
+  }
+
+  .lineage-qc-wo__no {
+    font-size: 13px;
+    font-weight: 600;
+    color: #1e293b;
+  }
+</style>

+ 44 - 10
web-admin/src/views/pilot-service/outbound-release-review/OutboundReleaseMaterialDialog.vue

@@ -21,10 +21,22 @@
         <span class="material-summary-label">申请单号</span>
         <span class="material-summary-value">{{ scheduleSummary.applicationNo || '—' }}</span>
       </div>
-      <div class="material-summary-row">
-        <span class="material-summary-label">患者姓名</span>
-        <span class="material-summary-value">{{ scheduleSummary.patientName || '—' }}</span>
-      </div>
+      <template v-if="showPatient">
+        <div class="material-summary-row">
+          <span class="material-summary-label">患者姓名</span>
+          <span class="material-summary-value">{{ scheduleSummary.patientName || '—' }}</span>
+        </div>
+      </template>
+      <template v-else>
+        <div class="material-summary-row">
+          <span class="material-summary-label">课题名称</span>
+          <span class="material-summary-value">{{ scheduleSummary.projectTitle || '—' }}</span>
+        </div>
+        <div class="material-summary-row">
+          <span class="material-summary-label">客户名称</span>
+          <span class="material-summary-value">{{ scheduleSummary.customerName || '—' }}</span>
+        </div>
+      </template>
       <div class="material-summary-row">
         <span class="material-summary-label">{{ pickupTimeLabel }}</span>
         <span class="material-summary-value">{{ scheduleSummary.infusionTime || '—' }}</span>
@@ -99,12 +111,16 @@
             </el-table-column>
             <el-table-column
               label="操作"
-              min-width="100"
+              min-width="200"
               align="center"
               header-align="center"
               fixed="right"
             >
-              <template #default="{ $index }">
+              <template #default="{ row, $index }">
+                <el-button type="primary" link @click="openLineageQc(row)">
+                  <el-icon><View /></el-icon>
+                  查看质检
+                </el-button>
                 <el-button type="danger" link @click="removeCellRow($index)">
                   <el-icon><Delete /></el-icon>
                   删除
@@ -406,6 +422,8 @@
       />
     </div>
   </el-dialog>
+
+  <CellProductLineageQcDialog v-model="lineageQcVisible" :product-id="lineageQcProductId" />
   </div>
 </template>
 
@@ -434,6 +452,7 @@
   import { cellProductStatusLabel, cellProductStatusTagType } from '@/utils/status-tag-registry'
   import { resolveCellProductSpecDisplay } from '@/utils/cell-product-spec'
   import { getPilotBizConfig, resolvePilotBizType } from '@/config/pilot-biz-type'
+  import CellProductLineageQcDialog from '@/components/business/cell-product-lineage-qc-dialog/index.vue'
 
   type AttachmentLike = {
     fileName?: string
@@ -473,13 +492,15 @@
     scheduleSummary?: {
       applicationNo?: string
       patientName?: string
+      projectTitle?: string
+      customerName?: string
       infusionTime?: string
     }
   }>()
 
-  const pickupTimeLabel = computed(
-    () => getPilotBizConfig(resolvePilotBizType(props.bizType)).pickupTimeLabel
-  )
+  const bizConfig = computed(() => getPilotBizConfig(resolvePilotBizType(props.bizType)))
+  const pickupTimeLabel = computed(() => bizConfig.value.pickupTimeLabel)
+  const showPatient = computed(() => bizConfig.value.showPatient)
 
   const emit = defineEmits<{
     (e: 'update:modelValue', val: boolean): void
@@ -504,6 +525,9 @@
   const previewUrl = ref('')
   const previewType = ref<'image' | 'video'>('image')
 
+  const lineageQcVisible = ref(false)
+  const lineageQcProductId = ref('')
+
   const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
 
   let paperRowSeq = 0
@@ -608,6 +632,16 @@
     cellRows.value.splice(index, 1)
   }
 
+  function openLineageQc(row: OutboundReleaseCellRow) {
+    const id = String(row.cellProductId || '')
+    if (!id) {
+      ElMessage.warning('缺少细胞产品信息')
+      return
+    }
+    lineageQcProductId.value = id
+    lineageQcVisible.value = true
+  }
+
   function isCellRowSelectable(row: Record<string, any>) {
     const ids = new Set(cellRows.value.map((c) => c.cellProductId))
     return !ids.has(row.id)
@@ -807,7 +841,7 @@
     border-radius: 8px;
     border: 1px solid #e2e8f0;
     display: grid;
-    grid-template-columns: repeat(3, 1fr);
+    grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
     gap: 8px 20px;
   }