diff --git a/backend/app/crud/presign_order.py b/backend/app/crud/presign_order.py index 98d8a119..6a5ecc0e 100644 --- a/backend/app/crud/presign_order.py +++ b/backend/app/crud/presign_order.py @@ -17,7 +17,9 @@ from app.crud.biz_no_generator import allocate_next_biz_no from app.crud.collection_protocol import urls_to_attachment_list from app.models.models import ( BizCellSampleType, + BizCollectionHospital, BizCollectionProtocol, + BizCollectionRegion, BizPresignOrder, BizPresignOrderSampleType, BizPresignTransfer, @@ -503,9 +505,107 @@ def list_presign_orders( .limit(page_size) .all() ) - return [ - order_to_dict(db, o, tenant_no, with_children=False) for o in rows - ], int(total) + items = [order_to_dict(db, o, tenant_no, with_children=False) for o in rows] + attach_presign_ledger_fields(db, items, tenant_no) + return items, int(total) + + +def attach_presign_ledger_fields( + db: Session, items: List[Dict[str, Any]], tenant_no: str +) -> None: + """补列表导出需要的地区、医院、样本类型和转账时间。含已停用主数据名称。""" + if not items: + return + order_ids = [str(item.get("id") or "") for item in items if item.get("id")] + region_ids = {str(item.get("regionId") or "") for item in items if item.get("regionId")} + hospital_ids = { + str(item.get("hospitalId") or "") for item in items if item.get("hospitalId") + } + region_names: Dict[str, str] = {} + if region_ids: + region_names = { + str(row.id): str(row.region_name or "").strip() + for row in db.query(BizCollectionRegion).filter( + BizCollectionRegion.id.in_(region_ids), + BizCollectionRegion.tenant_no == tenant_no, + ) + } + hospital_names: Dict[str, str] = {} + if hospital_ids: + hospital_names = { + str(row.id): str(row.hospital_name or "").strip() + for row in db.query(BizCollectionHospital).filter( + BizCollectionHospital.id.in_(hospital_ids), + BizCollectionHospital.tenant_no == tenant_no, + ) + } + type_ids_by_order: Dict[str, List[str]] = {order_id: [] for order_id in order_ids} + if order_ids: + links = ( + db.query(BizPresignOrderSampleType) + .filter( + BizPresignOrderSampleType.presign_order_id.in_(order_ids), + BizPresignOrderSampleType.tenant_no == tenant_no, + BizPresignOrderSampleType.del_flag == 0, + ) + .order_by( + BizPresignOrderSampleType.order_num.asc(), + BizPresignOrderSampleType.create_time.asc(), + BizPresignOrderSampleType.id.asc(), + ) + .all() + ) + for link in links: + if link.sample_type_id: + type_ids_by_order.setdefault(link.presign_order_id, []).append( + str(link.sample_type_id) + ) + type_ids = { + type_id + for order_type_ids in type_ids_by_order.values() + for type_id in order_type_ids + } + type_names: Dict[str, str] = {} + if type_ids: + type_names = { + str(row.id): str(row.type_name or "").strip() + for row in db.query(BizCellSampleType).filter( + BizCellSampleType.id.in_(type_ids), + BizCellSampleType.tenant_no == tenant_no, + ) + } + times_by_order: Dict[str, List[str]] = {} + if order_ids: + transfers = ( + db.query(BizPresignTransfer) + .filter( + BizPresignTransfer.presign_order_id.in_(order_ids), + BizPresignTransfer.tenant_no == tenant_no, + BizPresignTransfer.del_flag == 0, + ) + .order_by( + BizPresignTransfer.transfer_time.asc(), + BizPresignTransfer.create_time.asc(), + BizPresignTransfer.id.asc(), + ) + .all() + ) + for transfer in transfers: + if not transfer.transfer_time: + continue + times_by_order.setdefault(transfer.presign_order_id, []).append( + transfer.transfer_time.strftime("%Y-%m-%d %H:%M:%S") + ) + for item in items: + order_id = str(item.get("id") or "") + item["regionName"] = region_names.get(str(item.get("regionId") or ""), "") + item["hospitalName"] = hospital_names.get(str(item.get("hospitalId") or ""), "") + names = [ + type_names.get(type_id, "") + for type_id in type_ids_by_order.get(order_id, []) + ] + item["sampleTypeNames"] = [name for name in names if name] + item["transferTimes"] = times_by_order.get(order_id, []) def get_presign_order( diff --git a/backend/app/crud/protocol_sample_collection.py b/backend/app/crud/protocol_sample_collection.py index 4ca2b2a3..1d74658a 100644 --- a/backend/app/crud/protocol_sample_collection.py +++ b/backend/app/crud/protocol_sample_collection.py @@ -409,6 +409,8 @@ def list_protocol_sample_collections( item["newbornSex"] = (party.newborn_sex or "") if party else "" item["newbornDeliveryTime"] = _fmt_dt(party.newborn_delivery_time) if party else "" item["protocolRemark"] = (proto.remark or "") if proto else "" + item["voidType"] = (proto.void_type or "") if proto else "" + item["voidDesc"] = (proto.void_desc or "") if proto else "" item["protocolAttachments"] = (proto.protocol_attachments or "") if proto else "" item["consentFormUrls"] = (proto.consent_form_urls or "") if proto else "" item["healthSurveyFormUrls"] = (proto.health_survey_form_urls or "") if proto else "" diff --git a/backend/tests/test_market_presign_api.py b/backend/tests/test_market_presign_api.py index 609e84e9..b6578e8e 100644 --- a/backend/tests/test_market_presign_api.py +++ b/backend/tests/test_market_presign_api.py @@ -2550,3 +2550,41 @@ process.stdout.write(JSON.stringify([ '¥9,999,999,999,999,999.99', '¥9,999,999,999,999,999.99', ] + + +def test_web_presign_list_includes_ledger_fields(): + from datetime import datetime + + from app.crud.presign_order import list_presign_orders + + db = _session() + _add_master_data(db) + order = _order('ledger-1', region_id='region-a', sales_user_id='u1') + order.hospital_id = 'hospital-a' + order.mother_name = '陈影' + transfer = _transfer('ledger-transfer', order_id='ledger-1', pay_status='yjf') + transfer.transfer_time = datetime(2026, 4, 2, 16, 49, 42) + db.add_all( + [ + order, + transfer, + BizPresignOrderSampleType( + id='ledger-type', + presign_order_id='ledger-1', + sample_type_id='sample-a', + tenant_no='tenant-a', + create_dept_id='', + order_num=1, + del_flag=0, + ), + ] + ) + db.commit() + + items, total = list_presign_orders(db, tenant_no='tenant-a', page_size=10) + + assert total == 1 + assert items[0]['regionName'] == 'A区' + assert items[0]['hospitalName'] == 'A医院' + assert items[0]['sampleTypeNames'] == ['样本A'] + assert items[0]['transferTimes'] == ['2026-04-02 16:49:42'] diff --git a/web-admin/src/utils/collection-ledger-export.test.ts b/web-admin/src/utils/collection-ledger-export.test.ts new file mode 100644 index 00000000..d2a1738e --- /dev/null +++ b/web-admin/src/utils/collection-ledger-export.test.ts @@ -0,0 +1,186 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { + CELL_STORAGE_HEADERS, + CUSTOMER_SERVICE_HEADERS, + PRESIGN_HEADERS, + RESEARCH_HEADERS, + buildCellStorageLedgerRows, + buildCustomerServiceLedgerRows, + buildPresignLedgerRows, + buildResearchLedgerRows, + formatLedgerDateTime +} from './collection-ledger-export' +import type { ProtocolAuditFlatRow } from './protocol-audit-flatten' + +describe('formatLedgerDateTime', () => { + it('uses Chinese text and keeps seconds', () => { + assert.equal(formatLedgerDateTime('2026-04-02 16:49:42'), '2026年4月2日16时49分42秒') + assert.equal(formatLedgerDateTime('20250907'), '2025年9月7日') + }) +}) + +describe('buildPresignLedgerRows', () => { + it('maps order fields into the presign sheet columns', () => { + const [row] = buildPresignLedgerRows([ + { + orderDate: '20260402', + protocolNo: 'ZKZX1', + motherName: '杨育蕾', + sampleTypeNames: ['围产期造血干细胞'], + regionName: '亳州区域', + hospitalName: '涡阳县人民医院', + payMode: 'dj', + sampleCollectTime: '2026-04-02 16:49:42', + contractAmount: 22880, + confirmedDepositAmount: 1000, + transferTimes: ['2026-04-02 16:49:42'], + expectedDeliveryDate: '20260930', + salesUserName: '张慧茹', + regionManagerUserName: '张颖', + remark: '已存' + } + ]) + 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[13], '2026年4月2日16时49分42秒') + assert.equal(row[18], '') + }) +}) + +describe('buildCellStorageLedgerRows', () => { + it('places installment amounts and keeps template header width', () => { + const flat = { + protocolId: 'p1', + protocol: { + protocolStatus: 'sxz', + remark: '分期备注', + voidType: '', + voidDesc: '' + }, + sampleIndex: 0, + isCoStored: false, + sampleCollectTime: '2025-09-15 08:51:00', + motherName: '郭梦朵', + protocolNo: 'ZKZX34000849', + sampleTypeName: '围产期造血干细胞', + regionName: '亳州区域', + hospitalName: '涡阳县人民医院', + labStorageCode: 'B0000004', + salesUserName: '马丽', + regionManagerUserName: '张颖', + transportType: 'kdys', + paymentSummary: { + payMode: 'fq', + contractAmount: 22880, + receivedAmount: 6480, + pendingAmount: 16400, + firstPayTransferTime: '2025-09-15 11:13:01', + paymentDetails: [ + { + installmentNo: 1, + dueAmount: 6480, + transferAmount: 6480, + installmentDueDate: '20250915', + transferTime: '2025-09-15 11:13:01' + }, + { + installmentNo: 2, + dueAmount: 16400, + transferAmount: 0, + installmentDueDate: '20251215', + transferTime: '' + } + ] + } + } as unknown as ProtocolAuditFlatRow + const [row] = buildCellStorageLedgerRows([flat]) + assert.equal(row.length, CELL_STORAGE_HEADERS.length) + assert.equal(CELL_STORAGE_HEADERS[13], '涡阳县人民医院') + assert.equal(row[5], 'B0000004') + assert.equal(row[6], '生效中') + assert.equal(row[11], '否') + assert.equal(row[13], '涡阳县人民医院') + assert.equal(row[14], '分期') + assert.equal(row[16], '22880') + assert.equal(row[17], '6480') + assert.equal(row[22], '2025年9月15日11时13分01秒') + assert.equal(row[24], '16400') + assert.equal(row[25], '16400') + assert.equal(row[26], '2025年12月15日') + assert.equal(row[50], '马丽') + assert.equal(row[52], '快递运输') + }) +}) + +describe('buildResearchLedgerRows', () => { + it('maps donation fields and consent flag', () => { + const flat = { + protocol: { + donationStatus: 'yzf', + voidType: 'sysfq', + voidDesc: '母血乙肝阳性', + monthlyVerifyNo: '202508001' + }, + sampleIndex: 0, + sampleCollectTime: '2025-08-29 12:27:00', + motherName: '曹陆双', + sampleTypeName: '围产期造血干细胞', + regionName: '亳州', + hospitalName: '涡阳县人民医院', + labStorageCode: 'A0000001', + salesUserName: '杨莉莉', + regionManagerUserName: '张颖', + transportType: '', + newbornDeliveryTime: '2025-08-29 12:26:00', + newbornSex: '0', + newbornWeightGram: 3000, + deliveryMode: 'sc', + birthOrder: 1, + maternalBloodCollectTime: '2025-08-29 13:30:00', + consentFormUrls: 'http://example/consent', + remark: '' + } as unknown as ProtocolAuditFlatRow + const [row] = buildResearchLedgerRows([flat]) + assert.equal(row.length, RESEARCH_HEADERS.length) + assert.equal(row[1], '2025年') + assert.equal(row[4], '202508001') + assert.equal(row[6], '已作废(实验室废弃)') + assert.equal(row[7], '母血乙肝阳性') + assert.equal(row[18], '女') + assert.equal(row[19], '3000g') + assert.equal(row[20], '顺产') + assert.equal(row[23], '1') + }) +}) + +describe('buildCustomerServiceLedgerRows', () => { + it('keeps identity numbers as text columns', () => { + const [row] = buildCustomerServiceLedgerRows([ + { + labStorageCode: 'B0000002', + protocolStatus: 'sxz', + smsSendDate: '20250908', + smsSendStatus: '已发', + protocolNo: 'ZKZX34000852', + motherName: '高居巧', + motherPhone: '17860507206', + motherIdCard: '370911199712254424', + protocolAttachmentPresent: true, + insuranceDocPresent: false, + informedConsentPresent: true, + healthSurveyPresent: true, + payModeLabel: '趸交' + } + ]) + assert.equal(row.length, CUSTOMER_SERVICE_HEADERS.length) + assert.equal(row[0], 'B0000002') + assert.equal(row[2], '1') + assert.equal(row[5], 'ZKZX34000852') + assert.equal(row[8], '370911199712254424') + assert.equal(row[29], '1') + assert.equal(row[30], '0') + }) +}) diff --git a/web-admin/src/utils/collection-ledger-export.ts b/web-admin/src/utils/collection-ledger-export.ts new file mode 100644 index 00000000..3a145af9 --- /dev/null +++ b/web-admin/src/utils/collection-ledger-export.ts @@ -0,0 +1,583 @@ +import type { ProtocolAuditFlatRow } from './protocol-audit-flatten' + +type Row = Record +type PayDetail = { + installmentNo?: number | null + dueAmount?: number | null + transferAmount?: number | null + installmentDueDate?: string + transferTime?: string +} + +export const PRESIGN_HEADERS = [ + '序号', + '年', + '月', + '日', + '协议编号', + '母亲姓名', + '样本类型', + '采集地区', + '采集医院', + '缴费方式', + '采集时间', + '合同金额', + '到账金额', + '刷卡时间', + '预产期', + '市场专员', + '地区经理', + '备注', + '退款金额' +] + +/** 表头与《采集大表》自存表第 1 行一致,含原表里的医院名和「分期」。 */ +export const CELL_STORAGE_HEADERS = [ + '标准化加工全流程', + '年', + '月', + '日', + '当月核量号', + '实验室\n流转编码', + '储存状态', + '废弃\n原因', + '母亲姓名', + '协议编号', + '样本类型', + '是否同存', + '采集地区', + '涡阳县人民医院', + '分期', + '采集时间', + '合同金额\n(元)', + '首期应缴费\n金额(元)', + '已缴金额\n(元)', + '悔存/废弃\n已缴金额', + '退款金额', + '备注', + '首期\n缴费时间', + '首期欠缴\n金额(元)', + '分期欠缴\n金额(元)', + '第二次应缴费\n金额(元)', + '第二次应缴费\n时间', + '第二次已\n缴金额(元)', + '第二次已\n缴费时间', + '第二次欠缴\n金额(元)', + '第三次应缴费\n金额(元)', + '第三次应缴费\n时间', + '第三次已\n缴金额(元)', + '第三次已\n缴费时间', + '第三次欠缴\n金额(元)', + '第四次应缴费\n金额(元)', + '第四次应缴费\n时间', + '第四次已\n缴金额(元)', + '第四次已\n缴费时间', + '第四次欠缴\n金额(元)', + '第五次应缴费\n金额(元)', + '第五次应缴费\n时间', + '第五次已\n缴金额(元)', + '第五次已\n缴费时间', + '第五次欠缴\n金额(元)', + '第六次应缴费\n金额(元)', + '第六次应缴费\n时间', + '第六次已\n缴金额(元)', + '第六次已\n缴费时间', + '第六次欠缴\n金额(元)', + '市场专员', + '地区经理', + '运输方式', + '运费', + '回单超链接-快捷方式', + '发票', + '特殊情况说明' +] + +export const RESEARCH_HEADERS = [ + '序号', + '年份', + '月', + '日', + '当月核量号', + '实验室编码', + '储存状态', + '废弃原因', + '母亲姓名', + '围产期造血干细胞', + '采集\n地区', + '采集医院', + '分娩时间', + '采集时间', + '市场专员@@', + '地区经理', + '运输方式', + '运费', + '性别', + '新生儿体重', + '分娩方式', + '胎次', + '母血采集时间', + '知情同意书', + '备注' +] + +export const CUSTOMER_SERVICE_HEADERS = [ + '实验室流转编码', + '储存状态', + '序号', + '发送日期', + '短信发送\n情况', + '协议编号', + '母亲姓名', + '母亲电话', + '母亲身份证号码', + '父亲姓名', + '父亲电话', + '父亲身份证号码', + '回访日期', + '微生物\n回访情况', + '满月\n回访时间', + '回访情况', + '是否\n上户口', + '宝宝姓名', + '宝宝身份证号码', + '性别', + '分娩时间', + '购买保险时间', + '保单发送时间', + '电子发票\n发送时间', + '邮寄储存证时间', + '有效地址', + '市场专员', + '缴费方式', + '备注', + '协议书', + '保险单', + '知情同意书', + '健康调查表' +] + +const PROTOCOL_STATUS: Record = { + dsx: '待生效', + sxz: '生效中', + ygq: '已过期', + yzf: '已作废', + sdfq: '属地废弃', + zky: '转科研', + zckc: '转细胞存储' +} + +const VOID_TYPE: Record = { + khhc: '客户毁存', + jcbhg: '检测不合格', + sysfq: '实验室废弃' +} + +const PAY_MODE: Record = { + dj: '趸交', + fq: '分期', + djfq: '趸交+分期', + fgd: '分改趸' +} + +const TRANSPORT: Record = { + kdys: '快递运输', + ryxd: '人员携带' +} + +const SEX: Record = { + '1': '男', + '0': '女' +} + +const DELIVERY: Record = { + sc: '顺产', + pgc: '剖宫产' +} + +function text(value: unknown): string { + return value == null ? '' : String(value).trim() +} + +function label(map: Record, value: unknown): string { + const code = text(value) + if (!code) return '' + return map[code] || code +} + +export function formatLedgerMoney(value: unknown): string { + if (value == null || value === '') return '' + const amount = Number(value) + if (!Number.isFinite(amount)) return text(value) + if (Math.abs(amount - Math.round(amount)) < 1e-9) return String(Math.round(amount)) + return amount.toFixed(2).replace(/0+$/, '').replace(/\.$/, '') +} + +export function formatLedgerDate(value: unknown): string { + const parts = dateParts(value) + if (!parts) return text(value) + return `${parts.year}年${parts.month}月${parts.day}日` +} + +function dateParts(value: unknown): { + year: number + month: number + day: number + hour?: number + minute?: number + second?: number +} | null { + const raw = text(value) + if (!raw) return null + if (/^\d{8}$/.test(raw)) { + return { + year: Number(raw.slice(0, 4)), + month: Number(raw.slice(4, 6)), + day: Number(raw.slice(6, 8)) + } + } + const matched = raw.match( + /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/ + ) + if (!matched) return null + return { + year: Number(matched[1]), + month: Number(matched[2]), + day: Number(matched[3]), + hour: matched[4] == null ? undefined : Number(matched[4]), + minute: matched[5] == null ? undefined : Number(matched[5]), + second: matched[6] == null ? undefined : Number(matched[6]) + } +} + +export function formatLedgerDateTime(value: unknown): string { + const parts = dateParts(value) + if (!parts) return text(value) + let result = `${parts.year}年${parts.month}月${parts.day}日` + if (parts.hour == null) return result + result += `${parts.hour}时${String(parts.minute ?? 0).padStart(2, '0')}分` + if (parts.second) result += `${String(parts.second).padStart(2, '0')}秒` + return result +} + +function yearText(value: unknown, suffix: string): string { + const parts = dateParts(value) + if (!parts) return '' + return `${parts.year}${suffix}` +} + +function monthText(value: unknown): string { + const parts = dateParts(value) + return parts ? String(parts.month) : '' +} + +function dayText(value: unknown): string { + const parts = dateParts(value) + return parts ? String(parts.day) : '' +} + +function statusText(status: unknown, voidType: unknown): string { + const code = text(status) + const base = PROTOCOL_STATUS[code] || code + if (code !== 'yzf') return base + const reason = label(VOID_TYPE, voidType) + return reason ? `${base}(${reason})` : base +} + +function voidReason(row: Row): string { + return text(row.voidDesc) || label(VOID_TYPE, row.voidType) +} + +function payModeText(value: unknown, fallback = ''): string { + return label(PAY_MODE, value) || text(fallback) +} + +function joined(value: unknown): string { + if (Array.isArray(value)) return value.map((item) => text(item)).filter(Boolean).join('、') + return text(value) +} + +function flag01(value: unknown): string { + if (value === true || value === 1 || value === '1') return '1' + if (value === false || value === 0 || value === '0') return '0' + return '' +} + +function weightText(value: unknown): string { + const raw = text(value) + if (!raw) return '' + if (/g$/i.test(raw)) return raw + return `${formatLedgerMoney(raw) || raw}g` +} + +function paymentDetails(summary: unknown): PayDetail[] { + if (!summary || typeof summary !== 'object') return [] + const details = (summary as Row).paymentDetails + return Array.isArray(details) ? (details as PayDetail[]) : [] +} + +function installmentGroup(details: PayDetail[], installmentNo: number) { + let rows = details.filter((item) => Number(item.installmentNo) === installmentNo) + const numbered = details.some( + (item) => item.installmentNo != null && text(item.installmentNo) !== '' + ) + if (!rows.length && installmentNo === 1 && !numbered) rows = details.slice(0, 1) + const dueValues = rows + .map((item) => item.dueAmount) + .filter((item) => item != null && item !== ('' as unknown)) + const paidValues = rows + .map((item) => item.transferAmount) + .filter((item) => item != null && item !== ('' as unknown)) + const due = dueValues.length + ? dueValues.reduce((sum, item) => sum + Number(item || 0), 0) + : null + const paid = paidValues.length + ? paidValues.reduce((sum, item) => sum + Number(item || 0), 0) + : null + const dueDate = rows.map((item) => text(item.installmentDueDate)).find(Boolean) || '' + const paidTime = rows + .map((item) => text(item.transferTime)) + .filter(Boolean) + .join(',') + return { due, paid, dueDate, paidTime } +} + +function gapText(due: number | null, paid: number | null): string { + if (due == null || paid == null) return '' + return formatLedgerMoney(due - paid) +} + +function sampleTransport(row: ProtocolAuditFlatRow): Row { + const items = row.protocol.sampleItems + if (!Array.isArray(items)) return {} + const item = items[row.sampleIndex] + if (!item || typeof item !== 'object') return {} + const transport = (item as Row).transport + return transport && typeof transport === 'object' ? (transport as Row) : {} +} + +function transportText(row: ProtocolAuditFlatRow): string { + const transport = sampleTransport(row) + const company = text(transport.expressCompanyName) + if (company) return company + return label(TRANSPORT, row.transportType || transport.transportType) +} + +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, ''), + monthText(orderDate), + dayText(orderDate), + text(row.protocolNo), + text(row.motherName), + joined(row.sampleTypeNames), + text(row.regionName), + text(row.hospitalName), + payModeText(row.payMode), + formatLedgerDateTime(row.sampleCollectTime), + formatLedgerMoney(row.contractAmount), + received ? `${received}元` : '', + times.map((item) => formatLedgerDateTime(item)).filter(Boolean).join(','), + formatLedgerDate(row.expectedDeliveryDate), + text(row.salesUserName), + text(row.regionManagerUserName), + text(row.remark), + '' + ] + }) +} + +function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: boolean): string[] { + const protocol = row.protocol + const summary = (row.paymentSummary || protocol.paymentSummary) as Row | null + const details = paymentDetails(summary) + const first = installmentGroup(details, 1) + const collectTime = row.sampleCollectTime + const status = research ? protocol.donationStatus || protocol.status : protocol.protocolStatus + const received = summary ? formatLedgerMoney(summary.receivedAmount) : '' + const contract = formatLedgerMoney( + summary?.contractAmount ?? protocol.protocolTotalAmount + ) + const payMode = text(summary?.payMode) + const firstPaidTime = text(summary?.firstPayTransferTime) || first.paidTime + const voided = text(status) === 'yzf' || text(status) === 'sdfq' + const cells = research ? new Array(RESEARCH_HEADERS.length) : new Array(CELL_STORAGE_HEADERS.length) + cells.fill('') + if (research) { + cells[0] = String(index + 1) + cells[1] = yearText(collectTime, '年') + cells[2] = monthText(collectTime) + cells[3] = dayText(collectTime) + cells[4] = text(protocol.monthlyVerifyNo) + cells[5] = text(row.labStorageCode) + cells[6] = statusText(status, protocol.voidType) + cells[7] = voidReason(protocol) + cells[8] = text(row.motherName) + cells[9] = text(row.sampleTypeName) + cells[10] = text(row.regionName) + cells[11] = text(row.hospitalName) + cells[12] = formatLedgerDateTime(row.newbornDeliveryTime) + cells[13] = formatLedgerDateTime(collectTime) + cells[14] = text(row.salesUserName) + cells[15] = text(row.regionManagerUserName) + cells[16] = transportText(row) + cells[18] = label(SEX, row.newbornSex) + cells[19] = weightText(row.newbornWeightGram) + cells[20] = label(DELIVERY, row.deliveryMode) + cells[21] = text(row.birthOrder) + cells[22] = formatLedgerDateTime(row.maternalBloodCollectTime) + cells[23] = text(row.consentFormUrls) ? '1' : '' + cells[24] = text(row.remark) + return cells + } + cells[0] = String(index + 1) + cells[1] = yearText(collectTime, '') + cells[2] = monthText(collectTime) + cells[3] = dayText(collectTime) + cells[5] = text(row.labStorageCode) + cells[6] = statusText(status, protocol.voidType) + cells[7] = voidReason(protocol) + cells[8] = text(row.motherName) + cells[9] = text(row.protocolNo) + cells[10] = text(row.sampleTypeName) + cells[11] = row.isCoStored ? '是' : '否' + cells[12] = text(row.regionName) + cells[13] = text(row.hospitalName) + cells[14] = payModeText(payMode, text(summary?.payModeLabel)) + cells[15] = formatLedgerDateTime(collectTime) + cells[16] = contract + cells[17] = formatLedgerMoney(first.due) + cells[18] = received + cells[19] = voided ? received : '' + cells[21] = text(protocol.remark) + cells[22] = formatLedgerDateTime(firstPaidTime) + cells[23] = gapText(first.due, first.paid) + cells[24] = + payMode === 'fq' || payMode === 'djfq' + ? formatLedgerMoney(summary?.pendingAmount) + : contract + ? '0' + : '' + let cursor = 25 + for (const installmentNo of [2, 3, 4, 5, 6]) { + const group = installmentGroup(details, installmentNo) + cells[cursor++] = formatLedgerMoney(group.due) + cells[cursor++] = formatLedgerDate(group.dueDate) + cells[cursor++] = formatLedgerMoney(group.paid) + cells[cursor++] = formatLedgerDateTime(group.paidTime) + cells[cursor++] = gapText(group.due, group.paid) + } + cells[50] = text(row.salesUserName) + cells[51] = text(row.regionManagerUserName) + cells[52] = transportText(row) + return cells +} + +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 buildCustomerServiceLedgerRows(rows: Row[]): string[][] { + return rows.map((row, index) => [ + text(row.labStorageCode), + statusText(row.protocolStatus, row.voidType), + String(index + 1), + formatLedgerDate(row.smsSendDate), + text(row.smsSendStatus), + text(row.protocolNo), + text(row.motherName), + text(row.motherPhone), + text(row.motherIdCard), + text(row.fatherName), + text(row.fatherPhone), + text(row.fatherIdCard), + formatLedgerDate(row.microbeFollowupDate), + text(row.microbeFollowupStatus), + formatLedgerDate(row.fullmoonFollowupDate), + text(row.fullmoonFollowupStatus), + text(row.householdRegistered), + text(row.newbornName), + text(row.newbornIdCard), + label(SEX, row.newbornSex), + formatLedgerDateTime(row.newbornDeliveryTime), + formatLedgerDateTime(row.purchaseInsuranceTime), + formatLedgerDateTime(row.policySendTime), + formatLedgerDateTime(row.einvoiceSendTime), + formatLedgerDateTime(row.mailCertTime), + text(row.effectiveAddress), + text(row.marketSpecialistName), + text(row.payModeLabel) || payModeText(row.payMode), + text(row.protocolRemark), + flag01(row.protocolAttachmentPresent), + flag01(row.insuranceDocPresent), + flag01(row.informedConsentPresent), + flag01(row.healthSurveyPresent) + ]) +} + +export async function fetchAllPages( + load: (page: number, pageSize: number) => Promise<{ list?: T[]; total?: number }> +): Promise { + const pageSize = 100 + const all: T[] = [] + let page = 1 + let total = Number.POSITIVE_INFINITY + while (all.length < total && page <= 200) { + const result = await load(page, pageSize) + const list = result.list || [] + total = result.total ?? list.length + all.push(...list) + if (!list.length || list.length < pageSize) break + page += 1 + } + return all +} + +function fileStamp(): string { + const stamp = new Date() + const pad = (value: number) => String(value).padStart(2, '0') + return `${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}${pad(stamp.getHours())}${pad(stamp.getMinutes())}${pad(stamp.getSeconds())}` +} + +export async function saveCollectionLedgerWorkbook( + sheetName: string, + headers: string[], + rows: string[][], + filenamePrefix: string +) { + const ExcelJS = (await import('exceljs')).default + const FileSaver = (await import('file-saver')).default + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet(sheetName) + const headerRow = sheet.addRow(headers) + headerRow.eachCell((cell) => { + cell.numFmt = '@' + cell.alignment = { wrapText: true, vertical: 'middle' } + }) + rows.forEach((line) => { + const added = sheet.addRow(line) + line.forEach((value, index) => { + const cell = added.getCell(index + 1) + cell.value = value + cell.numFmt = '@' + }) + }) + headers.forEach((header, index) => { + sheet.getColumn(index + 1).width = Math.min(28, Math.max(12, header.replace(/\n/g, '').length + 2)) + }) + const buffer = await workbook.xlsx.writeBuffer() + FileSaver.saveAs( + new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }), + `${filenamePrefix}-${fileStamp()}.xlsx` + ) +} diff --git a/web-admin/src/views/cell-storage/presign-order/index.vue b/web-admin/src/views/cell-storage/presign-order/index.vue index 136c1af3..da268252 100644 --- a/web-admin/src/views/cell-storage/presign-order/index.vue +++ b/web-admin/src/views/cell-storage/presign-order/index.vue @@ -56,6 +56,10 @@ 新增预签约 + + + 导出 Excel + @@ -196,7 +200,14 @@ const projectFilter = ref('') const teamFilter = ref('') import { onMounted, reactive, ref } from 'vue' - import { Plus, Search, RefreshLeft, Edit, Delete, Check } from '@element-plus/icons-vue' + import { Plus, Search, RefreshLeft, Edit, Delete, Check, Download } from '@element-plus/icons-vue' + import { ElMessage } from 'element-plus' + import { + buildPresignLedgerRows, + fetchAllPages, + PRESIGN_HEADERS, + saveCollectionLedgerWorkbook + } from '@/utils/collection-ledger-export' import { formatDisplayOrDash } from '@/utils/datetime-display' import { formatMoneyDisplay } from '@/utils/money-format' import { presignOrderStatusLabel, presignOrderStatusTagType } from '@/utils/status-tag-registry' @@ -213,6 +224,7 @@ const tableData = ref[]>([]) const selectedRows = ref[]>([]) const loading = ref(false) + const exporting = ref(false) const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) const dialogVisible = ref(false) const editingId = ref(undefined) @@ -246,6 +258,40 @@ } } + async function handleExport() { + if (exporting.value) return + exporting.value = true + try { + const rows = selectedRows.value.length + ? selectedRows.value + : await fetchAllPages((page, pageSize) => + fetchPresignOrderList({ + page, + pageSize, + projectId: projectFilter.value || undefined, + teamId: teamFilter.value || undefined, + keyword: searchForm.keyword || undefined, + orderStatus: searchForm.orderStatus || undefined + }) + ) + if (!rows.length) { + ElMessage.warning('没有可导出的数据') + return + } + await saveCollectionLedgerWorkbook( + '预签约', + PRESIGN_HEADERS, + buildPresignLedgerRows(rows), + '预签约' + ) + ElMessage.success('导出成功') + } catch { + ElMessage.error('导出失败,请重试') + } finally { + exporting.value = false + } + } + function handleSearch() { pagination.page = 1 loadData() 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 eff8b788..83e94474 100644 --- a/web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue +++ b/web-admin/src/views/cell-storage/protocol-audit/DonationProtocolTab.vue @@ -75,6 +75,10 @@ 新增 + + + 导出 Excel + 审核 @@ -422,6 +426,7 @@ Delete, Search, RefreshLeft, + Download, CircleCheck, CircleClose, Select, @@ -456,6 +461,12 @@ formatProtocolDocBatchDeleteConfirm } from '@/utils/protocol-doc-label' import { loadVoidTypeDict } from '@/utils/void-type-dict' + import { + RESEARCH_HEADERS, + buildResearchLedgerRows, + fetchAllPages, + saveCollectionLedgerWorkbook + } from '@/utils/collection-ledger-export' import { flattenProtocolAuditRows, uniqueProtocolsFromFlatRows, @@ -476,6 +487,7 @@ const sampleCollectTimeRange = ref<[string, string] | null>(null) const tableData = ref([]) const selectedFlatRows = ref([]) + const exporting = ref(false) const loading = ref(false) const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) @@ -529,6 +541,46 @@ } } + async function handleExport() { + if (exporting.value) return + exporting.value = true + try { + const rows = selectedFlatRows.value.length + ? selectedFlatRows.value + : flattenProtocolAuditRows( + await fetchAllPages((page, pageSize) => + fetchResearchDonationList({ + page, + pageSize, + projectId: projectFilter.value || undefined, + teamId: teamFilter.value || undefined, + keyword: searchForm.keyword || undefined, + donationStatus: searchForm.donationStatus || undefined, + createTimeStart: createTimeRange.value?.[0] || undefined, + createTimeEnd: createTimeRange.value?.[1] || undefined, + sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined, + sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined + }) + ) + ) + if (!rows.length) { + ElMessage.warning('没有可导出的数据') + return + } + await saveCollectionLedgerWorkbook( + '科研表', + RESEARCH_HEADERS, + buildResearchLedgerRows(rows), + '科研表' + ) + ElMessage.success('导出成功') + } catch { + ElMessage.error('导出失败,请重试') + } finally { + exporting.value = false + } + } + function handleSearch() { pagination.page = 1 loadData() diff --git a/web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue b/web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue index 8c9c98a6..afc2ff17 100644 --- a/web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue +++ b/web-admin/src/views/cell-storage/protocol-audit/ProtocolAuditTab.vue @@ -79,6 +79,10 @@ 新增 + + + 导出 Excel + ([]) const selectedFlatRows = ref([]) const loading = ref(false) + const exporting = ref(false) const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) const flatRows = computed(() => flattenProtocolAuditRows(tableData.value)) @@ -494,6 +506,46 @@ } } + async function handleExport() { + if (exporting.value) return + exporting.value = true + try { + const rows = selectedFlatRows.value.length + ? selectedFlatRows.value + : flattenProtocolAuditRows( + await fetchAllPages((page, pageSize) => + fetchCollectionProtocolList({ + page, + pageSize, + projectId: projectFilter.value || undefined, + teamId: teamFilter.value || undefined, + keyword: searchForm.keyword || undefined, + protocolStatus: searchForm.protocolStatus || undefined, + createTimeStart: createTimeRange.value?.[0] || undefined, + createTimeEnd: createTimeRange.value?.[1] || undefined, + sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined, + sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined + }) + ) + ) + if (!rows.length) { + ElMessage.warning('没有可导出的数据') + return + } + await saveCollectionLedgerWorkbook( + '自存表', + CELL_STORAGE_HEADERS, + buildCellStorageLedgerRows(rows), + '自存表' + ) + ElMessage.success('导出成功') + } catch { + ElMessage.error('导出失败,请重试') + } finally { + exporting.value = false + } + } + function handleSearch() { pagination.page = 1 loadData() diff --git a/web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue b/web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue index 97d64b52..a124b6c4 100644 --- a/web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue +++ b/web-admin/src/views/cell-storage/sample-info/UnifiedSampleInfoPage.vue @@ -92,6 +92,15 @@ 登记入库 + + + 导出 Excel + []>([]) const loading = ref(false) + const exporting = ref(false) const pagination = reactive({ page: 1, pageSize: 10, total: 0 }) const selectedRows = ref[]>([]) const tableRef = ref<{ clearSelection?: () => void } | null>(null) @@ -632,6 +649,43 @@ } } + async function handleExport() { + if (exporting.value || !isCellStorage.value) return + exporting.value = true + try { + const rows = selectedRows.value.length + ? selectedRows.value + : await fetchAllPages((page, pageSize) => + fetchProtocolSampleCollectionList({ + page, + pageSize, + keyword: searchForm.keyword || undefined, + collectionFlag: searchForm.collectionFlag || undefined, + receptionFlag: searchForm.receptionFlag || undefined, + qcFlag: searchForm.qcFlag || undefined, + storageFlag: searchForm.storageFlag || undefined, + storageCertHandled: searchForm.storageCertHandled || undefined, + insuranceHandled: searchForm.insuranceHandled || undefined + }) + ) + if (!rows.length) { + ElMessage.warning('没有可导出的数据') + return + } + await saveCollectionLedgerWorkbook( + '客服表', + CUSTOMER_SERVICE_HEADERS, + buildCustomerServiceLedgerRows(rows), + '客服表' + ) + ElMessage.success('导出成功') + } catch { + ElMessage.error('导出失败,请重试') + } finally { + exporting.value = false + } + } + function handleSearch() { pagination.page = 1 loadData()