Missing Files
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 30s
Build and Push Docker Images / build-frontend (push) Successful in 1m6s

This commit is contained in:
2026-02-15 13:25:23 +01:00
parent 117da523d2
commit f596b46364
2 changed files with 249 additions and 1 deletions

View File

@@ -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
}
]
}
}

View File

@@ -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<string, unknown>)[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<number, {
sellingTotal: number;
purchaseTotal: number;
materialTotal: number;
materialPurchaseTotal: number;
workerTotal: number;
workerPurchaseTotal: number;
materialComposition: CompositionRow[];
personalComposition: CompositionRow[];
}>();
const stack = new Set<number>();
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);
}