feat(pl): pack selected vehicle cert attachments into zip.

Add JSZip builder with concurrency, injectable fetch/save for tests, and multi-vehicle folders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-08 20:25:27 +08:00
1 parent e07af251c0
commit 31ef9d4053
2 files changed
+219

No files matched your search

@@ -3,6 +3,7 @@ import { describe, it } from 'node:test'
import {
collectVehicleCertZipEntries,
buildVehicleCertZipFilename,
buildAndSaveVehicleCertZip,
uniqueZipPath,
sanitizeZipPathSegment
} from './pl-vehicle-cert-zip'
@@ -119,3 +120,103 @@ describe('uniqueZipPath', () => {
assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_3.jpg')
})
})
describe('buildAndSaveVehicleCertZip', () => {
it('packs single vehicle flat; skips failed blobs; returns counts', async () => {
const saved: { name: string; size: number }[] = []
const result = await buildAndSaveVehicleCertZip(
[
{
id: 'v1',
licensePlate: '皖AD12345',
certList: [
{
id: 'c1',
ownerType: 'vehicle',
ownerId: 'v1',
status: '1',
certName: '行驶证',
vehiclePart: 'head',
attachmentUrls: 'https://cdn.example/ok.jpg,https://cdn.example/bad.jpg'
}
]
}
],
{
yyyymmdd: '20260908',
fetchBlob: async (fileUrl) => {
if (fileUrl.includes('bad')) throw new Error('fail')
return new Blob([fileUrl], { type: 'application/octet-stream' })
},
saveAs: async (blob, name) => {
saved.push({ name, size: blob.size })
}
}
)
assert.equal(result.okCount, 1)
assert.equal(result.failCount, 1)
assert.equal(result.zipName, '车辆证书_皖AD12345.zip')
assert.equal(saved.length, 1)
assert.equal(saved[0].name, '车辆证书_皖AD12345.zip')
})
it('uses subfolders for multiple vehicles', async () => {
const paths: string[] = []
await buildAndSaveVehicleCertZip(
[
{
id: 'v1',
licensePlate: '皖A1',
certList: [
{
id: 'c1',
ownerType: 'vehicle',
ownerId: 'v1',
status: '1',
certName: '行驶证',
vehiclePart: 'head',
attachmentUrls: 'https://cdn.example/a.jpg'
}
]
},
{
id: 'v2',
licensePlate: '皖B2',
certList: [
{
id: 'c2',
ownerType: 'vehicle',
ownerId: 'v2',
status: '1',
certName: '营运证',
vehiclePart: 'head',
attachmentUrls: 'https://cdn.example/b.pdf'
}
]
}
],
{
yyyymmdd: '20260908',
fetchBlob: async () => new Blob(['x']),
saveAs: async () => {},
onFileAdded: (zipPath) => paths.push(zipPath)
}
)
assert.deepEqual(paths.sort(), ['皖A1/行驶证.jpg', '皖B2/营运证.pdf'].sort())
})
it('returns empty when no attachments', async () => {
const result = await buildAndSaveVehicleCertZip(
[{ id: 'v1', licensePlate: '皖A1', certList: [] }],
{
yyyymmdd: '20260908',
fetchBlob: async () => new Blob(['x']),
saveAs: async () => {}
}
)
assert.equal(result.okCount, 0)
assert.equal(result.failCount, 0)
assert.equal(result.zipName, '')
})
})
+118
View File
@@ -1,3 +1,4 @@
import JSZip from 'jszip'
import type { CertRead } from '@/api/pl'
import { formatPlVehiclePlateDisplay } from '@/utils/pl-display'
import {
@@ -145,3 +146,120 @@ export function uniqueZipPath(used: Set<string>, relativePath: string): string {
used.add(candidate)
return candidate
}
export type BuildVehicleCertZipOptions = {
yyyymmdd?: string
concurrency?: number
fetchBlob?: (fileUrl: string, fileName: string) => Promise<Blob>
saveAs?: (blob: Blob, filename: string) => void | Promise<void>
onFileAdded?: (zipPath: string) => void
}
export type BuildVehicleCertZipResult = {
okCount: number
failCount: number
zipName: string
}
function todayYyyymmdd(): string {
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}${m}${day}`
}
async function mapPool<T, R>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length)
let next = 0
const runners = Array.from({ length: Math.min(concurrency, items.length) || 0 }, async () => {
while (next < items.length) {
const idx = next
next += 1
results[idx] = await worker(items[idx])
}
})
await Promise.all(runners)
return results
}
async function defaultFetchBlob(fileUrl: string, fileName: string): Promise<Blob> {
const [{ fetchAttachmentBlob }, { resolvePhotoDisplayUrl }] = await Promise.all([
import('@/utils/download-attachment'),
import('@/utils/pl-photo')
])
return fetchAttachmentBlob({ fileUrl, fileName }, { fullFileUrl: resolvePhotoDisplayUrl })
}
async function defaultSaveAs(blob: Blob, filename: string): Promise<void> {
const { default: FileSaver } = await import('file-saver')
FileSaver.saveAs(blob, filename)
}
export async function buildAndSaveVehicleCertZip(
vehicles: VehicleCertZipSource[],
options: BuildVehicleCertZipOptions = {}
): Promise<BuildVehicleCertZipResult> {
const entries = collectVehicleCertZipEntries(vehicles)
if (!entries.length) {
return { okCount: 0, failCount: 0, zipName: '' }
}
const yyyymmdd = options.yyyymmdd || todayYyyymmdd()
const zipName = buildVehicleCertZipFilename(entries, yyyymmdd)
const multi = entries.length > 1
const concurrency = options.concurrency ?? 4
type Job = {
zipPath: string
fileUrl: string
fileName: string
}
const used = new Set<string>()
const jobs: Job[] = []
for (const entry of entries) {
for (const file of entry.files) {
const leaf = `${file.baseName}${file.ext ? `.${file.ext}` : ''}`
const rawPath = multi ? `${entry.plateFolder}/${leaf}` : leaf
const zipPath = uniqueZipPath(used, rawPath)
jobs.push({
zipPath,
fileUrl: file.fileUrl,
fileName: leaf
})
}
}
const fetchBlob = options.fetchBlob ?? defaultFetchBlob
const zip = new JSZip()
let okCount = 0
let failCount = 0
await mapPool(jobs, concurrency, async (job) => {
try {
const blob = await fetchBlob(job.fileUrl, job.fileName)
const data = await blob.arrayBuffer()
zip.file(job.zipPath, data)
options.onFileAdded?.(job.zipPath)
okCount += 1
} catch {
failCount += 1
}
})
if (okCount === 0) {
return { okCount: 0, failCount, zipName: '' }
}
const blob = await zip.generateAsync({ type: 'blob' })
const save = options.saveAs ?? defaultSaveAs
await save(blob, zipName)
return { okCount, failCount, zipName }
}