250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
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;
|
|
}
|
|
|
|
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<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: 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);
|
|
}
|