/** * Shipping price sheet — resolver + Tier write path. * * Reads CUST_CarrierRate (contract cost), CUST_ShippingCost_Default (Tier 1 sell) * and CUST_ShippingCost_BPOverride (Tier 2 sell, merchant org) and produces one * row per service/country with a price per weight column: * Tier 2 match → Tier 1 match → derived (contract cost × (1 + markup %)). * The Tier match mirrors ../logship-scripts calculate.py exactly: country + * inclusive weight range (or 0/0), validity only when both bounds are set, * `base + weight × perkg`; the Kleinpaket row is global (country ignored). * * Billing can only bill ONE price per country, so Tier rows are applied to the * primary services only (TIER_BILLED_SERVICES); Economy / Warenpost rows are * always derived and marked as not billable through the Tier tables. */ import fetchHelper from '../fetchHelper' import { loadCarrierRates, loadCountryMaps, loadShipperGroups } from './carrierRates' import { listFixtures, loadFixture } from './carrierRateImport' import { isEuCountryCode } from '../../../app/utils/shippingCountryMap' import { applyMarkup, priceAt, tierPriceAt, round2, weightMatches, validityMatches, isOneSidedValidity, sectionOfService, GERMANY_COUNTRY_ID, TIER_RANGE_STEP, DEFAULT_MARKUP_PCT, defaultSheetOptions, carrierCodeOf } from '../../../app/utils/shippingPriceSheet' import type { CarrierRate, FixtureService, PriceSheet, SheetCell, SheetOptions, SheetRow, SurchargeRow, TierRow, SectionKey, CellSource } from '../../../app/utils/shippingPriceSheet' const TIER1_MODEL = 'models/cust_shippingcost_default' const TIER2_MODEL = 'models/cust_shippingcost_bpoverride' /** Services whose sell price comes from the Tier tables (billing bills one price per country). */ export const TIER_BILLED_SERVICES = ['DHL_PAKET', 'DHL_PAKET_INT_PREMIUM'] export const KLEINPAKET_SERVICES = ['DHL_KLEINPAKET'] const n = (v: any, d = 0): number => (Number.isFinite(Number(v)) ? Number(v) : d) const dateOnly = (v: any): string => (v ? String(v).slice(0, 10) : '') export const mapTierRow = (r: any, table: 'default' | 'override'): TierRow => ({ id: n(r?.id), table, organizationId: n(r?.AD_Org_ID?.id), bpartnerId: r?.C_BPartner_ID?.id ? n(r.C_BPartner_ID.id) : null, shipperId: r?.M_Shipper_ID?.id ? n(r.M_Shipper_ID.id) : null, countryId: r?.C_Country_ID?.id ? n(r.C_Country_ID.id) : null, basePrice: n(r?.ShippingBasePrice ?? r?.shippingBasePrice), pricePerKg: n(r?.ShippingPricePerKG ?? r?.shippingPricePerKG), rangeFrom: n(r?.RangeWeightFrom ?? r?.rangeWeightFrom), rangeTo: n(r?.RangeWeightTo ?? r?.rangeWeightTo), validFrom: dateOnly(r?.ValidFrom ?? r?.validFrom), validTo: dateOnly(r?.ValidTo ?? r?.validTo), isKleinpaket: (r?.isDHLKleinPaket ?? r?.IsDHLKleinPaket) === true, isActive: (r?.IsActive ?? r?.isActive) !== false }) export const loadTier1 = async (event: any, token: string): Promise => { const res: any = await fetchHelper(event, `${TIER1_MODEL}?$filter=${encodeURIComponent('IsActive eq true')}&$top=1000`, 'GET', token, null) return (res?.records || []).map((r: any) => mapTierRow(r, 'default')) } export const loadTier2 = async (event: any, token: string, organizationId: number): Promise => { if (!organizationId) return [] const res: any = await fetchHelper(event, `${TIER2_MODEL}?$filter=${encodeURIComponent(`AD_Org_ID eq ${n(organizationId)} and IsActive eq true`)}&$top=1000`, 'GET', token, null) return (res?.records || []).map((r: any) => mapTierRow(r, 'override')) } /** Priority of calculate.py (lower = more specific). */ const tierPriority = (r: TierRow): number => { const bp = r.table === 'override' && !!r.bpartnerId const w = r.rangeFrom !== 0 || r.rangeTo !== 0 const d = !!r.validFrom if (r.table === 'override') { if (bp && w && d) return 1 if (bp && w) return 2 if (bp && d) return 3 if (w && d) return 4 if (bp) return 5 if (w) return 6 if (d) return 7 return 8 } if (w && d) return 1 if (w) return 2 if (d) return 3 return 4 } export interface TierMatch { row: TierRow; ties: number } export const findTierMatch = (rows: TierRow[], countryId: number, weight: number, validOn: string): TierMatch | null => { const hits = rows .filter((r) => r.isActive && !r.isKleinpaket && r.countryId === countryId && weightMatches(weight, r.rangeFrom, r.rangeTo) && validityMatches(validOn, r.validFrom, r.validTo)) .sort((a, b) => tierPriority(a) - tierPriority(b) || a.id - b.id) if (!hits.length) return null const best = hits[0] const ties = hits.filter((h) => tierPriority(h) === tierPriority(best)).length - 1 return { row: best, ties } } /** Kleinpaket: newest active Tier 1 row flagged isDHLKleinPaket, country ignored (calculate.py). */ export const findKleinpaketRow = (tier1: TierRow[]): TierRow | null => { const hits = tier1.filter((r) => r.isActive && r.isKleinpaket).sort((a, b) => b.id - a.id) return hits[0] || null } const serviceMetaFromRates = (rates: CarrierRate[]): FixtureService[] => { const map = new Map() for (const r of rates) { if (r.rateType !== 'base') continue const cur = map.get(r.serviceCode) || { code: r.serviceCode, name: r.serviceName || r.serviceCode, nameEn: r.serviceName || r.serviceCode, scope: r.countryId && r.countryCode !== 'DE' ? 'international' : 'national', pricing: 'tier', maxWeightKg: 0 } as FixtureService if (r.pricePerKg > 0) cur.pricing = 'perkg' if (r.countryId && r.countryCode !== 'DE') cur.scope = 'international' cur.maxWeightKg = Math.max(cur.maxWeightKg, r.rangeTo) map.set(r.serviceCode, cur) } return [...map.values()] } const loadServiceMeta = async (shipperIds: number[], rates: CarrierRate[]): Promise<{ services: FixtureService[]; countryZones: Record }> => { const derived = serviceMetaFromRates(rates) try { const fixtures = await listFixtures() const fx = fixtures.filter((f) => shipperIds.includes(f.shipperId)).sort((a, b) => String(b.validFrom).localeCompare(String(a.validFrom)))[0] if (!fx) return { services: derived, countryZones: {} } const f = await loadFixture(fx.fixture) if (!f) return { services: derived, countryZones: {} } const byCode = new Map(f.services.map((s) => [s.code, s])) return { services: derived.map((d) => ({ ...d, ...(byCode.get(d.code) || {}) })), countryZones: f.countryZones || {} } } catch { return { services: derived, countryZones: {} } } } const loadMerchant = async (event: any, token: string, organizationId: number) => { if (!organizationId) return null const res: any = await fetchHelper(event, `models/ad_org/${n(organizationId)}`, 'GET', token, null) return { name: String(res?.Name || ''), orgId: n(organizationId), bpartnerId: res?.C_BPartner_ID?.id ? n(res.C_BPartner_ID.id) : null } } export const resolvePriceSheet = async (event: any, token: string, input: Partial): Promise => { const options: SheetOptions = defaultSheetOptions({ ...input, markupPct: { ...DEFAULT_MARKUP_PCT, ...(input?.markupPct || {}) } }) options.columns = [...new Set((options.columns || []).map(Number).filter((w) => w > 0))].sort((a, b) => a - b) const validOn = String(options.validOn || new Date().toISOString()).slice(0, 10) const orgId = n(options.organizationId) const [countries, rates, tier1, tier2, merchant, shipperGroups] = await Promise.all([ loadCountryMaps(event, token), loadCarrierRates(event, token, { shipperId: options.shipperId, validOn }), loadTier1(event, token), loadTier2(event, token, orgId), loadMerchant(event, token, orgId), loadShipperGroups(event, token) ]) if (merchant && !options.bpartnerId) options.bpartnerId = merchant.bpartnerId // Carrier identity = M_Shipper.M_ShipperCfg_ID ('DHL' | 'DPD'): every shipper of the config shares one contract. const shipperGroup = shipperGroups.get(n(options.shipperId)) const carrier = shipperGroup?.carrier || carrierCodeOf('', rates[0]?.shipper) const { services, countryZones } = await loadServiceMeta(shipperGroup?.shipperIds || [options.shipperId], rates) const serviceByCode = new Map(services.map((s) => [s.code, s])) const enabled = (code: string) => options.services?.[code] !== false const warnings: string[] = [] const shipperName = carrier || rates[0]?.shipper || '' if (carrier !== 'DHL' && rates.length) warnings.push(`${carrier}: the Tier tables are carrier-agnostic — billing applies the same Tier 1 / Tier 2 rows (DHL-based) to ${carrier} shipments; all ${carrier} prices here are derived from the contract.`) // Tier rows of OTHER shippers are still billed (billing ignores the shipper) — keep all. const tierRows = [...tier2, ...tier1] for (const r of tierRows) if (isOneSidedValidity(r.validFrom, r.validTo)) warnings.push(`Tier ${r.table === 'override' ? '2' : '1'} row ${r.id} (${countries.names.get(r.countryId || 0) || 'no country'}) has only one validity bound and is never matched by billing.`) const inScope = (code: string): boolean => { if (options.countryScope === 'eu') return code === 'DE' || isEuCountryCode(code) if (options.countryScope === 'list') return (options.countryCodes || []).map((c) => c.toUpperCase()).includes(code.toUpperCase()) return true } const kleinpaket = findKleinpaketRow(tier1) const haustuerByCountry = new Map() for (const r of rates) if (r.rateType === 'surcharge' && r.countryId && /haust/i.test(r.description)) haustuerByCountry.set(r.countryId, r.basePrice) const sections: Record = { national: [], eu: [], nonEu: [], warenpost: [], surcharges: [] } const tieWarned = new Set() const sellFor = (serviceCode: string, countryId: number, w: number, carrierPrice: number | null, section: SectionKey): SheetCell => { const cell: SheetCell = { weight: w, price: null, source: 'none', carrierPrice } if (carrierPrice === null) return cell if (KLEINPAKET_SERVICES.includes(serviceCode)) { if (kleinpaket) { cell.price = round2(kleinpaket.basePrice); cell.source = 'tier1'; cell.tierRowId = kleinpaket.id; cell.tierTable = 'default'; return cell } cell.price = applyMarkup(carrierPrice, options.markupPct[section]); cell.source = 'derived'; return cell } if (TIER_BILLED_SERVICES.includes(serviceCode)) { const m2 = findTierMatch(tier2, countryId, w, validOn) const m = m2 || findTierMatch(tier1, countryId, w, validOn) if (m) { cell.price = tierPriceAt(m.row.basePrice, m.row.pricePerKg, w) cell.source = m.row.table === 'override' ? 'tier2' : 'tier1' cell.tierRowId = m.row.id cell.tierTable = m.row.table if (m.ties > 0) { const k = `${m.row.table}|${countryId}|${w}` if (!tieWarned.has(k)) { tieWarned.add(k); warnings.push(`${countries.names.get(countryId)} ${w} kg: ${m.ties + 1} Tier ${m.row.table === 'override' ? '2' : '1'} rows match equally — billing picks an arbitrary one (row ${m.row.id} shown).`) } } return cell } } cell.price = applyMarkup(carrierPrice, options.markupPct[section]) cell.source = 'derived' return cell } // ---- rows ---- const baseRates = rates.filter((r) => r.rateType === 'base' && enabled(r.serviceCode)) const isZoneRow = (r: CarrierRate) => !r.countryId && !!r.zone const nationalCodes = new Set() for (const r of baseRates) { if (isZoneRow(r)) continue const code = r.countryCode || (r.countryId ? countries.byId.get(r.countryId) || '' : '') if (!r.countryId || code === 'DE') nationalCodes.add(r.serviceCode) } /** Contract cost for weight w from a sorted list of rows of one service+destination (flat tiers or base+perkg). */ const costAt = (rows: CarrierRate[], w: number): number | null => { const t = rows.find((x) => weightMatches(w, x.rangeFrom, x.rangeTo)) || rows.find((x) => w <= x.rangeTo + 1e-9) if (!t) return null return t.pricePerKg > 0 ? priceAt(t.basePrice, t.pricePerKg, w) : round2(t.basePrice) } const groupMax = (rows: CarrierRate[], svc?: FixtureService) => Math.max(...rows.map((t) => t.rangeTo), 0) || svc?.maxWeightKg || 31.5 // National services: one row per service, cells from the service's tier rows. for (const code of nationalCodes) { const svc = serviceByCode.get(code) const tiers = baseRates.filter((r) => r.serviceCode === code && !isZoneRow(r) && (!r.countryId || r.countryCode === 'DE')).sort((a, b) => a.rangeTo - b.rangeTo) const maxWeight = groupMax(tiers, svc) const cells: SheetCell[] = options.columns.map((w) => { if (w > maxWeight + 1e-9) return { weight: w, price: null, source: 'none' as CellSource, carrierPrice: null } return sellFor(code, GERMANY_COUNTRY_ID, w, costAt(tiers, w), 'national') }) const row: SheetRow = { key: `${code}|DE`, serviceCode: code, serviceName: svc?.name || tiers[0]?.serviceName || code, section: 'national', countryId: GERMANY_COUNTRY_ID, countryCode: 'DE', countryName: countries.names.get(GERMANY_COUNTRY_ID) || 'Germany', zone: '', isEu: true, pricing: tiers.some((t) => t.pricePerKg > 0) ? 'perkg' : 'tier', maxWeight, cells } if (KLEINPAKET_SERVICES.includes(code)) row.note = kleinpaket ? `Tier 1 Kleinpaket row ${kleinpaket.id} (global, all merchants)` : 'No Tier 1 Kleinpaket row — derived from contract' else if (!TIER_BILLED_SERVICES.includes(code)) row.note = 'Not billed through the Tier tables (derived from contract)' if (inScope('DE')) sections.national.push(row) } // International per-country services — grouped by service + country (DHL: one base+perkg row; DPD: flat weight tiers). const intlGroups = new Map() for (const r of baseRates) { if (isZoneRow(r) || !r.countryId || !r.countryCode || r.countryCode === 'DE') continue const k = `${r.serviceCode}|${r.countryCode}` if (!intlGroups.has(k)) intlGroups.set(k, []) intlGroups.get(k)!.push(r) } for (const [, group] of intlGroups) { group.sort((a, b) => a.rangeTo - b.rangeTo) const r = group[0] const code = r.countryCode if (!inScope(code)) continue const isEu = isEuCountryCode(code) const section = sectionOfService(r.serviceCode, code, isEu) if (!section || section === 'surcharges') continue const svc = serviceByCode.get(r.serviceCode) const maxWeight = groupMax(group, svc) const pricing: SheetRow['pricing'] = group.length === 1 && r.pricePerKg > 0 ? 'perkg' : 'tier' const haustuer = options.includeHaustuer && TIER_BILLED_SERVICES.includes(r.serviceCode) ? (haustuerByCountry.get(r.countryId!) || 0) : 0 const cells = options.columns.map((w) => { if (w > maxWeight + 1e-9) return { weight: w, price: null, source: 'none' as CellSource, carrierPrice: null } const c = costAt(group, w) const cost = c === null ? null : round2(c + haustuer) const cell = sellFor(r.serviceCode, r.countryId!, w, cost, section) if (haustuer && cell.source === 'derived') cell.note = 'incl. Haustürzuschlag' return cell }) const row: SheetRow = { key: `${r.serviceCode}|${code}`, serviceCode: r.serviceCode, serviceName: svc?.name || r.serviceName || r.serviceCode, section, countryId: r.countryId, countryCode: code, countryName: countries.names.get(r.countryId!) || r.country || code, zone: r.zone, isEu, pricing, maxWeight, cells } if (pricing === 'perkg') { // base / per-kg fields: a 0/0 Tier row for the country (primary services only) else contract × markup row.base = { value: applyMarkup(r.basePrice, options.markupPct[section]), source: 'derived', carrier: r.basePrice } row.perKg = { value: applyMarkup(r.pricePerKg, options.markupPct[section]), source: 'derived', carrier: r.pricePerKg } if (TIER_BILLED_SERVICES.includes(r.serviceCode)) { const open = [...tier2, ...tier1].filter((t) => t.isActive && !t.isKleinpaket && t.countryId === r.countryId && t.rangeFrom === 0 && t.rangeTo === 0 && validityMatches(validOn, t.validFrom, t.validTo)).sort((a, b) => (a.table === 'override' ? 0 : 1) - (b.table === 'override' ? 0 : 1) || a.id - b.id)[0] if (open) { const src: CellSource = open.table === 'override' ? 'tier2' : 'tier1' row.base = { value: open.basePrice, source: src, rowId: open.id, table: open.table, carrier: r.basePrice } row.perKg = { value: open.pricePerKg, source: src, rowId: open.id, table: open.table, carrier: r.pricePerKg } } } } if (haustuer) row.surcharges = [{ label: 'Haustürzuschlag', price: haustuer }] if (!TIER_BILLED_SERVICES.includes(r.serviceCode)) row.note = 'Not billed through the Tier tables (derived from contract)' sections[section].push(row) } // Zone-level services (no country, e.g. DPD Express zones): one row per service + zone, always derived. const zoneGroups = new Map() for (const r of baseRates) { if (!isZoneRow(r)) continue const k = `${r.serviceCode}|${r.zone}` if (!zoneGroups.has(k)) zoneGroups.set(k, []) zoneGroups.get(k)!.push(r) } const zoneMembers = (zone: string): string[] => Object.entries(countryZones).filter(([, z]) => z === zone).map(([iso]) => iso).sort() for (const [, group] of zoneGroups) { group.sort((a, b) => a.rangeTo - b.rangeTo) const r = group[0] const svc = serviceByCode.get(r.serviceCode) const members = zoneMembers(r.zone) if (options.countryScope !== 'all' && members.length && !members.some((m) => inScope(m))) continue const section: SectionKey = /^1-/.test(r.zone) ? 'eu' : 'nonEu' const maxWeight = groupMax(group, svc) const cells = options.columns.map((w) => { if (w > maxWeight + 1e-9) return { weight: w, price: null, source: 'none' as CellSource, carrierPrice: null } const c = costAt(group, w) const cell: SheetCell = { weight: w, price: c === null ? null : applyMarkup(c, options.markupPct[section]), source: c === null ? 'none' : 'derived', carrierPrice: c } return cell }) const label = `Zone ${r.zone}` + (members.length ? ` (${members.join(', ')})` : '') sections[section].push({ key: `${r.serviceCode}|zone:${r.zone}`, serviceCode: r.serviceCode, serviceName: svc?.name || r.serviceName || r.serviceCode, section, countryId: null, countryCode: '', countryName: label, zone: r.zone, isEu: section === 'eu', pricing: 'tier', maxWeight, cells, note: 'Zone price (derived from contract, not billed through the Tier tables)' }) } // Tier-billed (primary) services first, then the rest; countries alphabetically within a service. const svcRank = (code: string) => (TIER_BILLED_SERVICES.includes(code) ? 0 : /PREMIUM/.test(code) ? 1 : 2) for (const k of ['eu', 'nonEu', 'warenpost'] as SectionKey[]) sections[k].sort((a, b) => svcRank(a.serviceCode) - svcRank(b.serviceCode) || a.serviceCode.localeCompare(b.serviceCode) || (a.zone && !a.countryId ? 1 : 0) - (b.zone && !b.countryId ? 1 : 0) || a.countryName.localeCompare(b.countryName, 'de')) // Surcharges / services const surcharges: SurchargeRow[] = [] if (options.includeSurcharges) { for (const r of rates) { if (r.rateType === 'base' || !enabled(r.serviceCode)) continue // country-specific surcharges (Haustür SE/FI, Brexit GB, Währungszuschlag CH/LI …) carry their countryCode const svc = serviceByCode.get(r.serviceCode) surcharges.push({ serviceCode: r.serviceCode, serviceName: svc?.name || r.serviceName || r.serviceCode, rateType: r.rateType, label: r.description || r.rateType, countryCode: r.countryCode || undefined, carrierPrice: r.basePrice, isPercentage: r.isPercentage, description: r.sourceRef, price: r.isPercentage ? r.basePrice : applyMarkup(r.basePrice, options.markupPct.surcharges) }) } surcharges.sort((a, b) => a.serviceCode.localeCompare(b.serviceCode) || (a.rateType === b.rateType ? a.label.localeCompare(b.label, 'de') : a.rateType === 'surcharge' ? -1 : 1)) } // Fallback warning: Tier-billed countries without any Tier row → billing charges €25 fallback. const derivedBilled = carrier !== 'DHL' ? [] : [...sections.eu, ...sections.nonEu].filter((row) => TIER_BILLED_SERVICES.includes(row.serviceCode) && row.cells.every((c) => c.source !== 'tier1' && c.source !== 'tier2')) if (derivedBilled.length) warnings.unshift(`${derivedBilled.length} countries of ${TIER_BILLED_SERVICES[1]} have no Tier price yet — shipments there are billed at the €25.00 fallback until a Tier row exists (use "persist row" / "apply markup to all derived").`) const nationalDerived = sections.national.filter((row) => row.serviceCode === 'DHL_PAKET' && row.cells.some((c) => c.source === 'derived')) if (nationalDerived.length) warnings.unshift('DHL Paket national has weight columns without a Tier row — those weights are billed at the €25.00 fallback.') return { options, generatedAt: new Date().toISOString(), merchant, shipperName, // ALL services of the carrier, not only the enabled ones: the options panel renders its checkboxes from this // list, so a disabled service must stay listed (unchecked) — otherwise it vanishes and cannot be re-enabled // (the disabled state is persisted in localStorage, so a reload did not bring it back either). Rows / PDF // consult options.services themselves. services, sections, surcharges, warnings, tierCounts: { tier1: tier1.length, tier2: tier2.length, carrier: rates.length } } } // ===== Write path ===== export interface TierUpsertInput { scope: 'tier1' | 'tier2' organizationId: number bpartnerId?: number | null shipperId: number countryId: number mode: 'tier' | 'perkg' | 'kleinpaket' weight?: number prevWeight?: number price?: number base?: number perKg?: number rowId?: number table?: 'default' | 'override' validFrom?: string validTo?: string deactivate?: boolean } const tierBody = (i: TierUpsertInput, orgId: number, bpartnerId: number | null, range: { from: number; to: number }, base: number, perKg: number, kleinpaket: boolean) => { const body: any = { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, M_Shipper_ID: { id: n(i.shipperId), tableName: 'M_Shipper' }, C_Country_ID: { id: n(i.countryId), tableName: 'C_Country' }, isActive: true, ShippingBasePrice: round2(base), ShippingPricePerKG: round2(perKg), RangeWeightFrom: round2(range.from), RangeWeightTo: round2(range.to), isDHLKleinPaket: kleinpaket, tableName: i.scope === 'tier2' ? 'CUST_ShippingCost_BPOverride' : 'CUST_ShippingCost_Default' } if (i.scope === 'tier2' && bpartnerId) body.C_BPartner_ID = { id: bpartnerId, tableName: 'C_BPartner' } if (i.validFrom && i.validTo) { body.ValidFrom = String(i.validFrom).slice(0, 10); body.ValidTo = String(i.validTo).slice(0, 10) } return body } /** Create or update ONE Tier row for a cell edit. Returns the row id + table. */ export const upsertTierRow = async (event: any, token: string, i: TierUpsertInput): Promise<{ rowId: number; table: 'default' | 'override' }> => { if (!i.countryId && i.mode !== 'kleinpaket') throw createError({ statusCode: 400, statusMessage: 'countryId missing' }) if (i.mode === 'kleinpaket') { i.scope = 'tier1'; i.countryId = i.countryId || GERMANY_COUNTRY_ID } if (i.scope === 'tier2' && !i.organizationId) throw createError({ statusCode: 400, statusMessage: 'Tier 2 needs a merchant organization' }) if ((i.validFrom && !i.validTo) || (!i.validFrom && i.validTo)) throw createError({ statusCode: 400, statusMessage: 'Validity needs both From and To (one-sided rows are ignored by billing)' }) const table: 'default' | 'override' = i.scope === 'tier2' ? 'override' : 'default' const model = table === 'override' ? TIER2_MODEL : TIER1_MODEL if (i.deactivate) { if (!i.rowId) throw createError({ statusCode: 400, statusMessage: 'rowId missing' }) await fetchHelper(event, `${model}/${n(i.rowId)}`, 'PUT', token, { isActive: false, tableName: table === 'override' ? 'CUST_ShippingCost_BPOverride' : 'CUST_ShippingCost_Default' }) return { rowId: n(i.rowId), table } } let bpartnerId = i.bpartnerId ? n(i.bpartnerId) : null if (i.scope === 'tier2' && !bpartnerId) { try { const org: any = await fetchHelper(event, `models/ad_org/${n(i.organizationId)}`, 'GET', token, null); bpartnerId = org?.C_BPartner_ID?.id ? n(org.C_BPartner_ID.id) : null } catch { /* optional */ } } const orgId = i.scope === 'tier2' ? n(i.organizationId) : 0 let range = { from: 0, to: 0 }, base = 0, perKg = 0, kleinpaket = false if (i.mode === 'tier') { const w = n(i.weight) if (!(w > 0)) throw createError({ statusCode: 400, statusMessage: 'weight missing' }) const prev = n(i.prevWeight) range = { from: prev > 0 ? round2(prev + TIER_RANGE_STEP) : 0, to: w } base = n(i.price); perKg = 0 } else if (i.mode === 'perkg') { base = n(i.base); perKg = n(i.perKg) } else { range = { from: 0, to: n(i.weight) || 1 } base = n(i.price); perKg = 0; kleinpaket = true } // Reuse an existing row with the same identity (scope, country, range, kleinpaket) when no rowId was given. let rowId = n(i.rowId) if (!rowId) { const filter = table === 'override' ? `AD_Org_ID eq ${orgId} and C_Country_ID eq ${n(i.countryId)} and IsActive eq true` : `C_Country_ID eq ${n(i.countryId)} and IsActive eq true` const res: any = await fetchHelper(event, `${model}?$filter=${encodeURIComponent(filter)}&$top=200`, 'GET', token, null) const rows = (res?.records || []).map((r: any) => mapTierRow(r, table)) const hit = rows.find((r: TierRow) => r.isKleinpaket === kleinpaket && round2(r.rangeFrom) === round2(range.from) && round2(r.rangeTo) === round2(range.to) && (r.validFrom || '') === String(i.validFrom || '').slice(0, 10) && (table === 'default' || (r.bpartnerId || null) === (bpartnerId || null))) if (hit) rowId = hit.id } const body = tierBody(i, orgId, bpartnerId, range, base, perKg, kleinpaket) const res: any = rowId ? await fetchHelper(event, `${model}/${rowId}`, 'PUT', token, body) : await fetchHelper(event, model, 'POST', token, body) return { rowId: n(res?.id) || rowId, table } }