feat(pl): add vehicle cert zip naming and collect helpers.
Pure functions for toolbar batch ZIP: plate folders, 主·/挂· labels, and collision-safe paths. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
65af6e6163
commit
8d3062a5af
2 files changed
+268
No files matched your search
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
collectVehicleCertZipEntries,
|
||||
buildVehicleCertZipFilename,
|
||||
uniqueZipPath,
|
||||
sanitizeZipPathSegment
|
||||
} from './pl-vehicle-cert-zip'
|
||||
|
||||
describe('sanitizeZipPathSegment', () => {
|
||||
it('replaces unsafe path chars', () => {
|
||||
assert.equal(sanitizeZipPathSegment('皖A/D12\\345'), '皖A_D12_345')
|
||||
assert.equal(sanitizeZipPathSegment(' 皖A '), '皖A')
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectVehicleCertZipEntries', () => {
|
||||
it('skips vehicles and certs without urls; prefixes 主·/挂· when trailer certs exist', () => {
|
||||
const entries = collectVehicleCertZipEntries([
|
||||
{
|
||||
id: 'v1',
|
||||
licensePlate: '皖AD12345',
|
||||
trailerNo: '挂A8888',
|
||||
certList: [
|
||||
{
|
||||
id: 'c1',
|
||||
ownerType: 'vehicle',
|
||||
ownerId: 'v1',
|
||||
status: '1',
|
||||
certName: '行驶证',
|
||||
vehiclePart: 'head',
|
||||
attachmentUrls: 'https://cdn.example/a.jpg'
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
ownerType: 'vehicle',
|
||||
ownerId: 'v1',
|
||||
status: '1',
|
||||
certName: '道路运输证',
|
||||
vehiclePart: 'head',
|
||||
attachmentUrls: ''
|
||||
},
|
||||
{
|
||||
id: 'c3',
|
||||
ownerType: 'vehicle',
|
||||
ownerId: 'v1',
|
||||
status: '1',
|
||||
certName: '行驶证',
|
||||
vehiclePart: 'trailer',
|
||||
attachmentUrls: 'https://cdn.example/b.pdf'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
licensePlate: '皖BF67890',
|
||||
certList: []
|
||||
}
|
||||
])
|
||||
|
||||
assert.equal(entries.length, 1)
|
||||
assert.equal(entries[0].vehicleId, 'v1')
|
||||
assert.equal(entries[0].plateFolder, '皖AD12345挂A8888')
|
||||
assert.equal(entries[0].files.length, 2)
|
||||
assert.equal(entries[0].files[0].baseName, '主·行驶证')
|
||||
assert.equal(entries[0].files[0].ext, 'jpg')
|
||||
assert.equal(entries[0].files[1].baseName, '挂·行驶证')
|
||||
assert.equal(entries[0].files[1].ext, 'pdf')
|
||||
})
|
||||
|
||||
it('omits 主·/挂· prefix when no trailer certs on that vehicle', () => {
|
||||
const entries = collectVehicleCertZipEntries([
|
||||
{
|
||||
id: 'v1',
|
||||
licensePlate: '皖BF67890',
|
||||
certList: [
|
||||
{
|
||||
id: 'c1',
|
||||
ownerType: 'vehicle',
|
||||
ownerId: 'v1',
|
||||
status: '1',
|
||||
certName: '行驶证',
|
||||
vehiclePart: 'head',
|
||||
attachmentUrls: 'https://cdn.example/a.jpg,https://cdn.example/a2.png'
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
assert.equal(entries[0].files.length, 2)
|
||||
assert.equal(entries[0].files[0].baseName, '行驶证')
|
||||
assert.equal(entries[0].files[1].baseName, '行驶证')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildVehicleCertZipFilename', () => {
|
||||
it('names single-vehicle zip with plate', () => {
|
||||
assert.equal(
|
||||
buildVehicleCertZipFilename([{ plateFolder: '皖AD12345挂A8888' }], '20260908'),
|
||||
'车辆证书_皖AD12345挂A8888.zip'
|
||||
)
|
||||
})
|
||||
|
||||
it('names multi-vehicle zip with count and date', () => {
|
||||
assert.equal(
|
||||
buildVehicleCertZipFilename(
|
||||
[{ plateFolder: 'A' }, { plateFolder: 'B' }],
|
||||
'20260908'
|
||||
),
|
||||
'车辆证书批量_2辆_20260908.zip'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueZipPath', () => {
|
||||
it('appends _2 _3 on collisions', () => {
|
||||
const used = new Set<string>()
|
||||
assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证.jpg')
|
||||
assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_2.jpg')
|
||||
assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_3.jpg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { CertRead } from '@/api/pl'
|
||||
import { formatPlVehiclePlateDisplay } from '@/utils/pl-display'
|
||||
import {
|
||||
PL_VEHICLE_CERT_PART_TRAILER,
|
||||
normalizeVehicleCertPart
|
||||
} from '@/utils/pl-vehicle-cert'
|
||||
|
||||
export type VehicleCertZipSource = {
|
||||
id: string
|
||||
licensePlate?: string | null
|
||||
trailerNo?: string | null
|
||||
certList?: CertRead[] | null
|
||||
}
|
||||
|
||||
export type VehicleCertZipFile = {
|
||||
fileUrl: string
|
||||
baseName: string
|
||||
ext: string
|
||||
}
|
||||
|
||||
export type VehicleCertZipEntry = {
|
||||
vehicleId: string
|
||||
plateFolder: string
|
||||
files: VehicleCertZipFile[]
|
||||
}
|
||||
|
||||
export function sanitizeZipPathSegment(raw: string): string {
|
||||
return String(raw || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|]+/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim() || '未命名'
|
||||
}
|
||||
|
||||
/** Mirrors `normalizeAttachmentUrls` in download-attachment.ts (inlined for node:test). */
|
||||
function normalizeAttachmentUrls(raw?: string | null): string {
|
||||
if (!raw) return ''
|
||||
const trimmed = String(raw).trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((url) => String(url).trim()).filter(Boolean).join(',')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function parseUrls(raw?: string | null): string[] {
|
||||
const normalized = normalizeAttachmentUrls(raw)
|
||||
if (!normalized) return []
|
||||
return normalized
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getFileExt(url: string): string {
|
||||
const name =
|
||||
String(url || '')
|
||||
.split('?')[0]
|
||||
.split('/')
|
||||
.pop() || ''
|
||||
const lastDot = name.lastIndexOf('.')
|
||||
if (lastDot === -1) return ''
|
||||
return name.slice(lastDot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
function partPrefix(cert: CertRead, enablePart: boolean): string {
|
||||
if (!enablePart) return ''
|
||||
return normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER
|
||||
? '挂·'
|
||||
: '主·'
|
||||
}
|
||||
|
||||
export function collectVehicleCertZipEntries(
|
||||
vehicles: VehicleCertZipSource[]
|
||||
): VehicleCertZipEntry[] {
|
||||
const result: VehicleCertZipEntry[] = []
|
||||
|
||||
for (const vehicle of vehicles) {
|
||||
const certs = vehicle.certList || []
|
||||
const enablePart = certs.some(
|
||||
(cert) => normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER
|
||||
)
|
||||
const files: VehicleCertZipFile[] = []
|
||||
|
||||
certs.forEach((cert) => {
|
||||
const certName = String(cert.certName || '').trim() || '证书'
|
||||
const prefix = partPrefix(cert, enablePart)
|
||||
const baseName = `${prefix}${certName}`
|
||||
parseUrls(cert.attachmentUrls).forEach((fileUrl) => {
|
||||
files.push({
|
||||
fileUrl,
|
||||
baseName,
|
||||
ext: getFileExt(fileUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if (!files.length) continue
|
||||
|
||||
const plateRaw = formatPlVehiclePlateDisplay(vehicle.licensePlate, vehicle.trailerNo)
|
||||
result.push({
|
||||
vehicleId: vehicle.id,
|
||||
plateFolder: sanitizeZipPathSegment(plateRaw === '—' ? vehicle.id : plateRaw),
|
||||
files
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function buildVehicleCertZipFilename(
|
||||
entries: Array<{ plateFolder: string }>,
|
||||
yyyymmdd: string
|
||||
): string {
|
||||
if (entries.length === 1) {
|
||||
return `车辆证书_${entries[0].plateFolder}.zip`
|
||||
}
|
||||
return `车辆证书批量_${entries.length}辆_${yyyymmdd}.zip`
|
||||
}
|
||||
|
||||
export function uniqueZipPath(used: Set<string>, relativePath: string): string {
|
||||
if (!used.has(relativePath)) {
|
||||
used.add(relativePath)
|
||||
return relativePath
|
||||
}
|
||||
const lastSlash = relativePath.lastIndexOf('/')
|
||||
const dir = lastSlash >= 0 ? relativePath.slice(0, lastSlash + 1) : ''
|
||||
const name = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath
|
||||
const lastDot = name.lastIndexOf('.')
|
||||
const stem = lastDot > 0 ? name.slice(0, lastDot) : name
|
||||
const ext = lastDot > 0 ? name.slice(lastDot) : ''
|
||||
let i = 2
|
||||
let candidate = `${dir}${stem}_${i}${ext}`
|
||||
while (used.has(candidate)) {
|
||||
i += 1
|
||||
candidate = `${dir}${stem}_${i}${ext}`
|
||||
}
|
||||
used.add(candidate)
|
||||
return candidate
|
||||
}
|
||||
Reference in new issue
Block a user