/** * Custom forecast ("Individuelle Prognose") — a standalone, freely editable cost * calculation a customer can ask for independently of the quote PDF. * * Seeded from the record's saved quote conditions (offer_conditions.quote) or * from the live quote form, then every position can be switched on/off, * relabelled and re-priced, extra positions added, and the calculation shown * for up to four order-volume scenarios side by side. * * Same division of labour as useOfferQuote.ts: the client computes the numbers, * the server routes (server/api/offers/forecast/*) only render/send what they * receive — the PDF always matches what the user saw in the modal. * * Position model — two bases cover everything: * perOrder: monthly = scenario.orders × factor × unitPrice * (factorMode 'pct' → factor is a percentage, e.g. return rate) * fixed: monthly = qty × unitPrice (identical in every scenario) */ import { QUOTE_LIST_PRICES, createQuoteForm, sanitizeCompanyForFilename } from './useOfferQuote' export const FORECAST_MAX_SCENARIOS = 4 // Default labels of the seeded positions (PDF language, not the app language). // A position's own `label` (user override) always wins over these. export const FORECAST_LINE_LABELS: Record> = { de: { fulfillment: 'Fulfillment (Basispreis pro Auftrag)', picks: 'Zusätzliche Picks', returns: 'Retouren', packaging: 'Verpackung', shipping: 'Versand (DHL, Annahme bis 2 kg)', shelfSmall: 'Lagerung — Fachbodenregal M', shelfLarge: 'Lagerung — Fachbodenregal L', pallet: 'Lagerung — Palettenstellplatz', volumeM3: 'Lagerung — Volumen (m³)', serviceFee: 'Servicepauschale', labor: 'Arbeitszeit (Stunden)', custom: 'Position' }, en: { fulfillment: 'Fulfillment (base price per order)', picks: 'Additional picks', returns: 'Returns', packaging: 'Packaging', shipping: 'Shipping (DHL, assumed up to 2 kg)', shelfSmall: 'Storage — shelf space M', shelfLarge: 'Storage — shelf space L', pallet: 'Storage — pallet space', volumeM3: 'Storage — volume (m³)', serviceFee: 'Service fee', labor: 'Labour (hours)', custom: 'Position' } } const fNum = (v: any): number => { const n = Number(v) return Number.isFinite(n) ? n : 0 } const round2 = (v: number): number => Math.round(v * 100) / 100 let forecastLineSeq = 0 const nextLineId = (): string => `fl${Date.now().toString(36)}${(forecastLineSeq++).toString(36)}` export type ForecastLine = { id: string key: string enabled: boolean label: string basis: 'perOrder' | 'fixed' factor: number factorMode: 'x' | 'pct' qty: number unitPrice: number } const makeLine = (key: string, patch: Partial): ForecastLine => ({ id: nextLineId(), key, enabled: true, label: '', basis: 'perOrder', factor: 1, factorMode: 'x', qty: 1, unitPrice: 0, ...patch }) /** A blank user-added position. */ export const createForecastCustomLine = (basis: 'perOrder' | 'fixed' = 'fixed'): ForecastLine => makeLine('custom', { basis }) /** * Positions derived from a quote form / saved quote conditions — the same math * as computeQuoteForecast, but split into transparent, individually editable rows. */ export const forecastLinesFromQuote = (quote: any): ForecastLine[] => { const a = quote?.assumptions || {} const p = quote?.pricing || {} const st = a.storage || {} const L = QUOTE_LIST_PRICES const sectionOn = (key: string) => quote?.sections?.[key] !== false const avgPicks = fNum(a.avgPicksPerOrder) || 1 const billablePicks = Math.max(0, round2(avgPicks - fNum(p.qtyPickfree))) const cartons = p.cartons || L.cartons const carton = cartons[a.typicalCarton] || cartons.m || L.cartons.m const showShipping = p.showShippingFees !== false && sectionOn('shipping') const price = (v: any, fallback: number) => (fNum(v) > 0 ? fNum(v) : fallback) return [ makeLine('fulfillment', { enabled: sectionOn('fulfillment'), unitPrice: price(p.orderBaseprice, L.orderBaseprice) }), makeLine('picks', { enabled: sectionOn('fulfillment') && billablePicks > 0, factor: billablePicks, unitPrice: price(p.orderPickprice, L.orderPickprice) }), makeLine('returns', { enabled: sectionOn('fulfillment') && fNum(a.returnRatePct) > 0, factorMode: 'pct', factor: fNum(a.returnRatePct), unitPrice: round2(price(p.returnBaseprice, L.returnBaseprice) + avgPicks * price(p.returnPickprice, L.returnPickprice)) }), makeLine('packaging', { enabled: sectionOn('packaging'), unitPrice: round2(fNum(carton?.price) + fNum(carton?.paper)) }), makeLine('shipping', { enabled: showShipping, unitPrice: price(p.dhlTiers?.kg2, L.dhlTiers.kg2) }), makeLine('shelfSmall', { basis: 'fixed', enabled: sectionOn('storage') && fNum(st.shelfSmallQty) > 0, qty: fNum(st.shelfSmallQty), unitPrice: price(p.shelfSmallPrice, L.shelfSmallPrice) }), makeLine('shelfLarge', { basis: 'fixed', enabled: sectionOn('storage') && fNum(st.shelfLargeQty) > 0, qty: fNum(st.shelfLargeQty), unitPrice: price(p.shelfLargePrice, L.shelfLargePrice) }), makeLine('pallet', { basis: 'fixed', enabled: sectionOn('storage') && fNum(st.palletQty) > 0, qty: fNum(st.palletQty), unitPrice: price(p.palletPrice, L.palletPrice) }), makeLine('volumeM3', { basis: 'fixed', enabled: sectionOn('storage') && fNum(st.volumeM3) > 0 && fNum(p.volumeM3Price) > 0, qty: fNum(st.volumeM3), unitPrice: fNum(p.volumeM3Price) }), makeLine('serviceFee', { basis: 'fixed', enabled: sectionOn('monthly'), qty: 1, unitPrice: price(p.monthlyFee, L.monthlyFee) }), makeLine('labor', { basis: 'fixed', enabled: false, qty: 0, unitPrice: price(p.hourlyRate, L.hourlyRate) }) ] } /** * Fresh forecast form. `quote` = a quote form or saved offer_conditions.quote * (both share the customer/assumptions/pricing/sections shape); without it the * standard list prices are used. */ export const createForecastForm = (prefill: any = {}, quote: any = null) => { const base = quote || createQuoteForm(prefill) const c = base?.customer || {} const orders = fNum(base?.assumptions?.ordersPerMonth) || 500 return { source: prefill?.source || 'partner', recordId: fNum(prefill?.recordId), recordUu: prefill?.recordUu || '', language: base?.language === 'en' ? 'en' : 'de', customer: { company: c.company || prefill?.company || '', contactName: c.contactName || prefill?.contactName || '', email: c.email || prefill?.email || '' }, // Optional custom document title / intro / closing notes (empty = defaults / omitted) title: '', intro: '', notes: '', scenarios: [{ label: '', orders }] as Array<{ label: string; orders: number }>, lines: forecastLinesFromQuote(base), // What the PDF shows and how display: { showCalculation: true, // column "Berechnungsgrundlage" (factor × unit price) showUnitPrices: true, // when off, the calculation column hides the prices (quantities only) showSubtotals: true, // variable vs. fixed monthly costs showPerOrder: true, // cost per order row showYearly: false, // annual total row showDisclaimer: true // non-binding / net prices note }, email: { to: c.email || prefill?.email || '', cc: '', subject: '', message: '' } } } /** Restore a previously saved custom forecast (offer_conditions.forecast). */ export const applySavedForecast = (form: any, saved: any) => { if (!saved || typeof saved !== 'object') return form if (saved.language === 'en' || saved.language === 'de') form.language = saved.language for (const k of ['company', 'contactName', 'email']) { if (saved.customer?.[k]) form.customer[k] = String(saved.customer[k]) } for (const k of ['title', 'intro', 'notes']) { if (typeof saved[k] === 'string') form[k] = saved[k] } if (Array.isArray(saved.scenarios) && saved.scenarios.length) { form.scenarios = saved.scenarios.slice(0, FORECAST_MAX_SCENARIOS).map((s: any) => ({ label: String(s?.label || ''), orders: fNum(s?.orders) })) } if (Array.isArray(saved.lines) && saved.lines.length) { form.lines = saved.lines.map((l: any) => makeLine(String(l?.key || 'custom'), { enabled: l?.enabled !== false, label: String(l?.label || ''), basis: l?.basis === 'fixed' ? 'fixed' : 'perOrder', factor: fNum(l?.factor), factorMode: l?.factorMode === 'pct' ? 'pct' : 'x', qty: fNum(l?.qty), unitPrice: fNum(l?.unitPrice) })) } if (saved.display && typeof saved.display === 'object') { for (const k of Object.keys(form.display)) { if (typeof saved.display[k] === 'boolean') form.display[k] = saved.display[k] } } if (!form.email.to && form.customer.email) form.email.to = form.customer.email return form } /** Display label of a position in the PDF language (own label wins). */ export const forecastLineLabel = (line: any, language: string): string => { const own = String(line?.label || '').trim() if (own) return own const dict = FORECAST_LINE_LABELS[language === 'en' ? 'en' : 'de'] const base = dict[line?.key] || dict.custom if (line?.basis === 'perOrder' && line?.factorMode === 'pct') { const pct = new Intl.NumberFormat(language === 'en' ? 'en-IE' : 'de-DE', { maximumFractionDigits: 2 }).format(fNum(line.factor)) return `${base} (${language === 'en' ? 'approx.' : 'ca.'} ${pct} %)` } return base } /** Scenario column heading ("500 Aufträge / Monat" unless a label is given). */ export const forecastScenarioLabel = (scenario: any, language: string): string => { const own = String(scenario?.label || '').trim() if (own) return own const n = new Intl.NumberFormat(language === 'en' ? 'en-IE' : 'de-DE').format(fNum(scenario?.orders)) return language === 'en' ? `${n} orders / month` : `${n} Aufträge / Monat` } /** "Berechnungsgrundlage" text of a position (scenario-independent). */ export const forecastLineCalculation = (line: any, language: string, showUnitPrices = true): string => { const loc = language === 'en' ? 'en-IE' : 'de-DE' const n = (v: any) => new Intl.NumberFormat(loc, { maximumFractionDigits: 2 }).format(fNum(v)) const eur = (v: any) => new Intl.NumberFormat(loc, { style: 'currency', currency: 'EUR' }).format(fNum(v)) const perOrder = language === 'en' ? 'per order' : 'pro Auftrag' const ofOrders = language === 'en' ? 'of orders' : 'der Aufträge' const perMonth = language === 'en' ? 'per month' : 'pro Monat' if (line?.basis === 'fixed') { return showUnitPrices ? `${n(line.qty)} × ${eur(line.unitPrice)}` : `${n(line.qty)} × ${perMonth}` } if (line?.factorMode === 'pct') { return showUnitPrices ? `${n(line.factor)} % ${ofOrders} × ${eur(line.unitPrice)}` : `${n(line.factor)} % ${ofOrders}` } if (fNum(line?.factor) === 1) return showUnitPrices ? `${eur(line.unitPrice)} ${perOrder}` : `1 × ${perOrder}` return showUnitPrices ? `${n(line.factor)} × ${eur(line.unitPrice)} ${perOrder}` : `${n(line.factor)} × ${perOrder}` } const lineMonthly = (line: any, orders: number): number => { if (line?.basis === 'fixed') return fNum(line.qty) * fNum(line.unitPrice) const factor = line?.factorMode === 'pct' ? fNum(line.factor) / 100 : fNum(line.factor) return orders * factor * fNum(line.unitPrice) } /** Pure function of the form: per-position amounts + totals for every scenario. */ export const computeCustomForecast = (form: any) => { const scenarios = (form?.scenarios || []).slice(0, FORECAST_MAX_SCENARIOS) const active = (form?.lines || []).filter((l: any) => l?.enabled !== false) const rows = active.map((line: any) => ({ id: line.id, key: line.key, basis: line.basis === 'fixed' ? 'fixed' : 'perOrder', amounts: scenarios.map((s: any) => round2(lineMonthly(line, fNum(s?.orders)))) })) const sum = (basis: string | null) => scenarios.map((_s: any, i: number) => round2(rows.filter((r: any) => !basis || r.basis === basis).reduce((acc: number, r: any) => acc + r.amounts[i], 0))) const totals = sum(null) return { rows, variableTotals: sum('perOrder'), fixedTotals: sum('fixed'), totals, perOrder: scenarios.map((s: any, i: number) => (fNum(s?.orders) > 0 ? round2(totals[i] / fNum(s.orders)) : 0)), yearly: totals.map((v: number) => round2(v * 12)) } } export const forecastFilename = (company: string): string => { const date = new Date().toISOString().slice(0, 10) return `Prognose_LogYou_${sanitizeCompanyForFilename(company)}_${date}.pdf` } /** * Payload for the preview/send/save routes. Labels and calculation texts are * resolved here (PDF language) so the server stays a pure renderer. */ export const buildForecastPayload = (form: any, withEmail = false) => { const language = form.language === 'en' ? 'en' : 'de' const computed = computeCustomForecast(form) const byId = new Map(computed.rows.map((r: any) => [r.id, r])) const payload: any = { docType: 'forecast', source: form.source, recordId: form.recordId, recordUu: form.recordUu || undefined, customer: { ...form.customer }, title: String(form.title || '').trim(), intro: String(form.intro || '').replace(/\r\n?/g, '\n').trim(), notes: String(form.notes || '').replace(/\r\n?/g, '\n').trim(), scenarios: (form.scenarios || []).slice(0, FORECAST_MAX_SCENARIOS).map((s: any) => ({ label: String(s?.label || '').trim(), orders: fNum(s?.orders), heading: forecastScenarioLabel(s, language) })), // ALL positions are stored (incl. disabled ones) so a re-open restores the // full editor state; the PDF prints only enabled rows. lines: (form.lines || []).map((l: any) => ({ key: l.key, enabled: l.enabled !== false, label: String(l.label || '').trim(), basis: l.basis === 'fixed' ? 'fixed' : 'perOrder', factor: fNum(l.factor), factorMode: l.factorMode === 'pct' ? 'pct' : 'x', qty: fNum(l.qty), unitPrice: fNum(l.unitPrice), displayLabel: forecastLineLabel(l, language), calculation: forecastLineCalculation(l, language, form.display?.showUnitPrices !== false), amounts: (byId.get(l.id) as any)?.amounts || [] })), totals: { variable: computed.variableTotals, fixed: computed.fixedTotals, total: computed.totals, perOrder: computed.perOrder, yearly: computed.yearly }, display: { ...form.display }, meta: { language, date: new Date().toISOString().slice(0, 10) } } if (withEmail) { payload.email = { to: String(form.email.to || '').trim(), cc: String(form.email.cc || '').trim(), subject: form.email.subject, message: form.email.message } } return payload } /** Language-dependent e-mail defaults (subject + body) — freely editable in the modal. */ export const forecastEmailDefaults = (language: string, contactName: string, company: string) => { if (language === 'en') { return { subject: `Your individual cost forecast from LogYou – ${company}`, message: `Dear ${contactName || 'Sir or Madam'},\n\nas discussed, please find attached your individual cost forecast as PDF. It shows your expected monthly fulfillment costs based on the volumes we talked about.\n\nThe calculation is a non-binding example — you are only ever billed for what you actually use.\n\nWould you like to see a different volume or an additional scenario? Just reply to this email or call us at +49 60 33 / 91 60 57-0 — we are happy to adjust it.\n\nBest regards\nYour LogYou team` } } return { subject: `Ihre individuelle Kostenprognose von LogYou – ${company}`, message: `Guten Tag ${contactName || 'Damen und Herren'},\n\nwie besprochen erhalten Sie anbei Ihre individuelle Kostenprognose als PDF. Sie zeigt Ihre voraussichtlichen monatlichen Fulfillment-Kosten auf Basis der besprochenen Mengen.\n\nDie Kalkulation ist ein unverbindliches Beispiel — abgerechnet wird ausschließlich, was Sie tatsächlich nutzen.\n\nSie möchten ein anderes Volumen oder ein weiteres Szenario sehen? Antworten Sie einfach auf diese E-Mail oder rufen Sie uns an: +49 60 33 / 91 60 57-0 — wir passen die Prognose gerne an.\n\nMit freundlichen Grüßen\nIhr LogYou Team` } }