import { and, eq } from "drizzle-orm"; import * as schema from "../../db/schema"; import { FastifyInstance } from "fastify"; type CompositionRow = { product?: number | string | null; service?: number | string | null; hourrate?: string | null; quantity?: number | string | null; price?: number | string | null; purchasePrice?: number | string | null; [key: string]: any; }; function toNumber(value: any): number { const num = Number(value ?? 0); return Number.isFinite(num) ? num : 0; } function round2(value: number): number { return Number(value.toFixed(2)); } function getJsonNumber(source: unknown, key: string): number { if (!source || typeof source !== "object") return 0; return toNumber((source as Record)[key]); } function normalizeId(value: unknown): number | null { if (value === null || value === undefined || value === "") return null; const num = Number(value); return Number.isFinite(num) ? num : null; } function normalizeUuid(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length ? trimmed : null; } function sanitizeCompositionRows(value: unknown): CompositionRow[] { if (!Array.isArray(value)) return []; return value.filter((entry): entry is CompositionRow => !!entry && typeof entry === "object"); } export async function recalculateServicePricesForTenant(server: FastifyInstance, tenantId: number, updatedBy?: string | null) { const [services, products, hourrates] = await Promise.all([ server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)), server.db.select().from(schema.products).where(eq(schema.products.tenant, tenantId)), server.db.select().from(schema.hourrates).where(eq(schema.hourrates.tenant, tenantId)), ]); const serviceMap = new Map(services.map((item) => [item.id, item])); const productMap = new Map(products.map((item) => [item.id, item])); const hourrateMap = new Map(hourrates.map((item) => [item.id, item])); const memo = new Map(); const stack = new Set(); const calculateService = (serviceId: number) => { if (memo.has(serviceId)) return memo.get(serviceId)!; const service = serviceMap.get(serviceId); const emptyResult = { sellingTotal: 0, purchaseTotal: 0, materialTotal: 0, materialPurchaseTotal: 0, workerTotal: 0, workerPurchaseTotal: 0, materialComposition: [], personalComposition: [], }; if (!service) return emptyResult; if (stack.has(serviceId)) return emptyResult; // Gesperrte Leistungen bleiben bei automatischen Preis-Updates unverändert. if (service.priceUpdateLocked) { const lockedResult = { sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice), purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"), materialTotal: getJsonNumber(service.sellingPriceComposed, "material"), materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"), workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"), workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"), materialComposition: sanitizeCompositionRows(service.materialComposition), personalComposition: sanitizeCompositionRows(service.personalComposition), }; memo.set(serviceId, lockedResult); return lockedResult; } stack.add(serviceId); try { const materialComposition = sanitizeCompositionRows(service.materialComposition); const personalComposition = sanitizeCompositionRows(service.personalComposition); const hasMaterialComposition = materialComposition.length > 0; const hasPersonalComposition = personalComposition.length > 0; // Ohne Zusammensetzung keine automatische Überschreibung: // manuell gepflegte Preise sollen erhalten bleiben. if (!hasMaterialComposition && !hasPersonalComposition) { const manualResult = { sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice), purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"), materialTotal: getJsonNumber(service.sellingPriceComposed, "material"), materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"), workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"), workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"), materialComposition, personalComposition, }; memo.set(serviceId, manualResult); return manualResult; } let materialTotal = 0; let materialPurchaseTotal = 0; const normalizedMaterialComposition = materialComposition.map((entry) => { const quantity = toNumber(entry.quantity); const productId = normalizeId(entry.product); const childServiceId = normalizeId(entry.service); let sellingPrice = toNumber(entry.price); let purchasePrice = toNumber(entry.purchasePrice); if (productId) { const product = productMap.get(productId); sellingPrice = toNumber(product?.selling_price); purchasePrice = toNumber(product?.purchase_price); } else if (childServiceId) { const child = calculateService(childServiceId); sellingPrice = toNumber(child.sellingTotal); purchasePrice = toNumber(child.purchaseTotal); } materialTotal += quantity * sellingPrice; materialPurchaseTotal += quantity * purchasePrice; return { ...entry, price: round2(sellingPrice), purchasePrice: round2(purchasePrice), }; }); let workerTotal = 0; let workerPurchaseTotal = 0; const normalizedPersonalComposition = personalComposition.map((entry) => { const quantity = toNumber(entry.quantity); const hourrateId = normalizeUuid(entry.hourrate); let sellingPrice = toNumber(entry.price); let purchasePrice = toNumber(entry.purchasePrice); if (hourrateId) { const hourrate = hourrateMap.get(hourrateId); if (hourrate) { sellingPrice = toNumber(hourrate.sellingPrice); purchasePrice = toNumber(hourrate.purchase_price); } } workerTotal += quantity * sellingPrice; workerPurchaseTotal += quantity * purchasePrice; return { ...entry, price: round2(sellingPrice), purchasePrice: round2(purchasePrice), }; }); const result = { sellingTotal: round2(materialTotal + workerTotal), purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal), materialTotal: round2(materialTotal), materialPurchaseTotal: round2(materialPurchaseTotal), workerTotal: round2(workerTotal), workerPurchaseTotal: round2(workerPurchaseTotal), materialComposition: normalizedMaterialComposition, personalComposition: normalizedPersonalComposition, }; memo.set(serviceId, result); return result; } finally { stack.delete(serviceId); } }; for (const service of services) { calculateService(service.id); } const updates = services .filter((service) => !service.priceUpdateLocked) .map(async (service) => { const calc = memo.get(service.id); if (!calc) return; const sellingPriceComposed = { worker: calc.workerTotal, material: calc.materialTotal, total: calc.sellingTotal, }; const purchasePriceComposed = { worker: calc.workerPurchaseTotal, material: calc.materialPurchaseTotal, total: calc.purchaseTotal, }; const unchanged = JSON.stringify(service.materialComposition ?? []) === JSON.stringify(calc.materialComposition) && JSON.stringify(service.personalComposition ?? []) === JSON.stringify(calc.personalComposition) && JSON.stringify(service.sellingPriceComposed ?? {}) === JSON.stringify(sellingPriceComposed) && JSON.stringify(service.purchasePriceComposed ?? {}) === JSON.stringify(purchasePriceComposed) && round2(toNumber(service.sellingPrice)) === calc.sellingTotal; if (unchanged) return; await server.db .update(schema.services) .set({ materialComposition: calc.materialComposition, personalComposition: calc.personalComposition, sellingPriceComposed, purchasePriceComposed, sellingPrice: calc.sellingTotal, updatedAt: new Date(), updatedBy: updatedBy ?? null, }) .where(and(eq(schema.services.id, service.id), eq(schema.services.tenant, tenantId))); }); await Promise.all(updates); }