/** * Store for the two marketplace-listing tables in iDempiere: * CUST_MarketplaceListing (models/cust_marketplacelisting) — one row per order source × listing * CUST_MarketplaceListingChange (models/cust_marketplacelistingchange) — outbox + audit (queued → sending → sent → confirmed | error) * * Two fetch flavours share one signature (`IdempiereFetch`): `eventFetch` = the * request's fetchHelper (caller token, UpdatedBy = user); `serviceFetch` = the * non-expiring service token for the background flush (no h3 event available). * * Write casing (iDempiere REST): scalars lowercase-first, FK objects `{ id, * tableName }`, list values `{ id: 'code' }`; the sync-status column is named * `CUST_ListingSyncStatus` (kept as created in the AD) and is written under that * name. Reads tolerate both casings; REST omits NULL columns. Verified against prod * 2026-10-26 (see .claude/rules/marketplaces/marketplace-listings.md). */ import { string } from 'alga-js' import fetchHelper from '../fetchHelper' export const LISTING_MODEL = 'cust_marketplacelisting' export const LISTING_TABLE = 'CUST_MarketplaceListing' export const CHANGE_MODEL = 'cust_marketplacelistingchange' export const CHANGE_TABLE = 'CUST_MarketplaceListingChange' export type IdempiereFetch = (path: string, method?: 'GET' | 'POST' | 'PUT' | 'DELETE', body?: any) => Promise export const eventFetch = (event: any, token: string): IdempiereFetch => (path, method = 'GET', body = null) => fetchHelper(event, path, method, token, body) export const serviceFetch = (): IdempiereFetch => { const config: any = useRuntimeConfig() const base = String(config.api?.url || '').replace(/\/$/, '') const token = config.api?.idempieretoken if (!base || !token) throw new Error('iDempiere service token / url not configured (api.url, api.idempieretoken)') return (path, method = 'GET', body = null) => $fetch(`${base}/${path}`, { method, retry: 0, timeout: 90_000, headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer ' + token }, ...(body ? { body } : {}) } as any) } export const enc = (s: string) => string.urlEncode(s) export const esc = (s: string) => String(s ?? '').replace(/'/g, "''") const fk = (v: any): number | null => (v && typeof v === 'object') ? (Number(v.id) || null) : (v != null ? Number(v) || null : null) const fkName = (v: any): string | null => (v && typeof v === 'object') ? (v.identifier ?? null) : null const lst = (v: any): string | null => (v && typeof v === 'object') ? (v.id != null ? String(v.id) : null) : (v != null ? String(v) : null) const num = (v: any): number | null => (v == null || v === '') ? null : (Number.isFinite(Number(v)) ? Number(v) : null) const str = (v: any): string | null => (v == null) ? null : String(v) /** * iDempiere REST emits Created/Updated (and every column IT fills) as the server's * LOCAL wall-clock time with a literal "Z" — "2026-10-27T12:45:01Z" means 12:45 in * Europe/Berlin, not UTC. Columns WE write (restDate → UTC string) come back unchanged. * Convert the server-written ones to real UTC ISO so age calculations (debounce, * stale claims) are not off by the UTC offset. Timezone: IDEMPIERE_TZ (Europe/Berlin). */ const SERVER_TZ = process.env.IDEMPIERE_TZ || 'Europe/Berlin' const tzOffsetMs = (utcMs: number): number => { try { const dtf = new Intl.DateTimeFormat('en-US', { timeZone: SERVER_TZ, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) const p: Record = Object.fromEntries(dtf.formatToParts(new Date(utcMs)).map(x => [x.type, x.value])) return Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second)) - utcMs } catch { return 0 } } export const serverLocalToIso = (v: any): string | null => { if (v == null || v === '') return null const m = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/.exec(String(v)) if (!m) return String(v) const [y, mo, d, h, mi, se] = m.slice(1, 7).map(Number) const guess = Date.UTC(y!, mo! - 1, d!, h!, mi!, se!) // offset at the guessed instant; re-evaluate once in case the guess straddles a DST switch const off1 = tzOffsetMs(guess) const off2 = tzOffsetMs(guess - off1) return new Date(guess - off2).toISOString() } const pick = (r: any, ...keys: string[]) => { for (const k of keys) if (r?.[k] !== undefined) return r[k]; return undefined } /* ------------------------------ types ------------------------------ */ export type ListingStatus = 'pending' | 'active' | 'inactive' | 'delisted' | 'error' export type SyncStatus = 'synced' | 'queued' | 'sending' | 'sent' | 'error' | 'drift' export type ChangeType = 'create' | 'update' | 'delist' | 'relist' | 'delete' export type ChangeStatus = 'queued' | 'sending' | 'sent' | 'confirmed' | 'error' | 'cancelled' | 'superseded' export type DeliveryMode = 'patch' | 'feed' export type ChangeField = 'price' | 'qty' | 'minprice' | 'maxprice' | 'sale' export interface ListingRow { id: number; uid: string | null orgId: number | null; orderSourceId: number | null; orderSourceName: string | null productId: number | null; productIdentifier: string | null name: string; sellerSku: string; listingKey: string externalProductId: string | null; externalInventoryId: string | null; productType: string | null listingStatus: ListingStatus price: number | null; minPrice: number | null; maxPrice: number | null salePrice: number | null; saleFrom: string | null; saleTo: string | null currencyId: number | null; currency: string | null; isPriceTaxIncluded: boolean qty: number | null; fulfillmentChannel: string | null remotePrice: number | null; remoteQty: number | null; remoteStatus: string | null; remoteTitle: string | null; remoteCondition: string | null dateListed: string | null; dateDelisted: string | null; lastSyncedAt: string | null; lastChangeAt: string | null syncStatus: SyncStatus; syncError: string | null buyBoxStatus: 'won' | 'lost' | 'unknown' | null; buyBoxPrice: number | null; lowestOtherPrice: number | null; competitorCount: number | null feesEstimate: number | null; feesJson: any; competitionCheckedAt: string | null meta: any; description: string | null; isActive: boolean created: string | null; updated: string | null } export interface ChangeRow { id: number; uid: string | null orgId: number | null; listingId: number | null; orderSourceId: number | null changeType: ChangeType; fieldName: ChangeField | null; oldValue: string | null; newValue: string | null changeStatus: ChangeStatus; deliveryMode: DeliveryMode | null batchId: string | null; feedId: string | null; submissionId: string | null; messageId: number | null sentAt: string | null; confirmedAt: string | null; attemptCount: number; errorMsg: string | null; description: string | null createdBy: { id: number | null; name: string | null }; created: string | null; updated: string | null } const safeJson = (s: any) => { if (s == null || s === '') return null; if (typeof s === 'object') return s; try { return JSON.parse(s) } catch { return null } } /* ------------------------------ mapping ------------------------------ */ export const mapListing = (r: any): ListingRow | null => { if (!r?.id) return null return { id: Number(r.id), uid: r.uid || null, orgId: fk(r.AD_Org_ID), orderSourceId: fk(r.C_OrderSource_ID), orderSourceName: fkName(r.C_OrderSource_ID), productId: fk(r.M_Product_ID), productIdentifier: fkName(r.M_Product_ID), name: str(pick(r, 'Name', 'name')) || '', sellerSku: str(pick(r, 'SellerSKU', 'sellerSKU', 'sellersku')) || '', listingKey: str(pick(r, 'ListingKey', 'listingKey')) || '', externalProductId: str(pick(r, 'ExternalProductId', 'externalProductId')), externalInventoryId: str(pick(r, 'ExternalInventoryId', 'externalInventoryId')), productType: str(pick(r, 'ProductType', 'productType')), listingStatus: (lst(pick(r, 'ListingStatus', 'listingStatus')) || 'pending') as ListingStatus, price: num(pick(r, 'Price', 'price')), minPrice: num(pick(r, 'MinPrice', 'minPrice')), maxPrice: num(pick(r, 'MaxPrice', 'maxPrice')), salePrice: num(pick(r, 'SalePrice', 'salePrice')), saleFrom: str(pick(r, 'SaleFrom', 'saleFrom')), saleTo: str(pick(r, 'SaleTo', 'saleTo')), currencyId: fk(r.C_Currency_ID), currency: fkName(r.C_Currency_ID), isPriceTaxIncluded: (pick(r, 'IsPriceTaxIncluded', 'isPriceTaxIncluded') ?? true) === true, qty: num(pick(r, 'Qty', 'qty')), fulfillmentChannel: str(pick(r, 'FulfillmentChannel', 'fulfillmentChannel')), remotePrice: num(pick(r, 'RemotePrice', 'remotePrice')), remoteQty: num(pick(r, 'RemoteQty', 'remoteQty')), remoteStatus: str(pick(r, 'RemoteStatus', 'remoteStatus')), remoteTitle: str(pick(r, 'RemoteTitle', 'remoteTitle')), remoteCondition: str(pick(r, 'RemoteCondition', 'remoteCondition')), dateListed: str(pick(r, 'DateListed', 'dateListed')), dateDelisted: str(pick(r, 'DateDelisted', 'dateDelisted')), lastSyncedAt: str(pick(r, 'LastSyncedAt', 'lastSyncedAt')), lastChangeAt: str(pick(r, 'LastChangeAt', 'lastChangeAt')), syncStatus: (lst(pick(r, 'CUST_ListingSyncStatus', 'cUST_ListingSyncStatus', 'SyncStatus')) || 'synced') as SyncStatus, syncError: str(pick(r, 'SyncError', 'syncError')), buyBoxStatus: (lst(pick(r, 'BuyBoxStatus', 'buyBoxStatus')) as any) || null, buyBoxPrice: num(pick(r, 'BuyBoxPrice', 'buyBoxPrice')), lowestOtherPrice: num(pick(r, 'LowestOtherPrice', 'lowestOtherPrice')), competitorCount: num(pick(r, 'CompetitorCount', 'competitorCount')), feesEstimate: num(pick(r, 'FeesEstimate', 'feesEstimate')), feesJson: safeJson(pick(r, 'FeesJson', 'feesJson')), competitionCheckedAt: str(pick(r, 'CompetitionCheckedAt', 'competitionCheckedAt')), meta: safeJson(pick(r, 'MetaJson', 'metaJson')), description: str(pick(r, 'Description', 'description')), isActive: (pick(r, 'IsActive', 'isActive') ?? true) === true, created: serverLocalToIso(r.Created), updated: serverLocalToIso(r.Updated) } } export const mapChange = (r: any): ChangeRow | null => { if (!r?.id) return null return { id: Number(r.id), uid: r.uid || null, orgId: fk(r.AD_Org_ID), listingId: fk(r.CUST_MarketplaceListing_ID), orderSourceId: fk(r.C_OrderSource_ID), changeType: (lst(pick(r, 'ChangeType', 'changeType')) || 'update') as ChangeType, fieldName: (str(pick(r, 'FieldName', 'fieldName')) as any) || null, oldValue: str(pick(r, 'OldValue', 'oldValue')), newValue: str(pick(r, 'NewValue', 'newValue')), changeStatus: (lst(pick(r, 'ChangeStatus', 'changeStatus')) || 'queued') as ChangeStatus, deliveryMode: (lst(pick(r, 'DeliveryMode', 'deliveryMode')) as any) || null, batchId: str(pick(r, 'BatchId', 'batchId')), feedId: str(pick(r, 'FeedId', 'feedId')), submissionId: str(pick(r, 'SubmissionId', 'submissionId')), messageId: num(pick(r, 'MessageID', 'messageID', 'MessageId')), sentAt: str(pick(r, 'SentAt', 'sentAt')), confirmedAt: str(pick(r, 'ConfirmedAt', 'confirmedAt')), attemptCount: num(pick(r, 'AttemptCount', 'attemptCount')) || 0, errorMsg: str(pick(r, 'ErrorMsg', 'errorMsg')), description: str(pick(r, 'Description', 'description')), createdBy: { id: fk(r.CreatedBy), name: fkName(r.CreatedBy) }, created: serverLocalToIso(r.Created), updated: serverLocalToIso(r.Updated) } } /* ------------------------------ write bodies ------------------------------ */ const fkObj = (id: number | null | undefined, tableName: string) => (id ? { id: Number(id), tableName } : 0) // iDempiere REST accepts exactly yyyy-MM-dd'T'HH:mm:ss'Z' (no milliseconds) or a plain yyyy-MM-dd. export const restDate = (v: any): string => { if (v instanceof Date) return v.toISOString().replace(/\.\d{3}Z$/, 'Z') const s = String(v) if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s const d = new Date(s) return Number.isNaN(d.getTime()) ? s : d.toISOString().replace(/\.\d{3}Z$/, 'Z') } const dateOrNull = (v: any) => (v == null || v === '') ? null : restDate(v) /** Partial listing → REST body (only keys that are present in `p` are written). */ export const listingWriteBody = (p: Partial): any => { const b: any = {} if (p.orgId !== undefined) b.AD_Org_ID = { id: p.orgId, tableName: 'AD_Org' } if (p.orderSourceId !== undefined) b.C_OrderSource_ID = fkObj(p.orderSourceId, 'C_OrderSource') if (p.productId !== undefined) b.M_Product_ID = fkObj(p.productId, 'M_Product') if (p.currencyId !== undefined) b.C_Currency_ID = fkObj(p.currencyId, 'C_Currency') if (p.name !== undefined) b.name = String(p.name || '').slice(0, 500) if (p.sellerSku !== undefined) b.sellerSKU = String(p.sellerSku || '').slice(0, 160) if (p.listingKey !== undefined) b.listingKey = String(p.listingKey || '').slice(0, 160) if (p.externalProductId !== undefined) b.externalProductId = p.externalProductId ? String(p.externalProductId).slice(0, 100) : null if (p.externalInventoryId !== undefined) b.externalInventoryId = p.externalInventoryId ? String(p.externalInventoryId).slice(0, 160) : null if (p.productType !== undefined) b.productType = p.productType ? String(p.productType).slice(0, 100) : null if (p.listingStatus !== undefined) b.listingStatus = { id: p.listingStatus } if (p.price !== undefined) b.price = p.price if (p.minPrice !== undefined) b.minPrice = p.minPrice if (p.maxPrice !== undefined) b.maxPrice = p.maxPrice if (p.salePrice !== undefined) b.salePrice = p.salePrice if (p.saleFrom !== undefined) b.saleFrom = dateOrNull(p.saleFrom) if (p.saleTo !== undefined) b.saleTo = dateOrNull(p.saleTo) if (p.isPriceTaxIncluded !== undefined) b.isPriceTaxIncluded = !!p.isPriceTaxIncluded if (p.qty !== undefined) b.qty = p.qty if (p.fulfillmentChannel !== undefined) b.fulfillmentChannel = p.fulfillmentChannel ? String(p.fulfillmentChannel).slice(0, 40) : null if (p.remotePrice !== undefined) b.remotePrice = p.remotePrice if (p.remoteQty !== undefined) b.remoteQty = p.remoteQty if (p.remoteStatus !== undefined) b.remoteStatus = p.remoteStatus ? String(p.remoteStatus).slice(0, 30) : null if (p.remoteTitle !== undefined) b.remoteTitle = p.remoteTitle ? String(p.remoteTitle).slice(0, 500) : null if (p.remoteCondition !== undefined) b.remoteCondition = p.remoteCondition ? String(p.remoteCondition).slice(0, 160) : null if (p.dateListed !== undefined) b.dateListed = dateOrNull(p.dateListed) if (p.dateDelisted !== undefined) b.dateDelisted = dateOrNull(p.dateDelisted) if (p.lastSyncedAt !== undefined) b.lastSyncedAt = dateOrNull(p.lastSyncedAt) if (p.lastChangeAt !== undefined) b.lastChangeAt = dateOrNull(p.lastChangeAt) if (p.syncStatus !== undefined) b.CUST_ListingSyncStatus = { id: p.syncStatus } if (p.syncError !== undefined) b.syncError = String(p.syncError || '').slice(0, 2000) if (p.buyBoxStatus !== undefined) b.buyBoxStatus = p.buyBoxStatus ? { id: p.buyBoxStatus } : null if (p.buyBoxPrice !== undefined) b.buyBoxPrice = p.buyBoxPrice if (p.lowestOtherPrice !== undefined) b.lowestOtherPrice = p.lowestOtherPrice if (p.competitorCount !== undefined) b.competitorCount = p.competitorCount if (p.feesEstimate !== undefined) b.feesEstimate = p.feesEstimate if (p.feesJson !== undefined) b.feesJson = p.feesJson == null ? null : (typeof p.feesJson === 'string' ? p.feesJson : JSON.stringify(p.feesJson)) if (p.competitionCheckedAt !== undefined) b.competitionCheckedAt = dateOrNull(p.competitionCheckedAt) if (p.meta !== undefined) b.metaJson = p.meta == null ? null : (typeof p.meta === 'string' ? p.meta : JSON.stringify(p.meta)) if (p.description !== undefined) b.description = p.description ? String(p.description).slice(0, 255) : null if (p.isActive !== undefined) b.isActive = !!p.isActive return b } export const changeWriteBody = (p: Partial): any => { const b: any = {} if (p.orgId !== undefined) b.AD_Org_ID = { id: p.orgId, tableName: 'AD_Org' } if (p.listingId !== undefined) b.CUST_MarketplaceListing_ID = fkObj(p.listingId, LISTING_TABLE) if (p.orderSourceId !== undefined) b.C_OrderSource_ID = fkObj(p.orderSourceId, 'C_OrderSource') if (p.changeType !== undefined) b.changeType = { id: p.changeType } if (p.fieldName !== undefined) b.fieldName = p.fieldName if (p.oldValue !== undefined) b.oldValue = p.oldValue == null ? null : String(p.oldValue).slice(0, 255) if (p.newValue !== undefined) b.newValue = p.newValue == null ? null : String(p.newValue).slice(0, 255) if (p.changeStatus !== undefined) b.changeStatus = { id: p.changeStatus } if (p.deliveryMode !== undefined) b.deliveryMode = p.deliveryMode ? { id: p.deliveryMode } : null if (p.batchId !== undefined) b.batchId = p.batchId ? String(p.batchId).slice(0, 80) : null if (p.feedId !== undefined) b.feedId = p.feedId ? String(p.feedId).slice(0, 80) : null if (p.submissionId !== undefined) b.submissionId = p.submissionId ? String(p.submissionId).slice(0, 80) : null if (p.messageId !== undefined) b.messageID = p.messageId if (p.sentAt !== undefined) b.sentAt = dateOrNull(p.sentAt) if (p.confirmedAt !== undefined) b.confirmedAt = dateOrNull(p.confirmedAt) if (p.attemptCount !== undefined) b.attemptCount = p.attemptCount if (p.errorMsg !== undefined) b.errorMsg = String(p.errorMsg || '').slice(0, 2000) if (p.description !== undefined) b.description = p.description ? String(p.description).slice(0, 255) : null return b } /* ------------------------------ probe ------------------------------ */ let probe: { ok: boolean; at: number; error?: string } | null = null export const listingTablesAvailable = async (f: IdempiereFetch, force = false): Promise => { if (!force && probe && Date.now() - probe.at < (probe.ok ? 10 * 60 * 1000 : 60 * 1000)) return probe.ok try { const a: any = await f(`models/${LISTING_MODEL}?$top=1&$select=ListingKey`) const b: any = await f(`models/${CHANGE_MODEL}?$top=1&$select=FieldName`) probe = { ok: Array.isArray(a?.records) && Array.isArray(b?.records), at: Date.now() } } catch (e: any) { const st = Number(e?.status || e?.statusCode || 0) if (st === 401 || st === 403) throw e probe = { ok: false, at: Date.now(), error: e?.data?.detail || e?.message || String(e) } } return probe.ok } export const listingBackendInfo = () => ({ available: !!probe?.ok, error: probe?.error || null, checkedAt: probe?.at || null }) /* ------------------------------ reads ------------------------------ */ const records = (res: any) => Array.isArray(res?.records) ? res.records : [] export const listListings = async (f: IdempiereFetch, orderSourceId: number, opts: { status?: ListingStatus; syncStatus?: SyncStatus | SyncStatus[]; activeOnly?: boolean; top?: number } = {}): Promise => { if (!orderSourceId) return [] const parts = [`C_OrderSource_ID eq ${Number(orderSourceId)}`] if (opts.activeOnly !== false) parts.push('IsActive eq true') if (opts.status) parts.push(`ListingStatus eq '${esc(opts.status)}'`) if (opts.syncStatus) { const list = Array.isArray(opts.syncStatus) ? opts.syncStatus : [opts.syncStatus] parts.push('(' + list.map(s => `CUST_ListingSyncStatus eq '${esc(s)}'`).join(' or ') + ')') } const res = await f(`models/${LISTING_MODEL}?$filter=${enc(parts.join(' and '))}&$orderby=${enc('Name asc')}&$top=${opts.top || 5000}`) return records(res).map(mapListing).filter(Boolean) as ListingRow[] } export const getListing = async (f: IdempiereFetch, id: number): Promise => { if (!id) return null try { return mapListing(await f(`models/${LISTING_MODEL}/${Number(id)}`)) } catch (e: any) { if (Number(e?.status || e?.statusCode) === 404) return null throw e } } export const getListingsByIds = async (f: IdempiereFetch, ids: number[]): Promise => { const out: ListingRow[] = [] const uniq = [...new Set(ids.filter(Boolean))] for (let i = 0; i < uniq.length; i += 50) { const chunk = uniq.slice(i, i + 50) const res = await f(`models/${LISTING_MODEL}?$filter=${enc('(' + chunk.map(id => `CUST_MarketplaceListing_ID eq ${Number(id)}`).join(' or ') + ')')}&$top=${chunk.length}`) out.push(...(records(res).map(mapListing).filter(Boolean) as ListingRow[])) } return out } export const findListingByKey = async (f: IdempiereFetch, orderSourceId: number, listingKey: string): Promise => { if (!orderSourceId || !listingKey) return null const res = await f(`models/${LISTING_MODEL}?$filter=${enc(`C_OrderSource_ID eq ${Number(orderSourceId)} and ListingKey eq '${esc(listingKey)}'`)}&$top=1`) return mapListing(records(res)[0]) } export const listChanges = async (f: IdempiereFetch, filter: { orderSourceId?: number; listingId?: number; status?: ChangeStatus | ChangeStatus[]; feedId?: string; deliveryMode?: DeliveryMode }, top = 2000): Promise => { const parts: string[] = [] if (filter.orderSourceId) parts.push(`C_OrderSource_ID eq ${Number(filter.orderSourceId)}`) if (filter.listingId) parts.push(`CUST_MarketplaceListing_ID eq ${Number(filter.listingId)}`) if (filter.status) { const list = Array.isArray(filter.status) ? filter.status : [filter.status] parts.push('(' + list.map(s => `ChangeStatus eq '${esc(s)}'`).join(' or ') + ')') } if (filter.feedId) parts.push(`FeedId eq '${esc(filter.feedId)}'`) if (filter.deliveryMode) parts.push(`DeliveryMode eq '${esc(filter.deliveryMode)}'`) const q = parts.length ? `$filter=${enc(parts.join(' and '))}&` : '' const res = await f(`models/${CHANGE_MODEL}?${q}$orderby=${enc('Created asc')}&$top=${top}`) return records(res).map(mapChange).filter(Boolean) as ChangeRow[] } /* ------------------------------ writes ------------------------------ */ export type NewListing = Omit, 'orgId' | 'orderSourceId' | 'sellerSku' | 'listingKey' | 'name'> & { orgId: number; orderSourceId: number; sellerSku: string; listingKey: string; name: string } export const insertListing = async (f: IdempiereFetch, data: NewListing): Promise => { const body = { isActive: true, ...listingWriteBody(data), tableName: LISTING_TABLE } const created = await f(`models/${LISTING_MODEL}`, 'POST', body) const row = mapListing(created) if (!row) throw new Error('Listing could not be created') return row } export const updateListing = async (f: IdempiereFetch, id: number, patch: Partial): Promise => { const body = listingWriteBody(patch) if (!Object.keys(body).length) return null return mapListing(await f(`models/${LISTING_MODEL}/${Number(id)}`, 'PUT', body)) } /** Listing of an order source by seller SKU (Shopify keys are variant GIDs, so the SKU is the human handle). */ export const findListingBySku = async (f: IdempiereFetch, orderSourceId: number, sku: string): Promise => { if (!orderSourceId || !sku) return null const res: any = await f(`models/${LISTING_MODEL}?$filter=${enc(`C_OrderSource_ID eq ${Number(orderSourceId)} and SellerSKU eq '${esc(sku)}'`)}&$top=1`) return mapListing(res?.records?.[0]) } export const deleteListing = async (f: IdempiereFetch, id: number): Promise => { await f(`models/${LISTING_MODEL}/${Number(id)}`, 'DELETE') } export type NewChange = Omit, 'orgId' | 'listingId' | 'orderSourceId' | 'changeType'> & { orgId: number; listingId: number; orderSourceId: number; changeType: ChangeType } export const insertChange = async (f: IdempiereFetch, data: NewChange): Promise => { const body = { isActive: true, changeStatus: { id: 'queued' }, attemptCount: 0, ...changeWriteBody(data), tableName: CHANGE_TABLE } const row = mapChange(await f(`models/${CHANGE_MODEL}`, 'POST', body)) if (!row) throw new Error('Change could not be created') return row } export const updateChange = async (f: IdempiereFetch, id: number, patch: Partial): Promise => { const body = changeWriteBody(patch) if (!Object.keys(body).length) return null return mapChange(await f(`models/${CHANGE_MODEL}/${Number(id)}`, 'PUT', body)) } export const deleteChange = async (f: IdempiereFetch, id: number): Promise => { await f(`models/${CHANGE_MODEL}/${Number(id)}`, 'DELETE') } /** * Last write wins: queued rows of the same listing + field (or same type for * create/delist/relist/delete) are marked `superseded`, the new change is * inserted and the listing flips to SyncStatus 'queued'. */ export const queueChange = async (f: IdempiereFetch, listing: ListingRow, change: { changeType: ChangeType; fieldName?: ChangeField | null; oldValue?: any; newValue?: any; description?: string | null }): Promise => { const open = await listChanges(f, { listingId: listing.id, status: ['queued'] }, 200) for (const c of open) { const sameField = change.changeType === 'update' ? (c.changeType === 'update' && c.fieldName === (change.fieldName || null)) : (c.changeType === change.changeType) if (sameField) await updateChange(f, c.id, { changeStatus: 'superseded' }) } const row = await insertChange(f, { orgId: listing.orgId!, listingId: listing.id, orderSourceId: listing.orderSourceId!, changeType: change.changeType, fieldName: change.fieldName ?? null, oldValue: change.oldValue == null ? null : String(change.oldValue), newValue: change.newValue == null ? null : String(change.newValue), description: change.description ?? null }) await updateListing(f, listing.id, { syncStatus: 'queued', syncError: '', lastChangeAt: new Date().toISOString() }) return row }