/** * POST /api/materials/products/quick-store (multipart) * * Minimal product creation for the "quick product" modal on /materials/products: * parts: name (required), price (number, optional → 0), priceListVersionIds (JSON array), * priceListVersionPrices (JSON object versionId → price, overrides `price` per version), * productCategoryId (optional, staff only — otherwise category "Standard"), * organizationId (falls back to the org cookie), image (optional file) * * Defaults resolved from iDempiere (cached 10 min): product category "Standard", * UOM "Each", tax category "Standard" (each with an IsDefault fallback). * After the product POST nothing may throw any more (a refresh-retry would create * a duplicate product) — Strapi record, image upload, prices and the Laravel sync * are best-effort and reported in `warnings` / `prices`. */ import { string } from 'alga-js' import refreshTokenHelper from '../../../utils/refreshTokenHelper' import errorHandlingHelper from '../../../utils/errorHandlingHelper' import fetchHelper from '../../../utils/fetchHelper' import strapiHelper from '../../../utils/strapiHelper' import laravelHelper from '../../../utils/laravelHelper' const DEFAULTS_TTL_MS = 10 * 60 * 1000 let defaultsCache: { at: number; value: any } | null = null const findFirst = async (event: any, token: string, model: string, filters: string[]) => { for (const filter of filters) { const res: any = await fetchHelper(event, `models/${model}?$filter=${string.urlEncode(filter)}&$top=1`, 'GET', token, null) if (res?.records?.[0]?.id) return res.records[0] } return null } const resolveDefaults = async (event: any, token: string) => { if (defaultsCache && Date.now() - defaultsCache.at < DEFAULTS_TTL_MS) return defaultsCache.value const [category, uom, taxCategory] = await Promise.all([ findFirst(event, token, 'm_product_category', ["Name eq 'Standard' AND IsActive eq true", 'IsDefault eq true AND IsActive eq true']), findFirst(event, token, 'c_uom', ["Name eq 'Each' AND IsActive eq true", "X12DE355 eq 'EA' AND IsActive eq true", 'IsDefault eq true AND IsActive eq true']), findFirst(event, token, 'c_taxcategory', ["Name eq 'Standard' AND IsActive eq true", 'IsDefault eq true AND IsActive eq true']) ]) const value = { categoryId: category?.id ?? null, category: category?.Name || '', uomId: uom?.id ?? null, uom: uom?.Name || '', taxCategoryId: taxCategory?.id ?? null, taxCategory: taxCategory?.Name || '' } if (value.categoryId && value.uomId && value.taxCategoryId) defaultsCache = { at: Date.now(), value } return value } const uploadProductImage = async (strapiNumericId: number, productId: number, productUu: string, file: { buffer: Buffer; filename: string; mime: string }) => { const config: any = useRuntimeConfig() const uploadBase = (config.api?.strapiupload || (config.public?.strapi || config.api?.strapi || '').replace(/\/media-api\/?$/, '') ).replace(/\/$/, '') // Native FormData + Blob (undici sets the multipart boundary) — same as inbox/strapiAttach.ts. 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([file.buffer], { type: file.mime || 'image/jpeg' }), file.filename) fd.append('fileInfo', JSON.stringify({ caption: String(productId), alternativeText: String(productUu || '') })) 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 default defineEventHandler(async (event) => { // Multipart body can only be read once — parse before any token work. const form = await readMultipartFormData(event) if (!form) return { status: 400, message: 'Empty request' } const fields: Record = {} let image: { buffer: Buffer; filename: string; mime: string } | null = null for (const part of form) { if (!part.name) continue if (part.filename && part.data?.length) { if (part.name === 'image') image = { buffer: part.data, filename: part.filename, mime: (part as any).type || 'image/jpeg' } } else { fields[part.name] = part.data?.toString('utf8') ?? '' } } const name = String(fields.name || '').trim() if (!name) return { status: 400, message: 'Name is required' } if (image && !/^image\//i.test(image.mime)) return { status: 415, message: 'Only image files are allowed' } const organizationId = fields.organizationId || getCookie(event, 'logship_organization_id') if (!organizationId) return { status: 400, message: 'No organization context' } const price = Number(String(fields.price || '0').replace(',', '.')) if (!Number.isFinite(price) || price < 0) return { status: 400, message: 'Invalid price' } let versionIds: number[] = [] try { versionIds = (JSON.parse(fields.priceListVersionIds || '[]') || []).map(Number).filter((n: number) => Number.isFinite(n) && n > 0) } catch {} const overrides: Record = {} try { const raw = JSON.parse(fields.priceListVersionPrices || '{}') || {} for (const [k, v] of Object.entries(raw)) { const n = Number(String(v ?? '').replace(',', '.')) if (Number.isFinite(n) && n >= 0) overrides[String(Number(k))] = n } } catch {} const priceFor = (versionId: number) => overrides[String(versionId)] ?? price const customCategoryId = Number(fields.productCategoryId || 0) > 0 ? Number(fields.productCategoryId) : null const userId = getCookie(event, 'logship_user_id') const persist = async (token: string) => { const defaults = await resolveDefaults(event, token) const missing = [!defaults.categoryId && 'product category "Standard"', !defaults.uomId && 'UOM "Each"', !defaults.taxCategoryId && 'tax category "Standard"'].filter(Boolean) if (missing.length) return { status: 422, message: 'Default not found: ' + missing.join(', ') } // Optional category override (staff): must be an active category, otherwise fall back to "Standard". let categoryId = defaults.categoryId let categoryName = defaults.category if (customCategoryId) { const cat: any = await fetchHelper(event, 'models/m_product_category/' + customCategoryId, 'GET', token, null) if (cat?.id && (cat?.IsActive ?? cat?.isActive ?? true) !== false) { categoryId = cat.id; categoryName = cat.Name || '' } } // ---- 1. product (the only write that may throw → refresh-retry safe) ---- const product: any = await fetchHelper(event, 'models/m_product', 'POST', token, { AD_Org_ID: { id: Number(organizationId), 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: 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' }, ...(userId ? { SalesRep_ID: { id: Number(userId), tableName: 'AD_User' } } : {}), tableName: 'M_Product' }) if (!product?.id) return { status: 500, message: 'Product was not created' } const warnings: string[] = [] const result: any = { status: 200, message: '', id: product.id, uid: product.uid, value: product.Value, name: product.Name, defaults, category: categoryName, categoryId, organizationId: Number(organizationId), organization: product?.AD_Org_ID?.identifier || '', strapi: { id: null, documentId: null }, imageUploaded: false, prices: [] as any[], warnings } // ---- 2. Strapi record + link (best-effort) ---- try { const sp: any = await strapiHelper(event, 'm-products', 'POST', { data: { M_Product_ID: product.id, value: product.Value, name: product.Name } }) if (sp?.data?.id) { result.strapi = { id: sp.data.id, documentId: sp.data.documentId } try { await fetchHelper(event, 'models/m_product/' + product.id, 'PUT', token, { Strapi_Product_ID: sp.data.id, Strapi_Product_documentId: sp.data.documentId }) } catch (e: any) { warnings.push('Strapi link not stored on the product: ' + (e?.data?.detail || e?.message || e)) } } else { warnings.push('Strapi product record not created') } } catch (e: any) { warnings.push('Strapi product record not created: ' + (e?.data?.error?.message || e?.message || e)) } // ---- 3. image (best-effort) ---- if (image && result.strapi.id) { try { const fileId = await uploadProductImage(result.strapi.id, product.id, product.uid, image) result.imageUploaded = !!fileId if (fileId && result.strapi.documentId) { try { await strapiHelper(event, `m-products/${result.strapi.documentId}`, 'PUT', { data: { Main_Image: fileId } }) } catch {} } } catch (e: any) { warnings.push('Image upload failed: ' + (e?.data?.error?.message || e?.message || e)) } } else if (image) { warnings.push('Image skipped (no Strapi product record)') } // ---- 4. prices (best-effort, one per version) ---- for (const versionId of versionIds) { const versionPrice = priceFor(versionId) try { const pp: any = await fetchHelper(event, 'models/m_productprice', 'POST', token, { AD_Org_ID: { id: Number(organizationId), tableName: 'AD_Org' }, isActive: true, priceList: versionPrice, priceStd: versionPrice, priceLimit: versionPrice, M_Product_ID: { id: product.id, tableName: 'M_Product' }, M_PriceList_Version_ID: { id: versionId, tableName: 'M_PriceList_Version' }, tableName: 'M_ProductPrice' }) result.prices.push({ versionId, id: pp?.id ?? null, ok: !!pp?.id, price: versionPrice }) } catch (e: any) { const msg = e?.data?.detail || e?.data?.title || e?.message || String(e) result.prices.push({ versionId, ok: false, message: msg }) warnings.push(`Price for version ${versionId} failed: ${msg}`) } } // ---- 5. Laravel sync (best-effort, same as the full create) ---- try { await laravelHelper(event, '/inventories/products/sync-idempiere', 'POST', null) } catch {} return result } let firstError: any = null try { return await persist(await getTokenHelper(event)) } catch (err: any) { firstError = err } try { return await persist(await refreshTokenHelper(event)) } catch (error: any) { return errorHandlingHelper(firstError?.data ?? firstError, error?.data ?? error) } })