/** * Import-time product side effects for marketplace listings: * - createProductForListing: a minimal M_Product for an Amazon offer that has * no ERP article yet (Value = seller SKU, asin, defaults Standard/Each/ * Standard like products/quick-store, Strapi record, Amazon price into the * active version of the default price list); * - importAmazonImageIfMissing: MAIN catalog image of the ASIN → Strapi gallery * + Main_Image, ONLY when the product has no picture yet (never replaces). * * Everything runs through the listing store's IdempiereFetch (caller token) so * it works from the import route; Strapi is reached with the service token. */ import strapiHelper from '../strapiHelper' import { getCatalogItemImages, pickMainCatalogImage } from '../amazonSpApi' import { acquire } from './rateLimiter' import { type IdempiereFetch, enc } from './listingStore' const DEFAULTS_TTL_MS = 10 * 60 * 1000 let defaultsCache: { at: number; value: ProductDefaults } | null = null const CATALOG_BUCKET = { ratePerSec: 2, burst: 2 } export interface ProductDefaults { categoryId: number | null; uomId: number | null; taxCategoryId: number | null } const findFirst = async (f: IdempiereFetch, model: string, filters: string[]) => { for (const filter of filters) { const res: any = await f(`models/${model}?$filter=${enc(filter)}&$top=1`) if (res?.records?.[0]?.id) return res.records[0] } return null } export const resolveProductDefaults = async (f: IdempiereFetch): Promise => { if (defaultsCache && Date.now() - defaultsCache.at < DEFAULTS_TTL_MS) return defaultsCache.value const [category, uom, taxCategory] = await Promise.all([ findFirst(f, 'm_product_category', ["Name eq 'Standard' AND IsActive eq true", 'IsDefault eq true AND IsActive eq true']), findFirst(f, 'c_uom', ["Name eq 'Each' AND IsActive eq true", "X12DE355 eq 'EA' AND IsActive eq true", 'IsDefault eq true AND IsActive eq true']), findFirst(f, 'c_taxcategory', ["Name eq 'Standard' AND IsActive eq true", 'IsDefault eq true AND IsActive eq true']) ]) const value = { categoryId: category?.id ?? null, uomId: uom?.id ?? null, taxCategoryId: taxCategory?.id ?? null } if (value.categoryId && value.uomId && value.taxCategoryId) defaultsCache = { at: Date.now(), value } return value } const versionCache = new Map() const resolvePriceListVersion = async (f: IdempiereFetch, priceListId: string | number): Promise => { const key = String(priceListId) const c = versionCache.get(key) if (c && Date.now() - c.at < DEFAULTS_TTL_MS) return c.id let id: number | null = null try { const res: any = await f(`models/m_pricelist_version?$filter=${enc(`M_PriceList_ID eq ${Number(priceListId)} and isActive eq true`)}&$orderby=${enc('ValidFrom desc')}&$top=1`) id = res?.records?.[0]?.id ?? null } catch { id = null } versionCache.set(key, { at: Date.now(), id }) return id } export interface StrapiProductRef { id: number | null; documentId: string | null; hasImage: boolean } /** * Strapi m-product of an iDempiere product, always looked up by M_Product_ID: * the m_product columns Strapi_Product_ID / Strapi_Product_documentId are not * reliable (legacy rows carry the numeric id in the documentId column, limited * roles cannot read them). Created + linked when missing. */ export const ensureStrapiProduct = async (f: IdempiereFetch, product: { id: number; value: string; name: string }): Promise => { try { const found: any = await strapiHelper(null, `m-products?filters[M_Product_ID][$eq]=${product.id}&populate=image`, 'GET', null) const d = found?.data?.[0] if (d?.documentId) { const images: any[] = Array.isArray(d.image) ? d.image : [] return { id: d.id, documentId: d.documentId, hasImage: images.length > 0 || (!!d.Main_Image && d.Main_Image != 0) } } } catch { /* fall through to create */ } const sp: any = await strapiHelper(null, 'm-products', 'POST', { data: { M_Product_ID: product.id, value: product.value, name: product.name } }) if (!sp?.data?.documentId) return { id: null, documentId: null, hasImage: false } try { await f(`models/m_product/${product.id}`, 'PUT', { Strapi_Product_ID: sp.data.id, Strapi_Product_documentId: sp.data.documentId }) } catch { /* link is best-effort */ } return { id: sp.data.id, documentId: sp.data.documentId, hasImage: false } } export interface CreateProductInput { orgId: number; name: string; sellerSku: string; asin?: string | null price?: number | null; priceListId?: string | number | null; salesRepId?: number | null } export interface CreateProductResult { productId: number; value: string; name: string; strapi: StrapiProductRef; warnings: string[] } /** * Minimal product for an offer without ERP article. Value = seller SKU so the * SKU→Value matcher finds it on the next preview; when that Value is taken * elsewhere in the client (unique per AD_Client) iDempiere numbers it instead. * After the product POST nothing throws any more (a retry would duplicate it). */ export const createProductForListing = async (f: IdempiereFetch, input: CreateProductInput): Promise => { const defaults = await resolveProductDefaults(f) const missing = [!defaults.categoryId && 'product category "Standard"', !defaults.uomId && 'UOM "Each"', !defaults.taxCategoryId && 'tax category "Standard"'].filter(Boolean) if (missing.length) throw Object.assign(new Error('Default not found: ' + missing.join(', ')), { status: 422 }) const name = String(input.name || input.sellerSku).trim().slice(0, 255) const asin = String(input.asin || '').trim() const body = (value: string) => ({ AD_Org_ID: { id: Number(input.orgId), tableName: 'AD_Org' }, isActive: true, name, value, isSummary: false, isStocked: true, isPurchased: true, isSold: true, discontinued: false, isBOM: false, ProductType: { id: 'I' }, M_Product_Category_ID: { id: defaults.categoryId, tableName: 'M_Product_Category' }, C_UOM_ID: { id: defaults.uomId, tableName: 'C_UOM' }, C_TaxCategory_ID: { id: defaults.taxCategoryId, tableName: 'C_TaxCategory' }, labelprinter: { id: 'b' }, ...(asin ? { asin } : {}), ...(input.salesRepId ? { SalesRep_ID: { id: Number(input.salesRepId), tableName: 'AD_User' } } : {}), tableName: 'M_Product' }) const warnings: string[] = [] let product: any = null const sku = String(input.sellerSku || '').trim().slice(0, 40) try { product = await f('models/m_product', 'POST', body(sku)) } catch (e: any) { const msg = String(e?.data?.detail || e?.data?.title || e?.message || e) if (!/unique|duplicate|already exists|m_product_value/i.test(msg)) throw e warnings.push(`SKU "${sku}" is already used as a product number in this client — product numbered by iDempiere`) product = await f('models/m_product', 'POST', body('')) } if (!product?.id) throw new Error('Product was not created') const result: CreateProductResult = { productId: Number(product.id), value: product.Value || sku, name: product.Name || name, strapi: { id: null, documentId: null, hasImage: false }, warnings } try { result.strapi = await ensureStrapiProduct(f, { id: result.productId, value: result.value, name: result.name }) } catch (e: any) { warnings.push('Strapi product record not created: ' + (e?.data?.error?.message || e?.message || e)) } if (input.price != null && Number.isFinite(Number(input.price)) && Number(input.price) >= 0 && input.priceListId) { const versionId = await resolvePriceListVersion(f, input.priceListId) if (versionId) { try { await f('models/m_productprice', 'POST', { AD_Org_ID: { id: Number(input.orgId), tableName: 'AD_Org' }, isActive: true, priceList: Number(input.price), priceStd: Number(input.price), priceLimit: Number(input.price), M_Product_ID: { id: result.productId, tableName: 'M_Product' }, M_PriceList_Version_ID: { id: versionId, tableName: 'M_PriceList_Version' }, tableName: 'M_ProductPrice' }) } catch (e: any) { warnings.push('Price not stored: ' + (e?.data?.detail || e?.message || e)) } } else warnings.push('No active price list version — price not stored') } return result } /** Product fields the image import needs. */ export const loadProductForImage = async (f: IdempiereFetch, productId: number): Promise<{ id: number; value: string; name: string; asin: string | null } | null> => { const p: any = await f(`models/m_product/${productId}`) if (!p?.id) return null return { id: Number(p.id), value: p.Value || '', name: p.Name || '', asin: p.asin || p.ASIN || null } } const uploadImage = async (strapiNumericId: number, productId: number, productValue: string, file: { buffer: Buffer; filename: string; mime: string }): Promise => { const config: any = useRuntimeConfig() const uploadBase = (config.api?.strapiupload || (config.public?.strapi || config.api?.strapi || '').replace(/\/media-api\/?$/, '')).replace(/\/$/, '') const fd: any = new FormData() fd.append('field', 'image') fd.append('ref', 'api::m-product.m-product') fd.append('refId', String(strapiNumericId)) fd.append('files', new Blob([new Uint8Array(file.buffer)], { type: file.mime }), file.filename) fd.append('fileInfo', JSON.stringify({ caption: String(productId), alternativeText: String(productValue || '') })) const up: any = await $fetch(`${uploadBase}/api/upload`, { method: 'POST', headers: { Authorization: `Bearer ${config.api?.strapitoken}` }, body: fd }) const uploaded = Array.isArray(up) ? up[0] : (up?.[0] ?? up) return uploaded?.id ?? null } export type ImageImportOutcome = 'imported' | 'has-image' | 'no-amazon-image' | 'no-strapi' | 'error' /** MAIN catalog picture URL of an ASIN (null when Amazon has none). */ export const amazonMainImageUrl = async (amazon: { accessToken: string; marketplaceId: string; sellerId: string }, asin: string): Promise => { await acquire(`catalog:${amazon.sellerId}`, CATALOG_BUCKET) const main = pickMainCatalogImage(await getCatalogItemImages(amazon.accessToken, asin, amazon.marketplaceId)) return main?.link || null } /** * Copies a picture (any https URL: Amazon catalog, Shopify CDN) to the product when the * product has no picture in Strapi yet. Never overwrites an existing gallery. */ export const importImageFromUrlIfMissing = async (f: IdempiereFetch, product: { id: number; value: string; name: string }, url: string | null, known?: StrapiProductRef): Promise<{ outcome: ImageImportOutcome; error?: string }> => { if (!url) return { outcome: 'no-amazon-image' } try { const strapi = known || await ensureStrapiProduct(f, product) if (!strapi.id || !strapi.documentId) return { outcome: 'no-strapi' } if (strapi.hasImage) return { outcome: 'has-image' } const bytes: ArrayBuffer = await $fetch(url, { responseType: 'arrayBuffer', timeout: 30000 } as any) if (!bytes?.byteLength) return { outcome: 'error', error: 'empty image download' } const ext = (url.match(/\.(jpe?g|png|gif|webp)(?:$|\?)/i)?.[1] || 'jpg').toLowerCase() const mime = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg' const fileId = await uploadImage(strapi.id, product.id, product.value, { buffer: Buffer.from(bytes), filename: `${(product.value || String(product.id)).replace(/[^\w.-]+/g, '_')}.${ext === 'jpeg' ? 'jpg' : ext}`, mime }) if (!fileId) return { outcome: 'error', error: 'Strapi upload returned no file id' } try { await strapiHelper(null, `m-products/${strapi.documentId}`, 'PUT', { data: { Main_Image: fileId } }) } catch { /* gallery image is attached anyway */ } return { outcome: 'imported' } } catch (e: any) { return { outcome: 'error', error: String(e?.data?.error?.message || e?.data?.detail || e?.message || e).slice(0, 300) } } } /** Amazon flavour: resolve the ASIN's MAIN picture, then the generic import. */ export const importAmazonImageIfMissing = async (f: IdempiereFetch, amazon: { accessToken: string; marketplaceId: string; sellerId: string }, product: { id: number; value: string; name: string; asin: string | null }, asin: string | null, known?: StrapiProductRef): Promise<{ outcome: ImageImportOutcome; error?: string }> => { const targetAsin = String(asin || product.asin || '').trim() if (!targetAsin) return { outcome: 'no-amazon-image' } try { const strapi = known || await ensureStrapiProduct(f, product) if (!strapi.id || !strapi.documentId) return { outcome: 'no-strapi' } if (strapi.hasImage) return { outcome: 'has-image' } return importImageFromUrlIfMissing(f, product, await amazonMainImageUrl(amazon, targetAsin), strapi) } catch (e: any) { return { outcome: 'error', error: String(e?.data?.error?.message || e?.data?.detail || e?.message || e).slice(0, 300) } } }