From 55abe614f8e3edea950b3e99cafe9b08c4d96450 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Tue, 8 Sep 2026 20:38:43 +0800 Subject: [PATCH] feat(pl): add personnel cert zip packing and shared pack core. Extract packAndSaveCertZip for reuse; keep vehicle API; name folders with _2 on collisions. Co-authored-by: Cursor --- .../src/utils/pl-vehicle-cert-zip.test.ts | 131 +++++++++++++++++ web-admin/src/utils/pl-vehicle-cert-zip.ts | 133 ++++++++++++++---- 2 files changed, 239 insertions(+), 25 deletions(-) diff --git a/web-admin/src/utils/pl-vehicle-cert-zip.test.ts b/web-admin/src/utils/pl-vehicle-cert-zip.test.ts index 0e1a833c6..8dc7a31b0 100644 --- a/web-admin/src/utils/pl-vehicle-cert-zip.test.ts +++ b/web-admin/src/utils/pl-vehicle-cert-zip.test.ts @@ -4,6 +4,9 @@ import { collectVehicleCertZipEntries, buildVehicleCertZipFilename, buildAndSaveVehicleCertZip, + collectPersonnelCertZipEntries, + buildPersonnelCertZipFilename, + buildAndSavePersonnelCertZip, uniqueZipPath, sanitizeZipPathSegment } from './pl-vehicle-cert-zip' @@ -220,3 +223,131 @@ describe('buildAndSaveVehicleCertZip', () => { assert.equal(result.zipName, '') }) }) + +describe('collectPersonnelCertZipEntries', () => { + it('uses personnel name folders; dedupes same names with _2; skips empty', () => { + const entries = collectPersonnelCertZipEntries([ + { + id: 'p1', + personnelName: '张三', + certList: [ + { + id: 'c1', + ownerType: 'personnel', + ownerId: 'p1', + status: '1', + certName: '驾驶证', + attachmentUrls: 'https://cdn.example/d.jpg' + } + ] + }, + { + id: 'p2', + personnelName: '张三', + certList: [ + { + id: 'c2', + ownerType: 'personnel', + ownerId: 'p2', + status: '1', + certName: '从业资格证', + attachmentUrls: 'https://cdn.example/q.pdf' + } + ] + }, + { + id: 'p3', + personnelName: '李四', + certList: [] + } + ]) + assert.equal(entries.length, 2) + assert.equal(entries[0].folderLabel, '张三') + assert.equal(entries[1].folderLabel, '张三_2') + assert.equal(entries[0].files[0].baseName, '驾驶证') + assert.equal(entries[1].files[0].baseName, '从业资格证') + }) + + it('does not add 主·/挂· for personnel certs', () => { + const entries = collectPersonnelCertZipEntries([ + { + id: 'p1', + personnelName: '王五', + certList: [ + { + id: 'c1', + ownerType: 'personnel', + ownerId: 'p1', + status: '1', + certName: '驾驶证', + vehiclePart: 'trailer', + attachmentUrls: 'https://cdn.example/a.jpg' + } + ] + } + ]) + assert.equal(entries[0].files[0].baseName, '驾驶证') + }) +}) + +describe('buildPersonnelCertZipFilename', () => { + it('names single and multi personnel zips', () => { + assert.equal( + buildPersonnelCertZipFilename([{ folderLabel: '张三' }], '20260908'), + '人员证书_张三.zip' + ) + assert.equal( + buildPersonnelCertZipFilename( + [{ folderLabel: '张三' }, { folderLabel: '张三_2' }], + '20260908' + ), + '人员证书批量_2人_20260908.zip' + ) + }) +}) + +describe('buildAndSavePersonnelCertZip', () => { + it('packs multi personnel into name subfolders', async () => { + const paths: string[] = [] + const result = await buildAndSavePersonnelCertZip( + [ + { + id: 'p1', + personnelName: '张三', + certList: [ + { + id: 'c1', + ownerType: 'personnel', + ownerId: 'p1', + status: '1', + certName: '驾驶证', + attachmentUrls: 'https://cdn.example/a.jpg' + } + ] + }, + { + id: 'p2', + personnelName: '李四', + certList: [ + { + id: 'c2', + ownerType: 'personnel', + ownerId: 'p2', + status: '1', + certName: '身份证', + attachmentUrls: 'https://cdn.example/b.png' + } + ] + } + ], + { + yyyymmdd: '20260908', + fetchBlob: async () => new Blob(['x']), + saveAs: async () => {}, + onFileAdded: (p) => paths.push(p) + } + ) + assert.equal(result.zipName, '人员证书批量_2人_20260908.zip') + assert.deepEqual(paths.sort(), ['张三/驾驶证.jpg', '李四/身份证.png'].sort()) + }) +}) diff --git a/web-admin/src/utils/pl-vehicle-cert-zip.ts b/web-admin/src/utils/pl-vehicle-cert-zip.ts index 9847caa1a..f8acf456f 100644 --- a/web-admin/src/utils/pl-vehicle-cert-zip.ts +++ b/web-admin/src/utils/pl-vehicle-cert-zip.ts @@ -161,6 +161,9 @@ export type BuildVehicleCertZipResult = { zipName: string } +export type BuildCertZipOptions = BuildVehicleCertZipOptions +export type BuildCertZipResult = BuildVehicleCertZipResult + function todayYyyymmdd(): string { const d = new Date() const y = d.getFullYear() @@ -200,43 +203,30 @@ async function defaultSaveAs(blob: Blob, filename: string): Promise { FileSaver.saveAs(blob, filename) } -export async function buildAndSaveVehicleCertZip( - vehicles: VehicleCertZipSource[], - options: BuildVehicleCertZipOptions = {} -): Promise { - const entries = collectVehicleCertZipEntries(vehicles) +export async function packAndSaveCertZip( + entries: Array<{ folderLabel: string; files: VehicleCertZipFile[] }>, + zipName: string, + options: BuildCertZipOptions = {} +): Promise { 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() - const jobs: Job[] = [] + const jobs: Array<{ zipPath: string; fileUrl: string; fileName: string }> = [] + 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 rawPath = multi ? `${entry.folderLabel}/${leaf}` : leaf const zipPath = uniqueZipPath(used, rawPath) - jobs.push({ - zipPath, - fileUrl: file.fileUrl, - fileName: leaf - }) + jobs.push({ zipPath, fileUrl: file.fileUrl, fileName: leaf }) } } const fetchBlob = options.fetchBlob ?? defaultFetchBlob - const zip = new JSZip() let okCount = 0 let failCount = 0 @@ -257,9 +247,102 @@ export async function buildAndSaveVehicleCertZip( return { okCount: 0, failCount, zipName: '' } } - const blob = await zip.generateAsync({ type: 'blob' }) + const out = await zip.generateAsync({ type: 'blob' }) const save = options.saveAs ?? defaultSaveAs - await save(blob, zipName) - + await save(out, zipName) return { okCount, failCount, zipName } } + +export async function buildAndSaveVehicleCertZip( + vehicles: VehicleCertZipSource[], + options: BuildVehicleCertZipOptions = {} +): Promise { + const collected = collectVehicleCertZipEntries(vehicles) + if (!collected.length) { + return { okCount: 0, failCount: 0, zipName: '' } + } + + const yyyymmdd = options.yyyymmdd || todayYyyymmdd() + const zipName = buildVehicleCertZipFilename(collected, yyyymmdd) + return packAndSaveCertZip( + collected.map((e) => ({ folderLabel: e.plateFolder, files: e.files })), + zipName, + options + ) +} + +export type PersonnelCertZipSource = { + id: string + personnelName?: string | null + certList?: CertRead[] | null +} + +export type PersonnelCertZipEntry = { + personnelId: string + folderLabel: string + files: VehicleCertZipFile[] +} + +export function collectPersonnelCertZipEntries( + people: PersonnelCertZipSource[] +): PersonnelCertZipEntry[] { + const result: PersonnelCertZipEntry[] = [] + const usedFolders = new Set() + + for (const person of people) { + const certs = person.certList || [] + const files: VehicleCertZipFile[] = [] + + certs.forEach((cert) => { + const certName = String(cert.certName || '').trim() || '证书' + parseUrls(cert.attachmentUrls).forEach((fileUrl) => { + files.push({ + fileUrl, + baseName: certName, + ext: getFileExt(fileUrl) + }) + }) + }) + + if (!files.length) continue + + const rawName = String(person.personnelName || '').trim() + const baseFolder = sanitizeZipPathSegment(rawName || person.id) + const folderLabel = uniqueZipPath(usedFolders, baseFolder) + result.push({ + personnelId: person.id, + folderLabel, + files + }) + } + + return result +} + +export function buildPersonnelCertZipFilename( + entries: Array<{ folderLabel: string }>, + yyyymmdd: string +): string { + if (entries.length === 1) { + return `人员证书_${entries[0].folderLabel}.zip` + } + return `人员证书批量_${entries.length}人_${yyyymmdd}.zip` +} + +export async function buildAndSavePersonnelCertZip( + people: PersonnelCertZipSource[], + options: BuildCertZipOptions = {} +): Promise { + const collected = collectPersonnelCertZipEntries(people) + if (!collected.length) { + return { okCount: 0, failCount: 0, zipName: '' } + } + + const yyyymmdd = options.yyyymmdd || todayYyyymmdd() + const zipName = buildPersonnelCertZipFilename(collected, yyyymmdd) + return packAndSaveCertZip( + collected.map((e) => ({ folderLabel: e.folderLabel, files: e.files })), + zipName, + options + ) +}