/** * Initial population of CUST_MarketplaceListing from what is already listed on * the marketplace ("Import from Amazon"). Preview = adapter.importRemote → * matchListingsToProducts (SKU → ASIN tiers, internal price / qty) → rows flagged * with the already-imported listing id; Import = upsert of the selected rows. * * Desired values (Price / Qty) are initialised from the remote values on first * import and never overwritten later; a re-import only refreshes Remote* fields * (and fills Price/Qty when they are still empty and nothing is queued). */ import { matchListingsToProducts, type SisRow } from '../sisMatching' import { getListingAdapter, marketplaceKeyOf, supportsListings } from './adapters' import type { AdapterCtx, RemoteListing } from './adapters/types' import { type IdempiereFetch, type ListingRow, type ListingStatus, listListings, insertListing, updateListing, restDate, enc, esc } from './listingStore' import { amazonConnectionFromOrderSource, amazonDomainForOrderSource } from '../amazonSpApi' import { createProductForListing, importAmazonImageIfMissing, importImageFromUrlIfMissing, loadProductForImage, type StrapiProductRef } from './productCreator' export interface ImportPreviewRow extends SisRow { listingKey: string externalProductId: string | null externalInventoryId: string | null imageUrl: string | null meta: any currency: string | null existingListingId: number | null existingSyncStatus: string | null existingPrice: number | null existingQty: number | null } export type ImportPreview = | { pending: true; startedAt: number } | { pending: false; rows: ImportPreviewRow[]; generatedAt: number; fromCache: boolean; marketplace: string; marketplaceDomain: string; counts: { total: number; matched: number; unmatched: number; alreadyImported: number; fba: number } } export interface ImportRowInput { listingKey: string sellerSku: string asin?: string | null amazonItemName?: string | null amazonPrice?: number | null amazonQty?: number | null fulfillmentChannel?: string | null status?: string | null openDate?: string | null currency?: string | null productId?: number | null productName?: string | null /** Marketplace ids (Shopify: product GID / inventoryItem GID); Amazon rows carry the ASIN in `asin`. */ externalProductId?: string | null externalInventoryId?: string | null /** Picture on the marketplace (Shopify CDN) — used by importImages for non-Amazon sources. */ imageUrl?: string | null meta?: any } export interface ImportResult { created: number; updated: number; skipped: number productsCreated: number images: { imported: number; hasImage: number; noAmazonImage: number; failed: number } warnings: string[] errors: { listingKey: string; error: string }[] } export interface ImportOptions { overwriteExisting?: boolean /** Create a minimal M_Product (Value = SKU, asin, Amazon price) for rows without a matched product. */ createProducts?: boolean /** Copy the ASIN's MAIN catalog image to the product — only when the product has no image in Strapi. */ importImages?: boolean /** Price list that receives the Amazon price of created products (default: config.public.pricelistid). */ priceListId?: string | number | null salesRepId?: number | null } const makeCtx = (f: IdempiereFetch, orderSource: any): AdapterCtx => ({ orderSource, orderSourceId: Number(orderSource.id), orgId: Number(orderSource?.AD_Org_ID?.id) || 0, marketplace: marketplaceKeyOf(orderSource), fetch: f, log: (msg, extra) => extra ? console.log(`[listing-import] [${orderSource?.Name || orderSource.id}] ${msg}`, extra) : console.log(`[listing-import] [${orderSource?.Name || orderSource.id}] ${msg}`) }) // Amazon's report open-date looks like "2023-05-02 10:21:33 CEST" — keep the // date/time, drop the zone token (REST wants yyyy-MM-ddTHH:mm:ssZ). export const parseRemoteDate = (v: string | null | undefined): string | null => { if (!v) return null const m = String(v).match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})/) if (m) return `${m[1]}T${m[2]}Z` // EU marketplaces: "30/01/2026 17:49:19 MET" (day/month/year); swap when the first part cannot be a day const eu = String(v).match(/^(\d{2})\/(\d{2})\/(\d{4})(?:[ T](\d{2}:\d{2}:\d{2}))?/) if (eu) { let [, a, b, y, t] = eu if (Number(b) > 12 && Number(a) <= 12) [a, b] = [b, a] return `${y}-${b}-${a}T${t || '00:00:00'}Z` } const m2 = String(v).match(/^(\d{4}-\d{2}-\d{2})/) if (m2) return `${m2[1]}T00:00:00Z` const d = new Date(String(v)) return Number.isNaN(d.getTime()) ? null : restDate(d) } export const remoteStatusToListingStatus = (status: string | null | undefined): ListingStatus => { const s = String(status || '').toLowerCase() if (!s || s === 'active') return 'active' if (s === 'inactive' || s === 'incomplete') return 'inactive' return 'inactive' } /** Currency of the order source's price list, else by ISO code (marketplace default). */ export const resolveOrderSourceCurrencyId = async (f: IdempiereFetch, orderSource: any, isoFallback: string | null): Promise<{ id: number | null; iso: string | null }> => { const plId = Number(orderSource?.M_PriceList_ID?.id) || 0 if (plId) { try { const pl: any = await f(`models/m_pricelist/${plId}`) const id = Number(pl?.C_Currency_ID?.id) || null if (id) return { id, iso: pl?.C_Currency_ID?.identifier || isoFallback } } catch { /* fall through */ } } if (isoFallback) { try { const res: any = await f(`models/c_currency?$filter=${enc(`ISO_Code eq '${esc(isoFallback)}'`)}&$top=1`) const id = Number(res?.records?.[0]?.id) || null if (id) return { id, iso: isoFallback } } catch { /* fall through */ } } return { id: null, iso: isoFallback } } export const buildImportPreview = async (event: any, token: string, f: IdempiereFetch, orderSource: any, opts: { forceRefresh?: boolean; maxWaitMs?: number } = {}): Promise => { const adapter = getListingAdapter(orderSource) if (!adapter || !supportsListings(orderSource)) throw Object.assign(new Error(`No listings adapter for marketplace "${marketplaceKeyOf(orderSource)}"`), { status: 400 }) const ctx = makeCtx(f, orderSource) const remote = await adapter.importRemote(ctx, { forceRefresh: !!opts.forceRefresh, maxWaitMs: opts.maxWaitMs ?? 20000 }) if (remote.pending) return { pending: true, startedAt: remote.startedAt } const config: any = useRuntimeConfig() const priceListId = config.public?.pricelistid || '101' const matched = await matchListingsToProducts(event, token, ctx.orgId, remote.rows.map((r: RemoteListing) => ({ sellerSku: r.sellerSku, asin: r.externalProductId || '', itemName: r.title, price: r.price, quantity: r.qty, fulfillmentChannel: r.fulfillmentChannel, status: r.status, openDate: r.dateListed })), priceListId) const existing = await listListings(f, ctx.orderSourceId, { activeOnly: false, top: 5000 }) const byKey = new Map(existing.map(l => [l.listingKey, l])) // matched[] is index-aligned with remote.rows (SKUs may repeat or be empty on Shopify → never key by SKU) const rows: ImportPreviewRow[] = matched.map((m, i) => { const r = remote.rows[i] const ex = byKey.get(r?.listingKey || m.sellerSku) || null return { ...m, listingKey: r?.listingKey || m.sellerSku, externalProductId: r?.externalProductId || null, externalInventoryId: r?.externalInventoryId || null, imageUrl: r?.meta?.imageUrl || null, meta: r?.meta || null, currency: r?.currency || null, existingListingId: ex?.id || null, existingSyncStatus: ex?.syncStatus || null, existingPrice: ex?.price ?? null, existingQty: ex?.qty ?? null } }) return { pending: false, rows, generatedAt: remote.generatedAt, fromCache: !!remote.fromCache, marketplace: ctx.marketplace, marketplaceDomain: amazonDomainForOrderSource(orderSource), counts: { total: rows.length, matched: rows.filter(r => r.productId).length, unmatched: rows.filter(r => !r.productId).length, alreadyImported: rows.filter(r => r.existingListingId).length, fba: rows.filter(r => String(r.fulfillmentChannel || 'DEFAULT').toUpperCase() !== 'DEFAULT').length } } } export const importRows = async (f: IdempiereFetch, orderSource: any, rows: ImportRowInput[], opts: ImportOptions = {}): Promise => { const out: ImportResult = { created: 0, updated: 0, skipped: 0, productsCreated: 0, images: { imported: 0, hasImage: 0, noAmazonImage: 0, failed: 0 }, warnings: [], errors: [] } const orderSourceId = Number(orderSource.id) const orgId = Number(orderSource?.AD_Org_ID?.id) || 0 if (!orderSourceId || !orgId) throw Object.assign(new Error('Order source has no organization'), { status: 400 }) const existing = await listListings(f, orderSourceId, { activeOnly: false, top: 5000 }) const byKey = new Map(existing.map(l => [l.listingKey, l])) const currencyCache = new Map() const currencyFor = async (iso: string | null) => { const k = iso || '' if (!currencyCache.has(k)) currencyCache.set(k, await resolveOrderSourceCurrencyId(f, orderSource, iso)) return currencyCache.get(k)! } const config: any = useRuntimeConfig() const priceListId = opts.priceListId || config.public?.pricelistid || '101' // Pictures: Amazon → catalog image of the ASIN (needs the SP-API connection); other marketplaces → the // picture URL the import preview carried (Shopify CDN). One connection failure disables the step. const marketplace = marketplaceKeyOf(orderSource) let amazon: { accessToken: string; marketplaceId: string; sellerId: string } | null = null let imagesEnabled = opts.importImages === true && supportsListings(orderSource) const amazonConnection = async () => { if (amazon || !imagesEnabled) return amazon try { amazon = await amazonConnectionFromOrderSource(orderSource) } catch (e: any) { imagesEnabled = false out.warnings.push('Image import disabled: ' + String(e?.message || e).slice(0, 200)) } return amazon } // Product picture (only when the product has none) — best-effort, counted, never fails the row. const importImage = async (productId: number, asin: string | null, imageUrl: string | null, known?: { value: string; name: string; strapi: StrapiProductRef }) => { const product = known ? { id: productId, value: known.value, name: known.name, asin } : await loadProductForImage(f, productId) if (!product) return let res: { outcome: string; error?: string } if (marketplace === 'amazon') { const conn = await amazonConnection() if (!conn) return res = await importAmazonImageIfMissing(f, conn, product, asin, known?.strapi) } else { res = await importImageFromUrlIfMissing(f, product, imageUrl, known?.strapi) } if (res.outcome === 'imported') out.images.imported++ else if (res.outcome === 'has-image') out.images.hasImage++ else if (res.outcome === 'no-amazon-image') out.images.noAmazonImage++ else { out.images.failed++; out.warnings.push(`${product.value || productId}: image not imported (${res.error || res.outcome})`) } } for (const r of rows) { const key = String(r.listingKey || r.sellerSku || '').trim() if (!key) { out.skipped++; continue } try { const ex = byKey.get(key) let createdProduct: { value: string; name: string; strapi: StrapiProductRef } | undefined // (never for rows that will be skipped anyway: an existing listing without overwrite) if (opts.createProducts && !r.productId && !ex?.productId && (!ex || opts.overwriteExisting)) { const cp = await createProductForListing(f, { orgId, name: r.amazonItemName || r.sellerSku || key, sellerSku: r.sellerSku || key, asin: r.asin || null, price: r.amazonPrice ?? null, priceListId, salesRepId: opts.salesRepId || null }) r.productId = cp.productId r.productName = cp.name createdProduct = cp out.productsCreated++ for (const w of cp.warnings) out.warnings.push(`${key}: ${w}`) } const remotePatch: Partial = { remotePrice: r.amazonPrice ?? null, remoteQty: r.amazonQty ?? null, remoteStatus: r.status || null, remoteTitle: r.amazonItemName || null, fulfillmentChannel: r.fulfillmentChannel || 'DEFAULT', externalProductId: r.externalProductId || r.asin || null, externalInventoryId: r.externalInventoryId || null, ...(r.meta ? { meta: r.meta } : {}), lastSyncedAt: restDate(new Date()) } if (ex) { if (!opts.overwriteExisting) { out.skipped++; continue } const patch: Partial = { ...remotePatch } if (ex.price == null && ex.syncStatus === 'synced') patch.price = r.amazonPrice ?? null if (ex.qty == null && ex.syncStatus === 'synced') patch.qty = r.amazonQty ?? null if (!ex.productId && r.productId) { patch.productId = r.productId; if (r.productName) patch.name = r.productName } if (!ex.dateListed && r.openDate) patch.dateListed = parseRemoteDate(r.openDate) await updateListing(f, ex.id, patch) out.updated++ const pid = patch.productId || ex.productId if (imagesEnabled && pid) await importImage(pid, r.asin || null, r.imageUrl || null, createdProduct) continue } const cur = await currencyFor(r.currency || null) const created = await insertListing(f, { ...remotePatch, orgId, orderSourceId, sellerSku: r.sellerSku || key, listingKey: key, name: (r.productName || r.amazonItemName || key).slice(0, 500), productId: r.productId || null, listingStatus: remoteStatusToListingStatus(r.status), price: r.amazonPrice ?? null, qty: r.amazonQty ?? null, currencyId: cur.id, isPriceTaxIncluded: true, dateListed: parseRemoteDate(r.openDate), syncStatus: 'synced', syncError: '', meta: { ...(r.meta || {}), importedFrom: marketplace === 'amazon' ? 'report' : marketplace, importedAt: restDate(new Date()) } }) byKey.set(key, created) out.created++ if (imagesEnabled && r.productId) await importImage(r.productId, r.asin || null, r.imageUrl || null, createdProduct) } catch (e: any) { out.errors.push({ listingKey: key, error: String(e?.data?.detail || e?.message || e).slice(0, 300) }) } } return out }