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>
This commit is contained in:
wuzilongandCursor committed 2026-09-08 20:38:43 +08:00
1 parent b4e59ab16b
commit 55abe614f8
2 files changed
+239 -25

No files matched your search

@@ -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
View File
@@ -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
)
}