/** * DHL Transportversicherung (additional insurance) for Parcel DE Shipping v2. * * DHL books the insurance from ONE amount per shipment * (`services.additionalInsurance: { currency, value }`) and derives the tier itself: * - standard liability ≤ 500 EUR → nothing to book * - Transportversicherung A ≤ 2 500 EUR → 6.99 EUR, may go into the DHL pickup * - Transportversicherung B ≤ 25 000 EUR → 19.99 EUR, Filiale / Paketshop ONLY * - above 25 000 EUR → not insurable, we cap at the maximum and warn * Insured parcels travel "im verdeckten Lauf": the DHL label shows nothing, so the * Filiale-only parcels get our own hint label (see buildDhlInsuranceHintLabel). * * Products: V01PAK, V53WPAK, V54EPAK. Kleinpaket (V62KP) has no insurance at all. * Business rule (not an API rule): never insure a parcel addressed to a Packstation, * Postfiliale or Paketshop — the API would accept it, so we enforce it here. * * Order flag: C_Order.IsDHLTransportInsurance. Booked amount: M_InOut.DHL_InsuranceValue. */ import { PDFDocument, StandardFonts, rgb } from 'pdf-lib' export const DHL_INSURANCE_FREE_LIMIT = 500 export const DHL_INSURANCE_TIER_A_MAX = 2500 export const DHL_INSURANCE_MAX = 25000 export const DHL_INSURANCE_PRODUCTS = ['V01PAK', 'V53WPAK', 'V54EPAK'] // Same vocabulary as useAddressFixer's PACKSTATION_RE — a Packstation order leaves // the ERP as a plain ContactAddress with street "Packstation", so the text is the // only reliable signal. const DROP_POINT_RE = /\b(packstation|postfiliale|paketshop|paketbox|dhl\s*shop|pickup\s*point|parcel\s*shop|locker)\b/i export type DhlInsuranceSkipReason = 'not_requested' | 'product' | 'kleinpaket' | 'drop_point' | 'below_limit' | null export interface DhlInsuranceDecision { requested: boolean booked: boolean value: number | null currency: string tier: 'A' | 'B' | null filialeOnly: boolean capped: boolean orderTotal: number insurableValue: number skipReason: DhlInsuranceSkipReason message: string } export const isDhlDropPointAddress = (body: any): boolean => { const joined = ['address', 'address2', 'name', 'name2', 'companyName', 'city'] .map((k) => String(body?.[k] ?? '').trim()) .filter(Boolean) .join(' | ') if (body?.toServicePoint) return true return DROP_POINT_RE.test(joined) } export const readOrderInsuranceFlag = (order: any): boolean => (order?.isDHLTransportInsurance ?? order?.IsDHLTransportInsurance) === true const safeCurrency = (c: any) => { const code = String(c ?? '').trim().toUpperCase() return /^[A-Z]{3}$/.test(code) ? code : 'EUR' } const fmtEur = (v: number, currency = 'EUR') => `${Number(v).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} ${currency === 'EUR' ? '€' : currency}` export const decideDhlInsurance = (opts: { requested: boolean orderTotal: number currency?: any product: string isDropPoint: boolean /** Value of THIS parcel when a shipment is split into several parcels (defaults to orderTotal). */ shareValue?: number | null }): DhlInsuranceDecision => { const currency = safeCurrency(opts.currency) const orderTotal = Math.max(0, Number(opts.orderTotal) || 0) const raw = opts.shareValue != null && Number.isFinite(Number(opts.shareValue)) ? Number(opts.shareValue) : orderTotal const insurableValue = Math.max(0, Math.ceil(raw)) const base: DhlInsuranceDecision = { requested: !!opts.requested, booked: false, value: null, currency, tier: null, filialeOnly: false, capped: false, orderTotal, insurableValue, skipReason: null, message: '' } if (!opts.requested) return { ...base, skipReason: 'not_requested' } if (opts.product === 'V62KP') { return { ...base, skipReason: 'kleinpaket', message: 'Transportversicherung nicht möglich: DHL Kleinpaket ist nicht versicherbar (Haftung 20 €).' } } if (!DHL_INSURANCE_PRODUCTS.includes(opts.product)) { return { ...base, skipReason: 'product', message: `Transportversicherung nicht möglich für DHL Produkt ${opts.product}.` } } if (opts.isDropPoint) { return { ...base, skipReason: 'drop_point', message: 'Transportversicherung nicht möglich: Zustellung an Packstation / Postfiliale / Paketshop.' } } if (insurableValue <= DHL_INSURANCE_FREE_LIMIT) { return { ...base, skipReason: 'below_limit', message: `Keine Transportversicherung nötig: Warenwert ${fmtEur(insurableValue, currency)} liegt innerhalb der DHL-Haftung (500 €).` } } const capped = insurableValue > DHL_INSURANCE_MAX const value = capped ? DHL_INSURANCE_MAX : insurableValue const tier: 'A' | 'B' = value <= DHL_INSURANCE_TIER_A_MAX ? 'A' : 'B' const filialeOnly = tier === 'B' let message = tier === 'A' ? `Transportversicherung A gebucht (${fmtEur(value, currency)}, bis 2.500 €) – Paket darf in die DHL-Abholung.` : `Transportversicherung B gebucht (${fmtEur(value, currency)}, bis 25.000 €) – NICHT in die Abholung, nur persönlich in der DHL Filiale abgeben!` if (capped) { message = `ACHTUNG: Warenwert ${fmtEur(insurableValue, currency)} übersteigt das DHL-Maximum – nur bis 25.000 € versichert. NICHT in die Abholung, nur persönlich in der DHL Filiale abgeben!` } return { ...base, booked: true, value, tier, filialeOnly, capped, message } } /** services.additionalInsurance fragment for the DHL payload (or null). */ export const dhlInsuranceService = (d: DhlInsuranceDecision) => d.booked && d.value ? { additionalInsurance: { currency: d.currency, value: d.value } } : null /** * Split an order total over parcels by their item value share. Falls back to an * equal split when the parcels carry no item values. */ export const splitOrderTotalByParcels = (orderTotal: number, parcels: any[]): number[] => { const total = Math.max(0, Number(orderTotal) || 0) const n = parcels?.length || 0 if (n === 0) return [] const weights = parcels.map((p) => (p?.items || []).reduce((s: number, it: any) => s + Number(it?.value || 0) * Math.max(1, Number(it?.quantity || 1)), 0)) const sum = weights.reduce((a, b) => a + b, 0) if (sum <= 0) return parcels.map(() => total / n) return weights.map((w) => total * (w / sum)) } const winAnsi = (s: any) => String(s ?? '') .replace(/€/g, 'EUR') .replace(/→/g, '->').replace(/—/g, '-').replace(/–/g, '-') .replace(/…/g, '...').replace(/[‘’]/g, "'").replace(/[“”„]/g, '"') .replace(/[^\x20-\x7E\xA0-\xFF]/g, '') /** * Internal warehouse hint label, 102 × 152 mm like the DHL label, printed right after * it on the same label printer. Only for Filiale-only (tier B / capped) parcels. */ export const buildDhlInsuranceHintLabel = async (opts: { documentNo?: string orderNo?: string trackingNo?: string value: number currency?: string capped?: boolean insurableValue?: number parcelLabel?: string }): Promise => { const W = 289.13, H = 430.87 // 102 × 152 mm in pt const pdf = await PDFDocument.create() const page = pdf.addPage([W, H]) const bold = await pdf.embedFont(StandardFonts.HelveticaBold) const reg = await pdf.embedFont(StandardFonts.Helvetica) const black = rgb(0, 0, 0) const white = rgb(1, 1, 1) const M = 12 const currency = safeCurrency(opts.currency) const centered = (text: string, y: number, size: number, font = bold, color = black) => { const t = winAnsi(text) const w = font.widthOfTextAtSize(t, size) page.drawText(t, { x: (W - w) / 2, y, size, font, color }) } const wrap = (text: string, size: number, font = reg, maxW = W - 2 * M) => { const words = winAnsi(text).split(/\s+/) const lines: string[] = [] let cur = '' for (const w of words) { const next = cur ? cur + ' ' + w : w if (font.widthOfTextAtSize(next, size) > maxW && cur) { lines.push(cur); cur = w } else cur = next } if (cur) lines.push(cur) return lines } // Thick frame page.drawRectangle({ x: 4, y: 4, width: W - 8, height: H - 8, borderColor: black, borderWidth: 4 }) // Black header band page.drawRectangle({ x: 4, y: H - 86, width: W - 8, height: 82, color: black }) centered('NICHT IN DIE', H - 40, 26, bold, white) centered('DHL-ABHOLUNG!', H - 74, 26, bold, white) let y = H - 118 centered('NUR DHL FILIALE', y, 24); y -= 26 centered('(persönliche Einlieferung)', y, 12, reg); y -= 30 const lines = [ `Transportversicherung B bis 25.000 ${currency === 'EUR' ? '€' : currency}.`, 'Versicherungsschutz gilt NUR bei Abgabe in einer DHL Filiale / Postfiliale / Paketshop.', 'Nicht in den Abholkäfig, nicht an den DHL-Fahrer, nicht in eine Packstation/Paketbox.', 'Einlieferungsbeleg aufbewahren und zum Auftrag ablegen!' ] for (const l of lines) { for (const ln of wrap(l, 10.5, reg)) { page.drawText(ln, { x: M, y, size: 10.5, font: reg, color: black }); y -= 13 } y -= 3 } if (opts.capped) { y -= 2 for (const ln of wrap('ACHTUNG: Warenwert über 25.000 € – DHL versichert maximal 25.000 €. Rücksprache mit dem Auftraggeber!', 10.5, bold)) { page.drawText(ln, { x: M, y, size: 10.5, font: bold, color: black }); y -= 13 } y -= 3 } // Details box y -= 4 const boxTop = y const rows: [string, string][] = [] if (opts.orderNo) rows.push(['Auftrag', String(opts.orderNo)]) if (opts.documentNo) rows.push(['Lieferung', String(opts.documentNo)]) if (opts.trackingNo) rows.push(['Sendung', String(opts.trackingNo)]) if (opts.parcelLabel) rows.push(['Paket', String(opts.parcelLabel)]) rows.push(['Versichert', fmtEur(opts.value, currency)]) if (opts.capped && opts.insurableValue) rows.push(['Warenwert', fmtEur(opts.insurableValue, currency) + ' (!)']) const rowH = 15 page.drawRectangle({ x: M, y: boxTop - rows.length * rowH - 6, width: W - 2 * M, height: rows.length * rowH + 6, borderColor: black, borderWidth: 1 }) let ry = boxTop - 12 for (const [k, v] of rows) { page.drawText(winAnsi(k), { x: M + 6, y: ry, size: 10, font: reg, color: black }) page.drawText(winAnsi(v), { x: M + 76, y: ry, size: 11, font: bold, color: black }) ry -= rowH } centered('LogShip · interner Hinweis · DHL druckt die Versicherung nicht aufs Label', 12, 7, reg) const bytes = await pdf.save() return Buffer.from(bytes).toString('base64') }