/** * Offer/quote (Angebot) business logic — single source of truth for the numbers. * The client computes defaults, forecast and recommendations; the server routes * (server/api/offers/quote/*) only render/send what they receive, so the PDF * always matches what the user saw in the modal. * * Extensible for the later contract (Vertrag) generator via the payload's * `docType` discriminator. */ // Standard list prices (status 2026, from the reference Word Angebot). // Used as defaults for new quotes and as floors for below-list warnings. export const QUOTE_LIST_PRICES = { orderBaseprice: 2.90, orderBaseprice2: 0, orderBaseprice3: 0, orderPickprice: 0.35, qtyPickfree: 1, customsSurcharge: 1.00, palletMovePrice: 9.50, returnBaseprice: 1.50, returnPickprice: 0.30, hourlyRate: 49, monthlyFee: 89.90, shelfSmallPrice: 3.50, shelfLargePrice: 5.50, palletPrice: 9.50, volumeM3Price: 0, einwegpalettePrice: 9.50, cartons: { s: { price: 0.55, paper: 0.15 }, m: { price: 0.60, paper: 0.20 }, l: { price: 0.85, paper: 0.25 }, xl: { price: 1.25, paper: 0.70 } }, dhlTiers: { kg1: 3.49, kg2: 4.50, kg5: 4.80, kg10: 5.20, kg20: 5.50, kg30: 7.50 } } // Volume-tier price recommendations keyed on orders/month. Tune here only. export const QUOTE_VOLUME_TIERS = [ { minOrders: 0, orderBaseprice: 2.90, monthlyFee: 89.90, hourlyRate: 49 }, { minOrders: 500, orderBaseprice: 2.50, monthlyFee: 89.90, hourlyRate: 49 }, { minOrders: 1000, orderBaseprice: 2.20, monthlyFee: 49.90, hourlyRate: 45 }, { minOrders: 2500, orderBaseprice: 1.90, monthlyFee: 49.90, hourlyRate: 42 }, { minOrders: 5000, orderBaseprice: 1.70, monthlyFee: 49.90, hourlyRate: 42 } ] // Price floors — anything below triggers an internal "needs approval" warning. export const QUOTE_PRICE_FLOORS: Record = { orderBaseprice: 1.90, orderPickprice: 0.35, returnBaseprice: 1.50, returnPickprice: 0.30, hourlyRate: 42, monthlyFee: 49.90, shelfSmallPrice: 3.50, shelfLargePrice: 5.50, palletPrice: 9.50 } const num = (v: any): number => { const n = Number(v) return Number.isFinite(n) ? n : 0 } // Prefer the partner's value, but never let a 0/empty price into the quote. const priceOr = (v: any, fallback: number): number => { const n = num(v) return n > 0 ? n : fallback } const firstOfNextMonth = (): string => { const d = new Date() const next = new Date(d.getFullYear(), d.getMonth() + 1, 1) const mm = String(next.getMonth() + 1).padStart(2, '0') return `${next.getFullYear()}-${mm}-01` } /** * Build the reactive quote form model from a host-page prefill. * prefill = { source, recordId, recordUu?, company, contactName, email, phone, pricing?, storageQty? } * pricing === null/undefined → pure list prices (lead case). */ export const createQuoteForm = (prefill: any = {}) => { const p = prefill?.pricing || {} const sq = prefill?.storageQty || {} const L = QUOTE_LIST_PRICES return { source: prefill?.source || 'partner', recordId: num(prefill?.recordId), recordUu: prefill?.recordUu || '', language: 'de', customer: { company: prefill?.company || '', contactName: prefill?.contactName || '', email: prefill?.email || '', phone: prefill?.phone || '', street: '', zip: '', city: '', country: 'Deutschland' }, assumptions: { businessModel: 'B2C', plannedStart: firstOfNextMonth(), shopSystem: '', avgPicksPerOrder: 1.5, ordersPerMonth: 500, skuCount: 100, returnRatePct: 5, typicalCarton: 'm', storage: { shelfSmallQty: num(sq.shelfSmall), shelfLargeQty: num(sq.shelfLarge), palletQty: num(sq.pallet), volumeM3: 0 } }, pricing: { orderBaseprice: priceOr(p.orderBaseprice, L.orderBaseprice), orderBaseprice2: num(p.orderBaseprice2), orderBaseprice3: num(p.orderBaseprice3), orderPickprice: priceOr(p.orderPickprice, L.orderPickprice), qtyPickfree: num(p.qtyPickfree) > 0 ? num(p.qtyPickfree) : L.qtyPickfree, customsSurcharge: L.customsSurcharge, palletMovePrice: L.palletMovePrice, returnBaseprice: priceOr(p.returnBaseprice, L.returnBaseprice), returnPickprice: priceOr(p.returnPickprice, L.returnPickprice), hourlyRate: priceOr(p.hourlyRate, L.hourlyRate), monthlyFee: priceOr(p.monthlyFee, L.monthlyFee), shelfSmallPrice: priceOr(p.shelfSmallPrice, L.shelfSmallPrice), shelfLargePrice: priceOr(p.shelfLargePrice, L.shelfLargePrice), palletPrice: priceOr(p.palletPrice, L.palletPrice), volumeM3Price: num(p.volumeM3Price), einwegpalettePrice: L.einwegpalettePrice, cartons: { s: { price: priceOr(p.cartons?.s, L.cartons.s.price), paper: L.cartons.s.paper }, m: { price: priceOr(p.cartons?.m, L.cartons.m.price), paper: L.cartons.m.paper }, l: { price: priceOr(p.cartons?.l, L.cartons.l.price), paper: L.cartons.l.paper }, xl: { price: priceOr(p.cartons?.xl, L.cartons.xl.price), paper: L.cartons.xl.paper } }, dhlTiers: { ...L.dhlTiers }, speditionNote: '' }, forecast: { includeInPdf: false }, meta: { validDays: 7 }, email: { to: prefill?.email || '', cc: '', subject: '', message: '' } } } /** * Monthly cost forecast. Pure function of the form model. * Shipping (DHL) is its own position, estimated with the ≤ 2 kg tier price * per order, and included in the monthly total. */ export const computeQuoteForecast = (form: any) => { const a = form.assumptions const p = form.pricing const orders = num(a.ordersPerMonth) const billablePicks = Math.max(0, num(a.avgPicksPerOrder) - num(p.qtyPickfree)) const carton = p.cartons[a.typicalCarton] || p.cartons.m const fulfillment = orders * (num(p.orderBaseprice) + billablePicks * num(p.orderPickprice)) const returns = orders * (num(a.returnRatePct) / 100) * (num(p.returnBaseprice) + num(a.avgPicksPerOrder) * num(p.returnPickprice)) const storage = num(a.storage.shelfSmallQty) * num(p.shelfSmallPrice) + num(a.storage.shelfLargeQty) * num(p.shelfLargePrice) + num(a.storage.palletQty) * num(p.palletPrice) + num(a.storage.volumeM3) * num(p.volumeM3Price) const serviceFee = num(p.monthlyFee) const packaging = orders * (num(carton.price) + num(carton.paper)) const shipping = orders * num(p.dhlTiers.kg2) const totalMonthly = fulfillment + returns + storage + serviceFee + packaging + shipping const perOrder = orders > 0 ? totalMonthly / orders : 0 return { lines: [ { key: 'fulfillment', monthly: fulfillment }, { key: 'returns', monthly: returns }, { key: 'storage', monthly: storage }, { key: 'serviceFee', monthly: serviceFee }, { key: 'packaging', monthly: packaging }, { key: 'shipping', monthly: shipping } ], totalMonthly, perOrder } } /** * Volume-tier suggestions + below-list warnings for the modal side panel. * Internal sales tool only — never printed into the PDF. */ export const computeQuoteRecommendations = (form: any) => { const orders = num(form.assumptions.ordersPerMonth) const tier = [...QUOTE_VOLUME_TIERS].reverse().find(t => orders >= t.minOrders) || QUOTE_VOLUME_TIERS[0] const suggestions: any[] = [] const tierFields: Array<'orderBaseprice' | 'monthlyFee' | 'hourlyRate'> = ['orderBaseprice', 'monthlyFee', 'hourlyRate'] for (const field of tierFields) { const current = num(form.pricing[field]) const suggested = num((tier as any)[field]) if (Math.abs(current - suggested) >= 0.01) { suggestions.push({ field, current, suggested, tierMinOrders: tier.minOrders, orders }) } } const warnings: any[] = [] for (const [field, floor] of Object.entries(QUOTE_PRICE_FLOORS)) { const current = num(form.pricing[field]) if (current > 0 && current < floor) { warnings.push({ field, current, floor }) } } return { suggestions, warnings } } // Umlaut-safe filename part (mail clients mangle RFC-2231-encoded names). export const sanitizeCompanyForFilename = (company: string): string => { return String(company || 'Kunde') .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue') .replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue') .replace(/ß/g, 'ss') .replace(/[^A-Za-z0-9-_ ]/g, '') .trim() .replace(/\s+/g, '-') || 'Kunde' } export const quoteFilename = (company: string): string => { const date = new Date().toISOString().slice(0, 10) return `Angebot_LogYou_${sanitizeCompanyForFilename(company)}_${date}.pdf` } /** Assemble the payload for the preview/send server routes. */ export const buildQuotePayload = (form: any, forecast: any, withEmail = false) => { const payload: any = { docType: 'quote', source: form.source, recordId: form.recordId, recordUu: form.recordUu || undefined, customer: { ...form.customer, country: form.customer.country || 'Deutschland' }, assumptions: JSON.parse(JSON.stringify(form.assumptions)), pricing: JSON.parse(JSON.stringify(form.pricing)), forecast: { includeInPdf: !!form.forecast.includeInPdf, lines: forecast.lines, totalMonthly: forecast.totalMonthly, perOrder: forecast.perOrder }, meta: { validDays: num(form.meta.validDays) || 7, language: form.language === 'en' ? 'en' : 'de', date: new Date().toISOString().slice(0, 10) } } if (withEmail) { payload.email = { to: form.email.to.trim(), cc: form.email.cc.trim(), subject: form.email.subject, message: form.email.message } } return payload } /** Language-dependent email defaults (subject + body). */ export const quoteEmailDefaults = (language: string, contactName: string, company: string, validDays: number) => { if (language === 'en') { return { subject: `Your fulfillment quote from LogYou – ${company}`, message: `Dear ${contactName || 'Sir or Madam'},\n\nthank you for your interest in LogYou fulfillment.\n\nPlease find attached our individual quote as PDF. The quote is valid for ${validDays} days.\nIf you have any questions, we are happy to help at any time.\n\nBest regards\nYour LogYou team` } } return { subject: `Ihr Fulfillment-Angebot von LogYou – ${company}`, message: `Guten Tag ${contactName || 'Damen und Herren'},\n\nvielen Dank für Ihr Interesse an LogYou Fulfillment.\n\nAnbei erhalten Sie unser individuelles Angebot als PDF. Das Angebot ist ${validDays} Tage gültig.\nBei Fragen stehen wir Ihnen jederzeit gerne zur Verfügung.\n\nMit freundlichen Grüßen\nIhr LogYou Team` } }