Просмотр исходного кода

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 <cursoragent@cursor.com>
wuzilong 1 месяц назад
Родитель
Сommit
55abe614f8
2 измененных файлов с 239 добавлено и 25 удалено
  1. 131 0
      web-admin/src/utils/pl-vehicle-cert-zip.test.ts
  2. 108 25
      web-admin/src/utils/pl-vehicle-cert-zip.ts

+ 131 - 0
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())
+  })
+})

+ 108 - 25
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<void> {
   FileSaver.saveAs(blob, filename)
 }
 
-export async function buildAndSaveVehicleCertZip(
-  vehicles: VehicleCertZipSource[],
-  options: BuildVehicleCertZipOptions = {}
-): Promise<BuildVehicleCertZipResult> {
-  const entries = collectVehicleCertZipEntries(vehicles)
+export async function packAndSaveCertZip(
+  entries: Array<{ folderLabel: string; files: VehicleCertZipFile[] }>,
+  zipName: string,
+  options: BuildCertZipOptions = {}
+): Promise<BuildCertZipResult> {
   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[] = []
+  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<BuildVehicleCertZipResult> {
+  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<string>()
+
+  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<BuildCertZipResult> {
+  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
+  )
+}