diff --git a/backend/app/services/qc_work_order_fee_summary.py b/backend/app/services/qc_work_order_fee_summary.py index 75da8e34..05e73e85 100644 --- a/backend/app/services/qc_work_order_fee_summary.py +++ b/backend/app/services/qc_work_order_fee_summary.py @@ -127,6 +127,7 @@ def _payment_detail_row(d: BizProtocolPaymentDetail, pay_mode: str, idx: int) -> "installmentDueDate": _fmt_ymd(d.installment_due_date), "dueAmount": _dec(d.due_amount), "transferAmount": _dec(d.transfer_amount), + "refundAmount": _dec(getattr(d, "refund_amount", None)), "payStatus": st, "payStatusLabel": _PAY_STATUS_LABELS.get(st, st or "—"), "transferTime": _fmt_dt(getattr(d, "transfer_time", None)), @@ -156,6 +157,14 @@ def build_protocol_payment_summary( detail_rows = [_payment_detail_row(d, pay_mode, i) for i, d in enumerate(sorted_details)] received = _received_amount_from_details(sorted_details) pending = max(contract - received, 0) if contract is not None else None + refund_total = 0.0 + has_refund = False + for d in sorted_details: + amt = _dec(getattr(d, "refund_amount", None)) + if amt is None: + continue + has_refund = True + refund_total += float(amt) statuses = [(d.pay_status or "").strip() for d in sorted_details] overall = _protocol_overall_status(statuses, contract, pending) hint = _protocol_status_hint(pay_mode, statuses) or overall["overallPayStatusLabel"] @@ -165,6 +174,7 @@ def build_protocol_payment_summary( "contractAmount": contract, "receivedAmount": received, "pendingAmount": pending, + "refundAmount": refund_total if has_refund else None, "statusHint": hint, "installmentCount": len(detail_rows) if pay_mode in ("fq", "djfq") else None, "paymentDetails": detail_rows, diff --git a/web-admin/src/utils/collection-ledger-export.test.ts b/web-admin/src/utils/collection-ledger-export.test.ts index ba51f63a..556b307a 100644 --- a/web-admin/src/utils/collection-ledger-export.test.ts +++ b/web-admin/src/utils/collection-ledger-export.test.ts @@ -45,10 +45,25 @@ describe('buildPresignLedgerRows', () => { assert.equal(row.length, PRESIGN_HEADERS.length) assert.deepEqual(row.slice(0, 6), ['1', '2026', '4', '2', 'ZKZX1', '杨育蕾']) assert.equal(row[9], '趸交') - assert.equal(row[12], '1000元') + assert.equal(row[12], '1000元(定金)') assert.equal(row[13], '2026年4月2日16时49分42秒') assert.equal(row[18], '') }) + + it('fills refund amount for voided deposit orders', () => { + const [row] = buildPresignLedgerRows([ + { + orderDate: '20250919', + motherName: '陈影', + confirmedDepositAmount: 1000, + transferTimes: ['2025-09-19 17:26:13'], + orderStatus: 'void', + remark: '悔存-申请退定金' + } + ]) + assert.equal(row[12], '1000元(定金)') + assert.equal(row[18], '1000元') + }) }) describe('buildCellStorageLedgerRows', () => { @@ -77,6 +92,7 @@ describe('buildCellStorageLedgerRows', () => { freightId: 'fr-cell-1', freightTransportLabel: '专线车', freightAmountLabel: '120', + monthlyVerifyNo: '202509012', paymentSummary: { payMode: 'fq', contractAmount: 22880, @@ -105,6 +121,7 @@ describe('buildCellStorageLedgerRows', () => { const row = rows[0] assert.equal(row.length, CELL_STORAGE_HEADERS.length) assert.equal(CELL_STORAGE_HEADERS[13], '涡阳县人民医院') + assert.equal(row[4], '202509012') assert.equal(row[5], 'B0000004') assert.equal(row[6], '生效中') assert.equal(row[9], 'ZKZX34000849') @@ -113,6 +130,7 @@ describe('buildCellStorageLedgerRows', () => { assert.equal(row[14], '分期') assert.equal(row[16], '22880') assert.equal(row[17], '6480') + assert.equal(row[20], '') assert.equal(row[22], '2025年9月15日11时13分01秒') assert.equal(row[24], '16400') assert.equal(row[25], '16400') @@ -122,6 +140,27 @@ describe('buildCellStorageLedgerRows', () => { assert.equal(row[53], '120') }) + it('writes negative refund amount for voided protocols', () => { + const { rows } = buildCellStorageLedgerRows([ + { + protocol: { protocolStatus: 'yzf', remark: '', voidType: 'khhc', voidDesc: '悔存' }, + sampleCollectTime: '2025-09-07 13:45:00', + motherName: '王晴晴', + sampleCode: 'S-VOID', + sampleTypeName: '围产期造血干细胞', + paymentSummary: { + payMode: 'dj', + contractAmount: 5000, + receivedAmount: 5000, + refundAmount: 5000, + paymentDetails: [] + } + } as unknown as ProtocolAuditFlatRow + ]) + assert.equal(rows[0][19], '5000') + assert.equal(rows[0][20], '-5000') + }) + it('merges transport and freight columns for the same freight batch', () => { const base = { protocol: { protocolStatus: 'sxz', remark: '', voidType: '', voidDesc: '' }, @@ -195,6 +234,7 @@ describe('buildResearchLedgerRows', () => { assert.equal(row[4], '202508001') assert.equal(row[6], '实验室废弃') assert.equal(row[7], '母血乙肝阳性') + assert.equal(row[9], '实验室废弃') assert.equal(row[16], '专线车') assert.equal(row[17], '100') assert.equal(row[18], '女') @@ -203,6 +243,24 @@ describe('buildResearchLedgerRows', () => { assert.equal(row[23], '1') }) + it('keeps sample type in column 10 for active research rows', () => { + const { rows } = buildResearchLedgerRows([ + { + protocol: { donationStatus: 'sxz', voidType: '', voidDesc: '', monthlyVerifyNo: '202508010' }, + sampleCollectTime: '2025-08-30 10:00:00', + motherName: '正常', + sampleTypeName: '围产期造血干细胞', + regionName: '亳州', + hospitalName: '涡阳县人民医院', + freightId: '', + freightTransportLabel: '', + freightAmountLabel: '' + } as unknown as ProtocolAuditFlatRow + ]) + assert.equal(rows[0][6], '') + assert.equal(rows[0][9], '围产期造血干细胞') + }) + it('maps sdfq void type code to 属地废弃 labels', () => { const flat = { protocol: { @@ -237,6 +295,7 @@ describe('buildResearchLedgerRows', () => { const row = rows[0] assert.equal(row[6], '属地废弃') assert.equal(row[7], '属地废弃') + assert.equal(row[9], '属地废弃') assert.equal(row[20], '剖腹产') assert.equal(row[22], '无母血') }) diff --git a/web-admin/src/utils/collection-ledger-export.ts b/web-admin/src/utils/collection-ledger-export.ts index edf670a2..8136a81d 100644 --- a/web-admin/src/utils/collection-ledger-export.ts +++ b/web-admin/src/utils/collection-ledger-export.ts @@ -470,11 +470,26 @@ function transportText(row: ProtocolAuditFlatRow): string { return label(TRANSPORT, row.transportType || transport.transportType) } +function presignDepositText(amount: unknown): string { + const money = formatLedgerMoney(amount) + return money ? `${money}元(定金)` : '' +} + +function presignRefundText(row: Row): string { + const explicit = formatLedgerMoney(row.refundAmount) + if (explicit) return `${explicit}元` + const status = text(row.orderStatus) + const remark = text(row.remark) + const voided = status === 'void' || remark.includes('悔存') || remark.includes('退定金') + if (!voided) return '' + const deposit = formatLedgerMoney(row.confirmedDepositAmount) + return deposit ? `${deposit}元` : '' +} + export function buildPresignLedgerRows(rows: Row[]): string[][] { return rows.map((row, index) => { const orderDate = row.orderDate const times = Array.isArray(row.transferTimes) ? row.transferTimes : [] - const received = formatLedgerMoney(row.confirmedDepositAmount) return [ String(index + 1), yearText(orderDate, ''), @@ -488,17 +503,31 @@ export function buildPresignLedgerRows(rows: Row[]): string[][] { payModeText(row.payMode), formatLedgerDateTime(row.sampleCollectTime), formatLedgerMoney(row.contractAmount), - received ? `${received}元` : '', + presignDepositText(row.confirmedDepositAmount), times.map((item) => formatLedgerDateTime(item)).filter(Boolean).join(','), formatLedgerDate(row.expectedDeliveryDate), text(row.salesUserName), text(row.regionManagerUserName), text(row.remark), - '' + presignRefundText(row) ] }) } +/** 自存表退款金额:有退费记录写负值;无退费但已作废时写 -已缴金额。 */ +function cellStorageRefundText(summary: Row | null, voided: boolean, received: string): string { + const explicit = summary?.refundAmount + if (explicit != null && explicit !== '') { + const amount = Number(explicit) + if (Number.isFinite(amount) && amount !== 0) { + const abs = formatLedgerMoney(Math.abs(amount)) + return abs ? `-${abs}` : '' + } + } + if (!voided || !received) return '' + return `-${received}` +} + function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: boolean): string[] { const protocol = row.protocol const summary = (row.paymentSummary || protocol.paymentSummary) as Row | null @@ -525,7 +554,8 @@ function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: bo cells[6] = researchStorageStatus(status, protocol.voidType) cells[7] = voidReason(protocol) cells[8] = text(row.motherName) - cells[9] = text(row.sampleTypeName) + // 标准大表废弃行第 10 列复用储存状态文案;正常行写样本类型 + cells[9] = cells[6] || text(row.sampleTypeName) cells[10] = text(row.regionName) cells[11] = text(row.hospitalName) cells[12] = formatLedgerDateTime(row.newbornDeliveryTime) @@ -547,6 +577,7 @@ function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: bo cells[1] = yearText(collectTime, '') cells[2] = monthText(collectTime) cells[3] = dayText(collectTime) + cells[4] = text(row.monthlyVerifyNo) || text(protocol.monthlyVerifyNo) cells[5] = text(row.labStorageCode) cells[6] = statusText(status, protocol.voidType) cells[7] = voidReason(protocol) @@ -562,6 +593,7 @@ function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: bo cells[17] = formatLedgerMoney(first.due) cells[18] = received cells[19] = voided ? received : '' + cells[20] = cellStorageRefundText(summary, voided, received) cells[21] = text(protocol.remark) cells[22] = formatLedgerDateTime(firstPaidTime) cells[23] = gapText(first.due, first.paid)