/**
* 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}`)