diff --git a/backend/app/crud/research_donation.py b/backend/app/crud/research_donation.py index fc19a2a2..2b5bb042 100644 --- a/backend/app/crud/research_donation.py +++ b/backend/app/crud/research_donation.py @@ -879,6 +879,20 @@ def list_donations( if t.express_company_id ], ) + from app.crud import sample_freight as sample_freight_svc + + all_list_sample_ids = [ + s.id + for samples in sample_map.values() + for s in samples + if getattr(s, "id", None) + ] + freight_by_sample = sample_freight_svc.freight_map_for_samples( + db, + tenant_no=tenant_no, + sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION, + sample_ids=all_list_sample_ids, + ) result = [] for r in rows: @@ -957,11 +971,13 @@ def list_donations( } sample_items.append( { + "id": s.id, "sampleTypeName": type_name, "sampleCode": code, "sampleStatus": (s.sample_status or "").strip(), "labStorageCode": lab_code_map.get(code, "") if code else "", "transport": transport_dict, + "freight": freight_by_sample.get(s.id), **sample_special_to_dict(s), **build_sample_item_reception_qc_fields( reception_summary_map.get(code) if code else None, diff --git a/web-admin/src/utils/collection-ledger-export.test.ts b/web-admin/src/utils/collection-ledger-export.test.ts index 039a6d45..fd99040b 100644 --- a/web-admin/src/utils/collection-ledger-export.test.ts +++ b/web-admin/src/utils/collection-ledger-export.test.ts @@ -7,6 +7,7 @@ import { RESEARCH_HEADERS, buildCellStorageLedgerRows, buildCustomerServiceLedgerRows, + buildFreightMergeRanges, buildPresignLedgerRows, buildResearchLedgerRows, formatLedgerDateTime @@ -141,14 +142,20 @@ describe('buildResearchLedgerRows', () => { birthOrder: 1, maternalBloodCollectTime: '2025-08-29 13:30:00', consentFormUrls: 'http://example/consent', - remark: '' + remark: '', + freightId: 'fr1', + freightTransportLabel: '专线车', + freightAmountLabel: '100' } as unknown as ProtocolAuditFlatRow - const [row] = buildResearchLedgerRows([flat]) + const { rows } = buildResearchLedgerRows([flat]) + const row = rows[0] assert.equal(row.length, RESEARCH_HEADERS.length) assert.equal(row[1], '2025年') assert.equal(row[4], '202508001') - assert.equal(row[6], '已作废(实验室废弃)') + assert.equal(row[6], '实验室废弃') assert.equal(row[7], '母血乙肝阳性') + assert.equal(row[16], '专线车') + assert.equal(row[17], '100') assert.equal(row[18], '女') assert.equal(row[19], '3000g') assert.equal(row[20], '顺产') @@ -176,15 +183,57 @@ describe('buildResearchLedgerRows', () => { newbornDeliveryTime: '', newbornSex: '', newbornWeightGram: '', - deliveryMode: '', + deliveryMode: 'pgc', birthOrder: '', maternalBloodCollectTime: '', consentFormUrls: '', - remark: '' + remark: '', + freightId: '', + freightTransportLabel: '', + freightAmountLabel: '' } as unknown as ProtocolAuditFlatRow - const [row] = buildResearchLedgerRows([flat]) - assert.equal(row[6], '已作废(属地废弃)') + const { rows } = buildResearchLedgerRows([flat]) + const row = rows[0] + assert.equal(row[6], '属地废弃') assert.equal(row[7], '属地废弃') + assert.equal(row[20], '剖腹产') + assert.equal(row[22], '无母血') + }) + + it('merges transport and freight columns for the same freight batch', () => { + const base = { + protocol: { donationStatus: 'sxz', voidType: '', voidDesc: '', monthlyVerifyNo: '' }, + sampleCollectTime: '2025-09-11 18:13:00', + motherName: '甲', + sampleTypeName: 'NK', + regionName: '常州', + hospitalName: '溧阳市人民医院', + labStorageCode: 'UBMC1', + freightId: 'batch-a', + freightTransportLabel: '专线车', + freightAmountLabel: '100' + } + const { rows, merges } = buildResearchLedgerRows([ + { ...base, sampleIndex: 0 } as unknown as ProtocolAuditFlatRow, + { + ...base, + sampleIndex: 1, + motherName: '乙', + labStorageCode: 'UBMC2' + } as unknown as ProtocolAuditFlatRow + ]) + assert.equal(rows[0][16], '专线车') + assert.equal(rows[0][17], '100') + assert.equal(rows[1][16], '') + assert.equal(rows[1][17], '') + assert.deepEqual(merges, [ + { startRow: 2, endRow: 3, startCol: 17, endCol: 17 }, + { startRow: 2, endRow: 3, startCol: 18, endCol: 18 } + ]) + assert.deepEqual(buildFreightMergeRanges(['', 'x', 'x', 'y']), [ + { startRow: 3, endRow: 4, startCol: 17, endCol: 17 }, + { startRow: 3, endRow: 4, startCol: 18, endCol: 18 } + ]) }) }) diff --git a/web-admin/src/utils/collection-ledger-export.ts b/web-admin/src/utils/collection-ledger-export.ts index f57cffd8..94afa812 100644 --- a/web-admin/src/utils/collection-ledger-export.ts +++ b/web-admin/src/utils/collection-ledger-export.ts @@ -198,6 +198,19 @@ const DELIVERY: Record = { pgc: '剖宫产' } +/** 科研表模板多用「剖腹产」。 */ +const RESEARCH_DELIVERY: Record = { + sc: '顺产', + pgc: '剖腹产' +} + +export type LedgerMergeRange = { + startRow: number + endRow: number + startCol: number + endCol: number +} + function text(value: unknown): string { return value == null ? '' : String(value).trim() } @@ -299,10 +312,55 @@ function statusText(status: unknown, voidType: unknown): string { return reason ? `${base}(${reason})` : base } +/** 科研表储存状态:废弃写类别文案,正常样本留空,对齐线下大表。 */ +function researchStorageStatus(status: unknown, voidType: unknown): string { + const code = text(status) + if (code === 'yzf') return voidTypeText(voidType) + if (code === 'sdfq') return PROTOCOL_STATUS.sdfq + return '' +} + function voidReason(row: Row): string { return text(row.voidDesc) || voidTypeText(row.voidType) } +function researchTransportText(row: ProtocolAuditFlatRow): string { + return text(row.freightTransportLabel) || transportText(row) +} + +function researchFreightText(row: ProtocolAuditFlatRow): string { + return text(row.freightAmountLabel) +} + +function researchMaternalBloodText(value: unknown): string { + const formatted = formatLedgerDateTime(value) + if (formatted) return formatted + return text(value) ? text(value) : '无母血' +} + +/** 相邻且 freightId 相同的导出行,合并运输方式/运费列(Excel 行号含表头)。 */ +export function buildFreightMergeRanges(freightIds: string[]): LedgerMergeRange[] { + const merges: LedgerMergeRange[] = [] + let index = 0 + while (index < freightIds.length) { + const freightId = text(freightIds[index]) + if (!freightId) { + index += 1 + continue + } + let end = index + 1 + while (end < freightIds.length && text(freightIds[end]) === freightId) end += 1 + if (end - index >= 2) { + const startRow = index + 2 + const endRow = end + 1 + merges.push({ startRow, endRow, startCol: 17, endCol: 17 }) + merges.push({ startRow, endRow, startCol: 18, endCol: 18 }) + } + index = end + } + return merges +} + /** 导出前加载作废类型字典,避免列表外的编码原样写入 Excel。 */ export async function prepareCollectionLedgerExport(): Promise { const { loadVoidTypeDict, getVoidTypeOptions } = await import('./void-type-dict') @@ -439,7 +497,7 @@ function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: bo cells[3] = dayText(collectTime) cells[4] = text(protocol.monthlyVerifyNo) cells[5] = text(row.labStorageCode) - cells[6] = statusText(status, protocol.voidType) + cells[6] = researchStorageStatus(status, protocol.voidType) cells[7] = voidReason(protocol) cells[8] = text(row.motherName) cells[9] = text(row.sampleTypeName) @@ -449,12 +507,13 @@ function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: bo cells[13] = formatLedgerDateTime(collectTime) cells[14] = text(row.salesUserName) cells[15] = text(row.regionManagerUserName) - cells[16] = transportText(row) + cells[16] = researchTransportText(row) + cells[17] = researchFreightText(row) cells[18] = label(SEX, row.newbornSex) cells[19] = weightText(row.newbornWeightGram) - cells[20] = label(DELIVERY, row.deliveryMode) + cells[20] = label(RESEARCH_DELIVERY, row.deliveryMode) || label(DELIVERY, row.deliveryMode) cells[21] = text(row.birthOrder) - cells[22] = formatLedgerDateTime(row.maternalBloodCollectTime) + cells[22] = researchMaternalBloodText(row.maternalBloodCollectTime) cells[23] = text(row.consentFormUrls) ? '1' : '' cells[24] = text(row.remark) return cells @@ -506,8 +565,23 @@ export function buildCellStorageLedgerRows(rows: ProtocolAuditFlatRow[]): string return rows.map((row, index) => storageLedgerRow(row, index, false)) } -export function buildResearchLedgerRows(rows: ProtocolAuditFlatRow[]): string[][] { - return rows.map((row, index) => storageLedgerRow(row, index, true)) +export function buildResearchLedgerRows(rows: ProtocolAuditFlatRow[]): { + rows: string[][] + merges: LedgerMergeRange[] +} { + const freightIds = rows.map((row) => text(row.freightId)) + const built = rows.map((row, index) => storageLedgerRow(row, index, true)) + const merges = buildFreightMergeRanges(freightIds) + // 合并区域内仅保留首行运输/运费,其余清空,避免合并后重复显示 + for (const merge of merges) { + if (merge.startCol !== 17 && merge.startCol !== 18) continue + const colIndex = merge.startCol - 1 + for (let excelRow = merge.startRow + 1; excelRow <= merge.endRow; excelRow += 1) { + const dataIndex = excelRow - 2 + if (dataIndex >= 0 && dataIndex < built.length) built[dataIndex][colIndex] = '' + } + } + return { rows: built, merges } } export function buildCustomerServiceLedgerRows(rows: Row[]): string[][] { @@ -576,7 +650,8 @@ export async function saveCollectionLedgerWorkbook( sheetName: string, headers: string[], rows: string[][], - filenamePrefix: string + filenamePrefix: string, + merges: LedgerMergeRange[] = [] ) { const ExcelJS = (await import('exceljs')).default const FileSaver = (await import('file-saver')).default @@ -593,8 +668,15 @@ export async function saveCollectionLedgerWorkbook( const cell = added.getCell(index + 1) cell.value = value cell.numFmt = '@' + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true } }) }) + merges.forEach((merge) => { + if (merge.endRow <= merge.startRow) return + sheet.mergeCells(merge.startRow, merge.startCol, merge.endRow, merge.endCol) + const cell = sheet.getCell(merge.startRow, merge.startCol) + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true } + }) headers.forEach((header, index) => { sheet.getColumn(index + 1).width = Math.min(28, Math.max(12, header.replace(/\n/g, '').length + 2)) }) diff --git a/web-admin/src/utils/protocol-audit-flatten.test.ts b/web-admin/src/utils/protocol-audit-flatten.test.ts index 97213d6e..8e209c28 100644 --- a/web-admin/src/utils/protocol-audit-flatten.test.ts +++ b/web-admin/src/utils/protocol-audit-flatten.test.ts @@ -110,7 +110,18 @@ describe('flattenProtocolAuditRows', () => { infectiousDiseaseReportUrls: '', attachmentUrls: 'https://x/b.pdf', remark: '备注一', - sampleItems: [{ sampleTypeName: 'NK', sampleCode: 'D2', labStorageCode: 'A1' }] + sampleItems: [ + { + sampleTypeName: 'NK', + sampleCode: 'D2', + labStorageCode: 'A1', + freight: { + id: 'fr-1', + transportModeLabel: '专线车', + freightAmountLabel: '100' + } + } + ] } ]) assert.equal(rows[0].monthlyVerifyNo, '202508001') @@ -122,6 +133,9 @@ describe('flattenProtocolAuditRows', () => { assert.equal(rows[0].infectiousDiseaseReportUrls, '') assert.equal(rows[0].attachmentUrls, 'https://x/b.pdf') assert.equal(rows[0].remark, '备注一') + assert.equal(rows[0].freightId, 'fr-1') + assert.equal(rows[0].freightTransportLabel, '专线车') + assert.equal(rows[0].freightAmountLabel, '100') }) }) diff --git a/web-admin/src/utils/protocol-audit-flatten.ts b/web-admin/src/utils/protocol-audit-flatten.ts index b64fb7c5..da2ef738 100644 --- a/web-admin/src/utils/protocol-audit-flatten.ts +++ b/web-admin/src/utils/protocol-audit-flatten.ts @@ -37,6 +37,10 @@ export type ProtocolAuditFlatRow = { labStorageCode: string transportType: string transportLabel: string + /** 样本运费批次;多样本共用时 id 相同 */ + freightId: string + freightTransportLabel: string + freightAmountLabel: string paymentSummary: QcProtocolPaymentSummary | null monthlyVerifyNo: string donationStatus: string @@ -90,7 +94,13 @@ export function flattenProtocolAuditRows( slots.forEach((item, sampleIndex) => { const rec = item || {} const transport = asRecord(rec.transport) + const freight = asRecord(rec.freight) const transportType = str(transport?.transportType) + const freightAmountLabel = str(freight?.freightAmountLabel) + const freightAmount = + freight?.freightAmount == null || freight?.freightAmount === '' + ? '' + : String(freight.freightAmount) out.push({ rowKey: `${protocolId}|${str(rec.sampleCode) || sampleIndex}`, protocolId, @@ -113,6 +123,9 @@ export function flattenProtocolAuditRows( labStorageCode: str(rec.labStorageCode), transportType, transportLabel: '', + freightId: str(freight?.id), + freightTransportLabel: str(freight?.transportModeLabel) || str(freight?.transportMode), + freightAmountLabel: freightAmountLabel || freightAmount, paymentSummary: protocol.paymentSummary && typeof protocol.paymentSummary === 'object' ? (protocol.paymentSummary as QcProtocolPaymentSummary) diff --git a/web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue b/web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue index 606376b3..ba0c0606 100644 --- a/web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue +++ b/web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue @@ -569,11 +569,13 @@ ElMessage.warning('没有可导出的数据') return } + const ledger = buildResearchLedgerRows(rows) await saveCollectionLedgerWorkbook( '科研表', RESEARCH_HEADERS, - buildResearchLedgerRows(rows), - '科研表' + ledger.rows, + '科研表', + ledger.merges ) ElMessage.success('导出成功') } catch {