import refreshTokenHelper from "../../../utils/refreshTokenHelper"; import errorHandlingHelper from "../../../utils/errorHandlingHelper"; import fetchHelper from "../../../utils/fetchHelper"; import laravelHelper from "../../../utils/laravelHelper"; // POST /api/ffn/stocks/set-all // Body: { orderSourceId: string, quantity: number (>= 0, integer), note?: string } // // VIRTUAL fixed-qty sync. Sets the JTL-FFN available stock level of EVERY (non-BOM) // product belonging to the selected Order Source's merchant to ONE fixed quantity. // JTL-FFN's stock API is delta-based, so per product we push (quantity - currentLevel). // // Pushed to JTL-FFN ONLY. iDempiere is never read for stock nor written — no // m_storageonhand / m_product change happens here. The single iDempiere call is the // read of the Order Source record (for FFN credentials), nothing more. const buildFfnHeaders = (os: any, ffnToken: string) => { const headers: any = { 'content-type': 'application/json', 'accept': 'application/json' } if(ffnToken) { // JTL FFN expects the proprietary scheme: Authorization: ffn headers['Authorization'] = `ffn ${ffnToken}` headers['x-application-id'] = 'JPCB0XLOGYOU' headers['x-application-version'] = '0.1' } if(os?.marketplace_key) headers['x-api-key'] = os.marketplace_key if(os?.marketplace_secret) headers['x-api-secret'] = os.marketplace_secret if(os?.jtl_merchantID) headers['x-merchant-id'] = os.jtl_merchantID return headers } const normalizeHost = (baseUrl: string) => { let host = baseUrl.trim() if(!/^https?:\/\//i.test(host)) host = `https://${host}` return host.replace(/\/$/, '') } // Resolve the path to append to `host` for a fulfiller sub-resource, tolerating a // marketplace_url that already includes /api/v1/fulfiller(/) — mirrors the // other ffn routes (stocks.get.ts, products/index.get.ts, adjustments.post.ts). const resolveEndpoint = (host: string, resource: string) => { const full = `/api/v1/fulfiller/${resource}` if(new RegExp(`/api/v1/fulfiller/${resource}/?$`, 'i').test(host)) return '' if(/\/api\/v1\/fulfiller\/?$/i.test(host)) return `/${resource}` return full } const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const body = await readBody(event) const orderSourceId = body?.orderSourceId as string const note: string = (body?.note && String(body.note)) || 'Virtual fixed-qty sync via ERP' if(!orderSourceId) return { status: 400, message: 'orderSourceId is required' } // Validate quantity: whole number >= 0 (0 is allowed — zeroes out FFN stock virtually) const quantity = Number(body?.quantity) if(!Number.isFinite(quantity) || quantity < 0 || !Number.isInteger(quantity)) { return { status: 400, message: 'quantity must be a whole number 0 or greater' } } // Load + validate Order Source (must be JTL-FFN) const os: any = await fetchHelper(event, `models/c_ordersource/${orderSourceId}`, 'GET', token, null) if(!os) return { status: 404, message: 'Order Source not found' } if(!(os?.Marketplace?.identifier === 'jtl-ffn' || os?.marketplace === '7' || os?.Marketplace?.id === '7')) { return { status: 400, message: 'Selected Order Source is not JTL-FFN' } } const cfg: any = useRuntimeConfig() const baseUrl: string = os?.marketplace_url || cfg?.api?.jtlFulfillerUrl || '' if(!baseUrl) return { status: 400, message: 'No marketplace_url configured for this Order Source' } const ffnToken: string = os?.marketplace_token || cfg?.api?.jtlFulfillerToken || '' const headers = buildFfnHeaders(os, ffnToken) const host = normalizeHost(baseUrl) const productsEndpoint = resolveEndpoint(host, 'products') const stocksEndpoint = resolveEndpoint(host, 'stocks') const adjEndpoint = resolveEndpoint(host, 'stocks/adjustments') // The fulfiller /products endpoint is fulfiller-wide and ignores x-merchant-id; scope // to THIS merchant with an OData $filter so we don't touch another merchant's articles. const merchantId: string = os?.jtl_merchantID ? String(os.jtl_merchantID).trim() : '' const merchantFilter = merchantId ? `merchantId eq '${merchantId.replace(/'/g, "''")}'` : '' const PAGE = 500 const SKIP_CAP = 200000 // runaway guard // 1) Page through ALL products of this merchant (need specifications for BOM detection) const products: any[] = [] for(let skip = 0; skip <= SKIP_CAP; skip += PAGE) { const params = new URLSearchParams() params.set('$top', String(PAGE)) params.set('$skip', String(skip)) if(merchantFilter) params.set('$filter', merchantFilter) const url = `${host}${productsEndpoint}?${params.toString()}` const res: any = await $fetch(url, { method: 'GET', headers }) const items = res?.items || [] products.push(...items) if(items.length < PAGE) break } // 2) Page through ALL stocks → map by jfsku (jfsku is globally unique, so cross-merchant // rows are simply never looked up). Carries current level + warehouse + batch details. const stocksByJfsku: Record = {} for(let skip = 0; skip <= SKIP_CAP; skip += PAGE) { const params = new URLSearchParams() params.set('$top', String(PAGE)) params.set('$skip', String(skip)) const url = `${host}${stocksEndpoint}?${params.toString()}` const res: any = await $fetch(url, { method: 'GET', headers }) const items = res?.items || [] for(const s of items) { if(s?.jfsku) stocksByJfsku[s.jfsku] = s } if(items.length < PAGE) break } // 3) Single fulfiller warehouse — fallback for products that have no stock record yet // (e.g. a merchant's first sync). Only needed when quantity > 0. let defaultWarehouseId = '' try { const wRes: any = await laravelHelper(event, 'fulfiller/warehouses', 'GET') const wh = (wRes?.items || wRes?.records || [])[0] if(wh?.warehouseId) defaultWarehouseId = wh.warehouseId } catch (e) { /* optional — rows with an existing stock record still resolve a warehouse */ } // 4) Build delta adjustments (target = quantity for every non-BOM product) const payloads: any[] = [] let skippedBom = 0, skippedNoWh = 0, alreadyAtTarget = 0 for(const p of products) { const jfsku = p?.jfsku || p?.Jfsku || '' if(!jfsku) continue if(p?.specifications?.isBillOfMaterials) { skippedBom++; continue } // JTL derives BOM stock from components const stock = stocksByJfsku[jfsku] || null const currentLevel = Number(stock?.stockLevel ?? 0) const delta = quantity - currentLevel if(delta === 0) { alreadyAtTarget++; continue } const wh = (stock?.warehouses && stock.warehouses[0]) ? stock.warehouses[0] : null const det = (stock?.stockLevelDetails && stock.stockLevelDetails[0]) ? stock.stockLevelDetails[0] : null const warehouseId = wh?.warehouseId || defaultWarehouseId if(!warehouseId) { skippedNoWh++; continue } const bb = det?.bestBefore payloads.push({ fulfillerStockChangeId: `setall-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, jfsku, warehouseId, quantity: delta, batch: det?.batch, bestBefore: bb ? { year: bb.year, month: bb.month, day: bb.day } : undefined, note, fulfillerTimestamp: new Date().toISOString() }) } // 5) Push each adjustment to JTL-FFN sequentially. Per-item try/catch so one failure // never aborts the batch (and never bubbles up to trigger a full retry/re-push). const adjUrl = `${host}${adjEndpoint}` const results: any[] = [] for(const payload of payloads) { try { const resp = await $fetch(adjUrl, { method: 'POST', headers, body: payload }) results.push({ ok: true, payload, response: resp }) } catch (err: any) { const e = errorHandlingHelper(err?.data ?? err, err?.data ?? err) const status = Number(e?.status || err?.status || err?.response?.status || 500) const message = e?.message || err?.data?.message || err?.message || 'Failed to send FFN stock adjustment' results.push({ ok: false, payload, error: { status, message } }) } } const failed = results.filter(r => !r.ok) return { status: failed.length ? 207 : 200, message: failed.length ? `${failed.length} of ${payloads.length} adjustment(s) failed — set rest to ${quantity}` : `Set ${payloads.length} product(s) to ${quantity} in JTL-FFN`, summary: { quantity, totalProducts: products.length, changed: payloads.length, alreadyAtTarget, skippedBom, skippedNoWh, failed: failed.length }, results } } export default defineEventHandler(async (event) => { try { return await handleFunc(event) } catch (err: any) { try { const authToken = await refreshTokenHelper(event) return await handleFunc(event, authToken) } catch (error: any) { const data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) if([401, 402, 403, 407].includes(Number(data.status))) { //@ts-ignore setCookie(event, 'user', null) } return data } } })