242 lines
7.2 KiB
TypeScript
242 lines
7.2 KiB
TypeScript
import JSZip from 'jszip'
|
|
import FileSaver from 'file-saver'
|
|
import type { QcWorkOrder } from '@/api/qc-work-order'
|
|
import type { QcInspectionBatch } from '@/utils/qc-inspection-batch'
|
|
import {
|
|
ensureUniqueZipEntryName,
|
|
fetchAttachmentBlob,
|
|
resolveAttachmentFileName,
|
|
sanitizeZipPathSegment
|
|
} from '@/utils/attachment-zip'
|
|
|
|
export type AttachmentZipExportPhase =
|
|
| 'preparing'
|
|
| 'downloading'
|
|
| 'zipping'
|
|
| 'done'
|
|
| 'cancelled'
|
|
|
|
export type AttachmentZipExportProgress = {
|
|
phase: AttachmentZipExportPhase
|
|
percent: number
|
|
currentFile?: string
|
|
currentIndex?: number
|
|
totalFiles?: number
|
|
contextHint?: string
|
|
}
|
|
|
|
export type AttachmentZipExportEntry = {
|
|
zipPath: string
|
|
fileUrl: string
|
|
fileName: string
|
|
contextHint?: string
|
|
}
|
|
|
|
export type AttachmentZipExportResult = {
|
|
added: number
|
|
failed: number
|
|
cancelled: boolean
|
|
}
|
|
|
|
const PREPARING_MAX = 5
|
|
const DOWNLOADING_MAX = 85
|
|
|
|
function isInboundQcWorkOrder(row: QcWorkOrder) {
|
|
return (row.workOrderType || 'inbound') === 'inbound'
|
|
}
|
|
|
|
function workOrderFolderName(row: QcWorkOrder, used: Set<string>) {
|
|
if (isInboundQcWorkOrder(row)) {
|
|
const labCode = sanitizeZipPathSegment(row.labStorageCode || '', '未命名')
|
|
const workOrderNo = sanitizeZipPathSegment(row.workOrderNo || row.id, '工单')
|
|
const base = `入库${labCode}_${workOrderNo}`
|
|
return ensureUniqueZipEntryName(base, used)
|
|
}
|
|
const parts = [sanitizeZipPathSegment(row.workOrderNo || row.id, '工单')]
|
|
if (row.sampleCode) {
|
|
parts.push(`样本${sanitizeZipPathSegment(row.sampleCode)}`)
|
|
}
|
|
return ensureUniqueZipEntryName(parts.join('_'), used)
|
|
}
|
|
|
|
function applyInboundLabStorageCodePrefix(code: string, workOrder: QcWorkOrder) {
|
|
if (!isInboundQcWorkOrder(workOrder)) return code
|
|
const raw = String(code || '').trim()
|
|
const workOrderNo = String(workOrder.workOrderNo || '').trim()
|
|
if (!raw || !workOrderNo || !raw.startsWith(workOrderNo)) return raw
|
|
const labCode = sanitizeZipPathSegment(workOrder.labStorageCode || '', '未命名')
|
|
return sanitizeZipPathSegment(`${labCode}${raw.slice(workOrderNo.length)}`, '')
|
|
}
|
|
|
|
function batchFolderName(batch: QcInspectionBatch, workOrder: QcWorkOrder, used: Set<string>) {
|
|
const no = sanitizeZipPathSegment(
|
|
applyInboundLabStorageCodePrefix(batch.batchNo || '', workOrder),
|
|
''
|
|
)
|
|
const name = sanitizeZipPathSegment(batch.batchName || '未命名批次')
|
|
const base = no ? `${no}_${name}` : name || `批次${batch.orderNum ?? 1}`
|
|
return ensureUniqueZipEntryName(base, used)
|
|
}
|
|
|
|
function taskFolderName(
|
|
task: { taskNo?: string; itemName?: string },
|
|
workOrder: QcWorkOrder,
|
|
used: Set<string>
|
|
) {
|
|
const no = sanitizeZipPathSegment(
|
|
applyInboundLabStorageCodePrefix(task.taskNo || '', workOrder),
|
|
''
|
|
)
|
|
const name = sanitizeZipPathSegment(task.itemName || '未命名任务')
|
|
const base = no ? `${no}_${name}` : name
|
|
return ensureUniqueZipEntryName(base, used)
|
|
}
|
|
|
|
export function buildQcWorkOrderZipEntries(
|
|
workOrders: QcWorkOrder[],
|
|
batchMap: Record<string, QcInspectionBatch[]>
|
|
): AttachmentZipExportEntry[] {
|
|
const entries: AttachmentZipExportEntry[] = []
|
|
const usedWorkOrderFolders = new Set<string>()
|
|
|
|
for (const wo of workOrders) {
|
|
const woFolder = workOrderFolderName(wo, usedWorkOrderFolders)
|
|
const batches = batchMap[wo.id] || []
|
|
const usedBatchFolders = new Set<string>()
|
|
|
|
for (const batch of batches) {
|
|
const batchFolder = batchFolderName(batch, wo, usedBatchFolders)
|
|
const tasks = batch.tasks || []
|
|
const usedTaskFolders = new Set<string>()
|
|
|
|
for (const task of tasks) {
|
|
const files = task.attachmentFiles?.filter((f) => String(f.fileUrl || '').trim()) || []
|
|
if (!files.length) continue
|
|
|
|
const taskFolder = taskFolderName(task, wo, usedTaskFolders)
|
|
const usedFileNames = new Set<string>()
|
|
const contextHint = [wo.workOrderNo, batch.batchName, task.itemName]
|
|
.filter(Boolean)
|
|
.join(' · ')
|
|
|
|
files.forEach((file, fileIndex) => {
|
|
const fileName = resolveAttachmentFileName(file, fileIndex)
|
|
const uniqueName = ensureUniqueZipEntryName(fileName, usedFileNames)
|
|
entries.push({
|
|
zipPath: `${woFolder}/${batchFolder}/${taskFolder}/${uniqueName}`,
|
|
fileUrl: file.fileUrl,
|
|
fileName,
|
|
contextHint
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
function calcDownloadingPercent(currentIndex: number, totalFiles: number) {
|
|
if (totalFiles <= 0) return PREPARING_MAX
|
|
const ratio = Math.min(1, (currentIndex + 1) / totalFiles)
|
|
return PREPARING_MAX + ratio * (DOWNLOADING_MAX - PREPARING_MAX)
|
|
}
|
|
|
|
function calcZippingPercent(zipPercent: number) {
|
|
return DOWNLOADING_MAX + (zipPercent / 100) * (100 - DOWNLOADING_MAX)
|
|
}
|
|
|
|
export async function runAttachmentZipExport(options: {
|
|
entries: AttachmentZipExportEntry[]
|
|
zipFileName: string
|
|
signal?: AbortSignal
|
|
onProgress?: (progress: AttachmentZipExportProgress) => void
|
|
}): Promise<AttachmentZipExportResult> {
|
|
const { entries, zipFileName, signal, onProgress } = options
|
|
const zip = new JSZip()
|
|
let added = 0
|
|
let failed = 0
|
|
|
|
const report = (partial: AttachmentZipExportProgress) => {
|
|
onProgress?.(partial)
|
|
}
|
|
|
|
const totalFiles = entries.length
|
|
for (let i = 0; i < entries.length; i += 1) {
|
|
if (signal?.aborted) {
|
|
report({ phase: 'cancelled', percent: calcDownloadingPercent(i, totalFiles) })
|
|
return { added, failed, cancelled: true }
|
|
}
|
|
|
|
const entry = entries[i]
|
|
report({
|
|
phase: 'downloading',
|
|
percent: Math.round(calcDownloadingPercent(i, totalFiles)),
|
|
currentFile: entry.fileName,
|
|
currentIndex: i + 1,
|
|
totalFiles,
|
|
contextHint: entry.contextHint
|
|
})
|
|
|
|
try {
|
|
const blob = await fetchAttachmentBlob(entry.fileUrl, entry.fileName, { signal })
|
|
if (signal?.aborted) {
|
|
report({ phase: 'cancelled', percent: calcDownloadingPercent(i, totalFiles) })
|
|
return { added, failed, cancelled: true }
|
|
}
|
|
if (blob) {
|
|
zip.file(entry.zipPath, blob)
|
|
added += 1
|
|
} else {
|
|
failed += 1
|
|
}
|
|
} catch {
|
|
if (signal?.aborted) {
|
|
report({ phase: 'cancelled', percent: calcDownloadingPercent(i, totalFiles) })
|
|
return { added, failed, cancelled: true }
|
|
}
|
|
failed += 1
|
|
}
|
|
}
|
|
|
|
if (added === 0) {
|
|
report({ phase: 'done', percent: 100 })
|
|
return { added, failed, cancelled: false }
|
|
}
|
|
|
|
if (signal?.aborted) {
|
|
return { added, failed, cancelled: true }
|
|
}
|
|
|
|
report({
|
|
phase: 'zipping',
|
|
percent: DOWNLOADING_MAX,
|
|
totalFiles,
|
|
currentIndex: totalFiles
|
|
})
|
|
|
|
const content = await zip.generateAsync({ type: 'blob' }, (metadata) => {
|
|
if (signal?.aborted) return
|
|
report({
|
|
phase: 'zipping',
|
|
percent: Math.round(calcZippingPercent(metadata.percent)),
|
|
totalFiles,
|
|
currentIndex: totalFiles
|
|
})
|
|
})
|
|
|
|
if (signal?.aborted) {
|
|
return { added, failed, cancelled: true }
|
|
}
|
|
|
|
FileSaver.saveAs(content, zipFileName)
|
|
report({ phase: 'done', percent: 100, totalFiles, currentIndex: totalFiles })
|
|
return { added, failed, cancelled: false }
|
|
}
|
|
|
|
export function reportPreparingProgress(
|
|
onProgress?: (progress: AttachmentZipExportProgress) => void
|
|
) {
|
|
onProgress?.({ phase: 'preparing', percent: 2 })
|
|
}
|