fix(web-admin): 样本接收展示协议知情同意书等附件 #63

Merged
cykj merged 1 commits from codex/issue-43-sample-reception-atts-f8ee4652859e into V2.0 2026-11-05 13:56:45 +00:00
6 changed files with 362 additions and 34 deletions

No files matched your search

+12
View File
@@ -287,9 +287,15 @@ def _donation_rows_to_sample_list_items(
),
"sourceType": "research_donation",
"sourceTypeLabel": "科研捐赠",
"consentAttachmentFiles": urls_to_attachment_list(
donation.consent_form_urls if donation else None, db, tenant_no
),
"infectiousDiseaseReportAttachmentFiles": urls_to_attachment_list(
donation.infectious_disease_report_urls if donation else None, db, tenant_no
),
"attachmentFiles": urls_to_attachment_list(
donation.attachment_urls if donation else None, db, tenant_no
),
"etaTime": _fmt_dt(transport.eta_time) if transport and transport.eta_time else "",
"transportType": (transport.transport_type if transport else "") or "",
"waybillNo": (transport.waybill_no if transport else "") or "",
@@ -462,6 +468,12 @@ def _rows_to_sample_list_items(
**sample_special_to_dict(sample),
"sourceType": "cell_storage",
"sourceTypeLabel": "细胞存储",
"protocolAttachmentFiles": urls_to_attachment_list(
protocol.protocol_attachments if protocol else None, db, tenant_no
),
"consentAttachmentFiles": urls_to_attachment_list(
protocol.consent_form_urls if protocol else None, db, tenant_no
),
"healthSurveyAttachmentFiles": urls_to_attachment_list(
protocol.health_survey_form_urls if protocol else None, db, tenant_no
),
@@ -0,0 +1,129 @@
"""样本接收列表项应携带协议侧附件字段,供质检查看。"""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
class _FakeQuery:
def filter(self, *args, **kwargs):
return self
def all(self):
return []
def first(self):
return None
class _FakeDb:
def query(self, *args, **kwargs):
return _FakeQuery()
class TestSampleReceptionProtocolAttachments(unittest.TestCase):
def test_cell_storage_item_includes_protocol_consent_health_files(self):
from app.crud import sample_reception as svc
protocol = SimpleNamespace(
id="proto-1",
protocol_no="P1",
protocol_status="effective",
protocol_attachments="http://a/p.pdf",
consent_form_urls="http://a/c.pdf",
health_survey_form_urls="http://a/h.pdf",
)
sample = SimpleNamespace(
id="sample-1",
sample_code="S001",
sample_type_id="t1",
collect_amount=None,
sample_status="",
)
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
transport = SimpleNamespace(
eta_time=None,
transport_type="",
waybill_no="",
express_company_id="",
carrier_name="",
carrier_phone="",
)
with patch.object(
svc,
"urls_to_attachment_list",
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
), patch(
"app.services.qc_work_order_fee_summary.build_protocol_payment_summaries",
return_value={},
):
items = svc._rows_to_sample_list_items(
_FakeDb(),
[(sample, protocol, party, transport)],
"t1",
reception_map={},
)
self.assertEqual(len(items), 1)
item = items[0]
self.assertEqual(item["protocolAttachmentFiles"][0]["fileUrl"], "http://a/p.pdf")
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
self.assertEqual(item["healthSurveyAttachmentFiles"][0]["fileUrl"], "http://a/h.pdf")
def test_donation_item_includes_consent_infectious_other_files(self):
from app.crud import sample_reception as svc
donation = SimpleNamespace(
id="don-1",
donation_no="D1",
donation_status="effective",
consent_form_urls="http://a/c.pdf",
infectious_disease_report_urls="http://a/i.pdf",
attachment_urls="http://a/o.pdf",
)
sample = SimpleNamespace(
id="ds-1",
sample_code="D001",
sample_type_id="t1",
collect_amount=None,
)
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
transport = SimpleNamespace(
eta_time=None,
transport_type="",
waybill_no="",
express_company_id="",
carrier_name="",
carrier_phone="",
)
with patch.object(
svc,
"urls_to_attachment_list",
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
):
items = svc._donation_rows_to_sample_list_items(
_FakeDb(),
[(sample, donation, party, transport)],
"t1",
reception_map={},
)
self.assertEqual(len(items), 1)
item = items[0]
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
self.assertEqual(
item["infectiousDiseaseReportAttachmentFiles"][0]["fileUrl"], "http://a/i.pdf"
)
self.assertEqual(item["attachmentFiles"][0]["fileUrl"], "http://a/o.pdf")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,62 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
getConsentProtocolAttachment,
getSourceProtocolAttachment,
getSourceProtocolAttachmentGroups
} from './protocol-source-attachment'
describe('protocol-source-attachment', () => {
it('returns health survey as primary for cell storage', () => {
const meta = getSourceProtocolAttachment({
sourceType: 'cell_storage',
healthSurveyAttachmentFiles: [{ id: '1', fileUrl: '/a.png' }]
})
assert.equal(meta.label, '孕妇健康调查表')
assert.equal(meta.files.length, 1)
})
it('returns infectious disease report as primary for research donation', () => {
const meta = getSourceProtocolAttachment({
sourceType: 'research_donation',
infectiousDiseaseReportAttachmentFiles: [{ id: '2', fileUrl: '/b.png' }]
})
assert.equal(meta.label, '传染病检测报告')
assert.equal(meta.files.length, 1)
})
it('returns consent attachment from shared field', () => {
const meta = getConsentProtocolAttachment({
consentAttachmentFiles: [{ id: '3', fileUrl: '/c.png' }]
})
assert.equal(meta.label, '知情同意书')
assert.equal(meta.files.length, 1)
})
it('groups cell storage attachments for reception dialog', () => {
const groups = getSourceProtocolAttachmentGroups({
sourceType: 'cell_storage',
protocolAttachmentFiles: [{ id: 'p' }],
consentAttachmentFiles: [{ id: 'c' }],
healthSurveyAttachmentFiles: [{ id: 'h' }]
})
assert.deepEqual(
groups.map((g) => g.label),
['协议附件', '知情同意书', '孕妇健康调查表']
)
assert.ok(groups.every((g) => g.files.length === 1))
})
it('groups research donation attachments for reception dialog', () => {
const groups = getSourceProtocolAttachmentGroups({
sourceType: 'research_donation',
consentAttachmentFiles: [{ id: 'c' }],
infectiousDiseaseReportAttachmentFiles: [{ id: 'i' }],
attachmentFiles: [{ id: 'o' }]
})
assert.deepEqual(
groups.map((g) => g.label),
['知情同意书', '传染病检测报告', '其他附件']
)
})
})
@@ -8,7 +8,7 @@ function normalizeAttachmentFiles(raw: unknown): Array<Record<string, unknown>>
return raw.filter((item) => item && typeof item === 'object') as Array<Record<string, unknown>>
}
/** 按来源返回协议级附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
/** 按来源返回协议级主附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
export function getSourceProtocolAttachment(
row: Record<string, unknown>
): ProtocolSourceAttachmentMeta {
@@ -24,3 +24,54 @@ export function getSourceProtocolAttachment(
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
}
}
/** 知情同意书(细胞存储与科研捐赠共用字段) */
export function getConsentProtocolAttachment(
row: Record<string, unknown>
): ProtocolSourceAttachmentMeta {
return {
label: '知情同意书',
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
}
}
/**
* 样本接收弹窗:按来源返回质检需查看的协议附件分组。
* 细胞存储:协议附件 / 知情同意书 / 孕妇健康调查表
* 科研捐赠:知情同意书 / 传染病检测报告 / 其他附件
*/
export function getSourceProtocolAttachmentGroups(
row: Record<string, unknown>
): ProtocolSourceAttachmentMeta[] {
const source = String(row.sourceType || 'cell_storage').trim()
if (source === 'research_donation') {
return [
{
label: '知情同意书',
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
},
{
label: '传染病检测报告',
files: normalizeAttachmentFiles(row.infectiousDiseaseReportAttachmentFiles)
},
{
label: '其他附件',
files: normalizeAttachmentFiles(row.attachmentFiles)
}
]
}
return [
{
label: '协议附件',
files: normalizeAttachmentFiles(row.protocolAttachmentFiles)
},
{
label: '知情同意书',
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
},
{
label: '孕妇健康调查表',
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
}
]
}
@@ -80,6 +80,16 @@
/>
</dd>
</div>
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
<dt>{{ consentAttachmentMeta(row).label }}</dt>
<dd>
<ProtocolAttachmentMiniReadonly
:files="consentAttachmentMeta(row).files"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</dd>
</div>
<div v-if="activeTab === 'received'" class="sample-reception-card__meta-item">
<dt>实验室入库编码</dt>
<dd>
@@ -242,7 +252,10 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
import { getSourceProtocolAttachment } from '@/utils/protocol-source-attachment'
import {
getConsentProtocolAttachment,
getSourceProtocolAttachment
} from '@/utils/protocol-source-attachment'
import { useAttachmentActions } from '@/composables/useAttachmentActions'
defineOptions({ name: 'SampleReceptionCardList' })
@@ -284,6 +297,10 @@
return getSourceProtocolAttachment(row)
}
function consentAttachmentMeta(row: Record<string, unknown>) {
return getConsentProtocolAttachment(row)
}
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
return source === 'research_donation'
? donationStatusDisplayLabel(status, voidType)
@@ -204,41 +204,62 @@
</div>
</div>
</div>
<div id="form-section-transport" class="sample-transport-col">
<div class="section-title section-title--compact">
<div class="section-title-left"><h3>运输信息</h3></div>
<div id="form-section-transport" class="sample-transport-col sample-transport-col--stack">
<div class="sample-transport-stack-block">
<div class="section-title section-title--compact">
<div class="section-title-left"><h3>运输信息</h3></div>
</div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">运输方式</div>
<div class="data-value">
{{ transportTypeLabel(String(currentRow.transportType || '')) }}
</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ formatDisplayOrDash(currentRow.etaTime) }}</div>
</div>
<template v-if="currentRow.transportType === 'kdys'">
<div class="data-item">
<div class="data-label">快递公司</div>
<div class="data-value">{{ currentRow.expressCompanyName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">快递单号</div>
<div class="data-value">{{ currentRow.waybillNo || '-' }}</div>
</div>
</template>
<template v-else-if="currentRow.transportType === 'ryxd'">
<div class="data-item">
<div class="data-label">携带人</div>
<div class="data-value">{{ currentRow.carrierName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">携带人电话</div>
<div class="data-value">{{ currentRow.carrierPhone || '-' }}</div>
</div>
</template>
</div>
</div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">运输方式</div>
<div class="data-value">
{{ transportTypeLabel(String(currentRow.transportType || '')) }}
<div class="sample-transport-stack-block sample-protocol-attachments">
<div class="section-title section-title--compact">
<div class="section-title-left"><h3>协议附件</h3></div>
</div>
<div class="sample-protocol-attachments__list">
<div
v-for="group in receiveProtocolAttachmentGroups"
:key="group.label"
class="sample-protocol-attachments__item"
>
<div class="data-label">{{ group.label }}</div>
<ProtocolAttachmentReadonly
:files="group.files"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ formatDisplayOrDash(currentRow.etaTime) }}</div>
</div>
<template v-if="currentRow.transportType === 'kdys'">
<div class="data-item">
<div class="data-label">快递公司</div>
<div class="data-value">{{ currentRow.expressCompanyName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">快递单号</div>
<div class="data-value">{{ currentRow.waybillNo || '-' }}</div>
</div>
</template>
<template v-else-if="currentRow.transportType === 'ryxd'">
<div class="data-item">
<div class="data-label">携带人</div>
<div class="data-value">{{ currentRow.carrierName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">携带人电话</div>
<div class="data-value">{{ currentRow.carrierPhone || '-' }}</div>
</div>
</template>
</div>
</div>
</div>
@@ -745,6 +766,7 @@
buildPrefilledLabStorageCode,
parseLabStorageReferenceDate
} from '@/utils/sample-reception-lab-storage-code'
import { getSourceProtocolAttachmentGroups } from '@/utils/protocol-source-attachment'
import type { ProductionReceptionRecord } from '@/utils/sample-production-reception-fields'
import {
formatProductionFieldValue,
@@ -955,6 +977,12 @@
.filter(Boolean) as Array<Record<string, unknown>>
})
const receiveProtocolAttachmentGroups = computed(() => {
const row = currentRow.value
if (!row) return []
return getSourceProtocolAttachmentGroups(row)
})
function formatMetric(v: unknown) {
if (v === null || v === undefined || v === '') return '-'
const n = Number(v)
@@ -1653,6 +1681,35 @@
padding-left: 20px;
border-left: 1px solid #e2e8f0;
}
&--stack {
display: flex;
flex-direction: column;
gap: 14px;
}
}
.sample-transport-stack-block {
min-width: 0;
}
.sample-protocol-attachments {
background: #ecfdf5;
border-radius: 8px;
padding: 12px 14px;
border: 1px solid #a7f3d0;
}
.sample-protocol-attachments__list {
display: flex;
flex-direction: column;
gap: 12px;
}
.sample-protocol-attachments__item {
.data-label {
margin-bottom: 6px;
}
}
.section-title {