/** * Build the DPD carrier-rate fixture from the myDPD price lists * (reference/shippingprices/dpd/DPD-DE G300.pdf + MDB-Gesamt_V05 2026-04.pdf). * * The tables are HAND-CODED below (the two-column / three-column layouts of * pdftotext are too irregular to parse safely); the country → zone table of * pages 9–11 is parsed with a regex and cross-checked against the zone headers. * * Services (shipper DPD 1000003, M_ShipperCfg "DPD"): * DPD_CLASSIC national flat tiers (bis 3/5/10/15/20/25/31.5 kg) * DPD_SHOP_DELIVERY national "Direkte Paketshop Zustellung" (bis 20 kg) * DPD_CLASSIC_EU Europe road, per country (zone 1-1 … 1-7), tiers 2…31.5 kg * DPD_SHOP_DELIVERY_EU Europe Paketshop, per country (zone 1-1 … 1-6), tiers 2…20 kg * DPD_RETOURE_SHOP_EU return via Pickup Paketshop, per country, tiers 3…20 kg * DPD_PICKUP_EU Rückholung (collection at the consignee), per country, flat ≤ 31.5 kg * DPD_EXPRESS_EU Express Europe — ZONE-level rows (country NULL, Zone 1-1 … 1-7), tiers 0.5…31.5 kg * DPD_EXPRESS_INT Express international — ZONE-level rows (Zone 2 … 7) * DPD_GENERAL product-independent surcharges * The fixture also carries `countryZones` (ISO2 → zone) for the zone-level services. * * Run from the repo root: * pdftotext -layout "reference/shippingprices/dpd/MDB-Gesamt_V05 2026-04.pdf" /tmp/dpd/MDB.txt * npx tsx reference/test-scripts/dpd-pricelist-parse.mts /tmp/dpd --out server/assets/shipping/dpd-pricelist-2026-04.json * Writes ONLY the JSON fixture. No database access. */ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs' import path from 'node:path' 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/dpd-pricelist-2026-04.json' if (!dir) { console.error('usage: dpd-pricelist-parse.mts [--out file.json]'); process.exit(1) } const SHIPPER_ID = 1000003 const VALID_FROM = '2026-04-01' const CONTRACT_NO = 'myDPD' const rates: FixtureRate[] = [] const step = (prevTo: number) => (prevTo === 0 ? 0 : Math.round((prevTo + 0.01) * 100) / 100) // --------------------------------------------------------------------------- // Zone membership (road zones, from the table headers of MDB p.1/p.2/p.3) // --------------------------------------------------------------------------- const CLASSIC_ZONES: Record = { '1-1': ['BE', 'LU', 'NL', 'AT'], '1-2': ['DK', 'LI', 'CH', 'CZ'], '1-3': ['FR', 'GB', 'MC'], '1-4': ['IT', 'PL', 'SK', 'SI', 'HU', 'VA'], '1-5': ['IE', 'PT', 'SE', 'ES'], '1-6': ['EE', 'FI', 'HR', 'LV', 'LT', 'NO'], '1-7': ['BA', 'BG', 'GR', 'IS', 'RO', 'RS'] } const SHOP_ZONES: Record = { '1-1': ['BE', 'LU', 'NL', 'AT'], '1-2': ['DK', 'CH', 'CZ'], '1-3': ['FR'], '1-4': ['IT', 'PL', 'SK', 'SI', 'HU'], '1-5': ['PT', 'SE', 'ES'], '1-6': ['EE', 'FI', 'HR', 'LV', 'LT', 'NO'] } const RETOURE_GROUPS: [string, string[], number[]][] = [ ['1-1', ['AT', 'BE', 'LU', 'NL'], [10.45, 11.50, 13.40, 16.90, 19.90]], ['1-2', ['CZ', 'DK'], [12.60, 13.90, 15.40, 18.90, 22.05]], ['1-3', ['FR'], [16.10, 17.80, 19.90, 24.20, 28.10]], ['1-4', ['HU', 'IT', 'PL', 'SK'], [19.40, 21.40, 23.90, 28.90, 32.90]], ['1-5', ['ES', 'IE', 'PT', 'SE'], [22.40, 25.00, 28.00, 32.90, 37.30]], ['1-6', ['EE', 'FI', 'LT', 'LV'], [27.40, 30.40, 33.70, 38.40, 43.60]] ] const PICKUP_GROUPS: [string, string[], number][] = [ ['1-1', ['AT', 'BE', 'LU', 'NL'], 18.00], ['1-2', ['CZ', 'DK'], 20.00], ['1-3', ['FR', 'MC'], 23.00], ['1-4', ['HU', 'IT', 'PL', 'SK', 'SI'], 28.00], ['1-5', ['ES', 'IE', 'PT', 'SE'], 35.00], ['1-6', ['EE', 'FI', 'HR', 'LT', 'LV'], 38.00], ['1-7', ['BG', 'RO'], 54.00] ] // --------------------------------------------------------------------------- // National (G300 p.1) // --------------------------------------------------------------------------- const NAT_TIERS = [3, 5, 10, 15, 20, 25, 31.5] const CLASSIC_NAT = [3.00, 3.60, 4.10, 4.60, 4.80, 5.00, 5.30] const SHOP_NAT = [2.55, 3.06, 3.49, 3.91, 4.08] // bis 20 kg, no service above const pushTiers = (serviceCode: string, countryCode: string | null, zone: string, tiers: number[], prices: number[], src: string) => { let prev = 0 prices.forEach((p, i) => { rates.push({ serviceCode, rateType: 'base', countryCode, zone, rangeFrom: step(prev), rangeTo: tiers[i], basePrice: p, pricePerKg: 0, sourceRef: src }) prev = tiers[i] }) } pushTiers('DPD_CLASSIC', 'DE', '', NAT_TIERS, CLASSIC_NAT, 'G300 p.1') pushTiers('DPD_SHOP_DELIVERY', 'DE', '', NAT_TIERS.slice(0, 5), SHOP_NAT, 'G300 p.1') const svc = (code: string, d: string, p: number, extra: Partial = {}, src = 'G300 p.1'): FixtureRate => ({ serviceCode: code, rateType: 'service', countryCode: null, rangeFrom: 0, rangeTo: 0, basePrice: p, pricePerKg: 0, description: d, sourceRef: src, ...extra }) const sur = (code: string, d: string, p: number, extra: Partial = {}, src = 'G300 p.1'): FixtureRate => ({ ...svc(code, d, p, extra, src), rateType: 'surcharge' }) // Options national for (const [d, p] of [['Predict (Zustellbenachrichtigung)', 0], ['DPD Express – Zustellung am nächsten Werktag bis 18:00', 7.50], ['Express – Zustellung am nächsten Werktag vor 12:00', 10.00], ['Express – Zustellung am nächsten Werktag vor 8:30', 46.20], ['Express Samstagszustellung', 17.00], ['DPD Food', 0.50], ['DPD Food Express (nächster Werktag bis 18:00)', 8.00], ['DPD Food 12:00 (nächster Werktag vor 12:00)', 10.50], ['Retourenservice – Abgabe im Pickup Paketshop', 0.80], ['Rückholung – Abholung beim Kunden Mo–Fr 9–17 Uhr', 3.20], ['Gefahrgut LQ (Limited Quantity)', 3.50], ['ID-Check', 6.50], ['Austausch', 7.00]] as [string, number][]) rates.push(svc('DPD_CLASSIC', d, p)) rates.push(svc('DPD_CLASSIC', 'Höherversicherung bis 13.000 € – 0,15 % des Warenwerts über 520 € (normale Ware), 0,35 % (braune/weiße Ware), zzgl. 19 % Versicherungssteuer', 0.15, { isPercentage: true })) // Optional + standard surcharges national for (const [d, p] of [['Inselzustellung', 15.00], ['Nicht förderbandfähiges Gut / NC-Zuschlag, Versand von Reifen', 7.00], ['Übergröße / Übergewicht', 43.00], ['Paket nicht zustellbar, Adress-/PLZ-Fehler', 7.00], ['Fehlerhafte oder nicht geroutete Pakete', 1.30], ['Fehlende oder unzureichende Avisdaten', 1.30], ['Erneuter Zustellversuch (Privatadresse ohne Predict)', 3.00], ['Mautzuschlag (Lkw-Maut)', 0.35], ['Sicherheitsgebühr', 0.15]] as [string, number][]) rates.push(sur('DPD_CLASSIC', d, p)) rates.push(sur('DPD_CLASSIC', 'Energiezuschlag Straße (variabel, siehe dpd.de/energiezuschlag)', 0, { isPercentage: true })) rates.push(sur('DPD_CLASSIC', 'Emergency Fuel Surcharge (variabel, siehe dpd.de/emergency-fuel-surcharge)', 0, { isPercentage: true })) for (const [d, p] of [['Servicepauschale monatlich: < 100 Pakete/Monat', 7.50], ['Servicepauschale monatlich: < 50 Pakete/Monat', 12.50], ['Servicepauschale monatlich: < 25 Pakete/Monat', 20.00], ['Servicepauschale monatlich: 0 Pakete/Monat', 10.00]] as [string, number][]) rates.push(sur('DPD_GENERAL', d, p)) // --------------------------------------------------------------------------- // Europe road (MDB p.1 CLASSIC, p.2 Paketshop) — expanded per country // --------------------------------------------------------------------------- const EU_TIERS = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 25, 31.5] const CLASSIC_EU: Record = { '1-1': [8.11, 8.81, 9.49, 10.23, 11.13, 12.01, 12.92, 13.79, 14.68, 15.56, 17.66, 19.82], '1-2': [9.25, 10.04, 10.81, 11.64, 12.60, 13.53, 14.49, 15.41, 16.35, 17.27, 19.43, 21.67], '1-3': [12.14, 13.17, 14.09, 15.05, 16.18, 17.32, 18.44, 19.61, 20.75, 21.88, 24.09, 26.45], '1-4': [14.87, 16.01, 17.04, 18.10, 19.33, 20.55, 21.78, 23.06, 24.29, 25.52, 27.88, 30.43], '1-5': [17.87, 19.14, 20.26, 21.43, 22.66, 23.88, 25.11, 26.41, 27.63, 28.86, 31.28, 33.92], '1-6': [21.18, 22.61, 23.83, 25.11, 26.54, 27.96, 29.38, 30.88, 32.30, 33.73, 36.32, 39.14], '1-7': [37.96, 39.93, 41.89, 44.02, 46.56, 49.01, 51.57, 54.02, 56.46, 58.92, 63.90, 70.16] } const SHOP_EU: Record = { '1-1': [7.70, 8.37, 9.02, 9.72, 10.57, 11.41, 12.27, 13.10, 13.95, 14.78], '1-2': [8.79, 9.54, 10.27, 11.06, 11.97, 12.85, 13.77, 14.64, 15.53, 16.41], '1-3': [11.53, 12.51, 13.39, 14.30, 15.37, 16.45, 17.52, 18.63, 19.71, 20.79], '1-4': [14.13, 15.21, 16.19, 17.20, 18.36, 19.52, 20.69, 21.91, 23.08, 24.24], '1-5': [16.98, 18.18, 19.25, 20.36, 21.53, 22.69, 23.85, 25.09, 26.25, 27.42], '1-6': [20.12, 21.48, 22.64, 23.85, 25.21, 26.56, 27.91, 29.34, 30.69, 32.04] } for (const [zone, isos] of Object.entries(CLASSIC_ZONES)) for (const iso of isos) pushTiers('DPD_CLASSIC_EU', iso, zone, EU_TIERS, CLASSIC_EU[zone], 'MDB p.1') for (const [zone, isos] of Object.entries(SHOP_ZONES)) for (const iso of isos) pushTiers('DPD_SHOP_DELIVERY_EU', iso, zone, EU_TIERS.slice(0, 10), SHOP_EU[zone], 'MDB p.2') for (const [zone, isos, prices] of RETOURE_GROUPS) for (const iso of isos) pushTiers('DPD_RETOURE_SHOP_EU', iso, zone, [3, 5, 10, 15, 20], prices, 'MDB p.3') for (const [zone, isos, price] of PICKUP_GROUPS) for (const iso of isos) rates.push({ serviceCode: 'DPD_PICKUP_EU', rateType: 'base', countryCode: iso, zone, rangeFrom: 0, rangeTo: 31.5, basePrice: price, pricePerKg: 0, description: 'Rückholung inkl. Paketpreis, bis 31,5 kg', sourceRef: 'MDB p.3' }) // --------------------------------------------------------------------------- // Express (MDB p.4 Europe zones 1-1…1-7, p.5 international zones 2…7) — ZONE-level rows // --------------------------------------------------------------------------- const EXPRESS_TIERS = [0.5, 1, 1.5, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31.5] const EXPRESS_EU: Record = { '1-1': [44.85, 48.18, 50.85, 53.52, 56.42, 59.03, 61.66, 64.46, 67.26, 70.08, 72.89, 76.11, 78.73, 81.38, 84.04, 86.74, 89.45, 92.20, 94.96, 97.74, 100.56, 103.38, 106.20, 109.03, 111.88, 114.77, 117.66, 120.59, 123.54, 126.51, 129.51, 132.52, 136.85], '1-2': [45.93, 50.34, 53.56, 56.79, 62.69, 68.12, 73.55, 78.24, 82.91, 87.59, 92.25, 97.42, 100.59, 103.80, 107.04, 110.30, 113.59, 116.90, 120.25, 123.61, 127.01, 130.43, 134.06, 137.73, 141.42, 145.14, 148.90, 152.67, 156.48, 160.31, 164.19, 168.07, 173.81], '1-3': [48.95, 53.64, 57.09, 60.54, 66.81, 72.61, 78.39, 83.38, 88.36, 93.35, 98.33, 104.08, 107.75, 111.46, 115.21, 118.99, 122.83, 126.70, 130.62, 134.59, 138.59, 142.63, 146.93, 151.27, 155.66, 160.09, 164.57, 169.09, 173.66, 178.28, 182.93, 187.64, 194.41], '1-4': [50.31, 55.40, 59.61, 63.82, 70.59, 77.70, 84.80, 90.30, 95.81, 101.30, 106.82, 112.99, 117.18, 121.42, 125.71, 130.03, 134.40, 138.81, 143.26, 147.75, 152.28, 156.85, 161.41, 166.02, 170.66, 175.35, 180.08, 184.83, 189.65, 194.49, 199.37, 204.31, 211.39], '1-5': [52.46, 57.77, 62.17, 66.56, 73.63, 81.03, 88.44, 94.17, 99.92, 105.66, 111.41, 117.90, 122.38, 126.90, 131.46, 136.08, 140.74, 145.45, 150.20, 155.00, 159.86, 164.76, 169.65, 174.59, 179.58, 184.62, 189.71, 194.85, 200.03, 205.24, 210.51, 215.84, 223.44], '1-6': [54.62, 60.15, 64.72, 69.30, 76.66, 84.37, 92.06, 98.06, 104.03, 110.01, 115.98, 122.84, 127.58, 132.37, 137.21, 142.12, 147.06, 152.07, 157.14, 162.26, 167.43, 172.66, 177.89, 183.17, 188.51, 193.90, 199.34, 204.84, 210.40, 216.01, 221.67, 227.38, 235.49], '1-7': [57.86, 63.72, 68.57, 73.41, 81.20, 89.37, 97.52, 103.87, 110.20, 116.53, 122.87, 130.28, 135.48, 140.75, 146.08, 151.48, 156.94, 162.49, 168.09, 173.76, 179.51, 185.31, 191.13, 197.02, 202.97, 208.99, 215.09, 221.25, 227.47, 233.76, 240.12, 246.55, 255.59] } const EXPRESS_EU_FOLLOW: Record = { '1-1': [2.86, 6.71], '1-2': [2.97, 7.96], '1-3': [3.27, 9.26], '1-4': [3.54, 10.35], '1-5': [3.59, 10.65], '1-6': [3.65, 11.51], '1-7': [3.90, 11.95] } const EXPRESS_INT: Record = { '2': [68.66, 73.62, 81.44, 89.25, 112.98, 129.19, 145.40, 156.10, 166.79, 177.49, 188.19, 199.80, 206.08, 212.41, 218.79, 225.22, 231.70, 238.22, 244.80, 251.42, 258.09, 264.81, 271.14, 277.52, 283.93, 290.40, 296.91, 303.45, 310.06, 316.70, 323.38, 330.12, 339.59], '3': [73.47, 82.75, 91.07, 99.77, 119.87, 134.38, 151.67, 162.03, 174.53, 187.03, 199.53, 212.57, 218.95, 225.35, 231.79, 238.25, 244.75, 251.27, 257.83, 264.41, 271.03, 277.67, 287.37, 297.12, 306.90, 316.73, 326.61, 336.53, 346.50, 356.52, 366.58, 376.68, 386.38], '4': [63.70, 69.38, 74.01, 78.67, 86.71, 95.67, 104.31, 110.92, 117.51, 124.11, 130.71, 137.53, 144.65, 149.28, 153.92, 158.58, 163.26, 167.94, 172.64, 177.36, 182.09, 186.83, 195.75, 201.41, 207.10, 212.79, 220.75, 228.83, 237.04, 245.38, 253.85, 262.44, 273.45], '5': [75.83, 88.41, 97.79, 107.18, 124.77, 142.68, 161.28, 173.96, 186.76, 199.67, 212.68, 226.36, 234.04, 241.81, 249.67, 257.61, 265.64, 273.75, 281.94, 290.22, 298.59, 307.03, 315.06, 323.16, 331.34, 341.79, 350.16, 358.61, 367.15, 375.76, 384.45, 393.22, 405.28], '6': [79.97, 95.31, 106.93, 118.53, 141.71, 164.21, 186.72, 203.09, 219.46, 235.82, 252.19, 269.00, 276.99, 285.00, 293.04, 301.10, 309.19, 317.30, 325.44, 333.60, 341.79, 350.00, 357.56, 365.14, 372.74, 380.37, 389.40, 398.49, 407.65, 416.90, 426.21, 435.60, 449.02], '7': [83.66, 100.07, 114.34, 128.60, 155.76, 181.92, 208.09, 226.95, 245.81, 264.67, 283.53, 302.65, 311.93, 321.22, 330.53, 339.84, 349.19, 358.54, 367.91, 377.30, 386.70, 396.11, 403.39, 410.69, 417.99, 425.31, 434.29, 443.32, 452.42, 461.58, 470.82, 480.12, 493.44] } const EXPRESS_INT_FOLLOW: Record = { '2': [4.38, 13.10], '3': [4.95, 15.85], '4': [4.08, 11.90], '5': [4.47, 13.62], '6': [4.70, 14.33], '7': [5.99, 21.75] } const pushExpress = (code: string, table: Record, follow: Record, src: string) => { for (const [zone, prices] of Object.entries(table)) { if (prices.length !== EXPRESS_TIERS.length) throw new Error(`${code} zone ${zone}: ${prices.length} prices, expected ${EXPRESS_TIERS.length}`) pushTiers(code, null, zone, EXPRESS_TIERS, prices, src) const [f80, f315] = follow[zone] rates.push(svc(code, `Folgerate je weiteres volles kg über 31,5 kg bis 80 kg (Zone ${zone})`, f80, { zone }, src)) rates.push(svc(code, `Folgerate je weiteres volles kg über 80 kg bis 315 kg (Zone ${zone})`, f315, { zone }, src)) } } pushExpress('DPD_EXPRESS_EU', EXPRESS_EU, EXPRESS_EU_FOLLOW, 'MDB p.4') pushExpress('DPD_EXPRESS_INT', EXPRESS_INT, EXPRESS_INT_FOLLOW, 'MDB p.5') rates.push(svc('DPD_EXPRESS_EU', 'DPD 12:00 – Express Zeitzustellung (BE, NL, LU, CH Raum Basel/Zürich/Rheinfelden)', 21.00, {}, 'MDB p.6')) rates.push(sur('DPD_EXPRESS_EU', 'Überlänge > 100 cm (Luftweg)', 90.00, {}, 'MDB p.6')) rates.push(sur('DPD_EXPRESS_EU', 'Nicht stapelbares Paket (nicht BNL, CH, LI)', 200.00, {}, 'MDB p.6')) rates.push(sur('DPD_EXPRESS_INT', 'Krisengebiete (je Lieferung)', 25.00, {}, 'MDB p.6')) // --------------------------------------------------------------------------- // International surcharges / customs (MDB p.3, p.7) // --------------------------------------------------------------------------- for (const [d, p] of [['Mautzuschlag (Lkw-Maut BFStrMG)', 0.35], ['Nicht förderbandfähiges Gut / NC-Zuschlag, Versand von Reifen', 7.00], ['Übergröße / Übergewicht (zzgl. zur kg-Rate)', 43.00], ['Speditionszuschlag (> 40 kg, > 250 cm, Gurtmaß > 330 cm)', 65.00], ['Fehlende oder unvollständige Exportpapiere / Daten', 35.00], ['Paket nicht zustellbar, Adress-/PLZ-Fehler international', 14.00], ['Fehlerhafte oder nicht geroutete Pakete', 1.30], ['Inselzuschlag international', 30.00], ['Sicherheitsgebühr', 0.15], ['Gefahrgut LQ (Limited Quantity)', 3.50], ['Fehlende oder unzureichende Avisdaten', 1.30], ['Erneuter Zustellversuch (Privatadresse ohne Predict)', 3.00]] as [string, number][]) rates.push(sur('DPD_CLASSIC_EU', d, p, {}, 'MDB p.7')) rates.push(sur('DPD_CLASSIC_EU', 'Energiezuschlag Straße (variabel, siehe dpd.de/energiezuschlag)', 0, { isPercentage: true }, 'MDB p.7')) rates.push(sur('DPD_CLASSIC_EU', 'Emergency Fuel Surcharge (variabel)', 0, { isPercentage: true }, 'MDB p.7')) rates.push(sur('DPD_CLASSIC_EU', 'Höherversicherung bis 13.000 € – 0,15 % / 0,35 % des Warenwerts über 520 € zzgl. 19 % Versicherungssteuer', 0.15, { isPercentage: true }, 'MDB p.7')) for (const iso of ['CH', 'LI']) rates.push(sur('DPD_CLASSIC_EU', 'Währungszuschlag 19 % zzgl. zur Frachtrate', 19, { isPercentage: true, countryCode: iso }, 'MDB p.7')) rates.push(svc('DPD_CLASSIC_EU', 'Zollabfertigung: DAP unverzollt (Frankatur 2)', 0, {}, 'MDB p.3')) rates.push(svc('DPD_CLASSIC_EU', 'Zollabfertigung: DAP zollabgefertigt / enhanced DAP (Frankatur 3, 5, 6)', 6.45, { countryCode: 'GB' }, 'MDB p.3')) for (const iso of ['CH', 'IS', 'LI', 'NO']) rates.push(svc('DPD_CLASSIC_EU', 'Zollabfertigung: DAP zollabgefertigt / DDP verzollt (Frankatur 3, 4, 5)', 26.00, { countryCode: iso }, 'MDB p.3')) rates.push(svc('DPD_CLASSIC_EU', 'Sondergebiete (Campione d’Italia, Livigno, Jan Mayen, Spitzbergen, Martina, Samnaun)', 52.00, {}, 'MDB p.3')) rates.push(sur('DPD_CLASSIC_EU', 'Brexit-Zuschlag', 6.45, { countryCode: 'GB' }, 'MDB p.6')) rates.push(svc('DPD_CLASSIC_EU', 'Abfertigungsgebühr Ausfuhr Norwegen (DAP unverzollt)', 13.00, { countryCode: 'NO' }, 'MDB p.3')) // --------------------------------------------------------------------------- // Country → zone (MDB p.9–11): " " // --------------------------------------------------------------------------- const ISO3_TO_ISO2: Record = { AFG: 'AF', EGY: 'EG', ALB: 'AL', DZA: 'DZ', AND: 'AD', AGO: 'AO', AIA: 'AI', ATG: 'AG', ANT: 'AN', ARG: 'AR', ARM: 'AM', ABW: 'AW', AZE: 'AZ', ETH: 'ET', AUS: 'AU', BHS: 'BS', BHR: 'BH', BGD: 'BD', BRB: 'BB', BEL: 'BE', BLZ: 'BZ', BEN: 'BJ', BMU: 'BM', BES: 'BQ', BTN: 'BT', BOL: 'BO', BIH: 'BA', BWA: 'BW', BRA: 'BR', BRN: 'BN', BGR: 'BG', BFA: 'BF', BDI: 'BI', ESP: 'ES', CHL: 'CL', CHN: 'CN', COK: 'CK', CRI: 'CR', CUW: 'CW', DNK: 'DK', DOM: 'DO', DMA: 'DM', DJI: 'DJ', ECU: 'EC', SLV: 'SV', CIV: 'CI', ERI: 'ER', EST: 'EE', FLK: 'FK', FRO: 'FO', FJI: 'FJ', FIN: 'FI', FRA: 'FR', PYF: 'PF', GAB: 'GA', GMB: 'GM', GEO: 'GE', GHA: 'GH', GIB: 'GI', GRD: 'GD', GRC: 'GR', GRL: 'GL', GBR: 'GB', GLP: 'GP', GUM: 'GU', GTM: 'GT', GUF: 'GF', GIN: 'GN', GNQ: 'GQ', GNB: 'GW', GUY: 'GY', HTI: 'HT', HND: 'HN', HKG: 'HK', IND: 'IN', IDN: 'ID', IRQ: 'IQ', IRL: 'IE', ISL: 'IS', ISR: 'IL', ITA: 'IT', JAM: 'JM', JPN: 'JP', YEM: 'YE', JOR: 'JO', CYM: 'KY', KHM: 'KH', CMR: 'CM', CAN: 'CA', CPV: 'CV', KAZ: 'KZ', QAT: 'QA', KEN: 'KE', KGZ: 'KG', KIR: 'KI', CCK: 'CC', COL: 'CO', COM: 'KM', KOR: 'KR', XKX: 'XK', HRV: 'HR', KWT: 'KW', LAO: 'LA', LSO: 'LS', LVA: 'LV', LBN: 'LB', LBR: 'LR', LBY: 'LY', LIE: 'LI', LTU: 'LT', LUX: 'LU', MAC: 'MO', MDG: 'MG', MWI: 'MW', MYS: 'MY', MDV: 'MV', MLI: 'ML', MLT: 'MT', MNP: 'MP', MAR: 'MA', MHL: 'MH', MTQ: 'MQ', MRT: 'MR', MUS: 'MU', MYT: 'YT', MEX: 'MX', FSM: 'FM', MDA: 'MD', MCO: 'MC', MNG: 'MN', MNE: 'ME', MSR: 'MS', MOZ: 'MZ', NAM: 'NA', NRU: 'NR', NPL: 'NP', NCL: 'NC', NZL: 'NZ', NIC: 'NI', NLD: 'NL', NER: 'NE', NGA: 'NG', NIU: 'NU', MKD: 'MK', NFK: 'NF', NOR: 'NO', OMN: 'OM', AUT: 'AT', PAK: 'PK', PSE: 'PS', PLW: 'PW', PAN: 'PA', PNG: 'PG', PRY: 'PY', PER: 'PE', PHL: 'PH', POL: 'PL', PRT: 'PT', PRI: 'PR', REU: 'RE', RWA: 'RW', ROU: 'RO', RUS: 'RU', BLM: 'BL', KNA: 'KN', LCA: 'LC', MAF: 'MF', VCT: 'VC', SPM: 'PM', SLB: 'SB', ZMB: 'ZM', ASM: 'AS', WSM: 'WS', SMR: 'SM', STP: 'ST', SAU: 'SA', SWE: 'SE', CHE: 'CH', SEN: 'SN', SRB: 'RS', SYC: 'SC', SLE: 'SL', ZWE: 'ZW', SGP: 'SG', SXM: 'SX', SVK: 'SK', SVN: 'SI', SOM: 'SO', LKA: 'LK', ZAF: 'ZA', SDN: 'SD', SUR: 'SR', SWZ: 'SZ', TJK: 'TJ', TWN: 'TW', TZA: 'TZ', THA: 'TH', TLS: 'TL', TGO: 'TG', TKL: 'TK', TON: 'TO', TTO: 'TT', TCD: 'TD', CZE: 'CZ', TUN: 'TN', TUR: 'TR', TCA: 'TC', TUV: 'TV', UGA: 'UG', UKR: 'UA', HUN: 'HU', URY: 'UY', USA: 'US', UZB: 'UZ', VUT: 'VU', VAT: 'VA', VEN: 'VE', ARE: 'AE', VNM: 'VN', VIR: 'VI', VGB: 'VG', WLF: 'WF', CXR: 'CX', BLR: 'BY', CAF: 'CF', CYP: 'CY', IM: 'IM', CD: 'CD', CG: 'CG', JE: 'JE' } const countryZones: Record = {} const mdb = readFileSync(path.join(dir, 'MDB.txt'), 'utf8').split('\f').slice(8, 11).join('\n') const re = /\b(ANT|BES|XKX|[A-Z]{3}|IM|CD|CG|JE)\s+(1-[1-7]|[2-7])\s+(?:\d(?:-\d)?|-)\s/g let m: RegExpExecArray | null while ((m = re.exec(mdb))) { const iso2 = ISO3_TO_ISO2[m[1]] if (!iso2) { console.warn('unknown ISO3', m[1]); continue } if (!countryZones[iso2]) countryZones[iso2] = m[2] } // Zone-header countries must agree with the parsed table (road zones). for (const [zone, isos] of Object.entries(CLASSIC_ZONES)) for (const iso of isos) { if (countryZones[iso] && countryZones[iso] !== zone) console.warn(`zone mismatch ${iso}: header ${zone} vs table ${countryZones[iso]}`) countryZones[iso] = zone } countryZones['DE'] = countryZones['DE'] || 'national' const services: FixtureService[] = [ { code: 'DPD_CLASSIC', name: 'DPD Classic (national)', nameEn: 'DPD Classic (domestic)', scope: 'national', pricing: 'tier', maxWeightKg: 31.5, deliveryDays: { de: '1–2 Werktage, Mo–Fr (Predict)', en: '1–2 working days, Mon–Fri (Predict)' }, note: { de: 'Max. Länge 175 cm, Gurtmaß 300 cm; Versicherung bis 520 € inklusive.', en: 'Max. length 175 cm, girth 300 cm; insurance up to €520 included.' } }, { code: 'DPD_SHOP_DELIVERY', name: 'DPD Direkte Paketshop Zustellung (national)', nameEn: 'DPD direct parcel-shop delivery (domestic)', scope: 'national', pricing: 'tier', maxWeightKg: 20, deliveryDays: { de: '1–2 Werktage', en: '1–2 working days' }, note: { de: 'Max. Länge 100 cm, Gurtmaß 250 cm, 20 kg.', en: 'Max. length 100 cm, girth 250 cm, 20 kg.' } }, { code: 'DPD_CLASSIC_EU', name: 'DPD Classic (Europa, Straße)', nameEn: 'DPD Classic (Europe, road)', scope: 'international', pricing: 'tier', maxWeightKg: 31.5, deliveryDays: { de: '2–5 Werktage je Zone', en: '2–5 working days per zone' } }, { code: 'DPD_SHOP_DELIVERY_EU', name: 'DPD Direkte Paketshop Zustellung (Europa)', nameEn: 'DPD direct parcel-shop delivery (Europe)', scope: 'international', pricing: 'tier', maxWeightKg: 20, deliveryDays: { de: '2–5 Werktage je Zone', en: '2–5 working days per zone' } }, { code: 'DPD_RETOURE_SHOP_EU', name: 'DPD Retoure zur Abgabe im Pickup Paketshop (Europa)', nameEn: 'DPD return via Pickup parcel shop (Europe)', scope: 'international', pricing: 'tier', maxWeightKg: 20 }, { code: 'DPD_PICKUP_EU', name: 'DPD Rückholung (Europa)', nameEn: 'DPD collection request (Europe)', scope: 'international', pricing: 'tier', maxWeightKg: 31.5, note: { de: 'Inkl. Paketpreis; nicht aus Drittländern.', en: 'Incl. parcel price; not from third countries.' } }, { code: 'DPD_EXPRESS_EU', name: 'DPD Express (Europa)', nameEn: 'DPD Express (Europe)', scope: 'international', pricing: 'tier', maxWeightKg: 31.5, deliveryDays: { de: 'Nächster Werktag bis 18 Uhr (EU)', en: 'Next working day by 6 pm (EU)' }, note: { de: 'Zonenpreise; Volumengewicht 1:5; max. Länge 100 cm.', en: 'Zone prices; volumetric weight 1:5; max. length 100 cm.' } }, { code: 'DPD_EXPRESS_INT', name: 'DPD Express (international)', nameEn: 'DPD Express (international)', scope: 'international', pricing: 'tier', maxWeightKg: 31.5, deliveryDays: { de: '2–7 Werktage je Zone', en: '2–7 working days per zone' }, note: { de: 'Zonenpreise (Luftfracht); Volumengewicht 1:5.', en: 'Zone prices (air); volumetric weight 1:5.' } }, { code: 'DPD_GENERAL', name: 'Allgemeine Zuschläge (alle Produkte)', nameEn: 'General surcharges (all products)', scope: 'national', pricing: 'tier', maxWeightKg: 0 } ] // Assertions const keys = new Set() for (const r of rates) { const k = [r.serviceCode, r.rateType, r.countryCode ?? '', r.zone ?? '', r.rangeFrom ?? 0, r.description ?? ''].join('|') if (keys.has(k)) throw new Error('duplicate rate key ' + k) keys.add(k) } 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) console.log('country zones parsed:', Object.keys(countryZones).length, 'e.g.', { US: countryZones.US, CH: countryZones.CH, AT: countryZones.AT, JP: countryZones.JP, AU: countryZones.AU }) const spot = (code: string, iso: string | null, to: number) => rates.find((r) => r.serviceCode === code && (r.countryCode ?? null) === iso && r.rateType === 'base' && r.rangeTo === to) console.log('spot:', { AT_classic_2kg: spot('DPD_CLASSIC_EU', 'AT', 2)?.basePrice, CH_shop_20kg: spot('DPD_SHOP_DELIVERY_EU', 'CH', 20)?.basePrice, DE_classic_10kg: spot('DPD_CLASSIC', 'DE', 10)?.basePrice, express_1_1_5kg: rates.find((r) => r.serviceCode === 'DPD_EXPRESS_EU' && r.zone === '1-1' && r.rangeTo === 5)?.basePrice }) const fixture: CarrierRateFixture = { fixture: 'dpd-2026-04', shipperId: SHIPPER_ID, shipperName: 'DPD', contractNo: CONTRACT_NO, validFrom: VALID_FROM, generatedAt: new Date().toISOString(), source: ['DPD-DE G300.pdf (Preisliste nationaler Versand, 2026/04)', 'MDB-Gesamt_V05 2026-04.pdf (Preisliste myDPD Classic/Express, 2026/04)'], services, rates, unresolvedCountries: [], countryZones } mkdirSync(path.dirname(out), { recursive: true }) writeFileSync(out, JSON.stringify(fixture, null, 1)) console.log(`wrote ${rates.length} rates → ${out}`)