feat(finished-product-progress): add filtered export with embedded images
Add an export action that collects all rows matching the current filters (or everything when unfiltered), fetches each row's image from object storage, and builds an ExcelJS workbook with the images embedded in the image column. The workbook headers match the import template so the file round-trips through the existing import flow. Export progress, image fetch failures and completion counts are surfaced in a status panel.
This commit is contained in:
parent
970cd3f234
commit
b129acdc48
@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/lib/finished-product-progress'
|
} from '@/lib/finished-product-progress'
|
||||||
import { FinishedProductColumnSettings } from './components/FinishedProductColumnSettings'
|
import { FinishedProductColumnSettings } from './components/FinishedProductColumnSettings'
|
||||||
import { FinishedProductEditorDrawer } from './components/FinishedProductEditorDrawer'
|
import { FinishedProductEditorDrawer } from './components/FinishedProductEditorDrawer'
|
||||||
|
import { FinishedProductExportProgress } from './components/FinishedProductExportProgress'
|
||||||
import { FinishedProductHistoryDialog } from './components/FinishedProductHistoryDialog'
|
import { FinishedProductHistoryDialog } from './components/FinishedProductHistoryDialog'
|
||||||
import { FinishedProductContractGroupEditor } from './components/FinishedProductContractGroupEditor'
|
import { FinishedProductContractGroupEditor } from './components/FinishedProductContractGroupEditor'
|
||||||
import { FinishedProductImportProgress } from './components/FinishedProductImportProgress'
|
import { FinishedProductImportProgress } from './components/FinishedProductImportProgress'
|
||||||
@ -19,6 +20,7 @@ import { FinishedProductMultiSelect } from './components/FinishedProductMultiSel
|
|||||||
import { FinishedProductPagination } from './components/FinishedProductPagination'
|
import { FinishedProductPagination } from './components/FinishedProductPagination'
|
||||||
import { FinishedProductTable } from './components/FinishedProductTable'
|
import { FinishedProductTable } from './components/FinishedProductTable'
|
||||||
import { finishedProductProgressStyles } from './finishedProductProgressStyles'
|
import { finishedProductProgressStyles } from './finishedProductProgressStyles'
|
||||||
|
import { useFinishedProductExport } from './useFinishedProductExport'
|
||||||
import { useFinishedProductProgressColumns } from './useFinishedProductProgressColumns'
|
import { useFinishedProductProgressColumns } from './useFinishedProductProgressColumns'
|
||||||
import { useFinishedProductImport } from './useFinishedProductImport'
|
import { useFinishedProductImport } from './useFinishedProductImport'
|
||||||
|
|
||||||
@ -41,6 +43,8 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
|
|||||||
const [pageInput, setPageInput] = useState(String(initialData.pageInfo.page))
|
const [pageInput, setPageInput] = useState(String(initialData.pageInfo.page))
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const columns = useFinishedProductProgressColumns(currentUserName)
|
const columns = useFinishedProductProgressColumns(currentUserName)
|
||||||
|
const exportTask = useFinishedProductExport()
|
||||||
|
const appliedFiltersRef = useRef<FinishedProductProgressFilters>({ ...EMPTY_FINISHED_PRODUCT_PROGRESS_FILTERS })
|
||||||
const pageSelectionKeys = data.rows.flatMap((group) => group.details.map((row) => row.id))
|
const pageSelectionKeys = data.rows.flatMap((group) => group.details.map((row) => row.id))
|
||||||
const allSelected = canEdit && pageSelectionKeys.length > 0 && pageSelectionKeys.every((key) => selectedIds.has(key))
|
const allSelected = canEdit && pageSelectionKeys.length > 0 && pageSelectionKeys.every((key) => selectedIds.has(key))
|
||||||
|
|
||||||
@ -62,10 +66,11 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
|
|||||||
if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry))
|
if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry))
|
||||||
else if (value) params.set(key, value)
|
else if (value) params.set(key, value)
|
||||||
})
|
})
|
||||||
const next = await readResponse(await fetch(`/api/supply-chain/finished-product-progress?${params}`, { cache: 'no-store' })) as FinishedProductProgressPage
|
const next = await readResponse(await fetch(`/api/supply-chain/finished-product-progress?${params}`, { cache: 'no-store' })) as FinishedProductProgressPage
|
||||||
setData(next)
|
setData(next)
|
||||||
setFilters(nextFilters)
|
setFilters(nextFilters)
|
||||||
setSelectedIds(new Set())
|
appliedFiltersRef.current = nextFilters
|
||||||
|
setSelectedIds(new Set())
|
||||||
setPageInput(String(next.pageInfo.page))
|
setPageInput(String(next.pageInfo.page))
|
||||||
} catch (error) { window.alert(error instanceof Error ? error.message : '加载失败') } finally { setLoading(false) }
|
} catch (error) { window.alert(error instanceof Error ? error.message : '加载失败') } finally { setLoading(false) }
|
||||||
}, [])
|
}, [])
|
||||||
@ -145,6 +150,7 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
|
|||||||
{canEdit && <button type="button" disabled={selectedIds.size === 0} onClick={openContractGroupEditor} className="finished-control-button" title="批量修改已勾选的同合同号明细"><Layers3 size={15} />批量修改</button>}
|
{canEdit && <button type="button" disabled={selectedIds.size === 0} onClick={openContractGroupEditor} className="finished-control-button" title="批量修改已勾选的同合同号明细"><Layers3 size={15} />批量修改</button>}
|
||||||
{canEdit && <button type="button" disabled={selectedIds.size === 0 || loading} onClick={() => void deleteSelectedGroups()} className="finished-control-button finished-danger-button" title="删除已勾选的明细"><Trash2 size={15} />批量删除</button>}
|
{canEdit && <button type="button" disabled={selectedIds.size === 0 || loading} onClick={() => void deleteSelectedGroups()} className="finished-control-button finished-danger-button" title="删除已勾选的明细"><Trash2 size={15} />批量删除</button>}
|
||||||
{canDownload && <button type="button" className="finished-control-button" onClick={() => { window.location.href = '/api/supply-chain/finished-product-progress/template' }}><FileDown size={15} />下载模板</button>}
|
{canDownload && <button type="button" className="finished-control-button" onClick={() => { window.location.href = '/api/supply-chain/finished-product-progress/template' }}><FileDown size={15} />下载模板</button>}
|
||||||
|
{canDownload && <button type="button" disabled={exportTask.state.status === 'collecting' || exportTask.state.status === 'images' || exportTask.state.status === 'building' || loading} title={exportTask.state.status === 'collecting' || exportTask.state.status === 'images' || exportTask.state.status === 'building' ? '导出进行中' : '按当前筛选导出(未筛选时导出全部)'} onClick={() => void exportTask.run(appliedFiltersRef.current)} className="finished-control-button"><FileDown size={15} />{exportTask.state.status === 'collecting' || exportTask.state.status === 'images' || exportTask.state.status === 'building' ? '导出中' : '导出'}</button>}
|
||||||
{canEdit && <><button type="button" disabled={importTask.isImporting} onClick={() => fileRef.current?.click()} className="finished-control-button"><Upload size={15} />{importTask.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) void importTask.importFile(file); event.currentTarget.value = '' }} /><button type="button" onClick={() => setEditor('new')} className="finished-primary-button"><Plus size={15} />新增</button></>}
|
{canEdit && <><button type="button" disabled={importTask.isImporting} onClick={() => fileRef.current?.click()} className="finished-control-button"><Upload size={15} />{importTask.isImporting ? '导入中' : '导入'}</button><input ref={fileRef} type="file" accept=".xlsx,.xls,.csv" hidden onChange={(event) => { const file = event.target.files?.[0]; if (file) void importTask.importFile(file); event.currentTarget.value = '' }} /><button type="button" onClick={() => setEditor('new')} className="finished-primary-button"><Plus size={15} />新增</button></>}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -175,6 +181,7 @@ export function FinishedProductProgressClient({ initialData, currentUserName, ca
|
|||||||
{viewer && <FinishedProductEditorDrawer item={viewer} readOnly onClose={() => setViewer(null)} onRowUpdated={updateRow} onSaved={() => setViewer(null)} />}
|
{viewer && <FinishedProductEditorDrawer item={viewer} readOnly onClose={() => setViewer(null)} onRowUpdated={updateRow} onSaved={() => setViewer(null)} />}
|
||||||
{logs && <FinishedProductHistoryDialog logs={logs} onClose={() => setLogs(null)} />}
|
{logs && <FinishedProductHistoryDialog logs={logs} onClose={() => setLogs(null)} />}
|
||||||
<FinishedProductImportProgress state={importTask.state} onDismiss={importTask.dismiss} />
|
<FinishedProductImportProgress state={importTask.state} onDismiss={importTask.dismiss} />
|
||||||
|
<FinishedProductExportProgress state={exportTask.state} onDismiss={exportTask.dismiss} />
|
||||||
{preview?.imageUrl && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) setPreview(null) }}><div className="relative h-[min(82vh,900px)] w-[min(90vw,1200px)]"><Image unoptimized src={preview.imageUrl} alt={preview.productName || preview.barcode} fill sizes="90vw" className="object-contain" /><button type="button" title="关闭" onClick={() => setPreview(null)} className="absolute right-0 top-0 flex h-10 w-10 items-center justify-center rounded-full bg-black/70 text-white"><X size={20} /></button></div></div>}
|
{preview?.imageUrl && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) setPreview(null) }}><div className="relative h-[min(82vh,900px)] w-[min(90vw,1200px)]"><Image unoptimized src={preview.imageUrl} alt={preview.productName || preview.barcode} fill sizes="90vw" className="object-contain" /><button type="button" title="关闭" onClick={() => setPreview(null)} className="absolute right-0 top-0 flex h-10 w-10 items-center justify-center rounded-full bg-black/70 text-white"><X size={20} /></button></div></div>}
|
||||||
<style jsx global>{finishedProductProgressStyles}</style>
|
<style jsx global>{finishedProductProgressStyles}</style>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { CircleAlert, CircleCheck, LoaderCircle, X } from 'lucide-react'
|
||||||
|
import type { FinishedProductExportState } from '../useFinishedProductExport'
|
||||||
|
|
||||||
|
export function FinishedProductExportProgress({ state, onDismiss }: { state: FinishedProductExportState; onDismiss: () => void }) {
|
||||||
|
if (state.status === 'idle') return null
|
||||||
|
const failed = state.status === 'error'
|
||||||
|
const done = state.status === 'done'
|
||||||
|
const running = state.status === 'collecting' || state.status === 'images' || state.status === 'building'
|
||||||
|
|
||||||
|
return <aside aria-live="polite" className="fixed bottom-6 right-7 z-[80] w-[320px] rounded-[7px] border border-zinc-300 bg-white p-4 text-zinc-800 shadow-xl max-sm:right-3 max-sm:w-[calc(100vw-24px)]">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<strong className="flex items-center gap-2 text-[13px]">
|
||||||
|
{failed ? <CircleAlert className="size-4 text-red-700" aria-hidden="true" /> : done ? <CircleCheck className="size-4 text-emerald-600" aria-hidden="true" /> : <LoaderCircle className="size-4 animate-pulse text-apple-blue" aria-hidden="true" />}
|
||||||
|
{failed ? '导出失败' : done ? '导出完成' : '导出中'}
|
||||||
|
</strong>
|
||||||
|
{done && <button type="button" title="关闭" aria-label="关闭导出进度" onClick={onDismiss} className="finished-icon-button"><X size={14} /></button>}
|
||||||
|
</header>
|
||||||
|
<p className="mt-2 text-xs text-zinc-600">{state.message}</p>
|
||||||
|
{running && <p className="mt-1 text-xs text-zinc-500">已收集 {state.processedRows}/{state.totalRows} 行,图片 {state.processedImages}/{state.totalImages}</p>}
|
||||||
|
{state.failedImages.length > 0 && !running && <p className="mt-1 text-xs text-amber-700">未嵌入图片 {state.failedImages.length} 项</p>}
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react'
|
||||||
|
import type { FinishedProductProgressDetailRow, FinishedProductProgressFilters, FinishedProductProgressPage } from '@/lib/finished-product-progress'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 200
|
||||||
|
|
||||||
|
export type FinishedProductExportStatus = 'idle' | 'collecting' | 'images' | 'building' | 'done' | 'error'
|
||||||
|
|
||||||
|
export type FinishedProductExportState = {
|
||||||
|
status: FinishedProductExportStatus
|
||||||
|
processedRows: number
|
||||||
|
totalRows: number
|
||||||
|
processedImages: number
|
||||||
|
totalImages: number
|
||||||
|
message: string
|
||||||
|
failedImages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE: FinishedProductExportState = {
|
||||||
|
status: 'idle',
|
||||||
|
processedRows: 0,
|
||||||
|
totalRows: 0,
|
||||||
|
processedImages: 0,
|
||||||
|
totalImages: 0,
|
||||||
|
message: '',
|
||||||
|
failedImages: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFinishedProductExport() {
|
||||||
|
const [state, setState] = useState<FinishedProductExportState>(IDLE)
|
||||||
|
const runningRef = useRef(false)
|
||||||
|
|
||||||
|
const run = useCallback(async (filters: FinishedProductProgressFilters) => {
|
||||||
|
if (runningRef.current) return
|
||||||
|
runningRef.current = true
|
||||||
|
setState({ ...IDLE, status: 'collecting' })
|
||||||
|
try {
|
||||||
|
// 1) 拉取当前筛选下的全部行(跨页)
|
||||||
|
const rows: FinishedProductProgressDetailRow[] = []
|
||||||
|
let page = 1
|
||||||
|
let total = Number.POSITIVE_INFINITY
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) })
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (Array.isArray(value)) value.forEach((entry) => params.append(key, entry))
|
||||||
|
else if (value) params.set(key, value)
|
||||||
|
})
|
||||||
|
const response = await fetch(`/api/supply-chain/finished-product-progress?${params.toString()}`, { cache: 'no-store' })
|
||||||
|
const payload = await response.json().catch(() => null)
|
||||||
|
if (!response.ok || !payload?.success) throw new Error(payload?.error?.message || '导出数据拉取失败')
|
||||||
|
const pageData = payload.data as FinishedProductProgressPage
|
||||||
|
pageData.rows.forEach((group) => rows.push(...group.details))
|
||||||
|
total = pageData.pageInfo.total
|
||||||
|
page += 1
|
||||||
|
setState((current) => ({ ...current, processedRows: rows.length, totalRows: total }))
|
||||||
|
} while (rows.length < total)
|
||||||
|
|
||||||
|
// 2) 拉取图片(浏览器会话可直接访问存储签名 URL;单张失败跳过并记录)
|
||||||
|
const imageRows = rows.filter((row) => row.imageUrl)
|
||||||
|
setState((current) => ({ ...current, status: 'images', totalImages: imageRows.length }))
|
||||||
|
const images: Array<{ rowId: string; bytes: Uint8Array; extension: 'png' | 'jpeg' | 'gif' }> = []
|
||||||
|
const failedImages: string[] = []
|
||||||
|
for (const row of imageRows) {
|
||||||
|
const label = [row.itemNumber, row.barcode].filter(Boolean).join('/') || row.id
|
||||||
|
try {
|
||||||
|
const response = await fetch(row.imageUrl, { cache: 'no-store' })
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||||
|
const type = (response.headers.get('Content-Type') || '').toLowerCase()
|
||||||
|
const extension = type.includes('gif') ? 'gif' as const : type.includes('jpeg') ? 'jpeg' as const : 'png' as const
|
||||||
|
images.push({ rowId: row.id, bytes: new Uint8Array(await response.arrayBuffer()), extension })
|
||||||
|
} catch {
|
||||||
|
failedImages.push(`${label}(图片拉取失败)`)
|
||||||
|
}
|
||||||
|
setState((current) => ({ ...current, status: 'images', processedImages: images.length + failedImages.length }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 生成工作簿并下载(ExcelJS 按需加载)
|
||||||
|
setState((current) => ({ ...current, status: 'building' }))
|
||||||
|
const { createFinishedProductExportWorkbookBuffer } = await import('@/lib/finished-product-progress-export')
|
||||||
|
const { buffer, rowCount, imageCount } = await createFinishedProductExportWorkbookBuffer({ rows, images })
|
||||||
|
const blob = new Blob([buffer as BlobPart], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = `成品进度明细-${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
status: 'done',
|
||||||
|
message: `导出完成:${rowCount} 行,嵌入图片 ${imageCount} 张${failedImages.length ? `;图片拉取失败 ${failedImages.length} 行(${failedImages.join('、')})` : ''}`,
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
setState((current) => ({ ...current, status: 'error', message: error instanceof Error ? error.message : '导出失败' }))
|
||||||
|
} finally {
|
||||||
|
runningRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => setState(IDLE), [])
|
||||||
|
|
||||||
|
return { state, run, dismiss }
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
import { parseFinishedProductProgressImport } from '@/lib/finished-product-progress-import'
|
||||||
|
import { createFinishedProductExportWorkbookBuffer } from './finished-product-progress-export'
|
||||||
|
import type { FinishedProductProgressDetailRow } from './finished-product-progress'
|
||||||
|
|
||||||
|
function detailRow(overrides: Partial<FinishedProductProgressDetailRow>): FinishedProductProgressDetailRow {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'row-1',
|
||||||
|
orderSequenceNo: '',
|
||||||
|
orderPlacedDate: '',
|
||||||
|
completedAt: null,
|
||||||
|
contractNumber: 'HT-001',
|
||||||
|
followUpOwner: '',
|
||||||
|
imageUrl: '',
|
||||||
|
itemNumber: 'SKU-001',
|
||||||
|
barcode: '6900001',
|
||||||
|
productName: '测试产品',
|
||||||
|
supplier: '测试供应商',
|
||||||
|
brand: '测试品牌',
|
||||||
|
orderedQuantity: 100,
|
||||||
|
inTransitQuantity: 40,
|
||||||
|
firstDueDate: '2026-09-05',
|
||||||
|
orderDueDate: '2026-09-10',
|
||||||
|
realtimeDate: '2026-09-01',
|
||||||
|
deliveryQuantity: 60,
|
||||||
|
remarks: '',
|
||||||
|
currentNode: '送货中',
|
||||||
|
orderDueDateChangeReason: '',
|
||||||
|
createdAt: '2026-09-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-09-01T00:00:00.000Z',
|
||||||
|
updatedByName: '测试用户',
|
||||||
|
deliveryRecords: [],
|
||||||
|
...overrides,
|
||||||
|
} as FinishedProductProgressDetailRow
|
||||||
|
}
|
||||||
|
|
||||||
|
const PNG_BYTES = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||||
|
|
||||||
|
describe('finished product progress export workbook', () => {
|
||||||
|
it('produces a workbook whose rows and embedded images round-trip through the import parser', async () => {
|
||||||
|
const rows = [
|
||||||
|
detailRow({ id: 'row-1', orderSequenceNo: 'SDRM001', orderPlacedDate: '2026-08-25', itemNumber: 'SKU-001', barcode: '6900001' }),
|
||||||
|
detailRow({ id: 'row-2', orderSequenceNo: 'SDRM002', orderPlacedDate: '2026-08-27', itemNumber: 'SKU-002', barcode: '6900002', orderedQuantity: 110, inTransitQuantity: 110, deliveryQuantity: 0 }),
|
||||||
|
]
|
||||||
|
const images = [
|
||||||
|
{ rowId: 'row-1', bytes: PNG_BYTES, extension: 'png' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { buffer, rowCount, imageCount } = await createFinishedProductExportWorkbookBuffer({ rows, images })
|
||||||
|
expect(rowCount).toBe(2)
|
||||||
|
expect(imageCount).toBe(1)
|
||||||
|
|
||||||
|
const parsed = await parseFinishedProductProgressImport(Buffer.from(buffer))
|
||||||
|
expect(parsed.rows).toHaveLength(2)
|
||||||
|
expect(parsed.embeddedImages).toHaveLength(1)
|
||||||
|
expect(parsed.embeddedImages[0].contentType).toBe('image/png')
|
||||||
|
expect(parsed.embeddedImages[0].buffer.byteLength).toBe(PNG_BYTES.byteLength)
|
||||||
|
expect(parsed.embeddedImages[0].rowNumber).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps rows without images importable and omits embedded images', async () => {
|
||||||
|
const rows = [detailRow({ id: 'row-1' })]
|
||||||
|
const { imageCount } = await createFinishedProductExportWorkbookBuffer({ rows, images: [] })
|
||||||
|
expect(imageCount).toBe(0)
|
||||||
|
|
||||||
|
const { buffer } = await createFinishedProductExportWorkbookBuffer({ rows, images: [] })
|
||||||
|
const parsed = await parseFinishedProductProgressImport(Buffer.from(buffer))
|
||||||
|
expect(parsed.rows).toHaveLength(1)
|
||||||
|
expect(parsed.embeddedImages).toHaveLength(0)
|
||||||
|
expect(parsed.rows[0]?.values.contractNumber).toBe('HT-001')
|
||||||
|
})
|
||||||
|
})
|
||||||
114
furniture-3d-system/src/lib/finished-product-progress-export.ts
Normal file
114
furniture-3d-system/src/lib/finished-product-progress-export.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import ExcelJS from 'exceljs'
|
||||||
|
import type { FinishedProductProgressDetailRow } from './finished-product-progress'
|
||||||
|
|
||||||
|
export type FinishedProductProgressExportImage = {
|
||||||
|
rowId: string
|
||||||
|
bytes: Uint8Array
|
||||||
|
extension: 'png' | 'jpeg' | 'gif'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinishedProductProgressExportInput = {
|
||||||
|
rows: FinishedProductProgressDetailRow[]
|
||||||
|
images: FinishedProductProgressExportImage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FinishedProductProgressExportResult = {
|
||||||
|
buffer: Uint8Array
|
||||||
|
rowCount: number
|
||||||
|
imageCount: number
|
||||||
|
imageColumnName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPORT_COLUMNS = [
|
||||||
|
{ header: '订单序号', key: 'orderSequenceNo', width: 14 },
|
||||||
|
{ header: '下单日期', key: 'orderPlacedDate', width: 12 },
|
||||||
|
{ header: '合同号', key: 'contractNumber', width: 22 },
|
||||||
|
{ header: '图片', key: 'imageUrl', width: 14 },
|
||||||
|
{ header: '货号', key: 'itemNumber', width: 16 },
|
||||||
|
{ header: '条码', key: 'barcode', width: 20 },
|
||||||
|
{ header: '产品名称', key: 'productName', width: 28 },
|
||||||
|
{ header: '供应商', key: 'supplier', width: 18 },
|
||||||
|
{ header: '品牌', key: 'brand', width: 14 },
|
||||||
|
{ header: '下单数量', key: 'orderedQuantity', width: 10 },
|
||||||
|
{ header: '在途数量', key: 'inTransitQuantity', width: 10 },
|
||||||
|
{ header: '首次交期', key: 'firstDueDate', width: 12 },
|
||||||
|
{ header: '订单交期(周更)', key: 'orderDueDate', width: 14 },
|
||||||
|
{ header: '备注', key: 'remarks', width: 24 },
|
||||||
|
{ header: '跟进人', key: 'followUpOwner', width: 12 },
|
||||||
|
{ header: '当前节点', key: 'currentNode', width: 14 },
|
||||||
|
{ header: '完成时间', key: 'completedAt', width: 18 },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const IMAGE_COLUMN_KEY = 'imageUrl'
|
||||||
|
|
||||||
|
function toExcelExtension(extension: FinishedProductProgressExportImage['extension']) {
|
||||||
|
return extension
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFinishedProductExportWorkbook({ rows, images }: FinishedProductProgressExportInput) {
|
||||||
|
const imageByRowId = new Map(images.map((image) => [image.rowId, image]))
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
workbook.creator = '成品进度明细'
|
||||||
|
const sheet = workbook.addWorksheet('成品进度明细')
|
||||||
|
sheet.columns = EXPORT_COLUMNS.map((column) => ({ ...column }))
|
||||||
|
sheet.views = [{ state: 'frozen', ySplit: 1 }]
|
||||||
|
sheet.getRow(1).font = { bold: true }
|
||||||
|
;['orderSequenceNo', 'contractNumber', 'itemNumber', 'barcode'].forEach((key) => {
|
||||||
|
sheet.getColumn(key).numFmt = '@'
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageColumnIndex = EXPORT_COLUMNS.findIndex((column) => column.key === IMAGE_COLUMN_KEY)
|
||||||
|
let rowIndex = 1
|
||||||
|
let imageCount = 0
|
||||||
|
rows.forEach((row) => {
|
||||||
|
rowIndex += 1
|
||||||
|
const image = imageByRowId.get(row.id)
|
||||||
|
const added = sheet.addRow({
|
||||||
|
orderSequenceNo: row.orderSequenceNo ?? '',
|
||||||
|
orderPlacedDate: row.orderPlacedDate ?? '',
|
||||||
|
contractNumber: row.contractNumber,
|
||||||
|
imageUrl: '',
|
||||||
|
itemNumber: row.itemNumber,
|
||||||
|
barcode: row.barcode ?? '',
|
||||||
|
productName: row.productName,
|
||||||
|
supplier: row.supplier,
|
||||||
|
brand: row.brand ?? '',
|
||||||
|
orderedQuantity: row.orderedQuantity,
|
||||||
|
inTransitQuantity: row.inTransitQuantity,
|
||||||
|
firstDueDate: row.firstDueDate ?? '',
|
||||||
|
orderDueDate: row.orderDueDate ?? '',
|
||||||
|
remarks: row.remarks ?? '',
|
||||||
|
followUpOwner: row.followUpOwner ?? '',
|
||||||
|
currentNode: row.currentNode ?? '',
|
||||||
|
completedAt: row.completedAt ?? '',
|
||||||
|
})
|
||||||
|
if (image) {
|
||||||
|
added.height = 72
|
||||||
|
const bytes = image.bytes instanceof Uint8Array ? image.bytes : new Uint8Array(image.bytes)
|
||||||
|
const imageId = workbook.addImage({
|
||||||
|
buffer: bytes as unknown as ExcelJS.Buffer,
|
||||||
|
extension: toExcelExtension(image.extension),
|
||||||
|
})
|
||||||
|
// 图片锚定在「图片」列(0 基),导入按列索引读取嵌入图片
|
||||||
|
sheet.addImage(imageId, {
|
||||||
|
tl: { col: imageColumnIndex, row: rowIndex - 1 },
|
||||||
|
ext: { width: 88, height: 88 },
|
||||||
|
editAs: 'oneCell',
|
||||||
|
})
|
||||||
|
imageCount += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return workbook
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFinishedProductExportWorkbookBuffer(input: FinishedProductProgressExportInput) {
|
||||||
|
const workbook = buildFinishedProductExportWorkbook(input)
|
||||||
|
const buffer = (await workbook.xlsx.writeBuffer()) as unknown as Uint8Array
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
rowCount: input.rows.length,
|
||||||
|
imageCount: input.images.length,
|
||||||
|
imageColumnName: '图片',
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user