/** * Import of a parsed contract fixture (server/assets/shipping/*.json, produced by * reference/test-scripts/dhl-contract-parse.mts) into CUST_CarrierRate. * * planImport() diffs fixture rows against the existing rows of the shipper by * rateKey() and never deletes — rows missing from the fixture are reported as * `orphans` for manual review. applyImport() POSTs/PUTs with small concurrency. * * ⚠ The dev `.env` points at the PROD iDempiere: a non-dry-run import from the * local dev server writes production data. */ import fetchHelper from '../fetchHelper' import { CARRIER_RATE_MODEL, carrierRateBody, loadCarrierRateRecords, loadCountryMaps, mapCarrierRate } from './carrierRates' import { rateKey, round2 } from '../../../app/utils/shippingPriceSheet' import type { CarrierRate, CarrierRateFixture, FixtureRate } from '../../../app/utils/shippingPriceSheet' export interface PlannedCreate { key: string; rate: FixtureRate; countryId: number | null } export interface PlannedUpdate { key: string; id: number; countryId: number | null; before: Partial; after: Partial; changed: string[] } export interface ImportPlan { fixture: string shipperId: number sections: string[] create: PlannedCreate[] update: PlannedUpdate[] unchanged: number orphans: { id: number; serviceCode: string; country: string; rangeFrom: number; description: string }[] unresolvedCodes: string[] skipped: number counts: Record } export const listFixtures = async (): Promise<{ key: string; fixture: string; shipperId: number; shipperName: string; contractNo: string; validFrom: string; generatedAt: string; rates: number; services: string[] }[]> => { const storage = useStorage('assets:server') const keys = (await storage.getKeys('shipping')).filter((k) => k.endsWith('.json')) const out: any[] = [] for (const k of keys) { try { const file = k.replace(/^shipping[:/]/, '').replace(/\.json$/, '') const f = await loadFixtureFile(file) if (f) out.push({ key: file, fixture: f.fixture, shipperId: f.shipperId, shipperName: f.shipperName, contractNo: f.contractNo, validFrom: f.validFrom, generatedAt: f.generatedAt, rates: f.rates.length, services: f.services.map((s) => s.code) }) } catch (e) { /* skip unreadable */ } } return out } const loadFixtureFile = async (file: string): Promise => { const safe = String(file || '').replace(/[^a-z0-9\-_.]/gi, '') if (!safe) return null const storage = useStorage('assets:server') const raw = await storage.getItem(`shipping/${safe}.json`) ?? await storage.getItem(`shipping:${safe}.json`) if (!raw) return null const f = typeof raw === 'string' ? JSON.parse(raw) : raw return f && Array.isArray(f.rates) ? f as CarrierRateFixture : null } /** Resolve by file name (dhl-contract-2025-07) OR by the fixture's logical name (dhl-2025-07). */ export const loadFixture = async (name: string): Promise => { const direct = await loadFixtureFile(name) if (direct) return direct for (const fx of await listFixtures()) if (fx.fixture === name) return loadFixtureFile(fx.key) return null } const COMPARE_FIELDS: (keyof CarrierRate)[] = ['serviceName', 'zone', 'rangeTo', 'basePrice', 'pricePerKg', 'isPercentage', 'contractNo', 'description', 'sourceRef', 'isActive'] const fixtureToRate = (f: CarrierRateFixture, r: FixtureRate, countryId: number | null): Partial => { const svc = f.services.find((s) => s.code === r.serviceCode) return { shipperId: f.shipperId, serviceCode: r.serviceCode, serviceName: svc?.name || r.serviceCode, rateType: r.rateType, countryId, zone: r.zone || '', rangeFrom: round2(r.rangeFrom ?? 0), rangeTo: round2(r.rangeTo ?? 0), basePrice: round2(r.basePrice ?? 0), pricePerKg: round2(r.pricePerKg ?? 0), isPercentage: r.isPercentage === true, contractNo: f.contractNo, validFrom: f.validFrom, validTo: '', description: String(r.description || '').slice(0, 255), sourceRef: String(r.sourceRef || '').slice(0, 60), isActive: true } } const same = (a: any, b: any) => { if (typeof a === 'number' || typeof b === 'number') return round2(Number(a || 0)) === round2(Number(b || 0)) if (typeof a === 'boolean' || typeof b === 'boolean') return !!a === !!b return String(a ?? '') === String(b ?? '') } export const planImport = async (event: any, token: string, fixture: CarrierRateFixture, sections?: string[]): Promise => { const countries = await loadCountryMaps(event, token) const wanted = new Set((sections || []).filter(Boolean)) const existingRecs = await loadCarrierRateRecords(event, token, { shipperId: fixture.shipperId, includeInactive: true }) const existing = existingRecs.map((r) => mapCarrierRate(r, countries.byId)) const byKey = new Map() for (const e of existing) byKey.set(rateKey(e), e) const plan: ImportPlan = { fixture: fixture.fixture, shipperId: fixture.shipperId, sections: [...wanted], create: [], update: [], unchanged: 0, orphans: [], unresolvedCodes: [], skipped: 0, counts: {} } const unresolved = new Set() const seen = new Set() const bump = (code: string, k: 'create' | 'update' | 'unchanged') => { plan.counts[code] = plan.counts[code] || { create: 0, update: 0, unchanged: 0 }; plan.counts[code][k]++ } for (const r of fixture.rates) { if (wanted.size && !wanted.has(r.serviceCode)) continue let countryId: number | null = null if (r.countryCode) { countryId = countries.byCode.get(String(r.countryCode).toUpperCase()) ?? null if (!countryId) { unresolved.add(String(r.countryCode)); plan.skipped++; continue } } const after = fixtureToRate(fixture, r, countryId) const key = rateKey({ shipperId: fixture.shipperId, serviceCode: r.serviceCode, countryId, rateType: r.rateType, rangeFrom: after.rangeFrom, validFrom: fixture.validFrom, description: after.description, zone: after.zone }) seen.add(key) const cur = byKey.get(key) if (!cur) { plan.create.push({ key, rate: r, countryId }); bump(r.serviceCode, 'create'); continue } const changed = COMPARE_FIELDS.filter((f) => !same((cur as any)[f], (after as any)[f])) if (!changed.length) { plan.unchanged++; bump(r.serviceCode, 'unchanged'); continue } const before: any = {}, diff: any = {} for (const f of changed) { before[f] = (cur as any)[f]; diff[f] = (after as any)[f] } plan.update.push({ key, id: cur.id, countryId, before, after: diff, changed }) bump(r.serviceCode, 'update') } for (const e of existing) { if (wanted.size && !wanted.has(e.serviceCode)) continue if (!seen.has(rateKey(e))) plan.orphans.push({ id: e.id, serviceCode: e.serviceCode, country: e.country, rangeFrom: e.rangeFrom, description: e.description }) } plan.unresolvedCodes = [...unresolved].sort() return plan } export interface ImportResult { created: number; updated: number; failed: { key: string; message: string }[] } const pool = async (items: T[], limit: number, fn: (item: T) => Promise) => { let i = 0 const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (i < items.length) { const item = items[i++]; await fn(item) } }) await Promise.all(workers) } export const applyImport = async (event: any, token: string, fixture: CarrierRateFixture, plan: ImportPlan): Promise => { const result: ImportResult = { created: 0, updated: 0, failed: [] } const msg = (e: any) => e?.data?.detail || e?.data?.title || e?.message || String(e) await pool(plan.create, 3, async (c) => { try { const body = carrierRateBody(fixtureToRate(fixture, c.rate, c.countryId), true) await fetchHelper(event, CARRIER_RATE_MODEL, 'POST', token, body) result.created++ } catch (e: any) { result.failed.push({ key: c.key, message: msg(e) }) } }) await pool(plan.update, 3, async (u) => { try { const body = carrierRateBody(u.after as any, false) await fetchHelper(event, `${CARRIER_RATE_MODEL}/${u.id}`, 'PUT', token, body) result.updated++ } catch (e: any) { result.failed.push({ key: u.key, message: msg(e) }) } }) return result }