/** * CUST_CarrierRate — carrier CONTRACT (purchase) prices per service / country / * weight range, plus surcharges and services. Carrier-agnostic (DHL today, * DPD later). Sell prices live in CUST_ShippingCost_Default / _BPOverride. * * Read casing is PascalCase (ServiceCode, BasePrice, PricePerKG …), writes use * lowercase-first scalars + PascalCase FK objects (see write-casing recipe). */ import fetchHelper from '../fetchHelper' import type { CarrierRate, RateType } from '../../../app/utils/shippingPriceSheet' import { carrierCodeOf } from '../../../app/utils/shippingPriceSheet' export const CARRIER_RATE_MODEL = 'models/cust_carrierrate' export const CARRIER_RATE_TABLE = 'CUST_CarrierRate' const n = (v: any, d = 0): number => (Number.isFinite(Number(v)) ? Number(v) : d) const s = (v: any): string => (v === null || v === undefined ? '' : String(v)) const bool = (v: any): boolean => v === true || v === 'Y' || v === 'true' const dateOnly = (v: any): string => (v ? String(v).slice(0, 10) : '') export const mapCarrierRate = (r: any, countryCodes?: Map): CarrierRate => { const countryId = r?.C_Country_ID?.id ?? null return { id: n(r?.id), uid: r?.uid, shipperId: n(r?.M_Shipper_ID?.id), shipper: s(r?.M_Shipper_ID?.identifier), serviceCode: s(r?.ServiceCode ?? r?.serviceCode), serviceName: s(r?.ServiceName ?? r?.serviceName), rateType: (s(r?.RateType ?? r?.rateType) || 'base') as RateType, countryId: countryId ? n(countryId) : null, country: s(r?.C_Country_ID?.identifier), countryCode: countryId && countryCodes ? (countryCodes.get(n(countryId)) || '') : '', zone: s(r?.Zone ?? r?.zone), rangeFrom: n(r?.RangeWeightFrom ?? r?.rangeWeightFrom), rangeTo: n(r?.RangeWeightTo ?? r?.rangeWeightTo), basePrice: n(r?.BasePrice ?? r?.basePrice), pricePerKg: n(r?.PricePerKG ?? r?.pricePerKG ?? r?.pricePerKg), isPercentage: bool(r?.IsPercentage ?? r?.isPercentage), contractNo: s(r?.ContractNo ?? r?.contractNo), validFrom: dateOnly(r?.ValidFrom ?? r?.validFrom), validTo: dateOnly(r?.ValidTo ?? r?.validTo), description: s(r?.Description ?? r?.description), sourceRef: s(r?.SourceRef ?? r?.sourceRef), isActive: r?.IsActive ?? r?.isActive ?? true } } export interface CarrierRateInput { shipperId?: number serviceCode?: string serviceName?: string rateType?: string countryId?: number | null zone?: string rangeFrom?: number rangeTo?: number basePrice?: number pricePerKg?: number isPercentage?: boolean contractNo?: string validFrom?: string validTo?: string description?: string sourceRef?: string isActive?: boolean } /** Body for POST (full) or PUT (only the keys present in `b`). */ export const carrierRateBody = (b: CarrierRateInput, full: boolean): any => { const body: any = { tableName: CARRIER_RATE_TABLE } const put = (cond: boolean, key: string, value: any) => { if (full || cond) body[key] = value } if (full) body.AD_Org_ID = { id: 0, tableName: 'AD_Org' } if (full || b.shipperId !== undefined) body.M_Shipper_ID = { id: n(b.shipperId), tableName: 'M_Shipper' } if (b.countryId) body.C_Country_ID = { id: n(b.countryId), tableName: 'C_Country' } put(b.serviceCode !== undefined, 'serviceCode', s(b.serviceCode)) put(b.serviceName !== undefined, 'serviceName', s(b.serviceName)) put(b.rateType !== undefined, 'rateType', s(b.rateType) || 'base') put(b.zone !== undefined, 'zone', s(b.zone)) put(b.rangeFrom !== undefined, 'rangeWeightFrom', n(b.rangeFrom)) put(b.rangeTo !== undefined, 'rangeWeightTo', n(b.rangeTo)) put(b.basePrice !== undefined, 'basePrice', n(b.basePrice)) put(b.pricePerKg !== undefined, 'pricePerKG', n(b.pricePerKg)) put(b.isPercentage !== undefined, 'isPercentage', b.isPercentage === true) put(b.contractNo !== undefined, 'contractNo', s(b.contractNo)) put(b.description !== undefined, 'description', s(b.description).slice(0, 255)) put(b.sourceRef !== undefined, 'sourceRef', s(b.sourceRef).slice(0, 60)) put(b.isActive !== undefined, 'isActive', b.isActive !== false) // Dates: only send when set (REST rejects null for these on PUT). if (b.validFrom) body.validFrom = String(b.validFrom).slice(0, 10) if (b.validTo) body.validTo = String(b.validTo).slice(0, 10) return body } export interface ShipperGroup { shipperId: number; shipperName: string; cfgId: number | null; carrier: string; shipperIds: number[]; primaryShipperId: number } /** M_Shipper → carrier group via M_ShipperCfg_ID (DHL cfg 1000000 = DHL/DHL Prime/…; DPD cfg 1000002). Cached 10 min. */ let shipperCache: { at: number; byShipper: Map } | null = null export const loadShipperGroups = async (event: any, token: string): Promise> => { if (shipperCache && Date.now() - shipperCache.at < 600000) return shipperCache.byShipper const res: any = await fetchHelper(event, `models/m_shipper?$top=200&$select=${encodeURIComponent('Name,M_ShipperCfg_ID,IsActive')}`, 'GET', token, null) const rows = (res?.records || []).map((r: any) => ({ id: n(r?.id), name: s(r?.Name), cfgId: r?.M_ShipperCfg_ID?.id ? n(r.M_ShipperCfg_ID.id) : null, cfgName: s(r?.M_ShipperCfg_ID?.identifier) })) const byShipper = new Map() for (const r of rows) { const carrier = carrierCodeOf(r.cfgName, r.name) const members = r.cfgId ? rows.filter((x: any) => x.cfgId === r.cfgId) : [r] const ids = members.map((m: any) => m.id).sort((a: number, b: number) => a - b) byShipper.set(r.id, { shipperId: r.id, shipperName: r.name, cfgId: r.cfgId, carrier, shipperIds: ids, primaryShipperId: ids[0] }) } shipperCache = { at: Date.now(), byShipper } return byShipper } export interface CarrierRateFilter { shipperId?: number /** expand shipperId to every shipper of the same M_ShipperCfg_ID (default true) */ byCarrier?: boolean serviceCode?: string countryId?: number rateType?: string validOn?: string includeInactive?: boolean top?: number } const buildFilter = (f: CarrierRateFilter, shipperIds?: number[]): string => { const parts: string[] = [] if (shipperIds && shipperIds.length > 1) parts.push('(' + shipperIds.map((id) => `M_Shipper_ID eq ${n(id)}`).join(' or ') + ')') else if (f.shipperId) parts.push(`M_Shipper_ID eq ${n(f.shipperId)}`) if (f.serviceCode) parts.push(`ServiceCode eq '${String(f.serviceCode).replace(/'/g, "''")}'`) if (f.countryId) parts.push(`C_Country_ID eq ${n(f.countryId)}`) if (f.rateType) parts.push(`RateType eq '${String(f.rateType).replace(/'/g, "''")}'`) parts.push(f.includeInactive ? '(IsActive eq true or IsActive eq false)' : 'IsActive eq true') return parts.join(' and ') } /** Load contract rows (raw iDempiere records), paged in 500s; `top` caps the total. */ export const loadCarrierRateRecords = async (event: any, token: string, f: CarrierRateFilter = {}): Promise => { const pageSize = 500 const cap = f.top && f.top > 0 ? f.top : 5000 const out: any[] = [] let skip = 0 let shipperIds: number[] | undefined if (f.shipperId && f.byCarrier !== false) { try { shipperIds = (await loadShipperGroups(event, token)).get(n(f.shipperId))?.shipperIds } catch { /* fall back to the single shipper */ } } while (out.length < cap) { const take = Math.min(pageSize, cap - out.length) const url = `${CARRIER_RATE_MODEL}?$filter=${encodeURIComponent(buildFilter(f, shipperIds))}&$orderby=${encodeURIComponent('ServiceCode asc, C_Country_ID asc, RangeWeightFrom asc, cust_carrierrate_id asc')}&$top=${take}&$skip=${skip}` const res: any = await fetchHelper(event, url, 'GET', token, null) const recs: any[] = Array.isArray(res?.records) ? res.records : [] out.push(...recs) if (recs.length < take) break skip += recs.length } return out } /** C_Country id → ISO2 and ISO2 → id (249 rows, cached 10 min per process). */ let countryCache: { at: number; byId: Map; byCode: Map; names: Map } | null = null export const loadCountryMaps = async (event: any, token: string) => { if (countryCache && Date.now() - countryCache.at < 600000) return countryCache const res: any = await fetchHelper(event, `models/c_country?$top=400&$select=${encodeURIComponent('Name,CountryCode')}`, 'GET', token, null) const byId = new Map(), byCode = new Map(), names = new Map() for (const c of (res?.records || [])) { const id = n(c?.id), code = s(c?.CountryCode).toUpperCase() if (!id) continue names.set(id, s(c?.Name)) if (code) { byId.set(id, code); if (!byCode.has(code)) byCode.set(code, id) } } countryCache = { at: Date.now(), byId, byCode, names } return countryCache } export const loadCarrierRates = async (event: any, token: string, f: CarrierRateFilter = {}): Promise => { const [recs, countries] = await Promise.all([loadCarrierRateRecords(event, token, f), loadCountryMaps(event, token)]) let rows = recs.map((r) => mapCarrierRate(r, countries.byId)) if (f.validOn) { const d = String(f.validOn).slice(0, 10) rows = rows.filter((r) => (!r.validFrom || r.validFrom <= d) && (!r.validTo || r.validTo >= d)) } return rows }