Macrostop and timeline segmentation

This commit is contained in:
mdares
2026-01-09 00:01:04 +00:00
parent 7790361a0a
commit d0ab254dd7
33 changed files with 1865 additions and 179 deletions

View File

@@ -15,11 +15,25 @@ import {
validateShiftSchedule,
validateThresholds,
} from "@/lib/settings";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
const machineIdSchema = z.string().uuid();
const machineSettingsSchema = z
.object({
source: z.string().trim().max(40).optional(),
overrides: z.any().optional(),
})
.passthrough();
function pickAllowedOverrides(raw: any) {
if (!isPlainObject(raw)) return {};
const out: Record<string, any> = {};
@@ -29,7 +43,11 @@ function pickAllowedOverrides(raw: any) {
return out;
}
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
async function ensureOrgSettings(
tx: Prisma.TransactionClient,
orgId: string,
userId?: string | null
) {
let settings = await tx.orgSettings.findUnique({
where: { orgId },
});
@@ -65,12 +83,13 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
alertsJson: DEFAULT_ALERTS,
defaultsJson: DEFAULT_DEFAULTS,
updatedBy: userId,
updatedBy: userId ?? null,
},
});
@@ -93,23 +112,40 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
}
export async function GET(
_req: NextRequest,
req: NextRequest,
{ params }: { params: Promise<{ machineId: string }> }
) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const { machineId } = await params;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true },
});
const session = await requireSession();
let orgId: string | null = null;
let userId: string | null = null;
let machine: { id: string; orgId: string } | null = null;
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
if (session) {
machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
orgId = machine.orgId;
userId = session.userId;
} else {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
machine = await prisma.machine.findFirst({
where: { id: machineId, apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
orgId = machine.orgId;
}
const { settings, overrides } = await prisma.$transaction(async (tx) => {
const orgSettings = await ensureOrgSettings(tx, session.orgId, session.userId);
const orgSettings = await ensureOrgSettings(tx, orgId as string, userId);
if (!orgSettings?.settings) throw new Error("SETTINGS_NOT_FOUND");
const machineSettings = await tx.machineSettings.findUnique({
@@ -140,7 +176,18 @@ export async function PUT(
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const membership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
select: { role: true },
});
if (!canManageSettings(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
const { machineId } = await params;
if (!machineIdSchema.safeParse(machineId).success) {
return NextResponse.json({ ok: false, error: "Invalid machine id" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
@@ -150,9 +197,13 @@ export async function PUT(
if (!machine) return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
const body = await req.json().catch(() => ({}));
const source = String(body.source ?? "control_tower");
const parsed = machineSettingsSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid settings payload" }, { status: 400 });
}
const source = String(parsed.data.source ?? "control_tower");
let patch = body.overrides ?? body;
let patch = parsed.data.overrides ?? parsed.data;
if (patch === null) {
patch = null;
}
@@ -238,16 +289,20 @@ export async function PUT(
if (patch?.thresholds) {
patch = {
...patch,
thresholds: {
...patch.thresholds,
stoppageMultiplier:
patch.thresholds.stoppageMultiplier !== undefined
? Number(patch.thresholds.stoppageMultiplier)
: patch.thresholds.stoppageMultiplier,
oeeAlertThresholdPct:
patch.thresholds.oeeAlertThresholdPct !== undefined
? Number(patch.thresholds.oeeAlertThresholdPct)
: patch.thresholds.oeeAlertThresholdPct,
thresholds: {
...patch.thresholds,
stoppageMultiplier:
patch.thresholds.stoppageMultiplier !== undefined
? Number(patch.thresholds.stoppageMultiplier)
: patch.thresholds.stoppageMultiplier,
macroStoppageMultiplier:
patch.thresholds.macroStoppageMultiplier !== undefined
? Number(patch.thresholds.macroStoppageMultiplier)
: patch.thresholds.macroStoppageMultiplier,
oeeAlertThresholdPct:
patch.thresholds.oeeAlertThresholdPct !== undefined
? Number(patch.thresholds.oeeAlertThresholdPct)
: patch.thresholds.oeeAlertThresholdPct,
performanceThresholdPct:
patch.thresholds.performanceThresholdPct !== undefined
? Number(patch.thresholds.performanceThresholdPct)
@@ -318,9 +373,30 @@ export async function PUT(
const overrides = pickAllowedOverrides(saved.overridesJson ?? {});
const effective = deepMerge(orgPayload, overrides);
return { orgPayload, overrides, effective };
return {
orgPayload,
overrides,
effective,
overridesUpdatedAt: saved.updatedAt,
};
});
const overridesUpdatedAt =
result.overridesUpdatedAt && result.overridesUpdatedAt instanceof Date
? result.overridesUpdatedAt.toISOString()
: undefined;
try {
await publishSettingsUpdate({
orgId: session.orgId,
machineId,
version: Number(result.orgPayload.version ?? 0),
source,
overridesUpdatedAt,
});
} catch (err) {
console.warn("[settings machine PUT] MQTT publish failed", err);
}
return NextResponse.json({
ok: true,
machineId,

View File

@@ -16,11 +16,29 @@ import {
validateShiftSchedule,
validateThresholds,
} from "@/lib/settings";
import { publishSettingsUpdate } from "@/lib/mqtt";
import { z } from "zod";
function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function canManageSettings(role?: string | null) {
return role === "OWNER" || role === "ADMIN";
}
const settingsPayloadSchema = z
.object({
source: z.string().trim().max(40).optional(),
timezone: z.string().trim().max(64).optional(),
shiftSchedule: z.any().optional(),
thresholds: z.any().optional(),
alerts: z.any().optional(),
defaults: z.any().optional(),
version: z.union([z.number(), z.string()]).optional(),
})
.passthrough();
async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, userId: string) {
let settings = await tx.orgSettings.findUnique({
where: { orgId },
@@ -57,6 +75,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
shiftChangeCompMin: 10,
lunchBreakMin: 30,
stoppageMultiplier: 1.5,
macroStoppageMultiplier: 5,
oeeAlertThresholdPct: 90,
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
@@ -108,15 +127,28 @@ export async function PUT(req: Request) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const membership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: session.userId } },
select: { role: true },
});
if (!canManageSettings(membership?.role)) {
return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 });
}
try {
const body = await req.json().catch(() => ({}));
const source = String(body.source ?? "control_tower");
const timezone = body.timezone;
const shiftSchedule = body.shiftSchedule;
const thresholds = body.thresholds;
const alerts = body.alerts;
const defaults = body.defaults;
const expectedVersion = body.version;
const parsed = settingsPayloadSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid settings payload" }, { status: 400 });
}
const source = String(parsed.data.source ?? "control_tower");
const timezone = parsed.data.timezone;
const shiftSchedule = parsed.data.shiftSchedule;
const thresholds = parsed.data.thresholds;
const alerts = parsed.data.alerts;
const defaults = parsed.data.defaults;
const expectedVersion = parsed.data.version;
if (
timezone === undefined &&
@@ -192,6 +224,10 @@ export async function PUT(req: Request) {
shiftSchedule?.lunchBreakMin !== undefined ? Number(shiftSchedule.lunchBreakMin) : undefined,
stoppageMultiplier:
thresholds?.stoppageMultiplier !== undefined ? Number(thresholds.stoppageMultiplier) : undefined,
macroStoppageMultiplier:
thresholds?.macroStoppageMultiplier !== undefined
? Number(thresholds.macroStoppageMultiplier)
: undefined,
oeeAlertThresholdPct:
thresholds?.oeeAlertThresholdPct !== undefined ? Number(thresholds.oeeAlertThresholdPct) : undefined,
performanceThresholdPct:
@@ -267,6 +303,22 @@ export async function PUT(req: Request) {
}
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
const updatedAt =
typeof payload.updatedAt === "string"
? payload.updatedAt
: payload.updatedAt
? payload.updatedAt.toISOString()
: undefined;
try {
await publishSettingsUpdate({
orgId: session.orgId,
version: Number(payload.version ?? 0),
source,
updatedAt,
});
} catch (err) {
console.warn("[settings PUT] MQTT publish failed", err);
}
return NextResponse.json({ ok: true, settings: payload });
} catch (err) {
console.error("[settings PUT] failed", err);