/** * Software-offer catalog ("LogShip Cloud" packages, feature matrix, add-ons) — * shared SHAPE + DEFAULTS for client (app/composables/useOfferSoftwareQuote.ts, * components/offers/SoftwareQuoteModal.vue, pages/settings/software-catalog) and * server (server/utils/offers/softwareCatalog.ts, softwareQuotePdf.ts). Pure * constants/functions only — no Nuxt auto-imports — so Nitro can import it by * relative path. * * Persisted catalog = iDempiere tables CUST_SoftwarePlan + CUST_SoftwareCatalogItem * (see .claude/rules/offers/software-quote.md). These defaults are (a) the * fail-soft fallback while the tables are missing/empty and (b) the seed * ("Standardwerte importieren"). ⚠ Amounts are DRAFT list prices (logship.de * publishes "Preis auf Anfrage" only) — maintain real prices in the settings page. */ export type SoftwareSupport = 'ticket' | 'phone' | 'sla' export type FeatureMode = 'standard' | 'onRequest' | 'quote' export type FeatureState = 'included' | 'excluded' | 'addon' | 'onRequest' export type AddonUnit = 'month' | 'once' | 'hour' | 'text' export interface CatalogPlan { id: number | null key: string name: string nameEn: string seqNo: number popular: boolean priceMonthly: number priceYearly: number includedOrders: number // 0 = individual includedUsers: number // 0 = individual salesChannels: number // 0 = unlimited extraOrderPrice: number extraUserPrice: number support: SoftwareSupport description: string active?: boolean } export interface CatalogFeature { id: number | null key: string name: string nameEn: string group: string // core | warehouse | advanced | enterprise (AD_Reference CUST_SoftwareFeatureGroup) minPlanKey: string // key of the smallest package that includes it ('' = never included) mode: FeatureMode seqNo: number active?: boolean } export interface CatalogAddon { id: number | null key: string name: string nameEn: string price: number unit: AddonUnit note: string noteEn: string defaultSelected: boolean seqNo: number active?: boolean } export interface SoftwareCatalog { plans: CatalogPlan[] features: CatalogFeature[] addons: CatalogAddon[] source: 'idempiere' | 'defaults' | 'snapshot' missingTables: string[] empty?: boolean loadedAt?: string } export const SOFTWARE_FEATURE_GROUPS = ['core', 'warehouse', 'advanced', 'enterprise'] as const // AD_Reference lists the two tables use (Value = code key, Name = iDempiere label). // Same set as reference/test-scripts/software-catalog-seed-lists.mts. export const SOFTWARE_REF_LISTS: Record> = { CUST_SoftwareSupportLevel: [ { value: 'ticket', name: 'Ticket' }, { value: 'phone', name: 'Ticket + Telefon' }, { value: 'sla', name: 'Ticket + Telefon + SLA' } ], CUST_SoftwareItemType: [ { value: 'feature', name: 'Funktion' }, { value: 'addon', name: 'Zusatzleistung' } ], CUST_SoftwareFeatureGroup: [ { value: 'core', name: 'Kern-WMS' }, { value: 'warehouse', name: 'Lager & Automatisierung' }, { value: 'advanced', name: 'Erweitert & Schnittstellen' }, { value: 'enterprise', name: 'Enterprise' } ], CUST_SoftwareFeatureMode: [ { value: 'standard', name: 'Standard' }, { value: 'onRequest', name: 'möglich / auf Anfrage' }, { value: 'quote', name: 'gegen Angebot' } ], CUST_SoftwarePriceUnit: [ { value: 'month', name: 'pro Monat' }, { value: 'once', name: 'einmalig' }, { value: 'hour', name: 'pro Stunde' }, { value: 'text', name: 'Text / auf Anfrage' } ] } const plan = (p: Omit & { nameEn?: string; description?: string }): CatalogPlan => ({ id: null, nameEn: p.name, description: '', ...p }) const DEFAULT_PLANS: CatalogPlan[] = [ // name = printed package / fee name, description = tagline under it (DE; EN offers fall back to the volumes text) plan({ key: 'starter', name: 'Start', nameEn: 'Start', description: 'Der komplette Fulfillment-Kern für Ihren Einstieg.', seqNo: 10, popular: false, priceMonthly: 119, priceYearly: 99, includedOrders: 1000, includedUsers: 1, salesChannels: 2, extraOrderPrice: 0.20, extraUserPrice: 49, support: 'ticket' }), plan({ key: 'business', name: 'Wachstum', nameEn: 'Growth', description: 'Mehr Volumen, Lagerautomatisierung und Ihr ganzes Team im System.', seqNo: 20, popular: true, priceMonthly: 229, priceYearly: 189, includedOrders: 2000, includedUsers: 2, salesChannels: 0, extraOrderPrice: 0.15, extraUserPrice: 49, support: 'ticket' }), plan({ key: 'pro', name: 'Premium', nameEn: 'Premium', description: 'Für Mandanten, Cross-Docking, BI-Auswertungen und Buchhaltungs-Anbindung.', seqNo: 30, popular: false, priceMonthly: 399, priceYearly: 329, includedOrders: 3500, includedUsers: 3, salesChannels: 0, extraOrderPrice: 0.12, extraUserPrice: 49, support: 'phone' }), plan({ key: 'enterprise', name: 'Enterprise', nameEn: 'Enterprise', description: 'Individuelle Volumen, API, dedizierte Instanz und SLA.', seqNo: 40, popular: false, priceMonthly: 0, priceYearly: 0, includedOrders: 0, includedUsers: 0, salesChannels: 0, extraOrderPrice: 0, extraUserPrice: 0, support: 'sla' }) ] const F = (key: string, group: string, minPlanKey: string, name: string, nameEn: string, mode: FeatureMode = 'standard'): Omit => ({ key, group, minPlanKey, name, nameEn, mode }) // Feature matrix (logship.de/preise). Order = print order inside each group. const DEFAULT_FEATURES: CatalogFeature[] = [ F('cockpit', 'core', 'starter', 'Auftrags-Cockpit (alle Kanäle in einer Ansicht)', 'Order cockpit (all channels in one view)'), F('scanPickPack', 'core', 'starter', 'Scanbasiertes Pick & Pack', 'Scan-based pick & pack'), F('labels', 'core', 'starter', 'Versandlabel DHL, DPD & GLS (UPS, Sendcloud)', 'Shipping labels DHL, DPD & GLS (UPS, Sendcloud)'), F('tracking', 'core', 'starter', 'Eigene Tracking-Seite im Kunden-Branding', 'Branded tracking page'), F('returnsBasic', 'core', 'starter', 'Retouren-Basisfunktionen', 'Basic returns handling'), F('b2bPortal', 'warehouse', 'business', 'B2B-Portal', 'B2B portal'), F('packStations', 'warehouse', 'business', 'Unbegrenzte Kommissionier-Tische', 'Unlimited packing stations'), F('inboundInventory', 'warehouse', 'business', 'Wareneingang, Inventur & Umlagerung', 'Goods receipt, stocktaking & transfers'), F('androidApp', 'warehouse', 'business', 'Native Android-App (LogShip WMS)', 'Native Android app (LogShip WMS)'), F('batching', 'warehouse', 'business', 'Intelligentes Batching / Sammelkommissionierung', 'Smart batching / batch picking'), F('lotTracking', 'warehouse', 'business', 'MHD, Chargen & Seriennummern', 'Best-before, lots & serial numbers'), F('crossDocking', 'advanced', 'pro', 'Cross-Docking', 'Cross-docking'), F('packCamera', 'advanced', 'pro', 'Packplatz-Kamera-Anbindung (VideoShip)', 'Packing-station camera (VideoShip)'), F('fulfillmentBilling', 'advanced', 'pro', '3PL-Fulfillment-Abrechnung (Pick/Pack/Lager pro Mandant)', '3PL fulfillment billing (pick/pack/storage per client)'), F('multiOrg', 'advanced', 'pro', 'Mandantenfähigkeit / Multi-Org', 'Multi-client / multi-org'), F('bi', 'advanced', 'pro', 'BI-Dashboards & Auszahlungsabgleich (Amazon/eBay)', 'BI dashboards & payout reconciliation (Amazon/eBay)'), F('accounting', 'advanced', 'pro', 'Buchhaltungs-Schnittstellen (Lexware, PayJoe)', 'Accounting interfaces (Lexware, PayJoe)'), F('autostore', 'enterprise', 'enterprise', 'AutoStore-Anbindung', 'AutoStore integration'), F('api', 'enterprise', 'enterprise', 'API-Zugang', 'API access'), F('dedicated', 'enterprise', 'enterprise', 'Dedizierte Instanz', 'Dedicated instance', 'onRequest'), F('accountManager', 'enterprise', 'enterprise', 'Persönlicher Ansprechpartner', 'Dedicated account manager'), F('customDev', 'enterprise', 'enterprise', 'Individuelle Entwicklungen', 'Custom development', 'quote') ].map((f, i) => ({ id: null, seqNo: (i + 1) * 10, ...f })) const DEFAULT_ADDONS: CatalogAddon[] = [ { key: 'apiAccess', name: 'API-Zugang', nameEn: 'API access', price: 79, unit: 'month', note: '', noteEn: '' }, { key: 'dedicated', name: 'Dedizierte Instanz (eigener Server, eigene Domain)', nameEn: 'Dedicated instance (own server, own domain)', price: 0, unit: 'text', note: 'auf Anfrage', noteEn: 'on request' }, { key: 'customDev', name: 'Individuelle Entwicklungen', nameEn: 'Custom development', price: 120, unit: 'hour', note: 'gegen gesondertes Angebot', noteEn: 'against separate quote' }, { key: 'training', name: 'Zusätzliche Team-Schulung (remote, 2 h)', nameEn: 'Additional team training (remote, 2 h)', price: 490, unit: 'once', note: '', noteEn: '' }, { key: 'fulfillment', name: 'Fulfillment durch LogYou (Lager Butzbach)', nameEn: 'Fulfillment by LogYou (warehouse Butzbach)', price: 0, unit: 'text', note: 'gemäß separatem Fulfillment-Angebot', noteEn: 'see separate fulfillment quote' } ].map((a, i) => ({ id: null, defaultSelected: false, seqNo: (i + 1) * 10, ...a } as CatalogAddon)) export const DEFAULT_SOFTWARE_CATALOG: SoftwareCatalog = { plans: DEFAULT_PLANS, features: DEFAULT_FEATURES, addons: DEFAULT_ADDONS, source: 'defaults', missingTables: [] } /** Deep copy of the defaults (callers mutate forms/rows). */ export const defaultSoftwareCatalog = (): SoftwareCatalog => JSON.parse(JSON.stringify(DEFAULT_SOFTWARE_CATALOG)) const num = (v: any): number => { const n = Number(v); return Number.isFinite(n) ? n : 0 } /** * Normalise anything catalog-shaped (API answer, payload snapshot, saved * conditions) into a complete SoftwareCatalog: missing parts fall back to the * defaults, rows are sorted by seqNo, keys trimmed, enums coerced. */ export const normalizeSoftwareCatalog = (raw: any): SoftwareCatalog => { const d = DEFAULT_SOFTWARE_CATALOG const plans: CatalogPlan[] = (Array.isArray(raw?.plans) && raw.plans.length ? raw.plans : d.plans).map((p: any, i: number) => ({ id: p?.id != null && Number(p.id) > 0 ? Number(p.id) : null, key: String(p?.key || '').trim(), name: String(p?.name || '').trim(), nameEn: String(p?.nameEn || '').trim(), seqNo: num(p?.seqNo) || (i + 1) * 10, popular: p?.popular === true, priceMonthly: num(p?.priceMonthly), priceYearly: num(p?.priceYearly), includedOrders: num(p?.includedOrders), includedUsers: num(p?.includedUsers), salesChannels: num(p?.salesChannels), extraOrderPrice: num(p?.extraOrderPrice), extraUserPrice: num(p?.extraUserPrice), support: (['ticket', 'phone', 'sla'] as const).includes(p?.support) ? p.support : 'ticket', description: String(p?.description || ''), active: p?.active !== false })).filter((p: CatalogPlan) => p.key).sort((a: CatalogPlan, b: CatalogPlan) => a.seqNo - b.seqNo) const features: CatalogFeature[] = (Array.isArray(raw?.features) && raw.features.length ? raw.features : d.features).map((f: any, i: number) => ({ id: f?.id != null && Number(f.id) > 0 ? Number(f.id) : null, key: String(f?.key || '').trim(), name: String(f?.name ?? f?.de ?? '').trim(), nameEn: String(f?.nameEn ?? f?.en ?? '').trim(), group: String(f?.group || 'core'), minPlanKey: String(f?.minPlanKey ?? f?.minPlan ?? '').trim(), mode: f?.mode === 'onRequest' || f?.mode === 'quote' ? f.mode : 'standard', seqNo: num(f?.seqNo) || (i + 1) * 10, active: f?.active !== false })).filter((f: CatalogFeature) => f.key).sort((a: CatalogFeature, b: CatalogFeature) => a.seqNo - b.seqNo) const addons: CatalogAddon[] = (Array.isArray(raw?.addons) ? raw.addons : d.addons).map((a: any, i: number) => ({ id: a?.id != null && Number(a.id) > 0 ? Number(a.id) : null, key: String(a?.key || '').trim(), name: String(a?.name || a?.label || '').trim(), nameEn: String(a?.nameEn || '').trim(), price: num(a?.price), unit: (['month', 'once', 'hour', 'text'] as const).includes(a?.unit) ? a.unit : 'month', note: String(a?.note || ''), noteEn: String(a?.noteEn || ''), defaultSelected: a?.defaultSelected === true, seqNo: num(a?.seqNo) || (i + 1) * 10, active: a?.active !== false })).filter((a: CatalogAddon) => a.key).sort((a: CatalogAddon, b: CatalogAddon) => a.seqNo - b.seqNo) return { plans, features, addons, source: raw?.source === 'idempiere' || raw?.source === 'snapshot' ? raw.source : 'defaults', missingTables: Array.isArray(raw?.missingTables) ? raw.missingTables : [], empty: raw?.empty === true, loadedAt: raw?.loadedAt } } /** Plans in upgrade order (seqNo asc), active only. */ export const activePlans = (c: SoftwareCatalog): CatalogPlan[] => c.plans.filter((p) => p.active !== false) export const planByKey = (c: SoftwareCatalog, key: any): CatalogPlan | undefined => c.plans.find((p) => p.key === String(key)) /** Upgrade rank of a package (index in seqNo order); -1 when unknown. */ export const planRank = (c: SoftwareCatalog, key: any): number => activePlans(c).findIndex((p) => p.key === String(key)) /** Catalog default state of a feature inside a given package (what the comparison columns print). */ export const featureDefaultState = (c: SoftwareCatalog, feature: Pick, planKey: any): FeatureState => { const r = planRank(c, planKey) const min = planRank(c, feature.minPlanKey) const incl = r >= 0 && min >= 0 && r >= min if (incl) return feature.mode === 'onRequest' ? 'onRequest' : 'included' return feature.mode === 'quote' ? 'addon' : 'excluded' } export const seedFeatureStates = (c: SoftwareCatalog, planKey: any): Record => Object.fromEntries(c.features.filter((f) => f.active !== false).map((f) => [f.key, featureDefaultState(c, f, planKey)])) /** Label in the offer language (EN falls back to DE). */ export const catalogLabel = (row: { name: string; nameEn?: string }, language: string): string => language === 'en' && String(row?.nameEn || '').trim() ? String(row.nameEn).trim() : String(row?.name || '').trim() export const catalogNote = (row: { note: string; noteEn?: string }, language: string): string => language === 'en' && String(row?.noteEn || '').trim() ? String(row.noteEn).trim() : String(row?.note || '').trim() /** The subset of the catalog that goes into an offer payload / saved conditions (what the PDF renders). */ export const catalogSnapshot = (c: SoftwareCatalog) => ({ source: 'snapshot', plans: activePlans(c).map(({ id, key, name, nameEn, seqNo, popular, support }) => ({ id, key, name, nameEn, seqNo, popular, support })), features: c.features.filter((f) => f.active !== false).map(({ id, key, name, nameEn, group, minPlanKey, mode, seqNo }) => ({ id, key, name, nameEn, group, minPlanKey, mode, seqNo })), addons: c.addons.filter((a) => a.active !== false).map(({ id, key, name, nameEn, price, unit, note, noteEn, defaultSelected, seqNo }) => ({ id, key, name, nameEn, price, unit, note, noteEn, defaultSelected, seqNo })) })