// NB: alga-js string.urlEncode leaves '+' unencoded → iDempiere reads "C+" as "C ". Use encodeURIComponent. const enc = (v: string) => encodeURIComponent(v) import refreshTokenHelper from "../../../utils/refreshTokenHelper" import forceLogoutHelper from "../../../utils/forceLogoutHelper" import errorHandlingHelper from "../../../utils/errorHandlingHelper" import getTokenHelper from "../../../utils/getTokenHelper" import fetchHelper from "../../../utils/fetchHelper" import { getNonShippableLocatorIds } from "../../../utils/returnLocatorHelper" /** * Mobile reconditioning — find the customer return(s) behind a scanned RMA number. * * The scan is matched ONLY against the RMA DocumentNo (the return label's barcode * encodes the RMA number; the label also prints it as "RETOURE ", so a leading * "RETOURE"/"RMA" prefix is tolerated when the text is typed). A customer-return * DocumentNo or a product code deliberately does NOT match — a product-code hit used * to surface every return containing that article, which looked like a search over * unrelated return documents. * * For each return only the lines whose stock sits on a NON-shippable locator * (M_LocatorType.IsAvailableForShipping = false) are returned, capped by the qty * currently on hand at that locator and reduced by what earlier reconditioning * movements (M_Movement.M_Customer_Return_ID) already moved. */ const MAX_RETURNS = 10 const esc = (v: string) => String(v).replace(/'/g, "''") const idOf = (v: any) => (v && typeof v === 'object') ? v.id : v const orFilter = (col: string, ids: any[]) => '(' + ids.map((id) => `${col} eq ${id}`).join(' OR ') + ')' const fetchReturnsByFilter = async (event: any, token: any, filter: string) => { const res: any = await fetchHelper( event, `models/m_inout?$filter=${enc(`(${filter}) AND MovementType eq 'C+' AND IsActive eq true`)}` + `&$expand=${enc('M_RMA_ID,C_BPartner_ID,AD_Org_ID')}` + `&$orderby=${enc('M_InOut_ID desc')}&$top=${MAX_RETURNS}`, 'GET', token, null ) return (res?.records || []).filter((r: any) => (r?.DocStatus?.id ?? r?.DocStatus) === 'CO') } const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const body = await readBody(event) // Tolerate the printed label text ("RETOURE 1000123") and an "RMA 1000123" prefix const search = String(body?.search ?? '').trim().replace(/^(RETOURE|RMA)[\s:#-]*/i, '').trim() if (!search) { throw createError({ statusCode: 400, statusMessage: 'search is required' }) } const safe = esc(search) let matchedBy = '' let returns: any[] = [] const matchedProductId: number | null = null // kept for the page's response shape // RMA DocumentNo only const rmaRes: any = await fetchHelper(event, `models/m_rma?$filter=${enc(`DocumentNo eq '${safe}' AND IsActive eq true`)}&$top=5`, 'GET', token, null) const rmaIds = (rmaRes?.records || []).map((r: any) => r.id).filter(Boolean) if (rmaIds.length) { returns = await fetchReturnsByFilter(event, token, orFilter('M_RMA_ID', rmaIds)) if (returns.length) matchedBy = 'rma' } if (!returns.length) { return { found: false, matchedBy: '', search, returns: [] } } // Non-shippable locators per org (cached helper) — resolve once per distinct org const orgIds = [...new Set(returns.map((r: any) => idOf(r.AD_Org_ID)).filter(Boolean))] const nonShippableByOrg: Record> = {} for (const orgId of orgIds) { nonShippableByOrg[String(orgId)] = await getNonShippableLocatorIds(event, token, orgId) } const mapped: any[] = [] for (const ret of returns) { const orgId = idOf(ret.AD_Org_ID) const nonShippable = nonShippableByOrg[String(orgId)] || new Set() const lineRes: any = await fetchHelper(event, `models/m_inoutline?$filter=${enc(`M_InOut_ID eq ${ret.id} AND IsActive eq true`)}` + `&$expand=${enc('M_Product_ID,M_Locator_ID,C_UOM_ID')}&$orderby=${enc('Line asc')}&$top=200`, 'GET', token, null) const rawLines = (lineRes?.records || []).filter((l: any) => l?.M_Product_ID?.id && Number(l?.MovementQty) > 0) // Stock currently on the return locators for these products (one query per return) const prodIds = [...new Set(rawLines.map((l: any) => l.M_Product_ID.id))] const locIds = [...new Set(rawLines.map((l: any) => idOf(l.M_Locator_ID)).filter(Boolean))] const onHand: Record = {} if (prodIds.length && locIds.length) { try { const stRes: any = await fetchHelper(event, `models/m_storage?$filter=${enc(`${orFilter('M_Product_ID', prodIds)} AND ${orFilter('M_Locator_ID', locIds)}`)}&$select=M_Product_ID,M_Locator_ID,QtyOnHand&$top=500`, 'GET', token, null) for (const st of (stRes?.records || [])) { const key = `${idOf(st.M_Product_ID)}|${idOf(st.M_Locator_ID)}` onHand[key] = (onHand[key] || 0) + (Number(st.QtyOnHand) || 0) } } catch (e) { console.warn('[reconditioning/lookup] storage lookup failed:', e) } } // Already reconditioned from this return (needs M_Movement.M_Customer_Return_ID — fail-soft) const alreadyMoved: Record = {} try { const mvRes: any = await fetchHelper(event, `models/m_movement?$filter=${enc(`M_Customer_Return_ID eq ${ret.id} AND DocStatus eq 'CO'`)}&$expand=m_movementline&$top=50`, 'GET', token, null) for (const mv of (mvRes?.records || [])) { for (const ml of (mv?.m_movementline || [])) { const pid = idOf(ml.M_Product_ID) if (pid) alreadyMoved[String(pid)] = (alreadyMoved[String(pid)] || 0) + (Number(ml.MovementQty) || 0) } } } catch (e) { // column not there yet → nothing to subtract } let hiddenShippable = 0 const lines: any[] = [] for (const l of rawLines) { const locId = idOf(l.M_Locator_ID) if (!locId || !nonShippable.has(Number(locId))) { hiddenShippable++; continue } const p = l.M_Product_ID const returnedQty = Number(l.MovementQty) || 0 const stockQty = onHand[`${p.id}|${locId}`] ?? null const doneQty = alreadyMoved[String(p.id)] || 0 let maxQty = Math.max(0, returnedQty - doneQty) if (stockQty !== null) maxQty = Math.min(maxQty, Math.max(0, stockQty)) lines.push({ lineId: l.id, line: l.Line, productId: p.id, productName: p.Name || '', productValue: p.Value || '', productSku: p.SKU || '', productUpc: p.UPC || '', imageUrl: p.ImageURL || null, uomId: idOf(l.C_UOM_ID) || null, returnedQty, stockQty, doneQty, maxQty, locatorId: locId, locatorCode: l.M_Locator_ID?.Value || l.M_Locator_ID?.identifier || '', locatorType: l.M_Locator_ID?.M_LocatorType_ID?.identifier || '', description: l.Description || '' }) } mapped.push({ id: ret.id, documentNo: ret.DocumentNo || String(ret.id), movementDate: ret.MovementDate || '', docStatus: ret.DocStatus?.id ?? ret.DocStatus ?? '', rmaId: idOf(ret.M_RMA_ID) || null, rmaDocumentNo: ret.M_RMA_ID?.DocumentNo || ret.M_RMA_ID?.identifier || '', partnerId: idOf(ret.C_BPartner_ID) || null, partnerName: ret.C_BPartner_ID?.Name || ret.C_BPartner_ID?.identifier || '', orgId: orgId || null, orgName: ret.AD_Org_ID?.Name || ret.AD_Org_ID?.identifier || '', warehouseId: idOf(ret.M_Warehouse_ID) || null, hiddenShippableLines: hiddenShippable, lines, matchedProductId }) } return { found: true, matchedBy, search, returns: mapped } } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { if (err?.statusCode === 400) throw err try { const authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })