import { string } from 'alga-js' import fetchHelper from "./fetchHelper" // Shared reconciliation WRITE core for the marketplace "Bestellabgleich" pages // (Amazon + eBay). For each selected matched order it can, in ONE reactivate cycle: // - amount: ADD a dedicated "difference adjustment" order line — a CHARGE line // (C_Charge_ID from AMAZON_ADJUSTMENT_CHARGE_ID) carrying the gross difference, // VAT-grossed-up with a known tax rate so GrandTotal == the marketplace total. // Positive price = increase, negative price = decrease. Report-only residual. // - date: set DateOrdered + DateAcct to the marketplace purchase day. // Flow per order: Re-Activate (RE) -> add line / edit dates -> Complete (CO) -> verify. // Why a NEW line instead of editing an existing one: these completed orders have // already-delivered lines, and iDempiere rejects in-place price edits on them (500). // A fresh, undelivered line is only ever created (POST), never updated. // Safety: never touches invoiced orders, never crosses currencies, never edits the // original lines, is idempotent on retry (won't stack a 2nd adjustment line), and // reports every order's outcome individually. NB: `Created` is never modified. // // Marketplace-neutral: the caller passes `linkIdFields` (the c_order columns that // hold this marketplace's order id, e.g. ['amazon_order_id','ExternalOrderId'] or // ['ebay_order_id','ExternalOrderId']). Per-item fields are read tolerantly so the // Amazon page (amazonTotal/amazonCurrency/amazonOrderId) and the eBay page // (marketplaceTotal/marketplaceCurrency/marketplaceOrderId) both work unchanged. export interface MarketplaceSyncOptions { items: any[] syncAmount: boolean syncDate: boolean adjustmentChargeId: number linkIdFields: string[] } const round2 = (n: number) => Math.round(n * 100) / 100 const dayOf = (s: string | null | undefined): string => { if (!s) return '' const str = String(s) const m = str.match(/^(\d{4}-\d{2}-\d{2})/) if (m) return m[1] const d = new Date(str) return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10) } const dsCodeOf = (order: any): string => String(order?.DocStatus?.id || order?.DocStatus?.identifier || order?.DocStatus || '').toUpperCase() const isCompleted = (code: string) => code === 'CO' || code.includes('COMPLET') const isFrozen = (code: string) => code === 'CL' || code === 'VO' || code.includes('CLOSED') || code.includes('VOID') || code.includes('REVERS') // Selects every possible marketplace link field (amazon + ebay + external) so the // same fetch works for both marketplaces; plus AD_Org_ID for the new line's org. const fetchOrder = async (event: any, token: any, id: number) => await fetchHelper( event, `models/c_order/${id}?$select=${string.urlEncode('C_Order_ID,DocumentNo,GrandTotal,TotalLines,DateOrdered,DateAcct,DocStatus,IsInvoiced,C_Currency_ID,M_PriceList_ID,AD_Org_ID,amazon_order_id,ebay_order_id,ExternalOrderId')}&$expand=${string.urlEncode('M_PriceList_ID($select=IsTaxIncluded)')}`, 'GET', token, null ) const docAction = async (event: any, token: any, id: number, action: string) => await fetchHelper(event, `models/c_order/${id}`, 'PUT', token, { 'doc-action': action }) const grandTotalOf = (order: any): number => typeof order?.GrandTotal === 'number' ? order.GrandTotal : Number(order?.GrandTotal || 0) const isAuthError = (e: any): boolean => { const s = e?.status ?? e?.statusCode ?? e?.response?.status return Number(s) === 401 } // Pick representative defaults from the order's own lines (largest by |LineNetAmt|): // the tax (so we know the exact rate to gross-up the adjustment line with, and tax // the difference consistently with the order) and a valid UOM (charge lines still // need a C_UOM_ID). Falls back to the default sales tax when the order has no lines. const pickOrderLineDefaults = async ( event: any, token: any, orderId: number ): Promise<{ taxId: number | null; rate: number; uomId: number | null }> => { const linesRes: any = await fetchHelper( event, `models/c_orderline?$filter=${string.urlEncode('C_Order_ID eq ' + orderId)}&$select=${string.urlEncode('C_OrderLine_ID,LineNetAmt,C_Tax_ID,C_UOM_ID')}&$expand=${string.urlEncode('C_Tax_ID($select=Rate)')}&$top=200`, 'GET', token, null ) const lines = (linesRes?.records || []).filter((l: any) => l?.C_Tax_ID) if (lines.length) { let line = lines[0] for (const l of lines) { if (Math.abs(Number(l.LineNetAmt || 0)) > Math.abs(Number(line.LineNetAmt || 0))) line = l } return { taxId: Number(line.C_Tax_ID?.id ?? line.C_Tax_ID?.C_Tax_ID) || null, rate: Number(line.C_Tax_ID?.Rate || 0) / 100, uomId: Number(line.C_UOM_ID?.id ?? line.C_UOM_ID?.C_UOM_ID) || null } } // Fallback: default sales tax (no representative UOM available). const taxRes: any = await fetchHelper( event, `models/c_tax?$filter=${string.urlEncode('IsDefault eq true AND IsSalesTax eq true AND IsActive eq true')}&$select=${string.urlEncode('C_Tax_ID,Rate')}&$top=1`, 'GET', token, null ) const t = taxRes?.records?.[0] return { taxId: t ? Number(t.id ?? t.C_Tax_ID) : null, rate: Number(t?.Rate || 0) / 100, uomId: null } } // Find an existing adjustment line on the order (added by a prior run / retry), so we // never stack a second one. Matches on the adjustment CHARGE. Returns the line id, or null. const findAdjustmentLine = async ( event: any, token: any, orderId: number, chargeId: number ): Promise => { const res: any = await fetchHelper( event, `models/c_orderline?$filter=${string.urlEncode('C_Order_ID eq ' + orderId + ' AND C_Charge_ID eq ' + chargeId)}&$select=${string.urlEncode('C_OrderLine_ID')}&$top=1`, 'GET', token, null ) const rec = res?.records?.[0] return rec ? (Number(rec.id ?? rec.C_OrderLine_ID) || null) : null } // Add ONE new adjustment line carrying the gross difference D (signed). It is a CHARGE // line (C_Charge_ID), not a product line. VAT-grossed-up with the order's own tax rate // so the GrandTotal moves by exactly D. POST only — never PUTs an existing line. // Returns { ok, error }. Order must already be re-activated. const addAdjustmentLine = async ( event: any, token: any, order: any, chargeId: number, D: number, taxIncluded: boolean ): Promise<{ ok: boolean; error?: string }> => { const orderId = Number(order.id ?? order.C_Order_ID) const orgId = Number(order.AD_Org_ID?.id ?? order.AD_Org_ID?.AD_Org_ID ?? order.AD_Org_ID) const { taxId, rate: t, uomId } = await pickOrderLineDefaults(event, token, orderId) const net = round2(D / (1 + t)) // qty 1 → lineNetAmt = net const priceActual = net const priceEntered = taxIncluded ? round2(net * (1 + t)) : net const payload: any = { AD_Org_ID: { id: orgId, tableName: 'AD_Org' }, C_Order_ID: { id: orderId, tableName: 'C_Order' }, C_Charge_ID: { id: chargeId, tableName: 'C_Charge' }, isActive: true, isDescription: false, description: 'Marketplace Bestellabgleich – Differenzausgleich', qtyEntered: 1, qtyOrdered: 1, priceActual, priceEntered, lineNetAmt: net, tableName: 'C_Orderline' } if (uomId) payload.C_UOM_ID = { id: uomId, tableName: 'C_UOM' } if (taxId) payload.C_Tax_ID = { id: taxId, tableName: 'C_Tax' } // explicit → known rate await fetchHelper(event, 'models/c_orderline', 'POST', token, payload) return { ok: true } } const syncOneOrder = async ( event: any, token: any, item: any, syncAmount: boolean, syncDate: boolean, adjustmentChargeId: number, linkIdFields: string[] ): Promise => { const internalOrderId = Number(item?.internalOrderId) // Tolerant: Amazon page sends amazonOrderId/amazonTotal/amazonCurrency; eBay page // sends marketplaceOrderId/marketplaceTotal/marketplaceCurrency. const providedOrderId = String(item?.marketplaceOrderId ?? item?.amazonOrderId ?? '') const result: any = { marketplaceOrderId: providedOrderId, amazonOrderId: providedOrderId, internalOrderId, documentNo: '', ok: false, skipped: false, error: null, syncedAmount: false, syncedDate: false, before: {}, after: {}, amountResidual: null } if (!internalOrderId) { result.error = 'Keine interne Auftrags-ID'; return result } let order: any = await fetchOrder(event, token, internalOrderId) if (!order || !(order.id ?? order.C_Order_ID)) { result.error = 'Auftrag nicht gefunden'; return result } result.documentNo = order.DocumentNo || '' // --- Safety gates -------------------------------------------------------- // The provided marketplace order id must match one of this order's link fields. const linkedIds = linkIdFields.map(f => String(order?.[f] ?? '')).filter(Boolean) if (providedOrderId && !linkedIds.includes(providedOrderId)) { result.error = 'Auftrag passt nicht zur Marketplace Order-ID (Abgleich verändert)'; return result } if (order.IsInvoiced === true || order.IsInvoiced === 'true' || order.IsInvoiced === 'Y') { result.error = 'Bestellung hat bereits eine Rechnung – nicht synchronisiert'; return result } const startCode = dsCodeOf(order) if (isFrozen(startCode)) { result.error = `Status ${startCode} kann nicht reaktiviert werden`; return result } // Only an already-completed order may run the reactivate→edit→complete cycle. // Refusing DR/IP drafts here prevents ever driving a draft to Completed (which // the operator never asked for) and keeps `reactivated` meaningful below. if (!isCompleted(startCode)) { result.error = `Status ${startCode || '—'} ist kein abgeschlossener Auftrag – nicht synchronisiert` return result } const beforeTotal = grandTotalOf(order) const orderCurrency = String(order.C_Currency_ID?.identifier || order.C_Currency_ID?.ISO_Code || '') const taxIncluded = order.M_PriceList_ID?.IsTaxIncluded === true || order.M_PriceList_ID?.IsTaxIncluded === 'true' result.before = { grandTotal: beforeTotal, dateOrdered: dayOf(order.DateOrdered), dateAcct: dayOf(order.DateAcct) } // --- Decide what actually needs doing ------------------------------------ const provTotal = item?.marketplaceTotal ?? item?.amazonTotal const marketTotal = provTotal != null ? Number(provTotal) : null const purchaseDay = dayOf(item?.purchaseDate) let doAmount = false if (syncAmount && marketTotal != null) { if (marketTotal <= 0) { result.error = 'Marketplace-Betrag ungültig'; return result } const marketCurrency = String(item?.marketplaceCurrency ?? item?.amazonCurrency ?? '') if (marketCurrency && orderCurrency && marketCurrency.toUpperCase() !== orderCurrency.toUpperCase()) { result.error = `Währung weicht ab (${marketCurrency} ↔ ${orderCurrency}) – Betrag nicht synchronisiert`; return result } doAmount = Math.abs(marketTotal - beforeTotal) >= 0.01 if (doAmount && !(adjustmentChargeId > 0)) { result.error = 'Ausgleichs-Charge nicht konfiguriert (AMAZON_ADJUSTMENT_CHARGE_ID)'; return result } } const doDate = !!(syncDate && purchaseDay && purchaseDay !== dayOf(order.DateAcct)) if (!doAmount && !doDate) { result.ok = true; result.skipped = true result.after = result.before return result } // --- Mutate inside a guard: any unexpected failure mid-cycle must NEVER // leave the order re-activated/drafted. On error we best-effort re-complete, // then rethrow so the caller can record it (auth errors → token refresh). ---- let reactivated = false try { // Re-activate if completed. if (isCompleted(startCode)) { await docAction(event, token, internalOrderId, 'RE') reactivated = true } // Date: set DateOrdered + DateAcct while editable. if (doDate) { await fetchHelper(event, `models/c_order/${internalOrderId}`, 'PUT', token, { dateOrdered: purchaseDay, dateAcct: purchaseDay, tableName: 'C_Order' }) result.syncedDate = true } // Amount: add ONE dedicated adjustment line (unless one already exists from a // prior run / retry — never stack a second), then complete. Report-only: we read // back the real GrandTotal and surface any residual cent; no convergence loop. if (doAmount) { const existing = await findAdjustmentLine(event, token, internalOrderId, adjustmentChargeId) if (!existing) { const D = (marketTotal as number) - beforeTotal // gross delta (signed) const adj = await addAdjustmentLine(event, token, order, adjustmentChargeId, D, taxIncluded) if (!adj.ok) { // Business failure (e.g. adjustment product not found): restore completed + report. await docAction(event, token, internalOrderId, 'CO') result.error = adj.error || 'Betragsanpassung fehlgeschlagen' const re = await fetchOrder(event, token, internalOrderId) result.after = { grandTotal: grandTotalOf(re), dateOrdered: dayOf(re.DateOrdered), dateAcct: dayOf(re.DateAcct), isInvoiced: !!re.IsInvoiced } return result } } await docAction(event, token, internalOrderId, 'CO') const after = await fetchOrder(event, token, internalOrderId) const afterTotal = grandTotalOf(after) result.syncedAmount = true result.amountResidual = round2(afterTotal - (marketTotal as number)) result.after = { grandTotal: afterTotal, dateOrdered: dayOf(after.DateOrdered), dateAcct: dayOf(after.DateAcct), isInvoiced: !!after.IsInvoiced } } else { // Date-only: complete after the header edit. await docAction(event, token, internalOrderId, 'CO') const after = await fetchOrder(event, token, internalOrderId) result.after = { grandTotal: grandTotalOf(after), dateOrdered: dayOf(after.DateOrdered), dateAcct: dayOf(after.DateAcct), isInvoiced: !!after.IsInvoiced } } } catch (e: any) { // Never leave a document re-activated. Restore the ORIGINAL dates first so the // recovery completion posts back into the original (open) period — a backdated // date into a closed period is exactly what could have made the CO fail, and // would make the recovery CO fail for the same reason. Then verify it took. if (reactivated) { try { if (doDate && result.before?.dateAcct) { await fetchHelper(event, `models/c_order/${internalOrderId}`, 'PUT', token, { dateOrdered: result.before.dateOrdered || result.before.dateAcct, dateAcct: result.before.dateAcct, tableName: 'C_Order' }) } } catch {} let restored = false try { await docAction(event, token, internalOrderId, 'CO') const chk = await fetchOrder(event, token, internalOrderId) restored = isCompleted(dsCodeOf(chk)) } catch { restored = false } // Couldn't get it back to Completed → flag so the operator is told to fix it. if (!restored) { e.__leftReactivated = true; e.__documentNo = result.documentNo } } throw e } // --- Final verdict -------------------------------------------------------- const amountOk = !doAmount || (result.amountResidual != null && Math.abs(result.amountResidual) < 0.01) const dateOk = !doDate || result.after?.dateAcct === purchaseDay result.ok = amountOk && dateOk if (!result.ok && !result.error) { const parts: string[] = [] if (doAmount && !amountOk) parts.push(`Restdifferenz ${result.amountResidual}`) if (doDate && !dateOk) parts.push('Datum nicht übernommen') result.error = parts.join('; ') || 'Synchronisierung unvollständig' } return result } // Run a whole sync batch. Throws on a 401 (so the route wrapper can refresh the token // and retry the whole batch — safe because syncOneOrder is idempotent). Returns the // API response object ({ status, results, summary } or a { status, message } error). export const runMarketplaceOrdersSync = async ( event: any, token: any, opts: MarketplaceSyncOptions ): Promise => { const { syncAmount, syncDate, adjustmentChargeId, linkIdFields } = opts const items = Array.isArray(opts.items) ? opts.items : [] if (!syncAmount && !syncDate) return { status: 400, message: 'Nichts zu synchronisieren (syncAmount/syncDate)' } if (!items.length) return { status: 400, message: 'Keine Aufträge ausgewählt' } if (items.length > 100) return { status: 400, message: 'Zu viele Aufträge auf einmal (max. 100)' } const results: any[] = [] const seenOrderIds = new Set() // Sequential on purpose: each order is a multi-step RE/edit/CO transaction and // iDempiere document processing must not race against itself. for (const item of items) { const oid = Number(item?.internalOrderId) const provId = String(item?.marketplaceOrderId ?? item?.amazonOrderId ?? '') // Two distinct marketplace rows can resolve to the SAME internal order (matched // via the order id AND ExternalOrderId). Never drive one document to two targets. if (oid && seenOrderIds.has(oid)) { results.push({ marketplaceOrderId: provId, amazonOrderId: provId, internalOrderId: oid, ok: false, skipped: false, error: 'Interner Auftrag in diesem Lauf bereits verarbeitet (mehrere Bestellungen → selber Auftrag) – übersprungen' }) continue } if (oid) seenOrderIds.add(oid) try { results.push(await syncOneOrder(event, token, item, syncAmount, syncDate, adjustmentChargeId, linkIdFields)) } catch (e: any) { // Auth failure → bubble up so the route refreshes the token and retries the // whole batch. Safe because syncOneOrder is idempotent: orders already brought // in line are detected as "nothing to do" and skipped. if (isAuthError(e)) throw e results.push({ marketplaceOrderId: provId, amazonOrderId: provId, internalOrderId: oid || null, documentNo: e?.__documentNo || '', ok: false, skipped: false, error: (e?.data?.message || e?.message || 'Unerwarteter Fehler') + (e?.__leftReactivated ? ' — ACHTUNG: Auftrag bleibt reaktiviert/offen, bitte manuell abschließen!' : '') }) } } return { status: 200, results, summary: { ok: results.filter(r => r.ok && !r.skipped).length, skipped: results.filter(r => r.skipped).length, failed: results.filter(r => !r.ok).length } } }