去掉临时隐藏开关,导出自存/科研表时先按运费批次聚拢再合并单元格。 Co-authored-by: Cursor <cursoragent@cursor.com>
797 lines
25 KiB
TypeScript
797 lines
25 KiB
TypeScript
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`
|
||
)
|
||
}
|