From f596b4636433936dbcfb30dac635342cdcfa0564 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sun, 15 Feb 2026 13:25:23 +0100 Subject: [PATCH] Missing Files --- backend/db/migrations/meta/_journal.json | 23 +- .../service-price-recalculation.service.ts | 227 ++++++++++++++++++ 2 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 backend/src/modules/service-price-recalculation.service.ts diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 25a83ac..c4e1e7a 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -36,6 +36,27 @@ "when": 1765716877146, "tag": "0004_stormy_onslaught", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1771096926109, + "tag": "0005_green_shinobi_shaw", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1772000000000, + "tag": "0006_nifty_price_lock", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1772000100000, + "tag": "0007_bright_default_tax_type", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/backend/src/modules/service-price-recalculation.service.ts b/backend/src/modules/service-price-recalculation.service.ts new file mode 100644 index 0000000..bdca89f --- /dev/null +++ b/backend/src/modules/service-price-recalculation.service.ts @@ -0,0 +1,227 @@ +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; +} + +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: Array.isArray(service.materialComposition) ? service.materialComposition as CompositionRow[] : [], + personalComposition: Array.isArray(service.personalComposition) ? service.personalComposition as CompositionRow[] : [], + }; + memo.set(serviceId, lockedResult); + return lockedResult; + } + + stack.add(serviceId); + + const materialComposition: CompositionRow[] = Array.isArray(service.materialComposition) + ? (service.materialComposition as CompositionRow[]) + : []; + const personalComposition: CompositionRow[] = Array.isArray(service.personalComposition) + ? (service.personalComposition as CompositionRow[]) + : []; + + 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); + stack.delete(serviceId); + return result; + }; + + 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); +}