/** * Software-offer catalog persisted in iDempiere: * CUST_SoftwarePlan (models/cust_softwareplan) — one row per package * CUST_SoftwareCatalogItem (models/cust_softwarecatalogitem) — features (ItemType 'feature', * FK CUST_SoftwarePlan_ID = minimum package) and add-ons ('addon') * Enum columns are AD_Reference lists (CUST_SoftwareSupportLevel, …ItemType, …FeatureGroup, * …FeatureMode, …PriceUnit): read as { id, identifier }, written as { id }. * * FAIL-SOFT: a missing/unreadable table or an empty one falls back to * DEFAULT_SOFTWARE_CATALOG (app/utils/softwareCatalogDefaults.ts) for that part and reports it * (`source`, `missingTables`, `empty`) so the modal/settings page can say so. 5-min cache per org, * invalidated by every write. Rows are scoped `(AD_Org_ID eq or AD_Org_ID eq 0)`. */ import fetchHelper from '../fetchHelper' import { DEFAULT_SOFTWARE_CATALOG, normalizeSoftwareCatalog, type SoftwareCatalog, type CatalogPlan, type CatalogFeature, type CatalogAddon } from '../../../app/utils/softwareCatalogDefaults' export const PLAN_MODEL = 'cust_softwareplan' export const ITEM_MODEL = 'cust_softwarecatalogitem' export const PLAN_TABLE = 'CUST_SoftwarePlan' export const ITEM_TABLE = 'CUST_SoftwareCatalogItem' const CACHE_TTL_MS = 5 * 60 * 1000 const g = globalThis as any const cache: Map = g.__softwareCatalogCache ?? (g.__softwareCatalogCache = new Map()) export const invalidateSoftwareCatalogCache = () => cache.clear() const num = (v: any): number => { const n = Number(v); return Number.isFinite(n) ? n : 0 } const listId = (v: any): string => (v && typeof v === 'object' ? String(v.id ?? v.identifier ?? '') : String(v ?? '')) const fkId = (v: any): number => num(v && typeof v === 'object' ? v.id : v) const orgOf = (event: any): number => num(getCookie(event, 'logship_organization_id')) const scopeFilter = (orgId: number) => (orgId > 0 ? `(AD_Org_ID eq ${orgId} or AD_Org_ID eq 0)` : 'AD_Org_ID eq 0') const isMissingTableError = (err: any): boolean => { const s = num(err?.statusCode ?? err?.status ?? err?.response?.status) const msg = String(err?.data?.detail ?? err?.data?.title ?? err?.message ?? '').toLowerCase() return s === 404 || /not found|does not exist|no table|invalid table|access denied for table/.test(msg) } // ---- row mappers (tolerant casing: REST returns the ColumnName verbatim) ---- const mapPlan = (r: any): CatalogPlan => ({ id: num(r?.id), key: String(r?.Value ?? r?.value ?? '').trim(), name: String(r?.Name ?? r?.name ?? '').trim(), nameEn: String(r?.NameEN ?? r?.nameEN ?? r?.nameen ?? '').trim(), seqNo: num(r?.SeqNo ?? r?.seqNo), popular: (r?.IsPopular ?? r?.isPopular) === true, priceMonthly: num(r?.PriceMonthly ?? r?.priceMonthly), priceYearly: num(r?.PriceYearly ?? r?.priceYearly), includedOrders: num(r?.IncludedOrders ?? r?.includedOrders), includedUsers: num(r?.IncludedUsers ?? r?.includedUsers), salesChannels: num(r?.SalesChannels ?? r?.salesChannels), extraOrderPrice: num(r?.ExtraOrderPrice ?? r?.extraOrderPrice), extraUserPrice: num(r?.ExtraUserPrice ?? r?.extraUserPrice), support: (listId(r?.SupportLevel ?? r?.supportLevel) || 'ticket') as any, description: String(r?.Description ?? r?.description ?? ''), active: (r?.IsActive ?? r?.isActive) !== false }) const mapItem = (r: any, planKeyById: Map) => { const type = listId(r?.ItemType ?? r?.itemType) === 'addon' ? 'addon' : 'feature' const base = { id: num(r?.id), key: String(r?.Value ?? r?.value ?? '').trim(), name: String(r?.Name ?? r?.name ?? '').trim(), nameEn: String(r?.NameEN ?? r?.nameEN ?? r?.nameen ?? '').trim(), seqNo: num(r?.SeqNo ?? r?.seqNo), active: (r?.IsActive ?? r?.isActive) !== false } if (type === 'addon') { const addon: CatalogAddon = { ...base, price: num(r?.Price ?? r?.price), unit: (listId(r?.PriceUnit ?? r?.priceUnit) || 'month') as any, note: String(r?.Description ?? r?.description ?? ''), noteEn: String(r?.DescriptionEN ?? r?.descriptionEN ?? r?.descriptionen ?? ''), defaultSelected: (r?.IsDefault ?? r?.isDefault) === true } return { type, row: addon } } const feature: CatalogFeature = { ...base, group: listId(r?.FeatureGroup ?? r?.featureGroup) || 'core', minPlanKey: planKeyById.get(fkId(r?.CUST_SoftwarePlan_ID ?? r?.cust_softwareplan_id)) || '', mode: (listId(r?.Mode ?? r?.mode) || 'standard') as any } return { type, row: feature } } const listAll = async (event: any, token: string, model: string, orgId: number) => { const res: any = await fetchHelper(event, `models/${model}?$filter=${encodeURIComponent(scopeFilter(orgId))}&$orderby=${encodeURIComponent('SeqNo asc')}&$top=500`, 'GET', token, null) return Array.isArray(res?.records) ? res.records : [] } /** Read the catalog (cached 5 min per org). `force` bypasses the cache. */ export const loadSoftwareCatalog = async (event: any, token: string, force = false): Promise => { const orgId = orgOf(event) const ck = String(orgId) const hit = cache.get(ck) if (!force && hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.catalog const missing: string[] = [] let empty = false let plans: CatalogPlan[] = [] let planRows: any[] = [] try { planRows = await listAll(event, token, PLAN_MODEL, orgId) plans = planRows.map(mapPlan).filter((p) => p.key) } catch (err: any) { if (!isMissingTableError(err)) { const s = num(err?.statusCode ?? err?.status ?? err?.response?.status) if (s === 401) throw err // let the route run its token-refresh path } missing.push(PLAN_TABLE) } let features: CatalogFeature[] = [] let addons: CatalogAddon[] = [] try { const planKeyById = new Map(plans.map((p) => [Number(p.id), p.key])) const rows = await listAll(event, token, ITEM_MODEL, orgId) for (const r of rows) { const m = mapItem(r, planKeyById) if (!m.row.key) continue if (m.type === 'addon') addons.push(m.row as CatalogAddon) else features.push(m.row as CatalogFeature) } } catch (err: any) { const s = num(err?.statusCode ?? err?.status ?? err?.response?.status) if (s === 401) throw err missing.push(ITEM_TABLE) } // Fallbacks: a missing OR empty part comes from the defaults (the app must // behave until the tables are created + seeded). const useDefaultPlans = missing.includes(PLAN_TABLE) || plans.length === 0 const useDefaultItems = missing.includes(ITEM_TABLE) || (features.length === 0 && addons.length === 0) if (!missing.length && (plans.length === 0 || (features.length === 0 && addons.length === 0))) empty = true const catalog = normalizeSoftwareCatalog({ plans: useDefaultPlans ? DEFAULT_SOFTWARE_CATALOG.plans : plans, features: useDefaultItems ? DEFAULT_SOFTWARE_CATALOG.features : features, addons: useDefaultItems ? DEFAULT_SOFTWARE_CATALOG.addons : addons, source: useDefaultPlans || useDefaultItems ? 'defaults' : 'idempiere', missingTables: missing, empty, loadedAt: new Date().toISOString() }) // Keep the real (possibly inactive) rows visible to the settings page when the // tables answered: expose them separately so "defaults" never look persisted. ;(catalog as any).persisted = { plans: missing.includes(PLAN_TABLE) ? null : plans, features: missing.includes(ITEM_TABLE) ? null : features, addons: missing.includes(ITEM_TABLE) ? null : addons } cache.set(ck, { at: Date.now(), catalog }) return catalog } // ---- writes ---- const SUPPORT = ['ticket', 'phone', 'sla'] const GROUPS = ['core', 'warehouse', 'advanced', 'enterprise'] const MODES = ['standard', 'onRequest', 'quote'] const UNITS = ['month', 'once', 'hour', 'text'] const str = (v: any, max: number) => String(v ?? '').trim().slice(0, max) const nonNeg = (v: any) => Math.max(0, num(v)) const keyOk = (k: string) => /^[A-Za-z0-9_-]{1,40}$/.test(k) const planBody = (p: any, orgId: number, forUpdate: boolean) => { const body: any = { value: str(p.key, 40), name: str(p.name, 160), nameEN: str(p.nameEn, 160), seqNo: Math.round(nonNeg(p.seqNo)), isPopular: p.popular === true, priceMonthly: nonNeg(p.priceMonthly), priceYearly: nonNeg(p.priceYearly), includedOrders: Math.round(nonNeg(p.includedOrders)), includedUsers: Math.round(nonNeg(p.includedUsers)), salesChannels: Math.round(nonNeg(p.salesChannels)), extraOrderPrice: nonNeg(p.extraOrderPrice), extraUserPrice: nonNeg(p.extraUserPrice), SupportLevel: { id: SUPPORT.includes(p.support) ? p.support : 'ticket' }, description: str(p.description, 500), isActive: p.active !== false, tableName: PLAN_TABLE } if (!forUpdate) body.AD_Org_ID = { id: orgId, tableName: 'AD_Org' } return body } const itemBody = (type: 'feature' | 'addon', it: any, planIdByKey: Map, orgId: number, forUpdate: boolean) => { const body: any = { ItemType: { id: type }, value: str(it.key, 60), name: str(it.name, 90), nameEN: str(it.nameEn, 160), seqNo: Math.round(nonNeg(it.seqNo)), isDefault: type === 'addon' && it.defaultSelected === true, isActive: it.active !== false, tableName: ITEM_TABLE } if (type === 'feature') { body.FeatureGroup = { id: GROUPS.includes(it.group) ? it.group : 'core' } body.Mode = { id: MODES.includes(it.mode) ? it.mode : 'standard' } const planId = planIdByKey.get(String(it.minPlanKey || '')) if (planId) body.CUST_SoftwarePlan_ID = { id: planId, tableName: PLAN_TABLE } else if (forUpdate) body.CUST_SoftwarePlan_ID = null } else { body.price = nonNeg(it.price) body.PriceUnit = { id: UNITS.includes(it.unit) ? it.unit : 'month' } body.description = str(it.note, 500) body.descriptionEN = str(it.noteEn, 500) } if (!forUpdate) body.AD_Org_ID = { id: orgId, tableName: 'AD_Org' } return body } /** * Persist a whole catalog edit (settings page "Speichern"): upsert plans, then * features + add-ons (FK by plan key), then delete removed rows. A plan that is * still referenced by a feature cannot be deleted (400). Returns the fresh catalog. */ export const saveSoftwareCatalog = async (event: any, token: string, input: any): Promise<{ catalog: SoftwareCatalog; warnings: string[] }> => { const orgId = orgOf(event) const warnings: string[] = [] const plans: any[] = Array.isArray(input?.plans) ? input.plans : [] const features: any[] = Array.isArray(input?.features) ? input.features : [] const addons: any[] = Array.isArray(input?.addons) ? input.addons : [] const deletedPlanIds: number[] = (Array.isArray(input?.deleted?.plans) ? input.deleted.plans : []).map(num).filter((n: number) => n > 0) const deletedItemIds: number[] = (Array.isArray(input?.deleted?.items) ? input.deleted.items : []).map(num).filter((n: number) => n > 0) // Validation const seenPlan = new Set() for (const p of plans) { const k = str(p.key, 40) if (!keyOk(k)) throw createError({ statusCode: 400, statusMessage: `Ungültiger Paket-Schlüssel "${p.key}" (a-z, 0-9, _ -)` }) if (seenPlan.has(k)) throw createError({ statusCode: 400, statusMessage: `Paket-Schlüssel doppelt: ${k}` }) seenPlan.add(k) if (!str(p.name, 160)) throw createError({ statusCode: 400, statusMessage: `Paket ${k}: Name fehlt` }) } const seenItem = new Set() for (const it of [...features.map((f) => ({ ...f, _t: 'feature' })), ...addons.map((a) => ({ ...a, _t: 'addon' }))]) { const k = str(it.key, 60) if (!keyOk(k)) throw createError({ statusCode: 400, statusMessage: `Ungültiger Schlüssel "${it.key}" (a-z, 0-9, _ -)` }) const kk = it._t + ':' + k if (seenItem.has(kk)) throw createError({ statusCode: 400, statusMessage: `Schlüssel doppelt: ${k}` }) seenItem.add(kk) if (!str(it.name, 90)) throw createError({ statusCode: 400, statusMessage: `${k}: Name fehlt` }) } for (const f of features) { if (f.minPlanKey && !seenPlan.has(str(f.minPlanKey, 40))) throw createError({ statusCode: 400, statusMessage: `Funktion ${f.key}: Mindestpaket "${f.minPlanKey}" existiert nicht` }) } if (deletedPlanIds.length) { const referenced = features.filter((f) => plans.some((p) => deletedPlanIds.includes(num(p.id)) && p.key === f.minPlanKey)) if (referenced.length) throw createError({ statusCode: 400, statusMessage: 'Paket wird noch von Funktionen verwendet — erst Funktionen umhängen oder Paket deaktivieren' }) } // 1) plans const planIdByKey = new Map() for (const p of plans) { const id = num(p.id) const body = planBody(p, orgId, id > 0) const res: any = id > 0 ? await fetchHelper(event, `models/${PLAN_MODEL}/${id}`, 'PUT', token, body) : await fetchHelper(event, `models/${PLAN_MODEL}`, 'POST', token, body) const newId = num(res?.id) || id if (!(newId > 0)) throw createError({ statusCode: 500, statusMessage: `Paket ${p.key}: Speichern fehlgeschlagen` }) planIdByKey.set(str(p.key, 40), newId) } // 2) items const upsertItem = async (type: 'feature' | 'addon', it: any) => { const id = num(it.id) const body = itemBody(type, it, planIdByKey, orgId, id > 0) const res: any = id > 0 ? await fetchHelper(event, `models/${ITEM_MODEL}/${id}`, 'PUT', token, body) : await fetchHelper(event, `models/${ITEM_MODEL}`, 'POST', token, body) if (!(num(res?.id) > 0 || id > 0)) throw createError({ statusCode: 500, statusMessage: `${it.key}: Speichern fehlgeschlagen` }) } for (const f of features) await upsertItem('feature', f) for (const a of addons) await upsertItem('addon', a) // 3) deletes (items before plans) for (const id of deletedItemIds) { try { await fetchHelper(event, `models/${ITEM_MODEL}/${id}`, 'DELETE', token, null) } catch (e: any) { warnings.push(`Eintrag ${id} konnte nicht gelöscht werden: ${e?.data?.detail || e?.message || e}`) } } for (const id of deletedPlanIds) { try { await fetchHelper(event, `models/${PLAN_MODEL}/${id}`, 'DELETE', token, null) } catch (e: any) { warnings.push(`Paket ${id} konnte nicht gelöscht werden: ${e?.data?.detail || e?.message || e}`) } } invalidateSoftwareCatalogCache() return { catalog: await loadSoftwareCatalog(event, token, true), warnings } } /** * "Standardwerte importieren": insert every default plan / feature / add-on whose * key does not exist yet (idempotent). Needs both tables to exist. */ export const seedSoftwareCatalog = async (event: any, token: string): Promise<{ created: { plans: number; features: number; addons: number }; catalog: SoftwareCatalog }> => { const orgId = orgOf(event) const current = await loadSoftwareCatalog(event, token, true) if (current.missingTables.length) throw createError({ statusCode: 400, statusMessage: `Tabelle(n) fehlen: ${current.missingTables.join(', ')}` }) const persisted: any = (current as any).persisted || {} const havePlans = new Map((persisted.plans || []).map((p: CatalogPlan) => [p.key, Number(p.id)])) const haveFeatures = new Set((persisted.features || []).map((f: CatalogFeature) => f.key)) const haveAddons = new Set((persisted.addons || []).map((a: CatalogAddon) => a.key)) const created = { plans: 0, features: 0, addons: 0 } for (const p of DEFAULT_SOFTWARE_CATALOG.plans) { if (havePlans.has(p.key)) continue const res: any = await fetchHelper(event, `models/${PLAN_MODEL}`, 'POST', token, planBody(p, orgId, false)) if (num(res?.id) > 0) { havePlans.set(p.key, num(res.id)); created.plans++ } } for (const f of DEFAULT_SOFTWARE_CATALOG.features) { if (haveFeatures.has(f.key)) continue await fetchHelper(event, `models/${ITEM_MODEL}`, 'POST', token, itemBody('feature', f, havePlans, orgId, false)) created.features++ } for (const a of DEFAULT_SOFTWARE_CATALOG.addons) { if (haveAddons.has(a.key)) continue await fetchHelper(event, `models/${ITEM_MODEL}`, 'POST', token, itemBody('addon', a, havePlans, orgId, false)) created.addons++ } invalidateSoftwareCatalogCache() return { created, catalog: await loadSoftwareCatalog(event, token, true) } }