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:
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, '')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user