1036 lines
32 KiB
JavaScript
1036 lines
32 KiB
JavaScript
/**
|
|
* Pricing Calculator für Skrift Konfigurator
|
|
* Berechnet Preise basierend auf Kundentyp (B2B/B2C) und Produktkonfiguration
|
|
*/
|
|
|
|
/**
|
|
* Sichere mathematische Formel-Auswertung ohne eval()
|
|
* Unterstützt: +, -, *, /, Klammern, Zahlen, Dezimalzahlen,
|
|
* Vergleichsoperatoren (>=, <=, >, <, ==, !=), ternärer Operator (?:),
|
|
* und Math-Funktionen (sqrt, abs, min, max, pow, floor, ceil, round)
|
|
*/
|
|
function safeEvaluateMathFormula(formula) {
|
|
// Erlaubte Math-Funktionen (Whitelist)
|
|
const mathFunctions = {
|
|
'sqrt': Math.sqrt,
|
|
'abs': Math.abs,
|
|
'min': Math.min,
|
|
'max': Math.max,
|
|
'pow': Math.pow,
|
|
'floor': Math.floor,
|
|
'ceil': Math.ceil,
|
|
'round': Math.round,
|
|
};
|
|
|
|
// Tokenizer: Zerlegt Formel in Tokens
|
|
function tokenize(str) {
|
|
const tokens = [];
|
|
let i = 0;
|
|
str = str.replace(/\s+/g, ''); // Whitespace entfernen
|
|
|
|
while (i < str.length) {
|
|
const char = str[i];
|
|
|
|
// Math.xxx Funktionen
|
|
if (str.substring(i, i + 5) === 'Math.') {
|
|
i += 5;
|
|
let funcName = '';
|
|
while (i < str.length && /[a-zA-Z]/.test(str[i])) {
|
|
funcName += str[i];
|
|
i++;
|
|
}
|
|
if (mathFunctions[funcName]) {
|
|
tokens.push({ type: 'function', name: funcName });
|
|
} else {
|
|
throw new Error(`Unbekannte Math-Funktion: Math.${funcName}`);
|
|
}
|
|
}
|
|
// Zahl (inkl. Dezimalzahlen)
|
|
else if (/[0-9.]/.test(char) || (char === '-' && (tokens.length === 0 || ['(', '+', '-', '*', '/', '>=', '<=', '>', '<', '==', '!=', '?', ':'].includes(tokens[tokens.length - 1]?.type || tokens[tokens.length - 1])))) {
|
|
let numStr = '';
|
|
if (char === '-') {
|
|
numStr = '-';
|
|
i++;
|
|
}
|
|
while (i < str.length && /[0-9.]/.test(str[i])) {
|
|
numStr += str[i];
|
|
i++;
|
|
}
|
|
const num = parseFloat(numStr);
|
|
if (isNaN(num)) {
|
|
throw new Error(`Ungültige Zahl: ${numStr}`);
|
|
}
|
|
tokens.push({ type: 'number', value: num });
|
|
}
|
|
// Zwei-Zeichen-Operatoren
|
|
else if (str.substring(i, i + 2) === '>=') {
|
|
tokens.push({ type: '>=' });
|
|
i += 2;
|
|
}
|
|
else if (str.substring(i, i + 2) === '<=') {
|
|
tokens.push({ type: '<=' });
|
|
i += 2;
|
|
}
|
|
else if (str.substring(i, i + 2) === '==') {
|
|
tokens.push({ type: '==' });
|
|
i += 2;
|
|
}
|
|
else if (str.substring(i, i + 2) === '!=') {
|
|
tokens.push({ type: '!=' });
|
|
i += 2;
|
|
}
|
|
// Ein-Zeichen-Operatoren und Klammern
|
|
else if (['+', '-', '*', '/', '(', ')', '>', '<', '?', ':', ','].includes(char)) {
|
|
tokens.push({ type: char });
|
|
i++;
|
|
}
|
|
// Ungültiges Zeichen
|
|
else {
|
|
throw new Error(`Ungültiges Zeichen in Formel: ${char}`);
|
|
}
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
// Rekursiver Parser mit Operator-Präzedenz
|
|
function parse(tokens) {
|
|
let pos = 0;
|
|
|
|
function peek() {
|
|
return tokens[pos];
|
|
}
|
|
|
|
function consume(expectedType) {
|
|
const token = tokens[pos];
|
|
if (expectedType && token?.type !== expectedType) {
|
|
throw new Error(`Erwartet ${expectedType}, gefunden ${token?.type}`);
|
|
}
|
|
pos++;
|
|
return token;
|
|
}
|
|
|
|
// Ternärer Operator (niedrigste Präzedenz)
|
|
function parseTernary() {
|
|
let condition = parseComparison();
|
|
|
|
if (peek()?.type === '?') {
|
|
consume('?');
|
|
const trueValue = parseTernary();
|
|
consume(':');
|
|
const falseValue = parseTernary();
|
|
return condition ? trueValue : falseValue;
|
|
}
|
|
|
|
return condition;
|
|
}
|
|
|
|
// Vergleichsoperatoren
|
|
function parseComparison() {
|
|
let left = parseAddSub();
|
|
|
|
while (peek()?.type && ['>=', '<=', '>', '<', '==', '!='].includes(peek().type)) {
|
|
const op = consume().type;
|
|
const right = parseAddSub();
|
|
switch (op) {
|
|
case '>=': left = left >= right ? 1 : 0; break;
|
|
case '<=': left = left <= right ? 1 : 0; break;
|
|
case '>': left = left > right ? 1 : 0; break;
|
|
case '<': left = left < right ? 1 : 0; break;
|
|
case '==': left = left === right ? 1 : 0; break;
|
|
case '!=': left = left !== right ? 1 : 0; break;
|
|
}
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
// Addition und Subtraktion
|
|
function parseAddSub() {
|
|
let left = parseMulDiv();
|
|
|
|
while (peek()?.type && ['+', '-'].includes(peek().type)) {
|
|
const op = consume().type;
|
|
const right = parseMulDiv();
|
|
left = op === '+' ? left + right : left - right;
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
// Multiplikation und Division
|
|
function parseMulDiv() {
|
|
let left = parseUnary();
|
|
|
|
while (peek()?.type && ['*', '/'].includes(peek().type)) {
|
|
const op = consume().type;
|
|
const right = parseUnary();
|
|
if (op === '/') {
|
|
if (right === 0) throw new Error('Division durch Null');
|
|
left = left / right;
|
|
} else {
|
|
left = left * right;
|
|
}
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
// Unäre Operatoren (negatives Vorzeichen)
|
|
function parseUnary() {
|
|
if (peek()?.type === '-') {
|
|
consume('-');
|
|
return -parseUnary();
|
|
}
|
|
return parsePrimary();
|
|
}
|
|
|
|
// Primäre Ausdrücke (Zahlen, Klammern, Funktionen)
|
|
function parsePrimary() {
|
|
const token = peek();
|
|
|
|
if (!token) {
|
|
throw new Error('Unerwartetes Ende der Formel');
|
|
}
|
|
|
|
// Zahl
|
|
if (token.type === 'number') {
|
|
consume();
|
|
return token.value;
|
|
}
|
|
|
|
// Funktion
|
|
if (token.type === 'function') {
|
|
consume();
|
|
consume('(');
|
|
|
|
// Argumente sammeln
|
|
const args = [];
|
|
if (peek()?.type !== ')') {
|
|
args.push(parseTernary());
|
|
while (peek()?.type === ',') {
|
|
consume(',');
|
|
args.push(parseTernary());
|
|
}
|
|
}
|
|
|
|
consume(')');
|
|
|
|
const func = mathFunctions[token.name];
|
|
return func(...args);
|
|
}
|
|
|
|
// Geklammerter Ausdruck
|
|
if (token.type === '(') {
|
|
consume('(');
|
|
const value = parseTernary();
|
|
consume(')');
|
|
return value;
|
|
}
|
|
|
|
throw new Error(`Unerwartetes Token: ${token.type}`);
|
|
}
|
|
|
|
const result = parseTernary();
|
|
|
|
if (pos < tokens.length) {
|
|
throw new Error(`Unerwartete Tokens am Ende: ${tokens.slice(pos).map(t => t.type).join(', ')}`);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const tokens = tokenize(formula);
|
|
return parse(tokens);
|
|
}
|
|
|
|
/**
|
|
* Holt Preise aus Backend-Settings
|
|
*/
|
|
function getPrices() {
|
|
return window.SkriftConfigurator?.settings?.prices || {};
|
|
}
|
|
|
|
/**
|
|
* Holt dynamische Pricing-Einstellungen aus Backend
|
|
*/
|
|
function getDynamicPricing() {
|
|
return window.SkriftConfigurator?.settings?.dynamic_pricing || {};
|
|
}
|
|
|
|
/**
|
|
* Prüft ob es sich um Follow-ups handelt
|
|
*/
|
|
function isFollowups(state) {
|
|
return state.ctx?.product?.key === "follow-ups";
|
|
}
|
|
|
|
/**
|
|
* Prüft ob Kunde Business-Kunde ist
|
|
*/
|
|
function isBusinessCustomer(state) {
|
|
return state.answers?.customerType === "business";
|
|
}
|
|
|
|
/**
|
|
* Prüft ob ein Land Deutschland ist (DE oder Deutschland)
|
|
* Case-insensitive Prüfung
|
|
*/
|
|
function isGermany(country) {
|
|
if (!country || country.trim() === "") return true; // Leeres Land = Deutschland (Standardannahme)
|
|
const normalized = country.trim().toLowerCase();
|
|
return normalized === "de" || normalized === "deutschland" || normalized === "germany" || normalized === "ger" || normalized === "deu";
|
|
}
|
|
|
|
/**
|
|
* Zählt Inland- und Ausland-Empfänger
|
|
* Berücksichtigt sowohl klassische Adressen (recipientRows) als auch freie Adressen (freeAddressRows)
|
|
*/
|
|
export function countRecipientsByCountry(state) {
|
|
const quantity = state.answers?.quantity || 0;
|
|
const addressMode = state.addressMode || 'classic';
|
|
|
|
let domesticCount = 0;
|
|
let internationalCount = 0;
|
|
|
|
if (addressMode === 'classic') {
|
|
const rows = Array.isArray(state.recipientRows) ? state.recipientRows : [];
|
|
for (let i = 0; i < quantity; i++) {
|
|
const country = rows[i]?.country || "";
|
|
if (isGermany(country)) {
|
|
domesticCount++;
|
|
} else {
|
|
internationalCount++;
|
|
}
|
|
}
|
|
} else {
|
|
// Freie Adresse: line5 ist das Land
|
|
const rows = Array.isArray(state.freeAddressRows) ? state.freeAddressRows : [];
|
|
for (let i = 0; i < quantity; i++) {
|
|
const country = rows[i]?.line5 || "";
|
|
if (isGermany(country)) {
|
|
domesticCount++;
|
|
} else {
|
|
internationalCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { domesticCount, internationalCount };
|
|
}
|
|
|
|
/**
|
|
* Berechnet die Versandpreise pro Stück für Inland und Ausland
|
|
* Formel: Porto + Serviceaufschlag + Kuvert + Aufschlag Beschriftung
|
|
*/
|
|
export function calculateDirectShippingPrices(state) {
|
|
const prices = getPrices();
|
|
|
|
// Basis-Komponenten
|
|
const portoDomestic = prices.shipping_domestic || 0.95;
|
|
const portoInternational = prices.shipping_international || 1.25;
|
|
const serviceCharge = prices.shipping_service || 0.95;
|
|
const envelopeBase = prices.envelope_base || 0.50;
|
|
const labelingCharge = prices.envelope_labeling || 0.50;
|
|
|
|
// Versand Inland = Porto DE + Serviceaufschlag + Kuvert + Aufschlag Beschriftung
|
|
const domesticPrice = portoDomestic + serviceCharge + envelopeBase + labelingCharge;
|
|
|
|
// Versand Ausland = Porto Ausland + Serviceaufschlag + Kuvert + Aufschlag Beschriftung
|
|
const internationalPrice = portoInternational + serviceCharge + envelopeBase + labelingCharge;
|
|
|
|
return {
|
|
domesticPrice,
|
|
internationalPrice,
|
|
portoDomestic,
|
|
portoInternational,
|
|
serviceCharge,
|
|
envelopeBase,
|
|
labelingCharge,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Berechnet Multiplikator basierend auf monatlichem Volumen (nur Follow-ups)
|
|
*/
|
|
function getFollowupMultiplier(monthlyVolume) {
|
|
const prices = getPrices();
|
|
|
|
if (monthlyVolume >= 1000) return prices.followup_mult_1000_plus || 1.0;
|
|
if (monthlyVolume >= 500) return prices.followup_mult_500_999 || 1.2;
|
|
if (monthlyVolume >= 200) return prices.followup_mult_200_499 || 1.4;
|
|
if (monthlyVolume >= 50) return prices.followup_mult_50_199 || 1.7;
|
|
if (monthlyVolume >= 5) return prices.followup_mult_5_49 || 2.0;
|
|
|
|
return 2.0; // Default für < 5
|
|
}
|
|
|
|
/**
|
|
* Berechnet dynamischen Preis-Multiplikator basierend auf Menge
|
|
* (außer für Follow-ups)
|
|
*/
|
|
function getDynamicPriceMultiplier(state) {
|
|
// Follow-ups verwenden eigene Logik
|
|
if (isFollowups(state)) {
|
|
return 1.0;
|
|
}
|
|
|
|
const quantity = state.answers.quantity || 0;
|
|
const isB2B = isBusinessCustomer(state);
|
|
const dynamicPricing = getDynamicPricing();
|
|
|
|
// Welche Formel verwenden?
|
|
const formula = isB2B
|
|
? dynamicPricing.business_formula
|
|
: dynamicPricing.private_formula;
|
|
|
|
// Welche Parameter verwenden?
|
|
const normQty = isB2B
|
|
? (dynamicPricing.business_normal_quantity || 200)
|
|
: (dynamicPricing.private_normal_quantity || 50);
|
|
|
|
const minQty = isB2B
|
|
? (dynamicPricing.business_min_quantity || 50)
|
|
: (dynamicPricing.private_min_quantity || 10);
|
|
|
|
// Keine Formel definiert? Standard-Multiplikator 1
|
|
if (!formula || formula.trim() === '') {
|
|
return 1.0;
|
|
}
|
|
|
|
try {
|
|
// Platzhalter ersetzen
|
|
let evaluableFormula = formula
|
|
.replace(/%qty%/g, String(quantity))
|
|
.replace(/%norm_b%/g, String(dynamicPricing.business_normal_quantity || 200))
|
|
.replace(/%mind_b%/g, String(dynamicPricing.business_min_quantity || 50))
|
|
.replace(/%norm_p%/g, String(dynamicPricing.private_normal_quantity || 50))
|
|
.replace(/%mind_p%/g, String(dynamicPricing.private_min_quantity || 10));
|
|
|
|
// Sichere Formel-Auswertung ohne eval()
|
|
const multiplier = safeEvaluateMathFormula(evaluableFormula);
|
|
|
|
// Sicherheitsprüfung
|
|
if (typeof multiplier !== 'number' || isNaN(multiplier) || !isFinite(multiplier) || multiplier < 0) {
|
|
console.warn('Ungültiger Multiplikator aus Formel:', multiplier);
|
|
return 1.0;
|
|
}
|
|
|
|
return multiplier;
|
|
} catch (error) {
|
|
console.error('Fehler beim Auswerten der dynamischen Preisformel:', error);
|
|
return 1.0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Berechnet Preis pro Schriftstück
|
|
* Bei Direktversand wird der Durchschnittspreis basierend auf Inland/Ausland-Verteilung berechnet
|
|
*/
|
|
export function calculatePricePerPiece(state) {
|
|
const prices = getPrices();
|
|
const product = state.ctx?.product;
|
|
|
|
if (!product) return 0;
|
|
|
|
let basePrice = product.basePrice || 0;
|
|
|
|
// A4 Aufpreis (nur für Einladungen und Follow-ups, NICHT für Postkarten)
|
|
const needsA4Surcharge =
|
|
(product.key === "einladungen" || product.key === "follow-ups") &&
|
|
state.answers.format === "a4";
|
|
|
|
if (needsA4Surcharge) {
|
|
basePrice += prices.a4_upgrade_surcharge || 0;
|
|
}
|
|
|
|
// Multiplikator NUR auf Basispreis anwenden (nicht auf Versand/Umschlag)
|
|
let pricePerPiece = basePrice;
|
|
if (isFollowups(state) && state.answers.followupYearlyVolume) {
|
|
// Follow-ups: Spezielle Mengenstaffel
|
|
const monthlyVolume = parseInt(state.answers.followupYearlyVolume) || 0;
|
|
const multiplier = getFollowupMultiplier(monthlyVolume);
|
|
pricePerPiece *= multiplier;
|
|
} else {
|
|
// Alle anderen Produkte: Dynamische Preisberechnung
|
|
const dynamicMultiplier = getDynamicPriceMultiplier(state);
|
|
pricePerPiece *= dynamicMultiplier;
|
|
}
|
|
|
|
// Versandkosten bei Direktversand - immer Inlandspreis für "ab"-Preis
|
|
if (state.answers.shippingMode === "direct") {
|
|
const shippingPrices = calculateDirectShippingPrices(state);
|
|
// Immer Inlandspreis verwenden für den "ab"-Preis
|
|
pricePerPiece += shippingPrices.domesticPrice;
|
|
}
|
|
|
|
// Umschlag-Kosten (nur bei Bulk-Versand)
|
|
if (
|
|
state.answers.shippingMode === "bulk" &&
|
|
state.answers.envelope === true
|
|
) {
|
|
// Grundpreis für Kuvert
|
|
pricePerPiece += prices.envelope_base || 0;
|
|
|
|
// Zusätzliche Beschriftungskosten wenn Umschlag beschrieben wird
|
|
const labelingPrice = prices.envelope_labeling || prices.envelope_recipient_address || 0;
|
|
if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") {
|
|
pricePerPiece += labelingPrice;
|
|
}
|
|
// Bei "none" nur Grundpreis
|
|
}
|
|
|
|
// Motiv-Kosten sind Einmalkosten und werden NICHT auf Preis pro Stück gerechnet
|
|
// (werden separat in calculateSetupCosts berechnet)
|
|
|
|
// Kaufmännische Rundung auf 2 Dezimalstellen
|
|
return Math.round(pricePerPiece * 100) / 100;
|
|
}
|
|
|
|
/**
|
|
* Berechnet Grundpreis pro Stück für Follow-ups OHNE Multiplikator
|
|
* (für die Anzeige in der Kopfzeile)
|
|
*/
|
|
export function calculateFollowupBasePricePerPiece(state) {
|
|
const prices = getPrices();
|
|
const product = state.ctx?.product;
|
|
|
|
if (!product) return 0;
|
|
|
|
let pricePerPiece = product.basePrice || 0;
|
|
|
|
// A4 Aufpreis (nur für Einladungen und Follow-ups, NICHT für Postkarten)
|
|
const needsA4Surcharge =
|
|
(product.key === "einladungen" || product.key === "follow-ups") &&
|
|
state.answers.format === "a4";
|
|
|
|
if (needsA4Surcharge) {
|
|
pricePerPiece += prices.a4_upgrade_surcharge || 0;
|
|
}
|
|
|
|
// Versandkosten für Follow-ups (immer Direktversand, Inland-Preis als Basis)
|
|
const shippingPrices = calculateDirectShippingPrices(state);
|
|
pricePerPiece += shippingPrices.domesticPrice;
|
|
|
|
// KEINE Einmalkosten (API-Anbindung, Motiv-Upload, etc.) - nur wiederkehrende Kosten pro Stück
|
|
// KEIN Multiplikator hier - das ist der Grundpreis
|
|
|
|
return pricePerPiece;
|
|
}
|
|
|
|
/**
|
|
* Berechnet Versandkosten
|
|
* Rückgabe: { total, shipping0Pct, shipping19Pct, domesticCount, internationalCount, shippingPrices }
|
|
*/
|
|
export function calculateShippingCosts(state) {
|
|
const prices = getPrices();
|
|
const quantity = state.answers.quantity || 1;
|
|
|
|
const result = {
|
|
total: 0,
|
|
shipping0Pct: 0, // Portoanteil mit 0% MwSt
|
|
shipping19Pct: 0, // Service + Kuvert + Beschriftung mit 19% MwSt
|
|
domesticCount: 0,
|
|
internationalCount: 0,
|
|
shippingPrices: null,
|
|
};
|
|
|
|
if (state.answers.shippingMode === "direct") {
|
|
// Direktversand: Preis abhängig vom Land
|
|
// Komponenten: Porto (0% MwSt) + Serviceaufschlag + Kuvert + Beschriftung (alle 19% MwSt)
|
|
const shippingPrices = calculateDirectShippingPrices(state);
|
|
result.shippingPrices = shippingPrices;
|
|
|
|
// Empfänger nach Land zählen
|
|
const { domesticCount, internationalCount } = countRecipientsByCountry(state);
|
|
result.domesticCount = domesticCount;
|
|
result.internationalCount = internationalCount;
|
|
|
|
// Porto (0% MwSt)
|
|
const totalPorto = (domesticCount * shippingPrices.portoDomestic) +
|
|
(internationalCount * shippingPrices.portoInternational);
|
|
result.shipping0Pct = totalPorto;
|
|
|
|
// Service + Kuvert + Beschriftung (19% MwSt) - gleich für alle
|
|
const servicePerPiece = shippingPrices.serviceCharge +
|
|
shippingPrices.envelopeBase +
|
|
shippingPrices.labelingCharge;
|
|
result.shipping19Pct = servicePerPiece * quantity;
|
|
|
|
result.total = result.shipping0Pct + result.shipping19Pct;
|
|
} else if (state.answers.shippingMode === "bulk") {
|
|
// Bulkversand: Einmalig, 0% MwSt
|
|
const shippingBulk = prices.shipping_bulk || 4.95;
|
|
result.shipping0Pct = shippingBulk;
|
|
result.total = shippingBulk;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Berechnet einmalige Einrichtungskosten
|
|
*/
|
|
export function calculateSetupCosts(state) {
|
|
const prices = getPrices();
|
|
let setupTotal = 0;
|
|
|
|
// API-Anbindung (nur bei Follow-ups mit auto mode)
|
|
if (isFollowups(state) && state.answers.followupCreateMode === "auto") {
|
|
setupTotal += prices.api_connection || 0;
|
|
}
|
|
|
|
// Motiv Upload - NUR wenn motifNeed = true
|
|
if (state.answers.motifNeed === true && state.answers.motifSource === "upload") {
|
|
setupTotal += prices.motif_upload || 0;
|
|
}
|
|
|
|
// Designservice - NUR wenn motifNeed = true
|
|
if (state.answers.motifNeed === true && state.answers.motifSource === "design") {
|
|
setupTotal += prices.motif_design || 0;
|
|
}
|
|
|
|
// Textservice
|
|
if (state.answers.contentCreateMode === "textservice") {
|
|
setupTotal += prices.textservice || 0;
|
|
}
|
|
|
|
return setupTotal;
|
|
}
|
|
|
|
/**
|
|
* Formatiert Preis inkl/zzgl MwSt je nach Kundentyp
|
|
*/
|
|
export function formatPrice(netPrice, state, options = {}) {
|
|
const isB2B = isBusinessCustomer(state);
|
|
const taxRate = (getPrices().tax_rate || 19) / 100;
|
|
|
|
// Option für 0% MwSt (Versand)
|
|
const actualTaxRate = options.zeroTax ? 0 : taxRate;
|
|
|
|
const grossPrice = netPrice * (1 + actualTaxRate);
|
|
|
|
if (isB2B) {
|
|
// Business: Netto + "zzgl. MwSt"
|
|
return {
|
|
display: `${formatEUR(netPrice)}`,
|
|
net: netPrice,
|
|
gross: grossPrice,
|
|
isB2B: true,
|
|
};
|
|
} else {
|
|
// Endkunde: Brutto (inkl. MwSt)
|
|
return {
|
|
display: formatEUR(grossPrice),
|
|
net: netPrice,
|
|
gross: grossPrice,
|
|
isB2B: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hilfsfunktion: EUR formatieren
|
|
*/
|
|
function formatEUR(amount) {
|
|
const safe = typeof amount === "number" && !isNaN(amount) ? amount : 0;
|
|
return safe.toLocaleString("de-DE", { style: "currency", currency: "EUR" });
|
|
}
|
|
|
|
/**
|
|
* Berechnet vollständiges Quote für Nicht-Follow-ups
|
|
*/
|
|
export function calculateStandardQuote(state) {
|
|
const quantity = state.answers.quantity || 1;
|
|
const pricePerPiece = calculatePricePerPiece(state);
|
|
const shipping = calculateShippingCosts(state);
|
|
const setup = calculateSetupCosts(state);
|
|
const taxRate = (getPrices().tax_rate || 19) / 100;
|
|
const isB2B = isBusinessCustomer(state);
|
|
|
|
// Erst die Zeilen erstellen (pricePerPiece übergeben um doppelte Berechnung zu vermeiden)
|
|
const lines = buildQuoteLines(state, {
|
|
productNet: pricePerPiece * quantity,
|
|
pricePerPiece,
|
|
shipping,
|
|
setup,
|
|
taxRate,
|
|
});
|
|
|
|
// Gesamtpreise berechnen:
|
|
// - Summe aller totalGross aus den Zeilen (NICHT aus isSubItem!)
|
|
// - Alles hat 19% MwSt. (auch Porto!)
|
|
|
|
// Gesamtsummen aus den Zeilen berechnen
|
|
// Nur Zeilen die NICHT isSubItem sind, werden zur Summe addiert
|
|
// (isSubItem sind nur Aufschlüsselungen innerhalb einer Hauptzeile)
|
|
let subtotalNet = 0;
|
|
let totalGross = 0;
|
|
|
|
for (const line of lines) {
|
|
// Sub-Items nicht zur Summe addieren (sind bereits in Hauptzeile enthalten)
|
|
if (!line.isSubItem) {
|
|
subtotalNet += line.totalNet || 0;
|
|
totalGross += line.totalGross || 0;
|
|
}
|
|
}
|
|
|
|
// Runden
|
|
subtotalNet = Math.round(subtotalNet * 100) / 100;
|
|
totalGross = Math.round(totalGross * 100) / 100;
|
|
|
|
// MwSt. berechnen (Differenz zwischen Brutto und Netto)
|
|
const vatAmount = Math.round((totalGross - subtotalNet) * 100) / 100;
|
|
|
|
// Gutschein-Rabatt berechnen
|
|
const voucher = state.order?.voucherStatus?.valid ? state.order.voucherStatus.voucher : null;
|
|
let discountAmount = 0;
|
|
let totalAfterDiscount = totalGross;
|
|
let vatAfterDiscount = vatAmount;
|
|
|
|
if (voucher) {
|
|
if (voucher.type === 'percent') {
|
|
// Prozentual: vom Bruttopreis abziehen
|
|
discountAmount = Math.round(totalGross * (voucher.value / 100) * 100) / 100;
|
|
} else {
|
|
// Festbetrag
|
|
discountAmount = voucher.value;
|
|
}
|
|
|
|
// Sicherstellen dass Rabatt nicht größer als Gesamtpreis ist
|
|
discountAmount = Math.min(discountAmount, totalGross);
|
|
|
|
// Neuer Gesamtpreis
|
|
totalAfterDiscount = Math.round((totalGross - discountAmount) * 100) / 100;
|
|
|
|
// MwSt. neu berechnen (proportional zum Rabatt)
|
|
if (totalGross > 0) {
|
|
vatAfterDiscount = Math.round(vatAmount * (totalAfterDiscount / totalGross) * 100) / 100;
|
|
}
|
|
}
|
|
|
|
return {
|
|
currency: "EUR",
|
|
quantity,
|
|
pricePerPiece,
|
|
productNet: subtotalNet,
|
|
shipping,
|
|
setup,
|
|
subtotalNet,
|
|
vatRate: taxRate,
|
|
vatAmount: vatAfterDiscount,
|
|
totalGross: totalAfterDiscount,
|
|
discountAmount,
|
|
totalBeforeDiscount: totalGross,
|
|
voucher,
|
|
lines,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Erstellt Zeilen für Quote-Tabelle mit detaillierter Aufschlüsselung
|
|
*/
|
|
function buildQuoteLines(state, calc) {
|
|
const lines = [];
|
|
const quantity = state.answers.quantity || 1;
|
|
const product = state.ctx?.product;
|
|
const prices = getPrices();
|
|
const taxRate = calc.taxRate || 0.19;
|
|
|
|
// Produktzeile (vollständiger Preis inkl. Versand etc.)
|
|
// Hilfsfunktion für kaufmännische Rundung auf 2 Dezimalstellen
|
|
const round2 = (val) => Math.round(val * 100) / 100;
|
|
|
|
if (product) {
|
|
// pricePerPiece aus calc verwenden (bereits in calculateStandardQuote berechnet)
|
|
// Falls nicht vorhanden, neu berechnen
|
|
const pricePerPiece = calc.pricePerPiece !== undefined
|
|
? calc.pricePerPiece
|
|
: calculatePricePerPiece(state);
|
|
|
|
lines.push({
|
|
description: product.label,
|
|
unitNet: pricePerPiece,
|
|
unitGross: round2(pricePerPiece * (1 + taxRate)),
|
|
quantity,
|
|
totalNet: round2(pricePerPiece * quantity),
|
|
totalGross: round2(pricePerPiece * quantity * (1 + taxRate)),
|
|
isMainProduct: true,
|
|
});
|
|
|
|
// Basispreis-Zeile: Berechne aus pricePerPiece minus Versand/Kuvert-Kosten
|
|
// (statt nochmal den teuren Multiplier zu berechnen)
|
|
let basePriceWithMultiplier = pricePerPiece;
|
|
|
|
// Versandkosten abziehen (falls Direktversand)
|
|
if (state.answers.shippingMode === "direct") {
|
|
const shippingPrices = calc.shipping?.shippingPrices || calculateDirectShippingPrices(state);
|
|
basePriceWithMultiplier -= shippingPrices.domesticPrice || 0;
|
|
}
|
|
|
|
// Kuvert-Kosten abziehen (falls Bulk mit Kuvert)
|
|
if (state.answers.shippingMode === "bulk" && state.answers.envelope === true) {
|
|
basePriceWithMultiplier -= prices.envelope_base || 0;
|
|
if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") {
|
|
basePriceWithMultiplier -= prices.envelope_labeling || prices.envelope_recipient_address || 0;
|
|
}
|
|
}
|
|
|
|
basePriceWithMultiplier = round2(basePriceWithMultiplier);
|
|
|
|
lines.push({
|
|
description: "Basispreis Schriftstück",
|
|
unitNet: basePriceWithMultiplier,
|
|
unitGross: round2(basePriceWithMultiplier * (1 + taxRate)),
|
|
quantity,
|
|
totalNet: round2(basePriceWithMultiplier * quantity),
|
|
totalGross: round2(basePriceWithMultiplier * quantity * (1 + taxRate)),
|
|
isSubItem: true,
|
|
});
|
|
|
|
// A4 Aufpreis (nur für Einladungen und Follow-ups)
|
|
const needsA4Surcharge =
|
|
(product.key === "einladungen" || product.key === "follow-ups") &&
|
|
state.answers.format === "a4";
|
|
if (needsA4Surcharge) {
|
|
const a4Price = prices.a4_upgrade_surcharge || 0;
|
|
lines.push({
|
|
description: "A4 Format Aufpreis",
|
|
unitNet: a4Price,
|
|
unitGross: a4Price * (1 + taxRate),
|
|
quantity,
|
|
totalNet: a4Price * quantity,
|
|
totalGross: a4Price * quantity * (1 + taxRate),
|
|
isSubItem: true,
|
|
});
|
|
}
|
|
|
|
// Direktversand - getrennte Zeilen für Inland und Ausland
|
|
// WICHTIG: Porto hat jetzt auch 19% MwSt. (nicht mehr 0%)
|
|
if (state.answers.shippingMode === "direct") {
|
|
const shipping = calc.shipping || {};
|
|
const shippingPrices = shipping.shippingPrices || calculateDirectShippingPrices(state);
|
|
const domesticCount = shipping.domesticCount || 0;
|
|
const internationalCount = shipping.internationalCount || 0;
|
|
|
|
// Service + Kuvert + Beschriftung + Porto - ALLES mit 19% MwSt
|
|
const servicePerPiece = shippingPrices.serviceCharge +
|
|
shippingPrices.envelopeBase +
|
|
shippingPrices.labelingCharge;
|
|
|
|
// Inland-Zeile (wenn Inland-Empfänger vorhanden)
|
|
if (domesticCount > 0) {
|
|
const domesticUnitNet = shippingPrices.portoDomestic + servicePerPiece;
|
|
const domesticUnitGross = domesticUnitNet * (1 + taxRate);
|
|
const domesticTotalNet = domesticUnitNet * domesticCount;
|
|
const domesticTotalGross = domesticUnitGross * domesticCount;
|
|
|
|
lines.push({
|
|
description: "Direktversand mit Kuvertierung (Inland)",
|
|
unitNet: domesticUnitNet,
|
|
unitGross: domesticUnitGross,
|
|
quantity: domesticCount,
|
|
totalNet: domesticTotalNet,
|
|
totalGross: domesticTotalGross,
|
|
isSubItem: true,
|
|
isShippingLine: true,
|
|
});
|
|
}
|
|
|
|
// Ausland-Zeile (wenn Ausland-Empfänger vorhanden)
|
|
if (internationalCount > 0) {
|
|
const intlUnitNet = shippingPrices.portoInternational + servicePerPiece;
|
|
const intlUnitGross = intlUnitNet * (1 + taxRate);
|
|
const intlTotalNet = intlUnitNet * internationalCount;
|
|
const intlTotalGross = intlUnitGross * internationalCount;
|
|
|
|
lines.push({
|
|
description: "Direktversand mit Kuvertierung (Ausland)",
|
|
unitNet: intlUnitNet,
|
|
unitGross: intlUnitGross,
|
|
quantity: internationalCount,
|
|
totalNet: intlTotalNet,
|
|
totalGross: intlTotalGross,
|
|
isSubItem: true,
|
|
isShippingLine: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Kuvert bei Bulkversand
|
|
if (
|
|
state.answers.shippingMode === "bulk" &&
|
|
state.answers.envelope === true
|
|
) {
|
|
const envelopeBase = prices.envelope_base || 0;
|
|
lines.push({
|
|
description: "Kuvert",
|
|
unitNet: envelopeBase,
|
|
unitGross: envelopeBase * (1 + taxRate),
|
|
quantity,
|
|
totalNet: envelopeBase * quantity,
|
|
totalGross: envelopeBase * quantity * (1 + taxRate),
|
|
isSubItem: true,
|
|
});
|
|
|
|
// Beschriftung (neues Feld: envelope_labeling, Fallback auf alte Felder)
|
|
if (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText") {
|
|
const labelPrice = prices.envelope_labeling || prices.envelope_recipient_address || 0;
|
|
const labelDescription = state.answers.envelopeMode === "recipientData"
|
|
? "Beschriftung mit Empfängeradresse"
|
|
: "Beschriftung mit individuellem Text";
|
|
lines.push({
|
|
description: labelDescription,
|
|
unitNet: labelPrice,
|
|
unitGross: labelPrice * (1 + taxRate),
|
|
quantity,
|
|
totalNet: labelPrice * quantity,
|
|
totalGross: labelPrice * quantity * (1 + taxRate),
|
|
isSubItem: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Bedruckte Karten - NUR wenn motifNeed = true
|
|
if (state.answers.motifNeed === true && state.answers.motifSource === "printed") {
|
|
const printedPrice = prices.motif_printed || 0;
|
|
if (printedPrice > 0) {
|
|
lines.push({
|
|
description: "Bedruckte Karten zusenden",
|
|
unitNet: printedPrice,
|
|
unitGross: printedPrice * (1 + taxRate),
|
|
quantity,
|
|
totalNet: printedPrice * quantity,
|
|
totalGross: printedPrice * quantity * (1 + taxRate),
|
|
isSubItem: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sammelversand (einmalig) - jetzt auch mit 19% MwSt
|
|
if (state.answers.shippingMode === "bulk") {
|
|
const bulkPrice = prices.shipping_bulk || 0;
|
|
lines.push({
|
|
description: "Sammelversand (einmalig)",
|
|
unitNet: bulkPrice,
|
|
unitGross: bulkPrice * (1 + taxRate),
|
|
quantity: 1,
|
|
totalNet: bulkPrice,
|
|
totalGross: bulkPrice * (1 + taxRate),
|
|
});
|
|
}
|
|
|
|
// Einmalige Setup-Kosten
|
|
const setupCosts = [];
|
|
|
|
// Motiv Upload - NUR wenn motifNeed = true
|
|
if (state.answers.motifNeed === true && state.answers.motifSource === "upload") {
|
|
const uploadPrice = prices.motif_upload || 0;
|
|
if (uploadPrice > 0) {
|
|
setupCosts.push({
|
|
description: "Motiv Upload (einmalig)",
|
|
unitNet: uploadPrice,
|
|
unitGross: uploadPrice * (1 + taxRate),
|
|
quantity: 1,
|
|
totalNet: uploadPrice,
|
|
totalGross: uploadPrice * (1 + taxRate),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Designservice - NUR wenn motifNeed = true
|
|
if (state.answers.motifNeed === true && state.answers.motifSource === "design") {
|
|
const designPrice = prices.motif_design || 0;
|
|
if (designPrice > 0) {
|
|
setupCosts.push({
|
|
description: "Designservice (einmalig)",
|
|
unitNet: designPrice,
|
|
unitGross: designPrice * (1 + taxRate),
|
|
quantity: 1,
|
|
totalNet: designPrice,
|
|
totalGross: designPrice * (1 + taxRate),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Textservice
|
|
if (state.answers.contentCreateMode === "textservice") {
|
|
const textPrice = prices.textservice || 0;
|
|
if (textPrice > 0) {
|
|
setupCosts.push({
|
|
description: "Textservice (einmalig)",
|
|
unitNet: textPrice,
|
|
unitGross: textPrice * (1 + taxRate),
|
|
quantity: 1,
|
|
totalNet: textPrice,
|
|
totalGross: textPrice * (1 + taxRate),
|
|
});
|
|
}
|
|
}
|
|
|
|
// API-Anbindung (nur bei Follow-ups)
|
|
if (isFollowups(state) && state.answers.followupCreateMode === "auto") {
|
|
const apiPrice = prices.api_connection || 0;
|
|
if (apiPrice > 0) {
|
|
setupCosts.push({
|
|
description: "API-Anbindung (einmalig)",
|
|
unitNet: apiPrice,
|
|
unitGross: apiPrice * (1 + taxRate),
|
|
quantity: 1,
|
|
totalNet: apiPrice,
|
|
totalGross: apiPrice * (1 + taxRate),
|
|
});
|
|
}
|
|
}
|
|
|
|
return [...lines, ...setupCosts];
|
|
}
|
|
|
|
/**
|
|
* Berechnet Follow-up Preisstaffelung (für Tabelle)
|
|
*/
|
|
export function calculateFollowupPricing(state) {
|
|
if (!isFollowups(state)) return null;
|
|
|
|
const pricePerPiece = calculatePricePerPiece(state);
|
|
const taxRate = (getPrices().tax_rate || 19) / 100;
|
|
const isB2B = isBusinessCustomer(state);
|
|
|
|
// Preis-Stufen basierend auf monatlichem Volumen
|
|
const tiers = [
|
|
{ min: 5, max: 49, label: "5-49 Stück/Monat" },
|
|
{ min: 50, max: 199, label: "50-199 Stück/Monat" },
|
|
{ min: 200, max: 499, label: "200-499 Stück/Monat" },
|
|
{ min: 500, max: 999, label: "500-999 Stück/Monat" },
|
|
{ min: 1000, max: null, label: "1000+ Stück/Monat" },
|
|
];
|
|
|
|
const pricing = tiers.map((tier) => {
|
|
const avgVolume = tier.max ? (tier.min + tier.max) / 2 : 1000;
|
|
const multiplier = getFollowupMultiplier(avgVolume);
|
|
const piecePrice = pricePerPiece / multiplier || 0; // Basispreis vor Multiplikator
|
|
const monthlyNet = piecePrice * avgVolume * multiplier;
|
|
const monthlyGross = monthlyNet * (1 + taxRate);
|
|
|
|
return {
|
|
label: tier.label,
|
|
multiplier,
|
|
pricePerPiece: piecePrice * multiplier,
|
|
monthlyNet,
|
|
monthlyGross,
|
|
displayNet: formatEUR(monthlyNet),
|
|
displayGross: formatEUR(monthlyGross),
|
|
};
|
|
});
|
|
|
|
return {
|
|
tiers: pricing,
|
|
setup: calculateSetupCosts(state),
|
|
shippingNote: null, // Bei Follow-ups keine Versandkosten-Meldung
|
|
isB2B,
|
|
};
|
|
}
|
|
|
|
export { formatEUR };
|