Mobile friendly, lint correction, typescript error clear
This commit is contained in:
418
lib/financial/impact.ts
Normal file
418
lib/financial/impact.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const COST_EVENT_TYPES = ["slow-cycle", "microstop", "macrostop", "quality-spike"] as const;
|
||||
|
||||
type CostProfile = {
|
||||
currency: string;
|
||||
machineCostPerMin: number | null;
|
||||
operatorCostPerMin: number | null;
|
||||
ratedRunningKw: number | null;
|
||||
idleKw: number | null;
|
||||
kwhRate: number | null;
|
||||
energyMultiplier: number | null;
|
||||
energyCostPerMin: number | null;
|
||||
scrapCostPerUnit: number | null;
|
||||
rawMaterialCostPerUnit: number | null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
type CostProfileOverride = Omit<Partial<CostProfile>, "currency">;
|
||||
type Category = "slowCycle" | "microstop" | "macrostop" | "scrap";
|
||||
type Totals = { total: number } & Record<Category, number>;
|
||||
type DayRow = { day: string } & Totals;
|
||||
|
||||
export type FinancialEventDetail = {
|
||||
id: string;
|
||||
ts: Date;
|
||||
eventType: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
category: Category;
|
||||
machineId: string;
|
||||
machineName: string | null;
|
||||
location: string | null;
|
||||
workOrderId: string | null;
|
||||
sku: string | null;
|
||||
durationSec: number | null;
|
||||
costMachine: number;
|
||||
costOperator: number;
|
||||
costEnergy: number;
|
||||
costScrap: number;
|
||||
costRawMaterial: number;
|
||||
costTotal: number;
|
||||
currency: string;
|
||||
};
|
||||
|
||||
export type FinancialImpactSummary = {
|
||||
currency: string;
|
||||
totals: Totals;
|
||||
byDay: DayRow[];
|
||||
};
|
||||
|
||||
export type FinancialImpactResult = {
|
||||
range: { start: Date; end: Date };
|
||||
currencySummaries: FinancialImpactSummary[];
|
||||
eventsEvaluated: number;
|
||||
eventsIncluded: number;
|
||||
events: FinancialEventDetail[];
|
||||
filters: {
|
||||
machineId?: string;
|
||||
location?: string;
|
||||
sku?: string;
|
||||
currency?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type FinancialImpactParams = {
|
||||
orgId: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
machineId?: string;
|
||||
location?: string;
|
||||
sku?: string;
|
||||
currency?: string;
|
||||
includeEvents?: boolean;
|
||||
};
|
||||
|
||||
function safeNumber(value: unknown) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function parseBlob(raw: unknown) {
|
||||
let parsed: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = raw;
|
||||
}
|
||||
}
|
||||
const blob = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
const blobRecord = typeof blob === "object" && blob !== null ? (blob as Record<string, unknown>) : null;
|
||||
const innerCandidate = blobRecord?.data ?? blobRecord ?? {};
|
||||
const inner =
|
||||
typeof innerCandidate === "object" && innerCandidate !== null
|
||||
? (innerCandidate as Record<string, unknown>)
|
||||
: {};
|
||||
return { blob: blobRecord, inner } as const;
|
||||
}
|
||||
|
||||
function dateKey(ts: Date) {
|
||||
return ts.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function applyOverride(
|
||||
base: CostProfile,
|
||||
override?: CostProfileOverride | null,
|
||||
currency?: string | null
|
||||
) {
|
||||
const out: CostProfile = { ...base };
|
||||
if (currency) out.currency = currency;
|
||||
if (!override) return out;
|
||||
|
||||
if (override.machineCostPerMin != null) out.machineCostPerMin = override.machineCostPerMin;
|
||||
if (override.operatorCostPerMin != null) out.operatorCostPerMin = override.operatorCostPerMin;
|
||||
if (override.ratedRunningKw != null) out.ratedRunningKw = override.ratedRunningKw;
|
||||
if (override.idleKw != null) out.idleKw = override.idleKw;
|
||||
if (override.kwhRate != null) out.kwhRate = override.kwhRate;
|
||||
if (override.energyMultiplier != null) out.energyMultiplier = override.energyMultiplier;
|
||||
if (override.energyCostPerMin != null) out.energyCostPerMin = override.energyCostPerMin;
|
||||
if (override.scrapCostPerUnit != null) out.scrapCostPerUnit = override.scrapCostPerUnit;
|
||||
if (override.rawMaterialCostPerUnit != null) out.rawMaterialCostPerUnit = override.rawMaterialCostPerUnit;
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeEnergyCostPerMin(profile: CostProfile, mode: "running" | "idle") {
|
||||
if (profile.energyCostPerMin != null) return profile.energyCostPerMin;
|
||||
const kw = mode === "running" ? profile.ratedRunningKw : profile.idleKw;
|
||||
const rate = profile.kwhRate;
|
||||
if (kw == null || rate == null) return null;
|
||||
const multiplier = profile.energyMultiplier ?? 1;
|
||||
return (kw / 60) * rate * multiplier;
|
||||
}
|
||||
|
||||
export async function computeFinancialImpact(params: FinancialImpactParams): Promise<FinancialImpactResult> {
|
||||
const { orgId, start, end, machineId, location, sku, currency, includeEvents } = params;
|
||||
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { orgId },
|
||||
select: { id: true, name: true, location: true },
|
||||
});
|
||||
|
||||
const machineMap = new Map(machines.map((m) => [m.id, m]));
|
||||
|
||||
let machineIds = machines.map((m) => m.id);
|
||||
if (location) {
|
||||
machineIds = machines.filter((m) => m.location === location).map((m) => m.id);
|
||||
}
|
||||
if (machineId) {
|
||||
machineIds = machineIds.includes(machineId) ? [machineId] : [];
|
||||
}
|
||||
|
||||
if (!machineIds.length) {
|
||||
return {
|
||||
range: { start, end },
|
||||
currencySummaries: [],
|
||||
eventsEvaluated: 0,
|
||||
eventsIncluded: 0,
|
||||
events: [],
|
||||
filters: { machineId, location, sku, currency },
|
||||
};
|
||||
}
|
||||
|
||||
const events = await prisma.machineEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
ts: { gte: start, lte: end },
|
||||
machineId: { in: machineIds },
|
||||
eventType: { in: COST_EVENT_TYPES as unknown as string[] },
|
||||
},
|
||||
orderBy: { ts: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
ts: true,
|
||||
eventType: true,
|
||||
data: true,
|
||||
machineId: true,
|
||||
workOrderId: true,
|
||||
sku: true,
|
||||
severity: true,
|
||||
},
|
||||
});
|
||||
|
||||
const missingSkuPairs = events
|
||||
.filter((e) => !e.sku && e.workOrderId)
|
||||
.map((e) => ({ machineId: e.machineId, workOrderId: e.workOrderId as string }));
|
||||
const workOrderIds = Array.from(new Set(missingSkuPairs.map((p) => p.workOrderId)));
|
||||
const workOrderMachines = Array.from(new Set(missingSkuPairs.map((p) => p.machineId)));
|
||||
|
||||
const workOrders = workOrderIds.length
|
||||
? await prisma.machineWorkOrder.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
workOrderId: { in: workOrderIds },
|
||||
machineId: { in: workOrderMachines },
|
||||
},
|
||||
select: { machineId: true, workOrderId: true, sku: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const workOrderSku = new Map<string, string>();
|
||||
for (const row of workOrders) {
|
||||
if (row.sku) {
|
||||
workOrderSku.set(`${row.machineId}:${row.workOrderId}`, row.sku);
|
||||
}
|
||||
}
|
||||
|
||||
const [orgProfileRaw, locationOverrides, machineOverrides, productOverrides] = await Promise.all([
|
||||
prisma.orgFinancialProfile.findUnique({ where: { orgId } }),
|
||||
prisma.locationFinancialOverride.findMany({ where: { orgId } }),
|
||||
prisma.machineFinancialOverride.findMany({ where: { orgId } }),
|
||||
prisma.productCostOverride.findMany({ where: { orgId } }),
|
||||
]);
|
||||
|
||||
const orgProfile: CostProfile = {
|
||||
currency: orgProfileRaw?.defaultCurrency ?? "USD",
|
||||
machineCostPerMin: orgProfileRaw?.machineCostPerMin ?? null,
|
||||
operatorCostPerMin: orgProfileRaw?.operatorCostPerMin ?? null,
|
||||
ratedRunningKw: orgProfileRaw?.ratedRunningKw ?? null,
|
||||
idleKw: orgProfileRaw?.idleKw ?? null,
|
||||
kwhRate: orgProfileRaw?.kwhRate ?? null,
|
||||
energyMultiplier: orgProfileRaw?.energyMultiplier ?? 1,
|
||||
energyCostPerMin: orgProfileRaw?.energyCostPerMin ?? null,
|
||||
scrapCostPerUnit: orgProfileRaw?.scrapCostPerUnit ?? null,
|
||||
rawMaterialCostPerUnit: orgProfileRaw?.rawMaterialCostPerUnit ?? null,
|
||||
};
|
||||
|
||||
const locationMap = new Map(locationOverrides.map((o) => [o.location, o]));
|
||||
const machineOverrideMap = new Map(machineOverrides.map((o) => [o.machineId, o]));
|
||||
const productMap = new Map(productOverrides.map((o) => [o.sku, o]));
|
||||
|
||||
const summaries = new Map<
|
||||
string,
|
||||
{
|
||||
currency: string;
|
||||
totals: Totals;
|
||||
byDay: Map<string, DayRow>;
|
||||
}
|
||||
>();
|
||||
|
||||
const detailed: FinancialEventDetail[] = [];
|
||||
let eventsIncluded = 0;
|
||||
|
||||
for (const ev of events) {
|
||||
const eventType = String(ev.eventType ?? "").toLowerCase();
|
||||
const { blob, inner } = parseBlob(ev.data);
|
||||
const status = String(blob?.status ?? inner?.status ?? "").toLowerCase();
|
||||
const severity = String(ev.severity ?? "").toLowerCase();
|
||||
const isAutoAck = Boolean(blob?.is_auto_ack ?? inner?.is_auto_ack);
|
||||
const isUpdate = Boolean(blob?.is_update ?? inner?.is_update);
|
||||
|
||||
const machine = machineMap.get(ev.machineId);
|
||||
const locationName = machine?.location ?? null;
|
||||
const skuResolved =
|
||||
ev.sku ??
|
||||
(ev.workOrderId ? workOrderSku.get(`${ev.machineId}:${ev.workOrderId}`) : null) ??
|
||||
null;
|
||||
|
||||
if (sku && skuResolved !== sku) continue;
|
||||
if (isAutoAck || isUpdate) continue;
|
||||
|
||||
const locationOverride = locationName ? locationMap.get(locationName) : null;
|
||||
const machineOverride = machineOverrideMap.get(ev.machineId) ?? null;
|
||||
|
||||
let profile = applyOverride(orgProfile, locationOverride, locationOverride?.currency ?? null);
|
||||
profile = applyOverride(profile, machineOverride, machineOverride?.currency ?? null);
|
||||
|
||||
const productOverride = skuResolved ? productMap.get(skuResolved) : null;
|
||||
if (productOverride?.rawMaterialCostPerUnit != null) {
|
||||
profile.rawMaterialCostPerUnit = productOverride.rawMaterialCostPerUnit;
|
||||
}
|
||||
if (productOverride?.currency) {
|
||||
profile.currency = productOverride.currency;
|
||||
}
|
||||
|
||||
let category: Category | null = null;
|
||||
let durationSec: number | null = null;
|
||||
let costMachine = 0;
|
||||
let costOperator = 0;
|
||||
let costEnergy = 0;
|
||||
let costScrap = 0;
|
||||
let costRawMaterial = 0;
|
||||
|
||||
if (eventType === "slow-cycle") {
|
||||
const actual =
|
||||
safeNumber(inner?.actual_cycle_time ?? blob?.actual_cycle_time ?? inner?.actualCycleTime ?? blob?.actualCycleTime) ??
|
||||
null;
|
||||
const theoretical =
|
||||
safeNumber(
|
||||
inner?.theoretical_cycle_time ??
|
||||
blob?.theoretical_cycle_time ??
|
||||
inner?.theoreticalCycleTime ??
|
||||
blob?.theoreticalCycleTime
|
||||
) ?? null;
|
||||
if (actual == null || theoretical == null) continue;
|
||||
durationSec = Math.max(0, actual - theoretical);
|
||||
if (!durationSec) continue;
|
||||
const durationMin = durationSec / 60;
|
||||
costMachine = durationMin * (profile.machineCostPerMin ?? 0);
|
||||
costOperator = durationMin * (profile.operatorCostPerMin ?? 0);
|
||||
costEnergy = durationMin * (computeEnergyCostPerMin(profile, "running") ?? 0);
|
||||
category = "slowCycle";
|
||||
} else if (eventType === "microstop" || eventType === "macrostop") {
|
||||
//future activestoppage handling
|
||||
if (status === "active") continue;
|
||||
const rawDurationSec =
|
||||
safeNumber(
|
||||
inner?.stoppage_duration_seconds ??
|
||||
blob?.stoppage_duration_seconds ??
|
||||
inner?.stop_duration_seconds ??
|
||||
blob?.stop_duration_seconds
|
||||
) ?? 0;
|
||||
if (!rawDurationSec || rawDurationSec <= 0) continue;
|
||||
const theoreticalSec =
|
||||
safeNumber(
|
||||
inner?.theoretical_cycle_time ??
|
||||
blob?.theoretical_cycle_time ??
|
||||
inner?.theoreticalCycleTime ??
|
||||
blob?.theoreticalCycleTime
|
||||
) ?? null;
|
||||
const lastCycleTimestamp = safeNumber(inner?.last_cycle_timestamp ?? blob?.last_cycle_timestamp);
|
||||
const isCycleGapStop = theoreticalSec != null && theoreticalSec > 0 && lastCycleTimestamp == null;
|
||||
durationSec = isCycleGapStop ? Math.max(0, rawDurationSec - theoreticalSec) : rawDurationSec;
|
||||
if (!durationSec || durationSec <= 0) continue;
|
||||
const durationMin = durationSec / 60;
|
||||
costMachine = durationMin * (profile.machineCostPerMin ?? 0);
|
||||
costOperator = durationMin * (profile.operatorCostPerMin ?? 0);
|
||||
costEnergy = durationMin * (computeEnergyCostPerMin(profile, "idle") ?? 0);
|
||||
category = eventType === "macrostop" ? "macrostop" : "microstop";
|
||||
} else if (eventType === "quality-spike") {
|
||||
if (severity === "info" || status === "resolved") continue;
|
||||
const scrapParts =
|
||||
safeNumber(
|
||||
inner?.scrap_parts ??
|
||||
blob?.scrap_parts ??
|
||||
inner?.scrapParts ??
|
||||
blob?.scrapParts
|
||||
) ?? 0;
|
||||
if (scrapParts <= 0) continue;
|
||||
costScrap = scrapParts * (profile.scrapCostPerUnit ?? 0);
|
||||
costRawMaterial = scrapParts * (profile.rawMaterialCostPerUnit ?? 0);
|
||||
category = "scrap";
|
||||
}
|
||||
|
||||
if (!category) continue;
|
||||
|
||||
const costTotal = costMachine + costOperator + costEnergy + costScrap + costRawMaterial;
|
||||
if (costTotal <= 0) continue;
|
||||
if (currency && profile.currency !== currency) continue;
|
||||
|
||||
const key = profile.currency || "USD";
|
||||
const bucket = summaries.get(key) ?? {
|
||||
currency: key,
|
||||
totals: { total: 0, slowCycle: 0, microstop: 0, macrostop: 0, scrap: 0 },
|
||||
byDay: new Map<string, DayRow>(),
|
||||
};
|
||||
|
||||
bucket.totals.total += costTotal;
|
||||
bucket.totals[category] += costTotal;
|
||||
|
||||
const day = dateKey(ev.ts);
|
||||
const dayRow: DayRow = bucket.byDay.get(day) ?? {
|
||||
day,
|
||||
total: 0,
|
||||
slowCycle: 0,
|
||||
microstop: 0,
|
||||
macrostop: 0,
|
||||
scrap: 0,
|
||||
};
|
||||
dayRow.total += costTotal;
|
||||
dayRow[category] += costTotal;
|
||||
bucket.byDay.set(day, dayRow);
|
||||
|
||||
summaries.set(key, bucket);
|
||||
eventsIncluded += 1;
|
||||
|
||||
if (includeEvents) {
|
||||
detailed.push({
|
||||
id: ev.id,
|
||||
ts: ev.ts,
|
||||
eventType,
|
||||
status,
|
||||
severity,
|
||||
category,
|
||||
machineId: ev.machineId,
|
||||
machineName: machine?.name ?? null,
|
||||
location: locationName,
|
||||
workOrderId: ev.workOrderId ?? null,
|
||||
sku: skuResolved,
|
||||
durationSec,
|
||||
costMachine,
|
||||
costOperator,
|
||||
costEnergy,
|
||||
costScrap,
|
||||
costRawMaterial,
|
||||
costTotal,
|
||||
currency: key,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const currencySummaries = Array.from(summaries.values()).map((summary) => {
|
||||
const byDay = Array.from(summary.byDay.values()).sort((a, b) => {
|
||||
return String(a.day).localeCompare(String(b.day));
|
||||
});
|
||||
return { currency: summary.currency, totals: summary.totals, byDay };
|
||||
});
|
||||
|
||||
return {
|
||||
range: { start, end },
|
||||
currencySummaries,
|
||||
eventsEvaluated: events.length,
|
||||
eventsIncluded,
|
||||
events: detailed,
|
||||
filters: { machineId, location, sku, currency },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user