/** * Parse the DHL business-customer contract annexes into the carrier-rate fixture * consumed by `POST /api/settings/carrier-rates/import`. * * Inputs (pdftotext -layout output of reference/shippingprices/dhl/*.pdf): * P01.txt DHL PAKET International Premium / Economy-CDP per country (base + €/kg) * P02.txt WARENPOST International Premium / Economy per country (base + €/100 g → stored ×10 as €/kg) * VERTRAG Anlage 1 (national tiers, services, surcharges) — values are HAND-CODED below * (the layout is too irregular to parse safely; keep in sync with the PDF). * * Run from the repo root: * pdftotext -layout reference/shippingprices/dhl/VERTRAGSUNTERLAGEN_6380721892_P01.pdf /tmp/dhl/P01.txt * pdftotext -layout reference/shippingprices/dhl/VERTRAGSUNTERLAGEN_6380721892_P02.pdf /tmp/dhl/P02.txt * npx tsx reference/test-scripts/dhl-contract-parse.mts /tmp/dhl --out server/assets/shipping/dhl-contract-2025-07.json * * Writes ONLY the JSON fixture. No database access. */ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' import path from 'node:path' import { dhlCountryToIso2, normalizeDhlCountryName } from '../../app/utils/shippingCountryMap' import type { CarrierRateFixture, FixtureRate, FixtureService } from '../../app/utils/shippingPriceSheet' const args = process.argv.slice(2) const dir = args.find((a) => !a.startsWith('--')) const outIdx = args.indexOf('--out') const out = outIdx >= 0 ? args[outIdx + 1] : 'server/assets/shipping/dhl-contract-2025-07.json' if (!dir) { console.error('usage: dhl-contract-parse.mts [--out file.json]'); process.exit(1) } const CONTRACT_NO = '6380721892' const VALID_FROM = '2025-07-01' const SHIPPER_ID = 1000000 const num = (s: string) => Number(String(s).replace(/\./g, '').replace(',', '.')) // --------------------------------------------------------------------------- // Country list pages (P01 pages ≥ 3, P02 pages ≥ 2) // --------------------------------------------------------------------------- const ROW_RE = /^\s{4,}(?\S.*?)\s{2,}(?[1-6](?: (?:EU|NON-EU))?)\s+(?(?:\d+,\d{2}(?: \*\d\))?\s*){2,4})$/ interface ParsedRow { name: string; iso2: string | null; zone: string; prices: { value: number; note: string }[]; line: number } const parseCountryPages = (file: string, fromPage: number): ParsedRow[] => { const text = readFileSync(file, 'utf8') const pages = text.split('\f') const rows: ParsedRow[] = [] pages.forEach((page, idx) => { if (idx + 1 < fromPage) return page.split('\n').forEach((line, li) => { const m = line.match(ROW_RE) if (!m?.groups) return const name = normalizeDhlCountryName(m.groups.name) if (/^DHL |^WARENPOST |^Land$/i.test(name)) return const tokens = m.groups.nums.trim().split(/\s+(?=\d)/) const prices = tokens.map((t) => { const mm = t.match(/^(\d+,\d{2})(?: (\*\d\)))?$/) if (!mm) throw new Error(`${path.basename(file)} p.${idx + 1}:${li + 1} bad price token "${t}" in "${line.trim()}"`) return { value: num(mm[1]), note: mm[2] || '' } }) rows.push({ name, iso2: dhlCountryToIso2(name), zone: m.groups.zone.trim(), prices, line: li + 1 }) }) }) return rows } const p01 = parseCountryPages(path.join(dir, 'P01.txt'), 3) const p02 = parseCountryPages(path.join(dir, 'P02.txt'), 2) const unresolved = new Set() for (const r of [...p01, ...p02]) if (!r.iso2) unresolved.add(r.name) if (unresolved.size) { console.error('Unresolved country names (add them to app/utils/shippingCountryMap.ts):\n ' + [...unresolved].join('\n ')) process.exit(2) } const dup = (rows: ParsedRow[], label: string) => { const seen = new Map() for (const r of rows) { const k = r.iso2! if (seen.has(k) && seen.get(k) !== r.name) throw new Error(`${label}: ISO ${k} mapped from two names: "${seen.get(k)}" and "${r.name}"`) seen.set(k, r.name) } } dup(p01, 'P01'); dup(p02, 'P02') const rates: FixtureRate[] = [] // P01 → DHL_PAKET_INT_PREMIUM (+ optional ECO) for (const r of p01) { const src = `P01 ${r.name}` const [pb, pk, eb, ek] = r.prices if (!pb || !pk) throw new Error(`P01 ${r.name}: expected ≥ 2 prices`) const premiumNote = [pb.note, pk.note].filter(Boolean).join(' ') rates.push({ serviceCode: 'DHL_PAKET_INT_PREMIUM', rateType: 'base', countryCode: r.iso2, countryNameDe: r.name, zone: r.zone, rangeFrom: 0, rangeTo: 31.5, basePrice: pb.value, pricePerKg: pk.value, description: premiumNote.includes('*3)') ? 'Haustürzuschlag 4,30 € je Sendung (*3)' : '', sourceRef: src }) if (premiumNote.includes('*3)')) { rates.push({ serviceCode: 'DHL_PAKET_INT_PREMIUM', rateType: 'surcharge', countryCode: r.iso2, countryNameDe: r.name, zone: r.zone, rangeFrom: 0, rangeTo: 0, basePrice: 4.30, pricePerKg: 0, description: 'Haustürzuschlag SE/FI', sourceRef: src }) } if (eb && ek) { const ecoNote = [eb.note, ek.note].filter(Boolean).join(' ') rates.push({ serviceCode: 'DHL_PAKET_INT_ECO', rateType: 'base', countryCode: r.iso2, countryNameDe: r.name, zone: r.zone, rangeFrom: 0, rangeTo: 31.5, basePrice: eb.value, pricePerKg: ek.value, description: ecoNote.includes('*2)') ? 'Closest Droppoint: Versandstart länderindividuell (*2)' : '', sourceRef: src }) } else if (r.prices.length === 3) { throw new Error(`P01 ${r.name}: 3 prices, expected 2 or 4`) } } // P02 → DHL_WARENPOST_INT_PREMIUM + _ECO (per 100 g → ×10 per kg) for (const r of p02) { const src = `P02 ${r.name}` const [pb, p100, eb, e100] = r.prices if (!pb || !p100 || !eb || !e100) throw new Error(`P02 ${r.name}: expected 4 prices`) rates.push({ serviceCode: 'DHL_WARENPOST_INT_PREMIUM', rateType: 'base', countryCode: r.iso2, countryNameDe: r.name, zone: r.zone, rangeFrom: 0, rangeTo: 1, basePrice: pb.value, pricePerKg: Math.round(p100.value * 1000) / 100, description: `100g-Preis ${p100.value.toFixed(2).replace('.', ',')} € (×10 als €/kg)`, sourceRef: src }) rates.push({ serviceCode: 'DHL_WARENPOST_INT_ECO', rateType: 'base', countryCode: r.iso2, countryNameDe: r.name, zone: r.zone, rangeFrom: 0, rangeTo: 1, basePrice: eb.value, pricePerKg: Math.round(e100.value * 1000) / 100, description: `100g-Preis ${e100.value.toFixed(2).replace('.', ',')} € (×10 als €/kg)`, sourceRef: src }) } // --------------------------------------------------------------------------- // VERTRAG Anlage 1 (hand-coded, pages 3–9 of VERTRAGSUNTERLAGEN_6380721892_VERTRAG.pdf) // --------------------------------------------------------------------------- const nat = (serviceCode: string, rateType: FixtureRate['rateType'], description: string, basePrice: number, extra: Partial = {}, sourceRef = 'VERTRAG Anlage 1'): FixtureRate => ({ serviceCode, rateType, countryCode: extra.countryCode ?? null, rangeFrom: 0, rangeTo: 0, basePrice, pricePerKg: 0, description, sourceRef, ...extra }) const tier = (serviceCode: string, prevTo: number, to: number, price: number, src: string): FixtureRate => ({ serviceCode, rateType: 'base', countryCode: 'DE', countryNameDe: 'Deutschland', rangeFrom: prevTo === 0 ? 0 : Math.round((prevTo + 0.01) * 100) / 100, rangeTo: to, basePrice: price, pricePerKg: 0, sourceRef: src }) // DHL PAKET national (Abrechnungsnummer 63807218920101, p.3–4) const PAKET_TIERS: [number, number][] = [[2, 4.15], [5, 4.50], [10, 4.99], [20, 5.79], [31.5, 6.99]] let prev = 0 for (const [to, price] of PAKET_TIERS) { rates.push(tier('DHL_PAKET', prev, to, price, 'VERTRAG Anlage 1 p.3')); prev = to } const PAKET_SERVICES: [string, number][] = [ ['Nachnahme (inkl. Nachnahmeübermittlungsentgelt)', 7.49], ['Sperrgut', 20.00], ['Transportversicherung bis 2.500 EUR', 6.99], ['Transportversicherung bis 25.000 EUR', 19.99], ['Ident-Check', 2.99], ['Persönliche Übergabe', 1.69], ['Alterssichtprüfung', 1.19], ['Wunschtag (Liefertag)', 0.99], ['Keine Nachbarschaftszustellung', 0.99], ['Empfängerunterschrift', 0.19], ['Filial-Routing', 1.99], ['Paketstopp', 0.00] ] for (const [d, p] of PAKET_SERVICES) rates.push(nat('DHL_PAKET', 'service', d, p, {}, 'VERTRAG Anlage 1 p.3')) const PAKET_SURCHARGES: [string, number][] = [['Rücksendeentgelt', 4.50], ['Leitcodierung', 0.49], ['Maut- und CO2-Zuschlag', 0.19], ['Peakzuschlag', 0.19]] for (const [d, p] of PAKET_SURCHARGES) rates.push(nat('DHL_PAKET', 'surcharge', d, p, {}, 'VERTRAG Anlage 1 p.3-4')) rates.push(nat('DHL_PAKET', 'surcharge', 'Energiezuschlag (prozentual auf den Basispreis, siehe dhl.de/energiezuschlag)', 0, { isPercentage: true }, 'VERTRAG Anlage 1 p.4')) // DHL RETOURE Online (63807218920701, p.5) rates.push({ serviceCode: 'DHL_RETOURE', rateType: 'base', countryCode: 'DE', countryNameDe: 'Deutschland', rangeFrom: 0, rangeTo: 31.5, basePrice: 5.25, pricePerKg: 0, description: 'Pauschalpreis bis 31,5 kg', sourceRef: 'VERTRAG Anlage 1 p.5' }) for (const [d, p] of [['Sperrgut', 20.00], ['Einzelabholauftrag ohne Etikettendruck über DHL GKP', 1.49], ['Einzelabholauftrag – Beauftragung über Callcenter (zusätzlich)', 2.50]] as [string, number][]) rates.push(nat('DHL_RETOURE', 'service', d, p, {}, 'VERTRAG Anlage 1 p.5')) for (const [d, p] of [['Maut- und CO2-Zuschlag', 0.19], ['Peakzuschlag', 0.19], ['Leitcodierung', 0.49]] as [string, number][]) rates.push(nat('DHL_RETOURE', 'surcharge', d, p, {}, 'VERTRAG Anlage 1 p.5')) rates.push(nat('DHL_RETOURE', 'surcharge', 'Energiezuschlag (prozentual, siehe dhl.de/energiezuschlag)', 0, { isPercentage: true }, 'VERTRAG Anlage 1 p.5')) // DHL Kleinpaket (63807218926201, p.6) rates.push({ serviceCode: 'DHL_KLEINPAKET', rateType: 'base', countryCode: 'DE', countryNameDe: 'Deutschland', rangeFrom: 0, rangeTo: 1, basePrice: 3.19, pricePerKg: 0, description: 'Pauschalpreis bis 1 kg', sourceRef: 'VERTRAG Anlage 1 p.6' }) for (const [d, p] of [['Filial-Routing', 1.49], ['Paketstopp', 0.00]] as [string, number][]) rates.push(nat('DHL_KLEINPAKET', 'service', d, p, {}, 'VERTRAG Anlage 1 p.6')) for (const [d, p] of [['Rücksendeentgelt Kleinpaket', 2.50], ['Peakzuschlag', 0.19], ['Peak-in-Peak Zuschlag', 0.50], ['Leitcodierung', 0.49]] as [string, number][]) rates.push(nat('DHL_KLEINPAKET', 'surcharge', d, p, {}, 'VERTRAG Anlage 1 p.6')) rates.push(nat('DHL_KLEINPAKET', 'surcharge', 'Energiezuschlag (prozentual, siehe dhl.de/energiezuschlag)', 0, { isPercentage: true }, 'VERTRAG Anlage 1 p.6')) // DHL PAKET International / Premium services (63807218925301, p.7) — apply to both international services for (const code of ['DHL_PAKET_INT_PREMIUM', 'DHL_PAKET_INT_ECO']) { for (const [d, p] of [['Nachnahme', 3.60], ['Nachnahme-Übermittlungsentgelt (ohne IBAN/BIC)', 5.00], ['Sperrgut', 21.00], ['Transportversicherung bis 2.500 EUR', 10.00], ['Transportversicherung bis 25.000 EUR', 50.00], ['Vorausverfügung Rücksendung – schnellster Weg (Luft)', 20.00], ['Vorausverfügung Rücksendung – preiswertester Weg (Straße)', 10.00], ['Vorausverfügung Rücksendung – Preisgabe', 0.00], ['Postal Delivered Duty Paid', 4.00]] as [string, number][]) rates.push(nat(code, 'service', d, p, {}, 'VERTRAG Anlage 1 p.7')) for (const [d, p] of [['Leitcodierung', 0.49], ['Maut- und CO2-Zuschlag', 0.19], ['Peakzuschlag', 0.19], ['Peak-in-Peak Zuschlag', 0.50]] as [string, number][]) rates.push(nat(code, 'surcharge', d, p, {}, 'VERTRAG Anlage 1 p.7')) rates.push(nat(code, 'surcharge', 'Transportkostenzuschlag International (siehe dhl.de/transportkostenzuschlag)', 0, { isPercentage: true }, 'VERTRAG Anlage 1 p.7')) rates.push(nat(code, 'surcharge', 'Zolldatennachbearbeitung (siehe dhl.de/zolldatennachbearbeitung)', 0, {}, 'VERTRAG Anlage 1 p.7')) } // WARENPOST International (63807218926601, p.8) for (const code of ['DHL_WARENPOST_INT_PREMIUM', 'DHL_WARENPOST_INT_ECO']) { for (const [d, p] of [['Rücksendeentgelt', 5.00], ['Peakzuschlag', 0.19], ['Peak-in-Peak Zuschlag', 0.50], ['Leitcodierung', 0.49]] as [string, number][]) rates.push(nat(code, 'surcharge', d, p, {}, 'VERTRAG Anlage 1 p.8')) } // Zuschläge Allgemein (p.9) — contract-wide for (const [d, p] of [['Einziehungsentgelt je Einzelsendung (Gewichtsfeststellung)', 2.20], ['Pauschalentgelt bei vorsätzlich falschen Angaben', 18.00], ['Kennzeichnungsentgelt 10-20 kg', 0.49], ['Kennzeichnungsentgelt 20+ kg', 0.49], ['Gewichtskorrekturentgelt 10-20 kg (DHL Paket)', 0.99], ['Gewichtskorrekturentgelt 20+ kg (DHL Paket)', 0.99], ['Bestellpauschale Versandmaterial per E-Mail', 5.00]] as [string, number][]) rates.push(nat('DHL_GENERAL', 'surcharge', d, p, {}, 'VERTRAG Anlage 1 p.9')) // --------------------------------------------------------------------------- const services: FixtureService[] = [ { code: 'DHL_PAKET', name: 'DHL Paket (national)', nameEn: 'DHL Paket (domestic)', scope: 'national', pricing: 'tier', maxWeightKg: 31.5, billingNo: '63807218920101', deliveryDays: { de: '1–2 Werktage, Mo–Sa', en: '1–2 working days, Mon–Sat' } }, { code: 'DHL_KLEINPAKET', name: 'DHL Kleinpaket', nameEn: 'DHL Kleinpaket (small parcel)', scope: 'national', pricing: 'tier', maxWeightKg: 1, billingNo: '63807218926201', deliveryDays: { de: '1–3 Werktage, Mo–Sa', en: '1–3 working days, Mon–Sat' } }, { code: 'DHL_RETOURE', name: 'DHL Retoure Online', nameEn: 'DHL Retoure Online (returns)', scope: 'national', pricing: 'tier', maxWeightKg: 31.5, billingNo: '63807218920701' }, { code: 'DHL_PAKET_INT_PREMIUM', name: 'DHL Paket International Premium', nameEn: 'DHL Paket International Premium', scope: 'international', pricing: 'perkg', maxWeightKg: 31.5, billingNo: '63807218925301', deliveryDays: { de: 'EU 2–5 Werktage, weltweit 4–12 Werktage', en: 'EU 2–5 working days, worldwide 4–12 working days' } }, { code: 'DHL_PAKET_INT_ECO', name: 'DHL Paket International Economy / Closest Droppoint', nameEn: 'DHL Paket International Economy / Closest Droppoint', scope: 'international', pricing: 'perkg', maxWeightKg: 31.5, billingNo: '63807218925301', deliveryDays: { de: 'EU 3–8 Werktage, weltweit 6–20 Werktage', en: 'EU 3–8 working days, worldwide 6–20 working days' }, note: { de: 'Zustellung an den nächstgelegenen Paketshop (Closest Droppoint), länderindividueller Versandstart.', en: 'Delivery to the closest droppoint; availability per country.' } }, { code: 'DHL_WARENPOST_INT_PREMIUM', name: 'Warenpost International Premium', nameEn: 'Warenpost International Premium', scope: 'international', pricing: 'perkg', maxWeightKg: 1, billingNo: '63807218926601', deliveryDays: { de: 'EU 2–6 Werktage, weltweit 5–14 Werktage', en: 'EU 2–6 working days, worldwide 5–14 working days' }, note: { de: 'Bis 1 kg, max. 35,3 × 25 × 10 cm; Abrechnung je angefangene 100 g.', en: 'Up to 1 kg, max. 35.3 × 25 × 10 cm; billed per started 100 g.' } }, { code: 'DHL_WARENPOST_INT_ECO', name: 'Warenpost International Economy', nameEn: 'Warenpost International Economy', scope: 'international', pricing: 'perkg', maxWeightKg: 1, billingNo: '63807218926601', deliveryDays: { de: 'EU 4–10 Werktage, weltweit 8–25 Werktage', en: 'EU 4–10 working days, worldwide 8–25 working days' } }, { code: 'DHL_GENERAL', name: 'Allgemeine Zuschläge (alle Produkte)', nameEn: 'General surcharges (all products)', scope: 'national', pricing: 'tier', maxWeightKg: 0 } ] // Assertions const count = (code: string, type = 'base') => rates.filter((r) => r.serviceCode === code && r.rateType === type).length const summary: Record = {} for (const r of rates) summary[`${r.serviceCode}/${r.rateType}`] = (summary[`${r.serviceCode}/${r.rateType}`] || 0) + 1 console.log('rows per service/type:', summary) const expect = (label: string, got: number, want: number) => { if (got !== want) throw new Error(`${label}: got ${got}, expected ${want}`) } expect('P01 Premium countries', count('DHL_PAKET_INT_PREMIUM'), p01.length) expect('P02 Warenpost Premium countries', count('DHL_WARENPOST_INT_PREMIUM'), p02.length) expect('P02 Warenpost Economy countries', count('DHL_WARENPOST_INT_ECO'), p02.length) expect('national tiers', count('DHL_PAKET'), 5) const keys = new Set() for (const r of rates) { const k = [r.serviceCode, r.rateType, r.countryCode ?? '', r.rangeFrom ?? 0, r.description ?? ''].join('|') if (keys.has(k)) throw new Error('duplicate rate key ' + k) keys.add(k) } const spot = (code: string, iso: string) => rates.find((r) => r.serviceCode === code && r.countryCode === iso && r.rateType === 'base') console.log('spot checks:', { AT: spot('DHL_PAKET_INT_PREMIUM', 'AT'), CH_eco: spot('DHL_PAKET_INT_ECO', 'CH'), GB_eco: spot('DHL_PAKET_INT_ECO', 'GB') ?? 'none (expected)', BE_wp: spot('DHL_WARENPOST_INT_PREMIUM', 'BE'), SE_haustuer: rates.find((r) => r.countryCode === 'SE' && r.rateType === 'surcharge') }) const fixture: CarrierRateFixture = { fixture: 'dhl-2025-07', shipperId: SHIPPER_ID, shipperName: 'DHL', contractNo: CONTRACT_NO, validFrom: VALID_FROM, generatedAt: new Date().toISOString(), source: ['VERTRAGSUNTERLAGEN_6380721892_VERTRAG.pdf (Anlage 1, 03.06.2025)', 'VERTRAGSUNTERLAGEN_6380721892_P01.pdf', 'VERTRAGSUNTERLAGEN_6380721892_P02.pdf'], services, rates, unresolvedCountries: [] } mkdirSync(path.dirname(out), { recursive: true }) writeFileSync(out, JSON.stringify(fixture, null, 1)) console.log(`wrote ${rates.length} rates (${p01.length} P01 countries, ${p02.length} P02 countries) → ${out}`)