/** * Software offer (LogShip Cloud SaaS) business logic — the second offer type * next to the 3PL fulfillment quote (useOfferQuote.ts). Same contract: the * client owns the numbers (defaults, forecast, plan comparison); the server * routes (server/api/offers/quote/{preview,send} with `offerType: 'software'`) * only render/send what they receive. * * The CATALOG (packages, feature matrix, add-ons) is maintained in iDempiere * (CUST_SoftwarePlan / CUST_SoftwareCatalogItem, Settings → Software-Katalog) * and loaded via GET /api/offers/software-catalog; every function here takes it * as a parameter and falls back to DEFAULT_SOFTWARE_CATALOG * (app/utils/softwareCatalogDefaults.ts). The payload carries a snapshot of the * catalog used, so the PDF and the saved conditions stay reproducible. */ import { quoteBusinessModelFromLabel } from './useOfferQuote' import { defaultSoftwareCatalog, normalizeSoftwareCatalog, activePlans, planByKey, planRank, featureDefaultState, seedFeatureStates, catalogLabel, catalogNote, catalogSnapshot, type SoftwareCatalog, type CatalogPlan, type FeatureState } from '~/utils/softwareCatalogDefaults' export type { SoftwareCatalog, CatalogPlan, FeatureState } export { defaultSoftwareCatalog, normalizeSoftwareCatalog, activePlans, planByKey, planRank, featureDefaultState, seedFeatureStates, catalogLabel, catalogNote } export const SOFTWARE_FEATURE_GROUPS = ['core', 'warehouse', 'advanced', 'enterprise'] as const export const ONBOARDING_DEFAULT_PRICE = 3250 // Onboarding package: six phases from process analysis to 30 days hypercare // (logship.de FAQ) + four highlight tiles. Both lists are editable per offer. export const ONBOARDING_DEFAULTS = (language: string) => (language === 'en' ? { intro: 'Sit back — we take you live. Our team sets up LogShip Cloud for you, trains your team and stays by your side until the go-live has succeeded, so you ship faster, more accurately and cheaper from day one.', phases: [ { title: 'Kickoff & process analysis', text: 'We get to know your warehouse, your processes and your goals and define the LogShip roadmap together — tailored to your business.' }, { title: 'Setup & data import', text: 'We configure LogShip step by step: sales channels, carriers, warehouse structure and bins, printers, automations — including the import of your articles and stock.' }, { title: 'Test phase & acceptance', text: 'Test orders through the whole chain (order · pick · pack · label · tracking) with your real data; open points are closed before the go-live.' }, { title: 'Live training for your team', text: 'An interactive session on the everyday functions — pick & pack, scanning, goods receipt, shipping, returns — with all your questions answered.' }, { title: 'Go-live support', text: 'We accompany the first live days closely: a fixed contact person, short reaction times, daily check-ins.' }, { title: '30 days hypercare', text: 'After the go-live we stay on board for 30 days: regular check-ins, fine-tuning of automations and reports, appointments whenever you need them.' } ], highlights: [ { value: '1:1', label: 'personal contact' }, { value: '30 days', label: 'hypercare after go-live' }, { value: '99.92 %', label: 'pick accuracy in our own warehouse' }, { value: '80+', label: 'brands live on LogShip' } ] } : { intro: 'Lehnen Sie sich zurück — wir bringen Sie live. Unser Team richtet LogShip Cloud komplett für Sie ein, schult Ihr Team und begleitet Sie bis zum erfolgreichen Go-Live — damit Sie vom ersten Tag an schneller, genauer und günstiger versenden.', phases: [ { title: 'Kickoff & Prozessaufnahme', text: 'Wir lernen Ihr Lager, Ihre Prozesse und Ihre Ziele kennen und legen gemeinsam den LogShip-Fahrplan fest — abgestimmt auf Ihr Business.' }, { title: 'Einrichtung & Datenimport', text: 'Wir richten LogShip Schritt für Schritt ein: Verkaufskanäle, Versanddienstleister, Lagerstruktur und Lagerplätze, Drucker, Automatisierungen — inklusive Import Ihrer Artikel und Bestände.' }, { title: 'Testphase & Abnahme', text: 'Testbestellungen durch die gesamte Kette (Auftrag · Pick · Pack · Label · Tracking) mit Ihren echten Daten; offene Punkte werden vor dem Go-Live geschlossen.' }, { title: 'Live-Schulung für Ihr Team', text: 'In einer interaktiven Schulung zeigen wir die wichtigsten Funktionen für den Alltag — Pick & Pack, Scannen, Wareneingang, Versand, Retouren — und beantworten alle Fragen.' }, { title: 'Go-Live-Begleitung', text: 'Die ersten Live-Tage begleiten wir eng: fester Ansprechpartner, kurze Reaktionszeiten, tägliche Check-ins.' }, { title: '30 Tage Hypercare', text: 'Auch nach dem Go-Live bleiben wir 30 Tage an Ihrer Seite: regelmäßige Check-ins, Feinjustierung von Automatisierungen und Auswertungen, Termine, wann immer Sie sie brauchen.' } ], highlights: [ { value: '1:1', label: 'persönliche Betreuung' }, { value: '30 Tage', label: 'Hypercare nach Go-Live' }, { value: '99,92 %', label: 'Pick-Genauigkeit im eigenen Lager' }, { value: '80+', label: 'Brands live mit LogShip' } ] }) // Onboarding ANNEX ("Anlage Onboarding-Paket", separate PDF attached to the // offer by default): the generic setup scope — everything that gets analysed, // configured and tested during the onboarding. Editable per offer. export const ONBOARDING_SCOPE_DEFAULTS = (language: string): Array<{ title: string; text: string }> => (language === 'en' ? [ { title: 'Process analysis on site', text: 'Warehouse walk-through and review of your current processes — goods receipt, storage, picking & packing, shipping, returns — with your team; target process and roadmap for the LogShip setup.' }, { title: 'Warehouse structure & bins', text: 'Zones, aisles, shelves and bins mapped in LogShip; bin labels with barcodes printed and placed; put-away and picking strategies defined.' }, { title: 'Articles & stock import', text: 'Import of your articles (SKU, EAN/GTIN, dimensions, weights, images), variants and bundles; opening stock counted or imported, serial / batch / best-before tracking where needed.' }, { title: 'Sales channels & order import', text: 'Connection of your shops and marketplaces (Shopify, Shopware, Amazon, eBay, OTTO …) or ERP: order import, stock sync, shipment confirmations and tracking feedback.' }, { title: 'Carriers & shipping rules', text: 'Carrier accounts (DHL, DPD, GLS, UPS, …) with products and services, shipping rules per channel/weight/country, customs documents (CN23) for non-EU destinations, returns labels.' }, { title: 'Label printers', text: 'Installation and configuration of your label printers for shipping labels, bin and article labels (sizes, DPI, print server, network); test prints at every workstation.' }, { title: 'Document printers', text: 'Setup of the document printers for delivery notes, invoices, pick lists and packing slips; document templates with your logo and legal texts; paper routing (A4/A5/label).' }, { title: 'Scanners, mobile devices & workstations', text: 'Hand scanners and Android devices with the LogShip app, packing stations with scale and screen, Wi-Fi coverage check in the warehouse, device assignment per user.' }, { title: 'Users, roles & automations', text: 'User accounts and permissions, e-mail notifications, automation rules (order release, carrier selection, priority), fulfilment billing if required.' }, { title: 'Test run & acceptance', text: 'End-to-end test with real orders — import, pick, pack, label, tracking, return — until every workstation runs cleanly; go-live checklist signed off together.' } ] : [ { title: 'Prozessaufnahme vor Ort', text: 'Lagerbegehung und Analyse Ihrer heutigen Abläufe — Wareneingang, Lagerung, Kommissionierung & Verpackung, Versand, Retouren — gemeinsam mit Ihrem Team; Zielprozess und Fahrplan für die LogShip-Einrichtung.' }, { title: 'Lagerstruktur & Lagerplätze', text: 'Zonen, Gänge, Regale und Lagerplätze in LogShip abgebildet; Lagerplatz-Etiketten mit Barcodes gedruckt und angebracht; Einlagerungs- und Pick-Strategie festgelegt.' }, { title: 'Artikel & Bestandsimport', text: 'Import Ihrer Artikel (SKU, EAN/GTIN, Maße, Gewichte, Bilder), Varianten und Bundles; Anfangsbestand gezählt oder importiert, bei Bedarf Seriennummern-, Chargen- und MHD-Führung.' }, { title: 'Verkaufskanäle & Auftragsimport', text: 'Anbindung Ihrer Shops und Marktplätze (Shopify, Shopware, Amazon, eBay, OTTO …) bzw. Ihres ERP: Auftragsimport, Bestandsabgleich, Versandbestätigungen und Tracking-Rückmeldung.' }, { title: 'Versanddienstleister & Versandregeln', text: 'Carrier-Zugänge (DHL, DPD, GLS, UPS, …) mit Produkten und Services, Versandregeln je Kanal/Gewicht/Land, Zolldokumente (CN23) für Nicht-EU-Ziele, Retourenlabels.' }, { title: 'Etikettendrucker', text: 'Installation und Konfiguration Ihrer Etikettendrucker für Versandlabels, Lagerplatz- und Artikeletiketten (Formate, DPI, Druckserver, Netzwerk); Testdrucke an jedem Arbeitsplatz.' }, { title: 'Papierdrucker & Dokumente', text: 'Einrichtung der Papierdrucker für Lieferscheine, Rechnungen, Picklisten und Packzettel; Dokumentvorlagen mit Ihrem Logo und Rechtstexten; Papiersteuerung (A4/A5/Label).' }, { title: 'Scanner, mobile Geräte & Arbeitsplätze', text: 'Handscanner und Android-Geräte mit der LogShip-App, Packplätze mit Waage und Bildschirm, WLAN-Abdeckung im Lager geprüft, Gerätezuordnung je Mitarbeiter.' }, { title: 'Benutzer, Rollen & Automatisierungen', text: 'Benutzerkonten und Berechtigungen, E-Mail-Benachrichtigungen, Automatisierungsregeln (Auftragsfreigabe, Carrier-Wahl, Priorisierung), bei Bedarf Fulfillment-Abrechnung.' }, { title: 'Testlauf & Abnahme', text: 'Durchgängiger Test mit echten Aufträgen — Import, Pick, Pack, Label, Tracking, Retoure — bis jeder Arbeitsplatz sauber läuft; Go-Live-Checkliste gemeinsam abgenommen.' } ]) // PDF sections the user can switch off per offer (PDF order). Address card, // intro, Hinweise and closing always print. export const SOFTWARE_SECTION_KEYS = ['overview', 'pricing', 'features', 'onboarding', 'addons', 'forecast'] as const export const SOFTWARE_TEXT_ANCHORS = ['overview', 'pricing', 'features', 'onboarding', 'addons', 'positions', 'forecast', 'end'] as const const isTextAnchor = (v: any): boolean => (SOFTWARE_TEXT_ANCHORS as readonly string[]).includes(String(v)) const num = (v: any): number => { const n = Number(v) return Number.isFinite(n) ? n : 0 } const firstOfNextMonth = (): string => { const d = new Date() const next = new Date(d.getFullYear(), d.getMonth() + 1, 1) return `${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, '0')}-01` } /** The package a fresh offer starts with: the "popular" one, else the second, else the first. */ export const defaultPlanKey = (catalog: SoftwareCatalog): string => { const plans = activePlans(catalog) return (plans.find((p) => p.popular) || plans[1] || plans[0])?.key || '' } /** Plan-derived fields of the form (what "apply package" writes). */ export const planFields = (catalog: SoftwareCatalog, key: any) => { const p = planByKey(catalog, key) || activePlans(catalog)[0] || defaultSoftwareCatalog().plans[0] return { key: p.key, name: p.name, nameEn: p.nameEn, description: p.description || '', // tagline from the catalog (fee-line subtext / card line) popular: p.popular === true, priceMonthly: p.priceMonthly, priceYearly: p.priceYearly, includedOrders: p.includedOrders, includedUsers: p.includedUsers, salesChannels: p.salesChannels, extraUserPrice: p.extraUserPrice, support: p.support, // Overage staffel: one flat tier by default ('' upTo = unlimited); the modal // can add absolute thresholds (e.g. up to 5.000 → 0,15, above → 0,12). overageTiers: p.extraOrderPrice > 0 ? [{ upTo: '', price: p.extraOrderPrice }] : [] } } /** Add-on rows of a fresh offer from the catalog (labels in the offer language). */ export const seedAddons = (catalog: SoftwareCatalog, language: string) => catalog.addons.filter((a) => a.active !== false).map((a) => ({ key: a.key, label: catalogLabel(a, language), enabled: a.defaultSelected === true, price: a.price, unit: a.unit, note: catalogNote(a, language) })) /** Fresh form from the host-page prefill (same prefill contract as the fulfillment quote) + catalog. */ export const createSoftwareQuoteForm = (prefill: any = {}, catalogIn?: SoftwareCatalog | null) => { const catalog = catalogIn ? normalizeSoftwareCatalog(catalogIn) : defaultSoftwareCatalog() const language = 'de' const planKey = defaultPlanKey(catalog) return { offerType: 'software', source: prefill?.source || 'partner', recordId: num(prefill?.recordId), recordUu: prefill?.recordUu || '', language, // Snapshot of the catalog this offer is built on (goes into the payload → PDF + saved conditions) catalog: catalogSnapshot(catalog), customer: { company: prefill?.company || '', contactName: prefill?.contactName || '', email: prefill?.email || '', phone: prefill?.phone || '', street: String(prefill?.street || ''), zip: String(prefill?.zip || ''), city: String(prefill?.city || ''), country: String(prefill?.country || '') || 'Deutschland', website: String(prefill?.website || '').trim() }, visuals: { includeScreenshot: String(prefill?.website || '').trim() !== '', screenshotDataUrl: prefill?.screenshotDataUrl || '', includeLogo: !!prefill?.logoDataUrl, logoDataUrl: prefill?.logoDataUrl || '' }, plan: { ...planFields(catalog, planKey), // Per-offer overrides for the monthly software-fee line in the PDF; empty = automatic // (fee name = package name, subtext = catalog tagline + included volumes) feeLabel: '', feeSubtext: '', billing: 'yearly' as 'yearly' | 'monthly', minTermMonths: 12, plannedStart: /^\d{4}-\d{2}-\d{2}$/.test(String(prefill?.plannedStart || '')) && String(prefill.plannedStart) >= new Date().toISOString().slice(0, 10) ? String(prefill.plannedStart) : firstOfNextMonth() }, // Customer situation — drives overage + plan recommendation assumptions: { ordersPerMonth: num(prefill?.monthlyOrders) || 1500, users: 2, salesChannels: 2, warehouses: 1, skuCount: num(prefill?.skuCount) || 300, shopSystem: String(prefill?.shopSystem || ''), businessModel: quoteBusinessModelFromLabel(prefill?.businessModel) || 'B2C' }, features: seedFeatureStates(catalog, planKey), customFeatures: [] as Array<{ label: string; state: FeatureState }>, featuresCompare: true, // print all package columns (selected one highlighted) vs. only the chosen plan addons: seedAddons(catalog, language) as Array<{ key: string; label: string; enabled: boolean; price: number; unit: string; note: string }>, onboarding: { enabled: true, price: ONBOARDING_DEFAULT_PRICE, priceIsFrom: true, // print "ab 3.250 €" — the final price follows the scoping call travelExcluded: true, // "zzgl. Reise- und Übernachtungskosten" for on-site appointments ...ONBOARDING_DEFAULTS(language), scope: ONBOARDING_SCOPE_DEFAULTS(language) // setup scope printed in the annex PDF }, customPositions: [] as Array<{ description: string; dimension: string; price: string }>, requirements: [] as Array<{ text: string; possible: boolean; answerType: 'check' | 'text'; answerText: string }>, sections: Object.fromEntries(SOFTWARE_SECTION_KEYS.map((k) => [k, true])) as Record, customTexts: [] as Array<{ title: string; text: string; after: string }>, forecast: { includeInPdf: true }, meta: { validDays: 14, validUntil: '' }, // onboarding = separate "Anlage Onboarding-Paket" PDF (default ON; needs onboarding.enabled) attachments: { flyer: false, onboarding: true }, confirmation: { enabled: true, attachPdf: true }, email: { to: prefill?.email || '', cc: '', subject: '', message: '' } } } /** Re-seed the plan-derived fields + feature states when the user picks another package. */ export const applySoftwarePlan = (form: any, key: any, catalog: SoftwareCatalog) => { Object.assign(form.plan, planFields(catalog, key)) form.features = seedFeatureStates(catalog, key) } /** Language switch: swap untouched default texts (add-ons, onboarding) to the other language. */ export const applySoftwareLanguageDefaults = (form: any, prev: string, next: string, catalog: SoftwareCatalog) => { if (prev === next) return const same = (a: any, b: any) => JSON.stringify(a) === JSON.stringify(b) const prevAddons = seedAddons(catalog, prev) if (same(form.addons.map((a: any) => ({ ...a, enabled: false })), prevAddons.map((a) => ({ ...a, enabled: false })))) { const enabled = new Set(form.addons.filter((a: any) => a.enabled).map((a: any) => a.key)) form.addons = seedAddons(catalog, next).map((a) => ({ ...a, enabled: enabled.has(a.key) })) } const prevOb = ONBOARDING_DEFAULTS(prev) const nextOb = ONBOARDING_DEFAULTS(next) if (same(form.onboarding.phases, prevOb.phases)) form.onboarding.phases = nextOb.phases.map((p) => ({ ...p })) if (same(form.onboarding.highlights, prevOb.highlights)) form.onboarding.highlights = nextOb.highlights.map((h) => ({ ...h })) if (form.onboarding.intro === prevOb.intro) form.onboarding.intro = nextOb.intro if (same(form.onboarding.scope, ONBOARDING_SCOPE_DEFAULTS(prev))) form.onboarding.scope = ONBOARDING_SCOPE_DEFAULTS(next) } const fillNonEmpty = (target: any, src: any) => { if (!target || !src || typeof src !== 'object') return for (const k of Object.keys(target)) { const v = src[k] if (v !== undefined && v !== null && String(v) !== '' && typeof v !== 'object') target[k] = v } } /** * Apply the record's saved software-offer conditions onto a fresh form (saved * values win). The CURRENT catalog stays the basis; a saved feature key the * catalog no longer knows is kept as a custom row (label from the saved snapshot). */ export const applySavedSoftwareQuoteConditions = (form: any, saved: any, catalog: SoftwareCatalog) => { if (!saved || typeof saved !== 'object') return form if (saved.language === 'en' || saved.language === 'de') { applySoftwareLanguageDefaults(form, form.language, saved.language, catalog) form.language = saved.language } fillNonEmpty(form.customer, saved.customer) if (saved.plan && typeof saved.plan === 'object') { if (planByKey(catalog, saved.plan.key)) applySoftwarePlan(form, saved.plan.key, catalog) fillNonEmpty(form.plan, saved.plan) if (Array.isArray(saved.plan.overageTiers)) { form.plan.overageTiers = saved.plan.overageTiers.map((t: any) => ({ upTo: t?.upTo === '' || t?.upTo == null ? '' : num(t.upTo), price: num(t?.price) })) } const today = new Date().toISOString().slice(0, 10) if (String(form.plan.plannedStart || '') < today) form.plan.plannedStart = firstOfNextMonth() } fillNonEmpty(form.assumptions, saved.assumptions) const knownKeys = new Set(Object.keys(form.features)) const snapshotFeatures: any[] = Array.isArray(saved?.catalog?.features) ? saved.catalog.features : [] if (saved.features && typeof saved.features === 'object') { for (const [k, v] of Object.entries(saved.features)) { if (!['included', 'excluded', 'addon', 'onRequest'].includes(String(v))) continue if (knownKeys.has(k)) { form.features[k] = v as FeatureState } else if (v !== 'excluded') { const snap = snapshotFeatures.find((f) => f?.key === k) form.customFeatures.push({ label: snap ? catalogLabel(snap, form.language) : k, state: v as FeatureState }) } } } if (Array.isArray(saved.customFeatures)) { for (const r of saved.customFeatures) { const label = String(r?.label || '') if (label && !form.customFeatures.some((c: any) => c.label === label)) { form.customFeatures.push({ label, state: ['included', 'excluded', 'addon', 'onRequest'].includes(r?.state) ? r.state : 'included' }) } } } if (typeof saved.featuresCompare === 'boolean') form.featuresCompare = saved.featuresCompare if (Array.isArray(saved.addons) && saved.addons.length) { form.addons = saved.addons.map((a: any) => ({ key: String(a?.key || ''), label: String(a?.label || ''), enabled: a?.enabled === true, price: num(a?.price), unit: ['month', 'once', 'hour', 'text'].includes(a?.unit) ? a.unit : 'month', note: String(a?.note || '') })) } if (saved.onboarding && typeof saved.onboarding === 'object') { if (typeof saved.onboarding.enabled === 'boolean') form.onboarding.enabled = saved.onboarding.enabled if (saved.onboarding.price !== undefined) form.onboarding.price = num(saved.onboarding.price) if (typeof saved.onboarding.priceIsFrom === 'boolean') form.onboarding.priceIsFrom = saved.onboarding.priceIsFrom if (typeof saved.onboarding.travelExcluded === 'boolean') form.onboarding.travelExcluded = saved.onboarding.travelExcluded if (typeof saved.onboarding.intro === 'string' && saved.onboarding.intro.trim()) form.onboarding.intro = saved.onboarding.intro if (Array.isArray(saved.onboarding.phases) && saved.onboarding.phases.length) { form.onboarding.phases = saved.onboarding.phases.map((p: any) => ({ title: String(p?.title || ''), text: String(p?.text || '') })) } if (Array.isArray(saved.onboarding.highlights)) { form.onboarding.highlights = saved.onboarding.highlights.map((h: any) => ({ value: String(h?.value || ''), label: String(h?.label || '') })) } if (Array.isArray(saved.onboarding.scope) && saved.onboarding.scope.length) { form.onboarding.scope = saved.onboarding.scope.map((p: any) => ({ title: String(p?.title || ''), text: String(p?.text || '') })) } } if (typeof saved?.attachments?.onboarding === 'boolean') form.attachments.onboarding = saved.attachments.onboarding if (Array.isArray(saved.customPositions)) { form.customPositions = saved.customPositions.map((r: any) => ({ description: String(r?.description || ''), dimension: String(r?.dimension || ''), price: String(r?.price ?? '') })) } if (Array.isArray(saved.requirements)) { form.requirements = saved.requirements.map((r: any) => ({ text: String(r?.text || ''), possible: r?.possible !== false, answerType: r?.answerType === 'text' ? 'text' : 'check', answerText: String(r?.answerText || '') })) } if (saved.sections && typeof saved.sections === 'object') { for (const k of SOFTWARE_SECTION_KEYS) if (typeof saved.sections[k] === 'boolean') form.sections[k] = saved.sections[k] } if (Array.isArray(saved.customTexts)) { form.customTexts = saved.customTexts.map((b: any) => ({ title: String(b?.title || ''), text: String(b?.text || ''), after: isTextAnchor(b?.after) ? String(b.after) : 'end' })) } if (saved.forecast && typeof saved.forecast.includeInPdf === 'boolean') form.forecast.includeInPdf = saved.forecast.includeInPdf if (num(saved.validDays) > 0) form.meta.validDays = num(saved.validDays) if (saved.confirmation && saved.confirmation.enabled === false) form.confirmation.enabled = false if (saved.confirmation && typeof saved.confirmation.attachPdf === 'boolean') form.confirmation.attachPdf = saved.confirmation.attachPdf if (!form.email.to && saved.customer?.email) form.email.to = saved.customer.email return form } /** Progressive overage cost for orders above `includedOrders` (tiers = absolute monthly thresholds). */ export const computeOverage = (includedOrders: number, ordersPerMonth: number, tiers: Array<{ upTo: any; price: any }>) => { const included = Math.max(0, num(includedOrders)) const extra = included > 0 ? Math.max(0, num(ordersPerMonth) - included) : 0 const rows: Array<{ from: number; to: number | null; price: number; qty: number; cost: number }> = [] if (!extra || !Array.isArray(tiers) || !tiers.length) { return { extraOrders: extra, cost: 0, rows } } const sorted = [...tiers] .map((t) => ({ upTo: t.upTo === '' || t.upTo == null ? null : num(t.upTo), price: num(t.price) })) .sort((a, b) => (a.upTo == null ? 1 : b.upTo == null ? -1 : a.upTo - b.upTo)) let remaining = extra let prev = included let cost = 0 for (const t of sorted) { if (remaining <= 0) break const cap = t.upTo == null ? remaining : Math.max(0, t.upTo - prev) const qty = Math.min(remaining, cap) if (qty > 0) { cost += qty * t.price rows.push({ from: prev + 1, to: t.upTo, price: t.price, qty, cost: qty * t.price }) } remaining -= qty if (t.upTo != null) prev = Math.max(prev, t.upTo) } if (remaining > 0 && sorted.length) { const last = sorted[sorted.length - 1] cost += remaining * last.price rows.push({ from: prev + 1, to: null, price: last.price, qty: remaining, cost: remaining * last.price }) } return { extraOrders: extra, cost, rows } } /** Monthly / one-time / first-year forecast of the configured offer. Pure function of the form. */ export const computeSoftwareForecast = (form: any) => { const pl = form.plan || {} const a = form.assumptions || {} const on = (k: string) => form.sections?.[k] !== false const planPrice = pl.billing === 'monthly' ? num(pl.priceMonthly) : num(pl.priceYearly) const overage = computeOverage(pl.includedOrders, a.ordersPerMonth, pl.overageTiers || []) const extraUsers = num(pl.includedUsers) > 0 ? Math.max(0, num(a.users) - num(pl.includedUsers)) : 0 const extraUsersCost = extraUsers * num(pl.extraUserPrice) const addonsOn = on('addons') ? (form.addons || []).filter((x: any) => x.enabled) : [] const addonsMonthly = addonsOn.filter((x: any) => x.unit === 'month').reduce((s: number, x: any) => s + num(x.price), 0) const addonsOnce = addonsOn.filter((x: any) => x.unit === 'once').reduce((s: number, x: any) => s + num(x.price), 0) // Onboarding one-time cost only when the offer itself prints the price — with the // separate annex PDF the offer carries a price-less "optional" position instead. const onboardingAnnex = form.onboarding?.enabled && form.attachments?.onboarding !== false const onboarding = on('onboarding') && form.onboarding?.enabled && !onboardingAnnex ? num(form.onboarding.price) : 0 const lines: Array<{ key: string; monthly: number; qty?: number }> = [{ key: 'plan', monthly: planPrice }] if (overage.extraOrders > 0) lines.push({ key: 'overage', monthly: overage.cost, qty: overage.extraOrders }) if (extraUsers > 0) lines.push({ key: 'extraUsers', monthly: extraUsersCost, qty: extraUsers }) if (addonsMonthly > 0) lines.push({ key: 'addons', monthly: addonsMonthly }) const totalMonthly = lines.reduce((s, l) => s + l.monthly, 0) const oneTime: Array<{ key: string; amount: number }> = [] if (onboarding > 0) oneTime.push({ key: 'onboarding', amount: onboarding }) if (addonsOnce > 0) oneTime.push({ key: 'addonsOnce', amount: addonsOnce }) const totalOneTime = oneTime.reduce((s, l) => s + l.amount, 0) const orders = num(a.ordersPerMonth) return { lines, totalMonthly, oneTime, totalOneTime, firstYear: totalMonthly * 12 + totalOneTime, perOrder: orders > 0 ? totalMonthly / orders : 0, planPrice, overage, extraUsers, isOnRequest: planPrice <= 0 } } /** * Side-panel helper: monthly total for EVERY catalog package at the customer's * volume (catalog list prices, not the edited ones) + the recommended package * (smallest one whose included orders AND users cover the assumptions). */ export const computePlanComparison = (form: any, catalog: SoftwareCatalog) => { const a = form.assumptions || {} const billing = form.plan?.billing === 'monthly' ? 'monthly' : 'yearly' const rows = activePlans(catalog).map((p) => { const price = billing === 'monthly' ? p.priceMonthly : p.priceYearly const ov = computeOverage(p.includedOrders, a.ordersPerMonth, p.extraOrderPrice > 0 ? [{ upTo: '', price: p.extraOrderPrice }] : []) const extraUsers = p.includedUsers > 0 ? Math.max(0, num(a.users) - p.includedUsers) : 0 const channelsOk = p.salesChannels === 0 || num(a.salesChannels) <= p.salesChannels const individual = p.includedOrders === 0 && p.includedUsers === 0 const fits = individual || (p.includedOrders >= num(a.ordersPerMonth) && p.includedUsers >= num(a.users) && channelsOk) return { key: p.key, name: catalogLabel(p, form.language), onRequest: price <= 0, monthly: price + ov.cost + extraUsers * p.extraUserPrice, extraOrders: ov.extraOrders, extraUsers, fits, channelsOk } }) const recommended = rows.find((r) => r.fits)?.key || rows[rows.length - 1]?.key || '' const priced = rows.filter((r) => !r.onRequest) const cheapest = priced.length ? priced.reduce((m, r) => (r.monthly < m.monthly ? r : m), priced[0]).key : null return { rows, recommended, cheapest } } export const softwareQuoteEmailDefaults = (language: string, contactName: string, company: string, validDays: number, planName: string, onlineConfirm = false, attachPdf = true) => { const linkOnly = onlineConfirm && !attachPdf if (language === 'en') { return { subject: `Your LogShip Cloud offer (${planName}) – ${company}`, message: `Dear ${contactName || 'Sir or Madam'},\n\nthank you for your interest in LogShip Cloud. ${linkOnly ? `Your individual offer is ready for you online — simply click "Open quote" in this email to read it and download it as PDF. Clicking the button does not accept anything. The offer is valid for ${validDays} days.` : `Please find attached your individual offer for the ${planName} package as PDF — it is valid for ${validDays} days.`}\n\nHow it continues:\n1. Accept the offer: ${onlineConfirm ? 'once you have read it, you can accept it on the same page in a separate step — or simply reply to this email with "Offer accepted".' : 'simply reply to this email with "Offer accepted".'}\n2. Kickoff: we schedule the onboarding kickoff within one business day and agree on your go-live date.\n3. Go-live: your channels, carriers and warehouse are set up, your team is trained — and you ship with LogShip.\n\nAny questions first? Just reply to this email or call us at +49 60 33 / 91 60 57-0 — we are happy to show you LogShip in a live demo.\n\nBest regards\nYour LogShip team` } } return { subject: `Ihr Angebot LogShip Cloud (${planName}) – ${company}`, message: `Guten Tag ${contactName || 'Damen und Herren'},\n\nvielen Dank für Ihr Interesse an LogShip Cloud. ${linkOnly ? `Ihr individuelles Angebot steht für Sie online bereit — klicken Sie einfach in dieser E-Mail auf "Angebot öffnen", um es zu lesen und als PDF herunterzuladen. Der Klick ist unverbindlich. Das Angebot ist ${validDays} Tage gültig.` : `Anbei erhalten Sie Ihr individuelles Angebot für das Paket ${planName} als PDF — es ist ${validDays} Tage gültig.`}\n\nSo geht es weiter:\n1. Angebot annehmen: ${onlineConfirm ? 'Wenn Sie das Angebot gelesen haben, können Sie es auf derselben Seite in einem separaten Schritt online annehmen — oder antworten Sie einfach formlos mit "Angebot angenommen".' : 'Antworten Sie auf diese E-Mail einfach mit "Angebot angenommen".'}\n2. Kickoff: Wir vereinbaren innerhalb von einem Werktag den Onboarding-Kickoff und legen gemeinsam Ihren Go-Live-Termin fest.\n3. Go-Live: Kanäle, Versanddienstleister und Lager sind eingerichtet, Ihr Team ist geschult — und Sie versenden mit LogShip.\n\nSie haben vorab noch Fragen? Antworten Sie einfach auf diese E-Mail oder rufen Sie uns an: +49 60 33 / 91 60 57-0 — gerne zeigen wir Ihnen LogShip in einer Live-Demo.\n\nMit freundlichen Grüßen\nIhr LogShip Team` } } /** Payload for the preview/send routes (offerType 'software' selects the SaaS renderer). */ export const buildSoftwareQuotePayload = (form: any, forecast: any, withEmail = false) => { const payload: any = { docType: 'quote', offerType: 'software', source: form.source, recordId: form.recordId, recordUu: form.recordUu || undefined, catalog: JSON.parse(JSON.stringify(form.catalog || {})), customer: { ...form.customer, country: form.customer.country || 'Deutschland' }, visuals: { includeScreenshot: form.visuals.includeScreenshot === true && !!form.visuals.screenshotDataUrl, screenshotDataUrl: form.visuals.includeScreenshot ? form.visuals.screenshotDataUrl : '', includeLogo: form.visuals.includeLogo === true && !!form.visuals.logoDataUrl, logoDataUrl: form.visuals.includeLogo ? form.visuals.logoDataUrl : '', websiteUrl: form.customer.website || '' }, plan: JSON.parse(JSON.stringify(form.plan)), assumptions: JSON.parse(JSON.stringify(form.assumptions)), features: { ...form.features }, customFeatures: form.customFeatures.filter((f: any) => String(f?.label || '').trim() !== ''), featuresCompare: form.featuresCompare !== false, addons: JSON.parse(JSON.stringify(form.addons)), onboarding: JSON.parse(JSON.stringify(form.onboarding)), customPositions: form.customPositions.filter((r: any) => String(r?.description || '').trim() !== ''), requirements: form.requirements.filter((r: any) => String(r?.text || '').trim() !== ''), sections: { ...form.sections }, customTexts: form.customTexts.filter((b: any) => String(b?.text || '').trim() !== ''), forecast: { includeInPdf: form.forecast.includeInPdf === true, lines: forecast.lines, totalMonthly: forecast.totalMonthly, oneTime: forecast.oneTime, totalOneTime: forecast.totalOneTime, firstYear: forecast.firstYear, perOrder: forecast.perOrder, overageRows: forecast.overage?.rows || [], extraUsers: forecast.extraUsers }, meta: { language: form.language, date: new Date().toISOString().slice(0, 10), validDays: form.meta.validDays, validUntil: form.meta.validUntil || undefined }, attachments: { flyer: form.attachments?.flyer === true, onboarding: form.attachments?.onboarding !== false && form.onboarding?.enabled === true }, confirmation: { enabled: form.confirmation?.enabled !== false, attachPdf: form.confirmation?.attachPdf === true } } if (withEmail) payload.email = { ...form.email } return payload }