/** * Lead qualification fields — custom AD_User columns (Entity Type U, created 2026-10-21): * * write key (REST, lowercase-first) → read key type notes * leadRating → LeadRating Integer 1–5 stars, NULL = unrated * followUpDate → FollowUpDate Date Wiedervorlage (YYYY-MM-DD) * followUpNote → FollowUpNote String 255 what to do on that date * monthlyOrders → MonthlyOrders Integer expected orders / month * skuCount → SkuCount Integer number of articles * shopSystem → ShopSystem String 255 shop / marketplace systems * expectedMonthlyValue → ExpectedMonthlyValue Amount € / month (quote forecast total) * winProbability → WinProbability Integer 0–100 %, auto-bumped by stage * expectedStartDate → ExpectedStartDate Date planned go-live * aiScore → AiScore Integer 0–100, KI-Analyse dealScore * avgParcelWeight → AvgParcelWeight String 30 e.g. "1-2 kg" * leadStreet / leadZip / leadCity / leadCountry → LeadStreet (255) / LeadZip (20) / LeadCity (120) / LeadCountry (100) * String address, mirrored to/from quote & contract `customer` * BusinessModel → BusinessModel List AD_Reference "BusinessModel" (ids 10000002…) * LostReason → LostReason List AD_Reference "LostReason" (ids 10000005…) * * Lists are written as `{ id: '' }` and read back as * `{ id, identifier }`. The list values are READ FROM THE API (getLeadRefList), never * hard-coded — the user maintains them in iDempiere. All writers here are fail-soft: * a qualification write must never break a send / confirmation / assessment flow. */ import fetchHelper from '../fetchHelper' export const QUALIFY_READ_KEYS: Record = { leadRating: 'LeadRating', followUpDate: 'FollowUpDate', followUpNote: 'FollowUpNote', monthlyOrders: 'MonthlyOrders', skuCount: 'SkuCount', shopSystem: 'ShopSystem', expectedMonthlyValue: 'ExpectedMonthlyValue', winProbability: 'WinProbability', expectedStartDate: 'ExpectedStartDate', aiScore: 'AiScore', avgParcelWeight: 'AvgParcelWeight', leadStreet: 'LeadStreet', leadZip: 'LeadZip', leadCity: 'LeadCity', leadCountry: 'LeadCountry', businessModel: 'BusinessModel', lostReason: 'LostReason' } const LIST_FIELDS: Record = { businessModel: 'BusinessModel', lostReason: 'LostReason' } const intField = (min: number, max: number, zeroIsNull = true) => (v: any) => { if (v === null || v === undefined || String(v).trim() === '') return null const n = Math.round(Number(v)) if (!Number.isFinite(n) || n < min || n > max) return undefined return zeroIsNull && n === 0 ? null : n } const dateField = (v: any) => { if (v === null || v === undefined || String(v).trim() === '') return null const m = String(v).trim().match(/^(\d{4}-\d{2}-\d{2})/) if (!m || Number.isNaN(Date.parse(m[1]))) return undefined return m[1] } const stringField = (max: number) => (v: any) => { if (v === null || v === undefined) return null const s = String(v).trim() return s === '' ? null : s.slice(0, max) } const amountField = (v: any) => { if (v === null || v === undefined || String(v).trim() === '') return null const n = Number(String(v).replace(',', '.')) if (!Number.isFinite(n) || n < 0 || n > 1e9) return undefined return n === 0 ? null : Math.round(n * 100) / 100 } /** Scalar normalisers: `undefined` = invalid (skip the key), `null` = clear the column. */ export const QUALIFY_SCALARS: Record number | string | null | undefined> = { leadRating: intField(0, 5), followUpDate: dateField, followUpNote: stringField(255), monthlyOrders: intField(0, 100000000), skuCount: intField(0, 100000000), shopSystem: stringField(255), expectedMonthlyValue: amountField, winProbability: intField(0, 100, false), expectedStartDate: dateField, aiScore: intField(0, 100, false), avgParcelWeight: stringField(30), leadStreet: stringField(255), leadZip: stringField(20), leadCity: stringField(120), leadCountry: stringField(100) } // ---- reference lists (AD_Reference by name → AD_Ref_List), cached per process ---------- const listCache = new Map }>() const LIST_TTL_MS = 10 * 60 * 1000 export const getLeadRefList = async (event: any, token: string, referenceName: string): Promise> => { const hit = listCache.get(referenceName) if (hit && Date.now() - hit.at < LIST_TTL_MS) return hit.items const ref: any = await fetchHelper(event, `models/ad_reference?$filter=Name eq '${referenceName}'&$top=1`, 'GET', token, null) const refId = Number(ref?.records?.[0]?.id) if (!refId) throw new Error(`AD_Reference "${referenceName}" not found`) const res: any = await fetchHelper(event, `models/ad_ref_list?$filter=AD_Reference_ID eq ${refId} and IsActive eq true&$orderby=Value asc&$top=200`, 'GET', token, null) const items = (res?.records || []).map((r: any) => ({ id: String(r.Value), name: String(r.Name || r.Value) })) listCache.set(referenceName, { at: Date.now(), items }) return items } export const getLeadLists = async (event: any, token: string) => { const [businessModel, lostReason] = await Promise.all([getLeadRefList(event, token, 'BusinessModel'), getLeadRefList(event, token, 'LostReason')]) return { businessModel, lostReason } } /** Map a quote/contract `assumptions.businessModel` ('B2C' | 'B2B' | 'B2C+B2B') to the list id. */ const squash = (s: any) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '') export const businessModelIdFor = (items: Array<{ id: string; name: string }>, value: any): string | null => { const key = squash(value) if (!key) return null const both = key.includes('b2b') && key.includes('b2c') const hit = items.find((i) => { const n = squash(i.name) return both ? (n.includes('b2b') && n.includes('b2c')) : n === key }) return hit ? hit.id : null } /** Reverse: list identifier ('B2B + B2C') → quote option id ('B2C+B2B'). */ export const quoteBusinessModelFor = (identifier: any): string => { const k = squash(identifier) if (k.includes('b2b') && k.includes('b2c')) return 'B2C+B2B' if (k === 'b2b') return 'B2B' if (k === 'b2c') return 'B2C' return '' } /** * Normalise a client body into an iDempiere PUT patch. Unknown keys and invalid values * are dropped; list values are validated against the API list (unknown id → dropped). */ export const normalizeQualifyPatch = async (event: any, token: string, body: Record): Promise> => { const patch: Record = {} for (const [k, v] of Object.entries(body || {})) { if (QUALIFY_SCALARS[k]) { const norm = QUALIFY_SCALARS[k](v) if (norm !== undefined) patch[k] = norm } else if (LIST_FIELDS[k]) { if (v === null || v === undefined || String(v).trim() === '') { patch[LIST_FIELDS[k]] = null; continue } const items = await getLeadRefList(event, token, LIST_FIELDS[k]) const id = String(v).trim() if (items.some((i) => i.id === id)) patch[LIST_FIELDS[k]] = { id } } } return patch } // ---- automatic stage values ---------------------------------------------------------- /** Win probability targets per pipeline stage; automatic bumps only ever RAISE the value * (a sales rep's higher manual estimate survives), except `lost` / `new` which reset it. */ export const WIN_PROBABILITY: Record = { new: 10, working: 25, quoteSent: 50, quoteConfirmed: 70, contractSent: 85, contractSigned: 95, converted: 100, lost: 0 } const readLead = async (event: any, token: string, leadId: number): Promise => { try { return await fetchHelper(event, `models/ad_user/${leadId}`, 'GET', token, null) } catch { return null } } /** PUT qualification values on a lead — fail-soft (logs, never throws). Returns true on success. */ export const writeLeadQualification = async (event: any, token: string, leadId: number, patch: Record, tag = 'lead-qualify'): Promise => { if (!(leadId > 0) || !patch || !Object.keys(patch).length) return false try { await fetchHelper(event, `models/ad_user/${leadId}`, 'PUT', token, patch) return true } catch (err: any) { console.error(`[${tag}] write skipped for lead ${leadId}:`, err?.data?.detail ?? err?.data?.title ?? err?.message ?? err) return false } } /** * Raise WinProbability to the stage's target (never lowers, except stage 'lost'/'new'). * `source` guards partner records (C_BPartner has none of these columns). */ export const bumpWinProbability = async (event: any, token: string, source: string, recordId: number, stage: keyof typeof WIN_PROBABILITY, extra: Record = {}): Promise => { if (source !== 'lead' || !(recordId > 0)) return null const target = WIN_PROBABILITY[stage] if (target === undefined) return null let next = target if (stage !== 'lost' && stage !== 'new') { const lead = await readLead(event, token, recordId) const current = Number(lead?.WinProbability ?? lead?.winProbability ?? 0) || 0 next = Math.max(current, target) } const ok = await writeLeadQualification(event, token, recordId, { winProbability: next, ...extra }, 'win-probability') return ok ? next : null } /** * Quote sent → copy the sizing assumptions and the forecast total onto the lead so the * CRM list carries what was offered: MonthlyOrders, SkuCount, BusinessModel, ShopSystem, * ExpectedStartDate, ExpectedMonthlyValue (+ WinProbability ≥ quoteSent). * Contract sent → the same minus the forecast total, WinProbability ≥ contractSent. */ export const syncLeadFromOffer = async (event: any, token: string, source: string, recordId: number, payload: any, stage: 'quoteSent' | 'contractSent'): Promise => { if (source !== 'lead' || !(recordId > 0)) return const a = payload?.assumptions || {} const patch: Record = {} const orders = QUALIFY_SCALARS.monthlyOrders(a.ordersPerMonth); if (orders) patch.monthlyOrders = orders const skus = QUALIFY_SCALARS.skuCount(a.skuCount); if (skus) patch.skuCount = skus const shop = QUALIFY_SCALARS.shopSystem(a.shopSystem); if (shop) patch.shopSystem = shop const start = QUALIFY_SCALARS.expectedStartDate(a.plannedStart); if (start) patch.expectedStartDate = start // address typed in the quote / contract → lead (only filled parts) — written as its OWN // PUT so a missing address column can never block the sizing / probability sync. const c = payload?.customer || {} const address: Record = {} const street = QUALIFY_SCALARS.leadStreet(c.street); if (street) address.leadStreet = street const zip = QUALIFY_SCALARS.leadZip(c.zip); if (zip) address.leadZip = zip const city = QUALIFY_SCALARS.leadCity(c.city); if (city) address.leadCity = city const country = QUALIFY_SCALARS.leadCountry(c.country); if (country) address.leadCountry = country await writeLeadQualification(event, token, recordId, address, 'lead-address') if (stage === 'quoteSent') { const total = QUALIFY_SCALARS.expectedMonthlyValue(payload?.forecast?.totalMonthly); if (total) patch.expectedMonthlyValue = total } try { const items = await getLeadRefList(event, token, 'BusinessModel') const id = businessModelIdFor(items, a.businessModel) if (id) patch.BusinessModel = { id } } catch (err: any) { console.error('[lead-qualify] BusinessModel list unavailable:', err?.message ?? err) } await bumpWinProbability(event, token, source, recordId, stage, patch) }