Files
zkxw/web-admin/src/utils/collection-ledger-export.ts
T
zlwuandCursor 3ac30a6c2c fix(web-admin): 恢复采集大表导出并按运费批次归拢合并
去掉临时隐藏开关,导出自存/科研表时先按运费批次聚拢再合并单元格。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 14:56:45 +08:00

797 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
type Row = Record<string, unknown>
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<string, string> = {
dsx: '待生效',
sxz: '生效中',
ygq: '已过期',
yzf: '已作废',
sdfq: '属地废弃',
zky: '转科研',
zckc: '转细胞存储'
}
/** 作废类型兜底;与 void-type-dict 默认项一致,并覆盖误写入作废类型的状态码。 */
const VOID_TYPE: Record<string, string> = {
khhc: '客户毁存',
jcbhg: '检测不合格',
sysfq: '实验室废弃'
}
/** 导出前由字典填充;单元测试环境可为空。 */
let runtimeVoidLabels: Record<string, string> = {}
const PAY_MODE: Record<string, string> = {
dj: '趸交',
fq: '分期',
djfq: '趸交+分期',
fgd: '分改趸'
}
const TRANSPORT: Record<string, string> = {
kdys: '快递运输',
ryxd: '人员携带'
}
const SEX: Record<string, string> = {
'1': '男',
'0': '女'
}
const DELIVERY: Record<string, string> = {
sc: '顺产',
pgc: '剖宫产'
}
/** 科研表模板多用「剖腹产」。 */
const RESEARCH_DELIVERY: Record<string, string> = {
sc: '顺产',
pgc: '剖腹产'
}
export type LedgerMergeRange = {
startRow: number
endRow: number
startCol: number
endCol: number
}
function text(value: unknown): string {
return value == null ? '' : String(value).trim()
}
function label(map: Record<string, string>, 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) : ''
}
/** 作废类型:字典 → 本地兜底 → 协议状态码文案(如 sdfq→属地废弃)。 */
function voidTypeText(value: unknown): string {
const code = text(value)
if (!code) return ''
return (
runtimeVoidLabels[code] ||
VOID_TYPE[code] ||
PROTOCOL_STATUS[code] ||
code
)
}
function statusText(status: unknown, voidType: unknown): string {
const code = text(status)
const base = PROTOCOL_STATUS[code] || code
if (code !== 'yzf') return base
const reason = voidTypeText(voidType)
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) : '无母血'
}
/**
* 将同一运费批次的行聚拢到一起(按首次出现顺序),无批次行保持相对位置。
* 模版要求同批次运输方式/运费合并单元格,故导出前先归拢再合并。
*/
export function groupFlatRowsByFreightBatch(
rows: ProtocolAuditFlatRow[]
): ProtocolAuditFlatRow[] {
const groups = new Map<string, ProtocolAuditFlatRow[]>()
const sequence: Array<{ kind: 'batch'; id: string } | { kind: 'single'; row: ProtocolAuditFlatRow }> =
[]
for (const row of rows) {
const freightId = text(row.freightId)
if (!freightId) {
sequence.push({ kind: 'single', row })
continue
}
if (!groups.has(freightId)) {
groups.set(freightId, [])
sequence.push({ kind: 'batch', id: freightId })
}
groups.get(freightId)!.push(row)
}
const arranged: ProtocolAuditFlatRow[] = []
for (const item of sequence) {
if (item.kind === 'single') arranged.push(item.row)
else arranged.push(...(groups.get(item.id) || []))
}
return arranged
}
/**
* 相邻且 freightId 相同的导出行,合并运输方式/运费列(Excel 行号含表头)。
* @param transportCol 运输方式列(1-based)
* @param freightCol 运费列(1-based)
*/
export function buildFreightMergeRanges(
freightIds: string[],
transportCol = 17,
freightCol = 18
): 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: transportCol, endCol: transportCol })
merges.push({ startRow, endRow, startCol: freightCol, endCol: freightCol })
}
index = end
}
return merges
}
function applyFreightMerges(
built: string[][],
freightIds: string[],
transportCol: number,
freightCol: number
): LedgerMergeRange[] {
const merges = buildFreightMergeRanges(freightIds, transportCol, freightCol)
for (const merge of merges) {
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 merges
}
/** 导出前加载作废类型字典,避免列表外的编码原样写入 Excel。 */
export async function prepareCollectionLedgerExport(): Promise<void> {
const { loadVoidTypeDict, getVoidTypeOptions } = await import('./void-type-dict')
await loadVoidTypeDict()
runtimeVoidLabels = Object.fromEntries(
getVoidTypeOptions().map((item) => [String(item.value), String(item.label)])
)
}
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<number>((sum, item) => sum + Number(item || 0), 0)
: null
const paid = paidValues.length
? paidValues.reduce<number>((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)
}
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 : []
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),
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
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<string>(RESEARCH_HEADERS.length) : new Array<string>(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] = researchStorageStatus(status, protocol.voidType)
cells[7] = voidReason(protocol)
cells[8] = text(row.motherName)
// 标准大表废弃行第 10 列复用储存状态文案;正常行写样本类型
cells[9] = cells[6] || 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] = researchTransportText(row)
cells[17] = researchFreightText(row)
cells[18] = label(SEX, row.newbornSex)
cells[19] = weightText(row.newbornWeightGram)
cells[20] = label(RESEARCH_DELIVERY, row.deliveryMode) || label(DELIVERY, row.deliveryMode)
cells[21] = text(row.birthOrder)
cells[22] = researchMaternalBloodText(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[4] = text(row.monthlyVerifyNo) || 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.sampleCode)
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[20] = cellStorageRefundText(summary, 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] = researchTransportText(row)
cells[53] = researchFreightText(row)
return cells
}
export function buildCellStorageLedgerRows(rows: ProtocolAuditFlatRow[]): {
rows: string[][]
merges: LedgerMergeRange[]
} {
const arranged = groupFlatRowsByFreightBatch(rows)
const freightIds = arranged.map((row) => text(row.freightId))
const built = arranged.map((row, index) => storageLedgerRow(row, index, false))
// 自存表:运输方式=53,运费=54(对齐模版《自存表》)
return { rows: built, merges: applyFreightMerges(built, freightIds, 53, 54) }
}
export function buildResearchLedgerRows(rows: ProtocolAuditFlatRow[]): {
rows: string[][]
merges: LedgerMergeRange[]
} {
const arranged = groupFlatRowsByFreightBatch(rows)
const freightIds = arranged.map((row) => text(row.freightId))
const built = arranged.map((row, index) => storageLedgerRow(row, index, true))
// 科研表:运输方式=17,运费=18(对齐模版《科研表》)
return { rows: built, merges: applyFreightMerges(built, freightIds, 17, 18) }
}
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.sampleCode),
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<T>(
load: (page: number, pageSize: number) => Promise<{ list?: T[]; total?: number }>
): Promise<T[]> {
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())}`
}
const LEDGER_CELL_BORDER = {
top: { style: 'thin' as const },
left: { style: 'thin' as const },
bottom: { style: 'thin' as const },
right: { style: 'thin' as const }
}
const LEDGER_HEADER_FILL = {
type: 'pattern' as const,
pattern: 'solid' as const,
fgColor: { argb: 'FFFFFF00' }
}
export async function saveCollectionLedgerWorkbook(
sheetName: string,
headers: string[],
rows: string[][],
filenamePrefix: string,
merges: LedgerMergeRange[] = []
) {
const ExcelJS = (await import('exceljs')).default
const FileSaver = (await import('file-saver')).default
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet(sheetName)
const colCount = headers.length
const headerRow = sheet.addRow(headers)
for (let col = 1; col <= colCount; col += 1) {
const cell = headerRow.getCell(col)
cell.numFmt = '@'
cell.alignment = { wrapText: true, vertical: 'middle', horizontal: 'center' }
cell.fill = LEDGER_HEADER_FILL
cell.border = LEDGER_CELL_BORDER
}
rows.forEach((line) => {
const added = sheet.addRow(line)
for (let col = 1; col <= colCount; col += 1) {
const cell = added.getCell(col)
cell.value = line[col - 1] ?? ''
cell.numFmt = '@'
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
cell.border = LEDGER_CELL_BORDER
}
})
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 }
cell.border = LEDGER_CELL_BORDER
})
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`
)
}