import { defineSisHandler } from "../../../../utils/listings/routeHelper" import { listListings } from "../../../../utils/listings/listingStore" import { enrichWithProducts } from "../../../../utils/sisMatching" import { amazonDomainForOrderSource } from "../../../../utils/amazonSpApi" import { marketplaceKeyOf, marketplaceLabel, supportsListings } from "../../../../utils/listings/adapters" import { shopifyStoreHandle } from "../../../../utils/listings/adapters/shopify" import { shopwareUrls } from "../../../../utils/listings/adapters/shopware" import { string } from 'alga-js' import { feeModelForListing, vatRateForTaxCategory } from "../../../../utils/listings/feeModel" import { minGrossPrice, marginAt } from "../../../../../shared/utils/sisPricing" // GET /api/sales/sms/listings?orderSourceId=1,2,3&includeInactive=1 — one or several order sources // GET /api/sales/sms/listings?all=1 — every order source with a listings adapter // The persisted listings (CUST_MarketplaceListing) joined with the internal product data // (price list price, qty on hand, BOM components). iDempiere only — no marketplace call. // `sources` carries the per-source marketplace / link data the page needs for mixed lists. const sourceInfo = (os: any) => { const marketplace = marketplaceKeyOf(os) const shopDomain = String(os?.marketplace_url || os?.Marketplace_URL || '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '') return { id: Number(os.id), name: os.Name || '', orgId: Number(os?.AD_Org_ID?.id) || 0, orgName: os?.AD_Org_ID?.identifier || '', marketplace, marketplaceName: marketplaceLabel(marketplace), supportsListings: supportsListings(os), marketplaceDomain: amazonDomainForOrderSource(os), shop: marketplace.startsWith('shopify') ? { storeHandle: shopifyStoreHandle(os), domain: shopDomain } : marketplace === 'shopware' ? { baseUrl: shopwareUrls(os).baseUrl } : null } } // Shipping cost of one national parcel ≤ 2 kg for the pricing floor, from the carrier CONTRACT rates // (CUST_CarrierRate): DHL Paket band containing 2 kg / lowest DPD Classic band. // DHL: + the per-parcel add-ons DHL bills on top of the base — "Maut- und CO2-Zuschlag" 0,19 € (contract // row), Peakzuschlag 0,19 € Nov–Dec (contract row), Peak-in-Peak 0,50 € (24.11.–7.12.) and the monthly // energy surcharge the table only holds as a 0 % placeholder → env SMS_DHL_ENERGY_PCT (11/2026 = 4,75 %). // DPD: per the myDPD G300 price list (reviewed 2026-11-04) prices are "zzgl. Maut- und Treibstoffzuschlägen"; the // standard surcharges per parcel are Mautzuschlag 0,35 € (SMS_DPD_TOLL), Sicherheitsgebühr 0,15 € // (SMS_DPD_SECURITY), Energiezuschlag Straße (variable, dpd.de/energiezuschlag → SMS_DPD_ENERGY_PCT, 11/2026 = // 31 %) and Emergency Fuel Surcharge (variable → SMS_DPD_EFS_PCT, 11/2026 = 4,08 %). No peak surcharge for // parcels (only tyres) → SMS_DPD_PEAK default 0. // Packaging per parcel: SMS_PACKAGING_COST (0,50 €). Cached per process (10 min). const SHIPPING_FALLBACK = { dhl: 4.15, dpd: 3.00 } const pctEnv = (k: string, d: number) => { const n = Number(process.env[k]); return Number.isFinite(n) && n >= 0 ? n : d } const r2 = (n: number) => Math.round(n * 100) / 100 let shippingCache: { at: number; value: any } | null = null const loadShippingDefaults = async (f: any) => { if (shippingCache && Date.now() - shippingCache.at < 10 * 60 * 1000) return shippingCache.value const packaging = Math.max(0, Number(process.env.SMS_PACKAGING_COST ?? 0.5) || 0) const now = new Date() const month = now.getMonth() + 1, day = now.getDate() const peakSeason = month === 11 || month === 12 const peakInPeak = (month === 11 && day >= 24) || (month === 12 && day <= 7) const energy = { dhl: pctEnv('SMS_DHL_ENERGY_PCT', 4.75), dpd: pctEnv('SMS_DPD_ENERGY_PCT', 31), dpdEfs: pctEnv('SMS_DPD_EFS_PCT', 4.08) } const dpdExtra = { toll: pctEnv('SMS_DPD_TOLL', 0.35), security: pctEnv('SMS_DPD_SECURITY', 0.15), peak: pctEnv('SMS_DPD_PEAK', 0) } type Part = { label: string; amount: number } const build = (base: number, baseLabel: string, parts: Part[]) => ({ base, baseLabel, parts, price: r2(base + parts.reduce((a, p) => a + p.amount, 0)), label: `${baseLabel} ${base.toFixed(2)} €` + parts.map(p => ` + ${p.label} ${p.amount.toFixed(2)} €`).join('') }) const value: any = { packaging, energy, peakSeason, peakInPeak, dhl: null as any, dpd: null as any } let dhlBase: any = null, dpdBase: any = null, dhlSur: any[] = [], dpdSur: any[] = [] try { const q = async (service: string, rateType: string) => { const res: any = await f(`models/cust_carrierrate?$filter=${string.urlEncode(`ServiceCode eq '${service}' and RateType eq '${rateType}' and IsActive eq true`)}&$orderby=${string.urlEncode('RangeWeightTo asc')}&$top=100&$select=ServiceName,C_Country_ID,RangeWeightFrom,RangeWeightTo,BasePrice,PricePerKG,IsPercentage,Description`) return (res?.records || []).filter((r: any) => /germany|deutschland/i.test(String(r?.C_Country_ID?.identifier || '')) || !r?.C_Country_ID) } const [dhl, dpd, ds, ps] = await Promise.all([q('DHL_PAKET', 'base'), q('DPD_CLASSIC', 'base'), q('DHL_PAKET', 'surcharge'), q('DPD_CLASSIC', 'surcharge')]) const band = (rows: any[], kg: number) => rows.find(r => Number(r.RangeWeightFrom) <= kg && Number(r.RangeWeightTo) >= kg) || rows[0] dhlBase = band(dhl, 2); dpdBase = band(dpd, 2); dhlSur = ds; dpdSur = ps // dpdSur kept for a later opt-in of contract surcharge rows } catch { /* fallbacks below */ } const basePrice = (r: any, fallback: number) => (r ? r2(Number(r.BasePrice) + Number(r.PricePerKG || 0) * 2) : fallback) const fixedRow = (rows: any[], re: RegExp) => { const r = rows.find(x => re.test(String(x.Description || '')) && !(x.IsPercentage === true || x.IsPercentage === 'Y')); return r ? Number(r.BasePrice) : null } // DHL Paket national { const base = basePrice(dhlBase, SHIPPING_FALLBACK.dhl) const parts: Part[] = [] if (energy.dhl > 0) parts.push({ label: `energy ${energy.dhl} %`, amount: r2(base * energy.dhl / 100) }) const toll = fixedRow(dhlSur, /maut/i) ?? 0.19 if (toll) parts.push({ label: 'toll/CO2', amount: toll }) if (peakSeason) parts.push({ label: 'peak Nov–Dec', amount: fixedRow(dhlSur, /^peakzuschlag/i) ?? 0.19 }) if (peakInPeak) parts.push({ label: 'peak-in-peak', amount: fixedRow(dhlSur, /peak-in-peak/i) ?? 0.5 }) value.dhl = build(base, dhlBase ? `${dhlBase.ServiceName || 'DHL Paket'} ${dhlBase.RangeWeightFrom}–${dhlBase.RangeWeightTo} kg` : 'DHL Paket ≤ 2 kg (fallback)', parts) } // DPD Classic national { const base = basePrice(dpdBase, SHIPPING_FALLBACK.dpd) const parts: Part[] = [] if (energy.dpd > 0) parts.push({ label: `energy ${energy.dpd} %`, amount: r2(base * energy.dpd / 100) }) if (energy.dpdEfs > 0) parts.push({ label: `emergency fuel ${energy.dpdEfs} %`, amount: r2(base * energy.dpdEfs / 100) }) if (dpdExtra.toll > 0) parts.push({ label: 'toll', amount: dpdExtra.toll }) if (dpdExtra.security > 0) parts.push({ label: 'security fee', amount: dpdExtra.security }) if (peakSeason && dpdExtra.peak > 0) parts.push({ label: 'peak Nov–Dec', amount: dpdExtra.peak }) value.dpd = build(base, dpdBase ? `${dpdBase.ServiceName || 'DPD Classic'} ${dpdBase.RangeWeightFrom}–${dpdBase.RangeWeightTo} kg` : 'DPD Classic (fallback)', parts) } shippingCache = { at: Date.now(), value } return value } export default defineSisHandler(async ({ event, token, f }) => { const q = getQuery(event) const all = q.all === '1' || q.all === 'true' const ids = String(q.orderSourceId || '').split(',').map(s => Number(s.trim())).filter(n => Number.isFinite(n) && n > 0) if (!all && !ids.length) return { status: 400, message: 'orderSourceId (comma separated) or all=1 is required' } let sources: any[] = [] if (all) { const res: any = await f(`models/c_ordersource?$filter=${string.urlEncode('isActive eq true')}&$orderby=${string.urlEncode('Name asc')}&$top=500`) sources = (res?.records || []).filter((os: any) => supportsListings(os)) } else { for (const id of ids) { const os: any = await f(`models/c_ordersource/${id}`) if (os?.id) sources.push(os) } if (!sources.length) return { status: 404, message: 'Order source not found' } } const infos = Object.fromEntries(sources.map(os => [String(os.id), sourceInfo(os)])) const activeOnly = q.includeInactive !== '1' const listings: any[] = [] for (const os of sources) listings.push(...await listListings(f, Number(os.id), { activeOnly, top: 5000 })) // product enrichment is org-scoped (return locators) → one call per org const config: any = useRuntimeConfig(event) const priceListId = config.public?.pricelistid || '101' const enrich = new Map() const byOrg = new Map() for (const l of listings) { if (!l.productId) continue const orgId = infos[String(l.orderSourceId)]?.orgId || 0 byOrg.set(orgId, [...(byOrg.get(orgId) || []), l.productId]) } for (const [orgId, productIds] of byOrg) for (const [pid, e] of await enrichWithProducts(event, token, orgId, productIds, priceListId)) enrich.set(pid, e) // Purchase cost of one offer unit: BOM → Σ component purchase price × qty per bundle (the bundle's own // list price only when a component has no price), plain product → its purchase price. const purchaseCostOf = (p: any): { cost: number | null; parts: { value: string; name: string; qty: number; price: number | null }[]; basis: 'bom' | 'product' | 'none'; complete: boolean } => { if (!p) return { cost: null, parts: [], basis: 'none', complete: false } const comps: any[] = p.bomComponents || [] if (p.isBom && comps.length) { const parts = comps.map(c => ({ value: c.value, name: c.name, qty: Number(c.qtyBom) || 0, price: c.purchasePrice ?? null })) const complete = parts.every(x => x.price != null) if (complete) return { cost: Math.round(parts.reduce((sum, x) => sum + x.qty * (x.price || 0), 0) * 100) / 100, parts, basis: 'bom', complete: true } if (p.purchasePrice != null) return { cost: p.purchasePrice, parts, basis: 'product', complete: false } return { cost: null, parts, basis: 'none', complete: false } } return p.purchasePrice != null ? { cost: p.purchasePrice, parts: [], basis: 'product', complete: true } : { cost: null, parts: [], basis: 'none', complete: false } } const shipping = await loadShippingDefaults(f) const rows = listings.map(l => { const p = l.productId ? enrich.get(l.productId) : null const src = infos[String(l.orderSourceId)] const purchase = purchaseCostOf(p) const vatRate = vatRateForTaxCategory(p?.taxCategoryName) const feeModel = feeModelForListing(src?.marketplace || 'unknown', l) // full cost of one shipped unit = purchase + packaging + carrier; DHL is the default carrier, DPD shown next to it const costDhl = purchase.cost != null ? Math.round((purchase.cost + shipping.packaging + shipping.dhl.price) * 100) / 100 : null const costDpd = purchase.cost != null ? Math.round((purchase.cost + shipping.packaging + shipping.dpd.price) * 100) / 100 : null const minPrice = costDhl != null ? minGrossPrice(costDhl, vatRate, feeModel) : null const minPriceDpd = costDpd != null ? minGrossPrice(costDpd, vatRate, feeModel) : null const target = l.price ?? l.remotePrice ?? null const margin = costDhl != null && target != null ? marginAt(Number(target), costDhl, vatRate, feeModel) : null return { ...l, orderSourceName: src?.name || l.orderSourceName || '', marketplace: src?.marketplace || 'unknown', productName: p?.productName || '', productValue: p?.productValue || '', productAsin: p?.asin || null, strapiProductDocumentId: p?.strapiProductDocumentId || null, isBom: p?.isBom || false, // BOM: components carry the stock, not the bundle → the FIRST component's qty is the row's // internal qty (all components with qty per bundle + stock go to the tooltip). bomComponents: p?.bomComponents || [], bomOwnQtyOnHand: p?.isBom ? (p?.internalQtyOnHand ?? null) : null, internalPrice: p?.internalPrice ?? null, // pricing floor: purchase cost (net) + marketplace fees + VAT → min. gross price, margin at the target price purchaseCost: purchase.cost, purchaseParts: purchase.parts, purchaseBasis: purchase.basis, purchaseComplete: purchase.complete, purchasePriceOwn: p?.purchasePrice ?? null, purchasePriceListName: p?.purchasePriceListName || null, packagingCost: shipping.packaging, shippingDhl: shipping.dhl.price, shippingDpd: shipping.dpd.price, vatRate, feeModel, minPrice, minPriceDpd, marginAtTarget: margin, internalQtyOnHand: p?.bomComponents?.[0] ? p.bomComponents[0].onHand : (p?.internalQtyOnHand ?? null), returnQtyOnHand: p?.bomComponents?.[0] ? p.bomComponents[0].returnOnHand : (p?.returnQtyOnHand ?? 0) } }) const counts = { total: rows.length, matched: rows.filter(r => r.productId).length, unmatched: rows.filter(r => !r.productId).length, queued: rows.filter(r => r.syncStatus === 'queued' || r.syncStatus === 'sending').length, sent: rows.filter(r => r.syncStatus === 'sent').length, errors: rows.filter(r => r.syncStatus === 'error').length, delisted: rows.filter(r => r.listingStatus === 'delisted').length } const first = infos[String(sources[0]?.id)] return { status: 200, all, sources: infos, // single-source fields kept for callers that pass one id orderSourceId: first?.id ?? null, orgId: first?.orgId ?? null, orderSourceName: first?.name || '', marketplace: first?.marketplace, marketplaceName: first?.marketplaceName, supportsListings: first?.supportsListings, marketplaceDomain: first?.marketplaceDomain, shop: first?.shop ?? null, shipping, rows, counts } })