/** * Shared types + pure helpers for carrier contract rates (CUST_CarrierRate), the * DHL fixture and the shipping price sheet. Imported by RELATIVE path from the * server (routes/utils) and the client (pages/components) — no Nuxt auto-imports. * * Three tables: * CUST_CarrierRate contract / purchase prices per carrier service (cost) * CUST_ShippingCost_Default Tier 1 sell prices (global), billed by calculate.py * CUST_ShippingCost_BPOverride Tier 2 sell prices (per merchant org), billed first * The price sheet reads all three; its edits write Tier 1 / Tier 2 rows only. */ export type RateType = 'base' | 'surcharge' | 'service' export type ServicePricing = 'tier' | 'perkg' export type ServiceScope = 'national' | 'international' export type SectionKey = 'national' | 'eu' | 'nonEu' | 'warenpost' | 'surcharges' export type CellSource = 'tier2' | 'tier1' | 'derived' | 'none' export const DHL_SHIPPER_ID = 1000000 export const DPD_SHIPPER_ID = 1000003 /** Carrier = M_Shipper.M_ShipperCfg_ID identifier ('DHL', 'DPD'); every shipper of a config shares the contract. */ export type CarrierCode = 'DHL' | 'DPD' | string /** Logo of a carrier (public asset) — '' when unknown; used by the page, the matrix and (as PNG) the PDF. */ export const CARRIER_LOGO_PATHS: Record = { DHL: { web: '/assets/shipper/dhl_logo_small.svg', png: 'dhl_logo_small.png' }, DPD: { web: '/assets/shipper/dpd_logo_small.png', png: 'dpd_logo_small.png' } } export const carrierLogoPath = (name: string | null | undefined): string => CARRIER_LOGO_PATHS[carrierCodeOf(name, name || '')]?.web || '' /** Countries pinned at the top of the price-sheet country picker (quick picks, always visible). */ export const PINNED_COUNTRY_CODES = ['DE', 'AT', 'FR', 'NL', 'CH'] /** Rows of a merged sheet grouped per carrier (in `sheet.carriers` order; single-carrier sheets → one group). */ export const groupRowsByCarrier = (rows: T[], carriers: { shipperId: number; shipperName: string }[] | undefined): { shipperId: number; shipperName: string; rows: T[] }[] => { const order = (carriers || []).map((c) => Number(c.shipperId)) const map = new Map() for (const r of rows) { const id = Number(r.shipperId || 0) if (!map.has(id)) map.set(id, { shipperId: id, shipperName: String(r.shipperName || (carriers || []).find((c) => Number(c.shipperId) === id)?.shipperName || ''), rows: [] }) map.get(id)!.rows.push(r) } return [...map.values()].sort((a, b) => { const ia = order.indexOf(a.shipperId), ib = order.indexOf(b.shipperId); return (ia < 0 ? 9999 : ia) - (ib < 0 ? 9999 : ib) }) } export const carrierCodeOf = (cfgName: string | null | undefined, shipperName = ''): CarrierCode => { const c = String(cfgName || '').trim().toUpperCase() if (c) return c const n = String(shipperName || '').toUpperCase() return n.startsWith('DPD') ? 'DPD' : n.startsWith('DHL') ? 'DHL' : n || 'OTHER' } export const GERMANY_COUNTRY_ID = 101 /** Weight columns of the customer price sheet (kg). */ export const DEFAULT_TIER_COLUMNS: number[] = [1, 2, 3, 5, 10, 15, 20, 31.5] /** Tier 1/2 convention on prod: the next flat tier starts 0.01 kg above the previous bound (2.01–5). */ export const TIER_RANGE_STEP = 0.01 /** No margin by default — derived prices equal the contract cost until a markup is entered. */ export const DEFAULT_MARKUP_PCT: Record = { national: 0, eu: 0, nonEu: 0, warenpost: 0, surcharges: 0 } // ===== Fixture (parsed contract) ===== export interface FixtureService { code: string name: string nameEn: string scope: ServiceScope pricing: ServicePricing maxWeightKg: number billingNo?: string deliveryDays?: { de: string; en: string } note?: { de: string; en: string } /** set on merged multi-carrier sheets: the carrier this service belongs to */ shipperId?: number shipperName?: string } export interface FixtureRate { serviceCode: string rateType: RateType /** ISO2 (C_Country.CountryCode); null = national / all countries of the service */ countryCode?: string | null countryNameDe?: string zone?: string rangeFrom?: number rangeTo?: number basePrice: number pricePerKg?: number isPercentage?: boolean description?: string sourceRef: string } export interface CarrierRateFixture { fixture: string shipperId: number shipperName: string contractNo: string validFrom: string generatedAt: string source: string[] services: FixtureService[] rates: FixtureRate[] unresolvedCountries: string[] /** optional: ISO2 → carrier zone label (for zone-priced services such as DPD Express) */ countryZones?: Record } // ===== CUST_CarrierRate row (mapped) ===== export interface CarrierRate { id: number uid?: string shipperId: number shipper: string serviceCode: string serviceName: string rateType: RateType countryId: number | null country: string countryCode: string zone: string rangeFrom: number rangeTo: number basePrice: number pricePerKg: number isPercentage: boolean contractNo: string validFrom: string validTo: string description: string sourceRef: string isActive: boolean } /** Identity of a contract row for the import diff. */ export const rateKey = (r: { shipperId: number; serviceCode: string; countryId?: number | null; countryCode?: string | null; rateType: string; rangeFrom?: number; validFrom?: string; description?: string; zone?: string }) => [ r.shipperId, r.serviceCode, r.countryId ?? (r.countryCode || 0), r.rateType, Number(r.rangeFrom ?? 0).toFixed(3), String(r.validFrom || '').slice(0, 10), r.rateType === 'base' ? '' : String(r.description || '').toLowerCase(), // zone-level rows (no country, e.g. DPD Express zones) differ only by zone r.countryId || r.countryCode ? '' : String(r.zone || '') ].join('|') // ===== Tier rows (CUST_ShippingCost_Default / _BPOverride, mapped) ===== export interface TierRow { id: number table: 'default' | 'override' organizationId: number bpartnerId: number | null shipperId: number | null countryId: number | null basePrice: number pricePerKg: number rangeFrom: number rangeTo: number validFrom: string validTo: string isKleinpaket: boolean isActive: boolean } // ===== Price sheet ===== export interface SheetOptions { /** 0 = standard sheet (Tier 1 only) */ organizationId: number bpartnerId?: number | null /** primary carrier (= shipperIds[0]); single-carrier routes (cell / row-persist) key on it */ shipperId: number /** every selected carrier (config-group primary ids); > 1 → the resolve route merges one sheet per carrier */ shipperIds?: number[] language: 'de' | 'en' columns: number[] markupPct: Record countryScope: 'all' | 'eu' | 'list' countryCodes?: string[] /** service code → enabled */ services: Record validOn: string includeSurcharges: boolean includeHaustuer: boolean notes: string title?: string customerName?: string } export interface SheetCell { weight: number price: number | null source: CellSource tierRowId?: number tierTable?: 'default' | 'override' carrierPrice?: number | null note?: string } export interface SheetPriceField { value: number | null source: CellSource rowId?: number table?: 'default' | 'override' carrier?: number | null } export interface SheetRow { key: string serviceCode: string serviceName: string section: SectionKey countryId: number | null countryCode: string countryName: string zone: string isEu: boolean pricing: ServicePricing maxWeight: number /** perkg rows: the effective base / per-kg pair (Tier row or contract) */ base?: SheetPriceField perKg?: SheetPriceField cells: SheetCell[] surcharges?: { label: string; price: number }[] /** Kleinpaket etc.: a note shown under the row */ note?: string /** carrier of this row (set on merged multi-carrier sheets; Tier writes must use it, not options.shipperId) */ shipperId?: number shipperName?: string } export interface SurchargeRow { serviceCode: string serviceName: string rateType: RateType label: string countryCode?: string carrierPrice: number price: number isPercentage: boolean description?: string shipperId?: number shipperName?: string } export interface PriceSheet { options: SheetOptions generatedAt: string merchant: { name: string; orgId: number; bpartnerId: number | null } | null /** display name — "DHL" or, merged, "DHL + DPD" */ shipperName: string /** > 1 entry = merged multi-carrier sheet (rows / services / surcharges carry shipperId + shipperName) */ carriers?: { shipperId: number; shipperName: string }[] services: FixtureService[] sections: Record surcharges: SurchargeRow[] warnings: string[] tierCounts: { tier1: number; tier2: number; carrier: number } } export const round2 = (n: number): number => Math.round((Number(n) + Number.EPSILON) * 100) / 100 /** DHL bills weight-based prices rounded UP to 100 g: base + ceil(w, 0.1) × perKg. */ export const priceAt = (base: number, perKg: number, weightKg: number): number => { const w = Math.ceil(Number(weightKg) * 10 - 1e-9) / 10 return round2(Number(base || 0) + w * Number(perKg || 0)) } /** Billing's tier formula (full weight × per-kg, no rounding of the weight). */ export const tierPriceAt = (base: number, perKg: number, weightKg: number): number => round2(Number(base || 0) + Number(weightKg) * Number(perKg || 0)) export const applyMarkup = (price: number, pct: number): number => round2(Number(price) * (1 + Number(pct || 0) / 100)) /** Billing weight match: 0/0 = any weight, else inclusive range. */ export const weightMatches = (w: number, from: number, to: number): boolean => { const f = Number(from || 0), t = Number(to || 0) if (f === 0 && t === 0) return true return w >= f && w <= t } /** Billing date match: only when BOTH bounds are set (or both empty). One-sided rows never match. */ export const validityMatches = (dayIso: string, validFrom: string, validTo: string): boolean => { const vf = String(validFrom || '').slice(0, 10), vt = String(validTo || '').slice(0, 10) if (!vf && !vt) return true if (!vf || !vt) return false const d = String(dayIso || '').slice(0, 10) return d >= vf && d <= vt } export const isOneSidedValidity = (validFrom: string, validTo: string): boolean => { const vf = String(validFrom || '').slice(0, 10), vt = String(validTo || '').slice(0, 10) return (!!vf) !== (!!vt) } const safeFilePart = (s: string) => String(s || '').normalize('NFKD').replace(/[^\w\- ]+/g, '').trim().replace(/\s+/g, '_').slice(0, 60) /** Never `Angebot_` — the contract package picks those files up as Anlage 1. */ export const priceSheetFilename = (language: 'de' | 'en', merchantName: string, dateIso: string): string => { const who = safeFilePart(merchantName) || (language === 'en' ? 'Standard' : 'Standard') const date = String(dateIso || new Date().toISOString()).slice(0, 10) return language === 'en' ? `Shipping_Price_Sheet_${who}_${date}.pdf` : `Preisliste_Versand_${who}_${date}.pdf` } export const defaultSheetOptions = (over: Partial = {}): SheetOptions => ({ organizationId: 0, bpartnerId: null, shipperId: DHL_SHIPPER_ID, language: 'de', columns: [...DEFAULT_TIER_COLUMNS], markupPct: { ...DEFAULT_MARKUP_PCT }, countryScope: 'list', countryCodes: ['DE'], services: {}, validOn: new Date().toISOString().slice(0, 10), includeSurcharges: true, includeHaustuer: true, notes: '', ...over }) export const SECTION_ORDER: SectionKey[] = ['national', 'eu', 'nonEu', 'warenpost', 'surcharges'] /** Selected carrier ids of an options object (always ≥ 1 entry, primary first, de-duplicated). */ export const sheetShipperIds = (o: Partial | null | undefined): number[] => { const ids = [...(Array.isArray(o?.shipperIds) ? o!.shipperIds! : []), Number(o?.shipperId)].map(Number).filter((n) => Number.isFinite(n) && n > 0) const unique = [...new Set(ids)] return unique.length ? unique : [DHL_SHIPPER_ID] } /** * One sheet per carrier → one merged sheet (multi-carrier export). Rows, surcharges and services are tagged with * their carrier, row keys are prefixed with the shipper id (unique across carriers), sections keep each carrier's * own order (carrier A's rows, then carrier B's — the PDF / matrix group by service anyway), counts are summed. */ export const mergePriceSheets = (sheets: PriceSheet[]): PriceSheet => { if (sheets.length === 1) return sheets[0] const first = sheets[0] const carriers = sheets.map((s) => ({ shipperId: Number(s.options?.shipperId), shipperName: String(s.shipperName || '') })) const sections = {} as Record for (const k of SECTION_ORDER) sections[k] = [] const services: FixtureService[] = [] const seen = new Set() const surcharges: SurchargeRow[] = [] const warnings: string[] = [] const tierCounts = { tier1: 0, tier2: 0, carrier: 0 } for (const s of sheets) { const shipperId = Number(s.options?.shipperId) const shipperName = String(s.shipperName || '') for (const k of SECTION_ORDER) { for (const r of s.sections?.[k] || []) sections[k].push({ ...r, key: `${shipperId}:${r.key}`, shipperId, shipperName }) } for (const svc of s.services || []) { if (seen.has(svc.code)) continue seen.add(svc.code) services.push({ ...svc, shipperId, shipperName }) } for (const sr of s.surcharges || []) surcharges.push({ ...sr, shipperId, shipperName }) for (const w of s.warnings || []) warnings.push(`${shipperName}: ${w}`) tierCounts.tier1 += Number(s.tierCounts?.tier1) || 0 tierCounts.tier2 += Number(s.tierCounts?.tier2) || 0 tierCounts.carrier += Number(s.tierCounts?.carrier) || 0 } return { ...first, options: { ...first.options, shipperIds: carriers.map((c) => c.shipperId) }, shipperName: carriers.map((c) => c.shipperName).filter(Boolean).join(' + '), carriers, services, sections, surcharges, warnings, tierCounts } } /** Which section a contract service belongs to. */ export const sectionOfService = (serviceCode: string, countryCode: string | null, isEu: boolean): SectionKey | null => { const c = String(serviceCode || '').toUpperCase() if (c.includes('WARENPOST')) return 'warenpost' if (c.endsWith('_GENERAL')) return 'surcharges' if (!countryCode || countryCode === 'DE') return 'national' return isEu ? 'eu' : 'nonEu' }