/** * Parcel-contents report (multi-parcel commission) — shared logic for the * modal on the sales-order edit page and its A4 PDF download. * * Source is the M_InOut.commission_parcels_json snapshot written by the * commission page (see server/api/commission/parcels/contents.post.ts for the * shape). The report shows, per parcel, which article was scanned into it — * grouped by the code the operator scanned: the FBA relabel code * (C_OrderLine.printLabelCode) when one was used, otherwise the product SKU. */ import { loadPdfMake } from '~/utils/loadPdfMake' export interface ParcelContentsItem { inoutLineId?: number | null orderLineId?: number | null productId?: number | null sku?: string name?: string qty?: number weight?: number scannedCodes?: Array printLabelCode?: string | null } export interface ParcelContentsParcel { no?: number weight?: number trackingNo?: string | null items?: ParcelContentsItem[] } export interface ParcelContents { v?: number createdAt?: string totalWeight?: number parcels: ParcelContentsParcel[] } export interface ParcelContentsRow { code: string // grouping code: printLabelCode > SKU > name isLabelCode: boolean // true when the code is a relabel/printLabelCode name: string sku: string qty: number weight: number } export const useParcelContentsDoc = () => { // Tolerant parse of the raw column value (string or already-parsed object). const parseParcelContents = (raw: any): ParcelContents | null => { try { const data = typeof raw === 'string' ? JSON.parse(raw) : raw if (!data || !Array.isArray(data.parcels) || data.parcels.length === 0) return null return data } catch { return null } } // Group a parcel's items by the scanned/label code so e.g. two order lines // relabeled with the same number collapse into one row with the summed qty. const groupParcelItems = (parcel: ParcelContentsParcel): ParcelContentsRow[] => { const groups: Record = {} for (const item of (parcel?.items || [])) { const labelCode = String(item?.printLabelCode ?? '').trim() const sku = String(item?.sku ?? '').trim() const name = String(item?.name ?? '').trim() const code = labelCode || sku || name || '-' const key = (labelCode ? 'L:' : 'S:') + code if (!groups[key]) { groups[key] = { code, isLabelCode: !!labelCode, name, sku, qty: 0, weight: 0 } } groups[key].qty += Number(item?.qty || 0) groups[key].weight = Number((groups[key].weight + Number(item?.weight || 0)).toFixed(2)) // Keep the first non-empty name/sku (grouped lines share the product anyway) if (!groups[key].name && name) groups[key].name = name if (!groups[key].sku && sku) groups[key].sku = sku } return Object.values(groups) } const parcelTotals = (data: ParcelContents) => { const parcels = data?.parcels || [] const pieces = parcels.reduce((sum, p) => sum + (p.items || []).reduce((s, i) => s + Number(i?.qty || 0), 0), 0) const weight = Number(data?.totalWeight ?? parcels.reduce((sum, p) => sum + Number(p?.weight || 0), 0)) || 0 return { parcels: parcels.length, pieces, weight: Number(weight.toFixed(2)) } } const formatCreatedAt = (createdAt?: string) => { if (!createdAt) return '' const d = new Date(createdAt) return isNaN(d.getTime()) ? '' : d.toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) } /** * DIN-A4 report: one section per parcel (tracking + weight in the heading), * grouped item rows, grand-total footer. German labels — same convention as * buildRelabelReportDoc. */ const buildParcelContentsDoc = async (data: ParcelContents, shipmentDocNo: string, orderDocNo: string = '') => { const pdfMake = await loadPdfMake() const totals = parcelTotals(data) const content: any[] = [ { text: `Paketinhalt – Sendung ${shipmentDocNo || ''}`, fontSize: 15, bold: true }, { text: [ orderDocNo ? `Auftrag ${orderDocNo} · ` : '', `Kommissioniert: ${formatCreatedAt(data?.createdAt) || '-'}` ].join(''), fontSize: 9, color: '#555555', margin: [0, 3, 0, 2] }, { text: 'Übersicht: welcher Artikel wurde in welches Paket gescannt (gruppiert nach gescanntem Code / Etiketten-Code).', fontSize: 9, color: '#555555', margin: [0, 0, 0, 12] } ] for (const parcel of (data?.parcels || [])) { const headParts = [`Paket ${parcel?.no ?? '?'}`] if (parcel?.trackingNo) headParts.push(`Tracking: ${parcel.trackingNo}`) headParts.push(`${Number(parcel?.weight || 0).toFixed(2)} kg`) content.push({ text: headParts.join(' · '), fontSize: 11, bold: true, margin: [0, 10, 0, 4] }) const tableBody: any[] = [[ { text: 'Code', style: 'th' }, { text: 'Artikel', style: 'th' }, { text: 'SKU', style: 'th' }, { text: 'Menge', style: 'th', alignment: 'right' }, { text: 'Gewicht (kg)', style: 'th', alignment: 'right' } ]] const rows = groupParcelItems(parcel) for (const r of rows) { tableBody.push([ { text: r.code, bold: r.isLabelCode }, { text: r.name }, { text: r.sku }, { text: String(r.qty), alignment: 'right', bold: true }, { text: Number(r.weight || 0).toFixed(2), alignment: 'right' } ]) } const parcelQty = rows.reduce((s, r) => s + r.qty, 0) tableBody.push([ { text: '', colSpan: 2 }, {}, { text: 'Summe', bold: true, alignment: 'right' }, { text: String(parcelQty), bold: true, alignment: 'right' }, { text: Number(parcel?.weight || 0).toFixed(2), bold: true, alignment: 'right' } ]) content.push({ table: { headerRows: 1, widths: [120, '*', 90, 40, 60], body: tableBody }, layout: { hLineColor: () => '#cccccc', vLineColor: () => '#cccccc', hLineWidth: () => 0.5, vLineWidth: () => 0.5 } }) } content.push({ text: `Gesamt: ${totals.parcels} Pakete · ${totals.pieces} Artikel · ${totals.weight.toFixed(2)} kg`, fontSize: 11, bold: true, margin: [0, 14, 0, 0] }) content.push({ text: `Erstellt: ${new Date().toLocaleString('de-DE', { timeZone: 'Europe/Berlin' })}`, fontSize: 8, color: '#777777', margin: [0, 8, 0, 0] }) const docDefinition: any = { pageSize: 'A4', pageMargins: [40, 50, 40, 50], content, styles: { th: { bold: true, fontSize: 9, fillColor: '#f0f0f0', margin: [0, 2, 0, 2] } }, defaultStyle: { fontSize: 9 } } return pdfMake.createPdf(docDefinition) } return { parseParcelContents, groupParcelItems, parcelTotals, formatCreatedAt, buildParcelContentsDoc } }