L13 Planung: Plantafel-, Einplan-, Empfehlungs-, Live- und Verzugsdienste mit API und Meldungen
Services board/schedule (L2 assign+update in inTransaction, Versionskonflikt, Audit), recommend (Luftlinie + freie Kolonnenzeit), live (Kolonnen, ohne Geräte-Koordinaten), watch (Verzug ab 80 %, Überschreitung, gefährdete Folgeaufträge, früher fertig mit Vorschlägen), Kolonnenkapazität. Job planning-watch alle 5 min, Events planning.capacity_freed/overrun/followup_at_risk (In-App an Backoffice + Teamleiter, Dedupe im Audit-Log), L12-Adapter für source/approvalStatus/manual. API /api/v1/planning/{board,schedule,recommendations,live} + OpenAPI. Tests core, recommend, live, watch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, withApi } from "@/server/api/respond";
|
||||
import { getPlanningBoard, weekRange } from "@/server/services/planning/board";
|
||||
|
||||
/**
|
||||
* GET /api/v1/planning/board?from=YYYY-MM-DD&to=YYYY-MM-DD&teamId=…&orderTypeId=…&priority=…
|
||||
* Default range: current week (Mon–Sun, tenant timezone), max. 42 days. Backoffice: all teams;
|
||||
* team leads: own teams (read-only). Access is checked in the service.
|
||||
*/
|
||||
export const GET = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("work_orders");
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const week = await weekRange(ctx, sp.get("from"));
|
||||
const teamIds = sp.getAll("teamId").filter(Boolean);
|
||||
return json(
|
||||
await getPlanningBoard(ctx, {
|
||||
from: sp.get("from") ?? week.from,
|
||||
to: sp.get("to") ?? week.to,
|
||||
teamIds: teamIds.length ? teamIds : undefined,
|
||||
orderTypeId: sp.get("orderTypeId") || undefined,
|
||||
priority: (sp.get("priority") || undefined) as "low" | "normal" | "high" | "urgent" | undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, withApi } from "@/server/api/respond";
|
||||
import { getLiveSituation, LIVE_STATUSES, type LiveStatus } from "@/server/services/planning/live";
|
||||
|
||||
/**
|
||||
* GET /api/v1/planning/live?teamId=…&status=en_route|working|paused|free — polled every 30 s by
|
||||
* /planning/live. Location = site of the running order; never device coordinates.
|
||||
*/
|
||||
export const GET = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("work_orders");
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const status = sp.get("status");
|
||||
return json(
|
||||
await getLiveSituation(ctx, {
|
||||
teamId: sp.get("teamId") || undefined,
|
||||
status: status && (LIVE_STATUSES as readonly string[]).includes(status) ? (status as LiveStatus) : undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { ApiError, json, withApi } from "@/server/api/respond";
|
||||
import { findNearbyUnplanned, recommendAssignments } from "@/server/services/planning/recommend";
|
||||
|
||||
/**
|
||||
* GET /api/v1/planning/recommendations?workOrderId=…&days=10&radiusKm=25&nearbyRadiusKm=5&locale=de
|
||||
* Suggestions only (straight-line distance + free capacity); nothing is saved.
|
||||
*/
|
||||
export const GET = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("work_orders", "work_order:assign");
|
||||
const sp = new URL(req.url).searchParams;
|
||||
const workOrderId = sp.get("workOrderId");
|
||||
if (!workOrderId) throw new ApiError("invalid", "workOrderId required");
|
||||
const locale = sp.get("locale") === "en" ? "en" : "de";
|
||||
const [recommendations, nearby] = await Promise.all([
|
||||
recommendAssignments(ctx, {
|
||||
workOrderId,
|
||||
days: sp.get("days") ?? undefined,
|
||||
radiusKm: sp.get("radiusKm") ?? undefined,
|
||||
locale,
|
||||
}),
|
||||
findNearbyUnplanned(ctx, { workOrderId, radiusKm: sp.get("nearbyRadiusKm") ?? undefined }),
|
||||
]);
|
||||
return json({ ...recommendations, nearby });
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
import { scheduleWorkOrder, type ScheduleInput } from "@/server/services/planning/schedule";
|
||||
|
||||
/**
|
||||
* POST /api/v1/planning/schedule — body { workOrderId, teamId, plannedStart, plannedEnd?, plannedDurationMinutes?, baseVersion }.
|
||||
* Assign + dates in one transaction (L2 services, event work_order.assigned, audit). 409 conflict when
|
||||
* the order changed meanwhile ("Auftrag wurde zwischenzeitlich geändert"). Response contains the
|
||||
* conflicts of the target team on the target day(s).
|
||||
*/
|
||||
export const POST = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("work_orders", "work_order:assign");
|
||||
const body = await readJsonObject(req);
|
||||
return json(await scheduleWorkOrder(ctx, body as ScheduleInput));
|
||||
});
|
||||
@@ -1106,6 +1106,139 @@ const paths: Record<string, Record<string, Schema>> = {
|
||||
responses: { "200": binaryResponse("Datei"), ...errors("not_found") },
|
||||
}),
|
||||
},
|
||||
// ---------- L13 Planung ----------
|
||||
"/planning/board": {
|
||||
get: op({
|
||||
tag: "Planung",
|
||||
operationId: "getPlanningBoard",
|
||||
summary: "Plantafel: Aufträge je Team und Tag mit Kapazität, Auslastung und Konflikten",
|
||||
description:
|
||||
"Standard-Zeitraum: aktuelle Woche (Mo–So, Mandanten-Zeitzone), maximal 42 Tage. Teams sind Kolonnen: Kapazität = `dailyCapacityMinutes` je Arbeitstag (nicht je Person). `work_order:read_all` sieht alle Teams (Einplanen mit `work_order:assign` + `work_order:write`); Teamleiter (`work_order:read_team` + Leitung eines Teams) nur eigene Teams, lesend; sonst 403. Konflikte (`severity: conflict`): `overbooked`, `overlap`, `assignee_double_booked`, `outside_working_days`; Hinweis (`severity: hint`, nicht in `conflictCount`): `crew_incomplete` (< 2 aktive Personen). Liegt heute im Zeitraum, tragen Karten `delay` (≥ 80 % erfasste Kolonnenzeit) bzw. `atRisk` (Folgeauftrag gefährdet). Filter `orderTypeId`/`priority` wirken auf die angezeigten Karten, nicht auf die Auslastung.",
|
||||
module: "work_orders",
|
||||
permissions: [],
|
||||
parameters: [
|
||||
query("from", str({ format: "date" }), "Erster Tag (YYYY-MM-DD)"),
|
||||
query("to", str({ format: "date" }), "Letzter Tag (YYYY-MM-DD)"),
|
||||
{ name: "teamId", in: "query", required: false, schema: arr(str()), style: "form", explode: true, description: "mehrfach möglich" },
|
||||
query("orderTypeId", str()),
|
||||
query("priority", str({ enum: ["low", "normal", "high", "urgent"] })),
|
||||
],
|
||||
responses: {
|
||||
"200": jsonResponse(
|
||||
"Plantafel",
|
||||
open(
|
||||
{
|
||||
from: str({ format: "date" }),
|
||||
to: str({ format: "date" }),
|
||||
timeZone: str(),
|
||||
days: arr(obj({ key: str({ format: "date" }), weekday: int({ minimum: 0, maximum: 6 }) })),
|
||||
teams: arr(open({ id: str(), name: str(), dailyCapacityMinutes: int({ description: "Kolonnen-Arbeitstag in Minuten" }), workingDays: int({ description: "Bitmaske Mo=1 … So=64" }), days: arr(open({ day: str(), memberCount: int(), capacityMinutes: int(), plannedMinutes: int(), utilization: { type: ["integer", "null"] }, conflicts: arr({ type: "object" }) })) })),
|
||||
orders: { type: "object", additionalProperties: { type: "object" } },
|
||||
unplanned: arr({ type: "object" }),
|
||||
conflictCount: int(),
|
||||
},
|
||||
["from", "to", "teams", "orders", "unplanned"],
|
||||
),
|
||||
),
|
||||
...errors("unprocessable"),
|
||||
},
|
||||
}),
|
||||
},
|
||||
"/planning/schedule": {
|
||||
post: op({
|
||||
tag: "Planung",
|
||||
operationId: "scheduleWorkOrder",
|
||||
summary: "Auftrag einplanen (Team + Termin) – atomar über Zuweisung und Auftragsänderung",
|
||||
description:
|
||||
"Nutzt `assignWorkOrder` (Event `work_order.assigned`) und `updateWorkOrder` in einer Transaktion; Audit. Nur Status draft/review_required/planned/assigned/accepted. Veraltete `baseVersion` → 409 („Auftrag wurde zwischenzeitlich geändert“). Antwort enthält die Konflikte des Zielteams am Zieltag.",
|
||||
module: "work_orders",
|
||||
permissions: ["work_order:assign", "work_order:write"],
|
||||
requestBody: jsonBody(
|
||||
obj(
|
||||
{
|
||||
workOrderId: str(),
|
||||
teamId: str(),
|
||||
plannedStart: dateTime(),
|
||||
plannedEnd: nDateTime({ description: "weggelassen: bisherige Länge bleibt erhalten" }),
|
||||
plannedDurationMinutes: { type: ["integer", "null"], minimum: 15, maximum: 20160 },
|
||||
baseVersion: int({ minimum: 1 }),
|
||||
},
|
||||
["workOrderId", "teamId", "plannedStart", "baseVersion"],
|
||||
),
|
||||
),
|
||||
responses: {
|
||||
"200": jsonResponse(
|
||||
"Eingeplant",
|
||||
open({ id: str(), number: str(), version: int(), status: str({ enum: WORK_ORDER_STATUSES }), teamId: str(), plannedStart: dateTime(), plannedEnd: nDateTime(), plannedDurationMinutes: { type: ["integer", "null"] }, conflicts: arr({ type: "object" }) }, ["id", "version", "conflicts"]),
|
||||
),
|
||||
...errors("not_found", "conflict", "unprocessable"),
|
||||
},
|
||||
}),
|
||||
},
|
||||
"/planning/recommendations": {
|
||||
get: op({
|
||||
tag: "Planung",
|
||||
operationId: "getPlanningRecommendations",
|
||||
summary: "Einsatz-Empfehlungen (Luftlinie + Terminlage) und nahe ungeplante Aufträge",
|
||||
description:
|
||||
"Nur Vorschläge, nichts wird gespeichert. Ohne Koordinaten am Objekt: `status: no_coordinates` + Hinweis; Verortung wird im Hintergrund angestoßen. Top 5 nach Score (Distanz dominant, freie Kapazität, frühes Datum gewichtet nach Priorität).",
|
||||
module: "work_orders",
|
||||
permissions: ["work_order:assign"],
|
||||
parameters: [
|
||||
{ name: "workOrderId", in: "query", required: true, schema: str() },
|
||||
query("days", int({ minimum: 1, maximum: 31, default: 10 })),
|
||||
query("radiusKm", num({ minimum: 0.5, maximum: 200, default: 25 })),
|
||||
query("nearbyRadiusKm", num({ minimum: 0.5, maximum: 100, default: 5 })),
|
||||
query("locale", str({ enum: ["de", "en"], default: "de" })),
|
||||
],
|
||||
responses: {
|
||||
"200": jsonResponse(
|
||||
"Empfehlungen",
|
||||
open(
|
||||
{
|
||||
status: str({ enum: ["ok", "no_coordinates", "not_schedulable"] }),
|
||||
hint: nstr(),
|
||||
requiredMinutes: int(),
|
||||
recommendations: arr(open({ teamId: str(), teamName: str(), day: str({ format: "date" }), distanceKm: num(), nearOrder: obj({ id: str(), number: str() }), freeMinutes: int(), tight: bool(), score: num(), suggestedStart: dateTime(), text: str() })),
|
||||
nearby: open({ status: str(), radiusKm: num(), items: arr({ type: "object" }) }),
|
||||
},
|
||||
["status", "recommendations", "nearby"],
|
||||
),
|
||||
),
|
||||
...errors("not_found", "unprocessable"),
|
||||
},
|
||||
}),
|
||||
},
|
||||
"/planning/live": {
|
||||
get: op({
|
||||
tag: "Planung",
|
||||
operationId: "getLiveSituation",
|
||||
summary: "Live-Lage der Monteure (ohne GPS)",
|
||||
description:
|
||||
"Standort = Einsatzort (Objekt) des Auftrags der aktiven WorkSession; Status aus der Session (`en_route`, `working`, `paused`, sonst `free`). Es werden keine Geräte-Koordinaten gelesen oder ausgeliefert. `crews`: ein Eintrag je Team/Kolonne mit individuellem Status der Mitglieder und Verzugswarnung (`delay`); `freed`: Kolonnen, die heute früher fertig wurden, mit Vorzieh-/Umkreis-Vorschlägen (nur Vorschlag). Teamleiter: nur eigene Teams.",
|
||||
module: "work_orders",
|
||||
permissions: [],
|
||||
parameters: [query("teamId", str()), query("status", str({ enum: ["en_route", "working", "paused", "free"] }))],
|
||||
responses: {
|
||||
"200": jsonResponse(
|
||||
"Live-Lage",
|
||||
open(
|
||||
{
|
||||
generatedAt: dateTime(),
|
||||
crews: arr(open({ teamId: str(), teamName: str(), status: str(), current: { type: ["object", "null"] }, delay: { type: ["object", "null"] }, members: arr({ type: "object" }), freedMinutes: { type: ["integer", "null"] } })),
|
||||
technicians: arr(open({ userId: str(), name: str(), crewId: nstr(), status: str(), since: nDateTime(), current: { type: ["object", "null"] }, delay: { type: ["object", "null"] }, next: arr({ type: "object" }) })),
|
||||
freed: arr({ type: "object" }),
|
||||
counts: { type: "object" },
|
||||
attention: arr({ type: "object" }),
|
||||
withoutLocation: int(),
|
||||
},
|
||||
["generatedAt", "technicians"],
|
||||
),
|
||||
),
|
||||
...errors("unprocessable"),
|
||||
},
|
||||
}),
|
||||
},
|
||||
"/openapi.json": {
|
||||
get: op({
|
||||
tag: "Meta",
|
||||
@@ -1137,6 +1270,7 @@ export const openApiDocument = {
|
||||
{ name: "Import", description: "Dokumentenimport mit KI-Extraktion" },
|
||||
{ name: "Berichte", description: "Tages-/Abschlussberichte" },
|
||||
{ name: "Einsatz", description: "Mobile/Offline: Sync, Uploads, Bundle" },
|
||||
{ name: "Planung", description: "Plantafel, Live-Lage, Einsatz-Empfehlungen" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
paths,
|
||||
|
||||
@@ -19,6 +19,10 @@ export const EVENT_TYPES = [
|
||||
"import.ready_for_review",
|
||||
"import.failed",
|
||||
"sync.failed",
|
||||
// L13 Planung (emitted only by the planning-watch job; in-app to back office + team leads)
|
||||
"planning.capacity_freed",
|
||||
"planning.overrun",
|
||||
"planning.followup_at_risk",
|
||||
] as const;
|
||||
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { toActionError, type ActionState } from "@/server/api/action-state";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { updateTeamPlanningSettings } from "@/server/services/planning/team-settings";
|
||||
|
||||
const guard = moduleGuard("work_orders");
|
||||
|
||||
/** L13 Planung: crew capacity (working day in minutes) + working days (popup on the planning board). */
|
||||
export async function saveTeamCapacityAction(teamId: string, returnTo: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = ctxFromGuard(await guard("team:manage"));
|
||||
const workingDays = fd
|
||||
.getAll("day")
|
||||
.map(Number)
|
||||
.filter((n) => [1, 2, 4, 8, 16, 32, 64].includes(n))
|
||||
.reduce((mask, bit) => mask | bit, 0);
|
||||
await updateTeamPlanningSettings(ctx, teamId, { dailyCapacityMinutes: String(fd.get("minutes") ?? ""), workingDays });
|
||||
} catch (err) {
|
||||
return toActionError(err);
|
||||
}
|
||||
revalidatePath("/planning");
|
||||
redirect(returnTo.startsWith("/planning") && !returnTo.startsWith("//") ? returnTo : "/planning");
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
|
||||
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
|
||||
"image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||
"ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker)
|
||||
"geocode-site": () => import("./geocode-site").then((m) => m.process), // L13: geocoding of sites (OSM Nominatim)
|
||||
"planning-watch": () => import("./planning-watch").then((m) => m.process), // L13: delay/overrun/freed-capacity alerts (every 5 min)
|
||||
};
|
||||
|
||||
/** Inline fallback when no Redis is available (dev/demo). */
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { runPlanningWatch, runPlanningWatchAllTenants } from "@/server/services/planning/watch";
|
||||
import type { JobPayload } from "../queues";
|
||||
|
||||
/**
|
||||
* Queue "planning-watch" (L13 Planung): every 5 min (job scheduler registered by the craftvia worker)
|
||||
* evaluates running orders (delay ≥ 100 % → planning.overrun, follow-up order at risk →
|
||||
* planning.followup_at_risk) and early completions (planning.capacity_freed). Deduplicated via the
|
||||
* audit-log ledger; payload tenantId "*" = all tenants.
|
||||
*/
|
||||
export async function process(payload: JobPayload): Promise<void> {
|
||||
if (!payload?.tenantId || payload.tenantId === "*") {
|
||||
const r = await runPlanningWatchAllTenants();
|
||||
if (r.overrun || r.followupAtRisk || r.capacityFreed || r.failed) console.info(`[planning-watch] ${JSON.stringify(r)}`);
|
||||
return;
|
||||
}
|
||||
await runPlanningWatch(payload.tenantId);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export const JOB_QUEUES = {
|
||||
reportPdf: "report-pdf",
|
||||
imageDerivatives: "image-derivatives",
|
||||
aiRetention: "ai-retention",
|
||||
geocodeSite: "geocode-site", // L13 Planung: site address → coordinates (OSM Nominatim, 1 req/s)
|
||||
planningWatch: "planning-watch", // L13 Planung: delay/overrun/freed-capacity alerts every 5 min
|
||||
} as const;
|
||||
|
||||
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
|
||||
@@ -97,6 +99,17 @@ export async function scheduleRecurringJobs(connection: Redis): Promise<void> {
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
// L13 Planung: planning watch every 5 minutes (all tenants)
|
||||
const watch = new Queue<JobPayload>(JOB_QUEUES.planningWatch, { connection });
|
||||
try {
|
||||
await watch.upsertJobScheduler(
|
||||
"planning-watch-5min",
|
||||
{ every: 5 * 60 * 1000 },
|
||||
{ name: JOB_QUEUES.planningWatch, data: { tenantId: "*", entityId: "watch" }, opts: { removeOnComplete: { count: 50 }, removeOnFail: { count: 50 } } },
|
||||
);
|
||||
} finally {
|
||||
await watch.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeJobQueues(): Promise<void> {
|
||||
|
||||
@@ -136,6 +136,11 @@ function textVars(locale: Locale, f: EventFacts): Record<string, string | undefi
|
||||
actor: f.actorName ?? fallbackText(locale, "system"),
|
||||
fileName: f.fileName ?? fallbackText(locale, "document"),
|
||||
reason: f.syncErrorCode,
|
||||
// L13 Planung
|
||||
team: f.team,
|
||||
minutes: f.planningMinutes,
|
||||
percent: f.planningPercent,
|
||||
blocker: f.planningBlocker,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export type EventFacts = {
|
||||
actorName?: string;
|
||||
emergencyStart?: Date | null;
|
||||
emergencyEnd?: Date | null;
|
||||
// L13 Planung
|
||||
planningMinutes?: string;
|
||||
planningPercent?: string;
|
||||
planningBlocker?: string;
|
||||
};
|
||||
|
||||
export type RecipientPlan = {
|
||||
@@ -292,6 +296,20 @@ export async function resolveRecipients(
|
||||
rule = { users: [backofficeWhere()], userIds: [op.userId] };
|
||||
break;
|
||||
}
|
||||
case "planning.capacity_freed":
|
||||
case "planning.overrun":
|
||||
case "planning.followup_at_risk": {
|
||||
// L13 Planung: in-app only — back office + team leads of the order's crew.
|
||||
const wo = await loadWorkOrder(ctx, event.entityId);
|
||||
if (!wo) return null;
|
||||
Object.assign(facts, workOrderFacts(wo));
|
||||
const d = event.data ?? {};
|
||||
if (typeof d.minutes === "number" || typeof d.minutes === "string") facts.planningMinutes = String(d.minutes);
|
||||
if (typeof d.percent === "number" || typeof d.percent === "string") facts.planningPercent = String(d.percent);
|
||||
if (typeof d.blockerNumber === "string") facts.planningBlocker = d.blockerNumber;
|
||||
rule = { users: [backofficeWhere()], userIds: teamLeadIds(wo), mailToUsers: false };
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* Who may use the planning views (L13):
|
||||
* - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`.
|
||||
* - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only.
|
||||
* - everyone else (Monteur): forbidden.
|
||||
*/
|
||||
export type PlanningAccess = {
|
||||
all: boolean;
|
||||
/** null = all teams of the tenant */
|
||||
teamIds: string[] | null;
|
||||
canSchedule: boolean;
|
||||
canManageTeams: boolean;
|
||||
};
|
||||
|
||||
export async function planningAccess(ctx: ServiceCtx): Promise<PlanningAccess> {
|
||||
if (can(ctx, "work_order:read_all")) {
|
||||
return {
|
||||
all: true,
|
||||
teamIds: null,
|
||||
canSchedule: can(ctx, "work_order:assign") && can(ctx, "work_order:write"),
|
||||
canManageTeams: can(ctx, "team:manage"),
|
||||
};
|
||||
}
|
||||
if (can(ctx, "work_order:read_team")) {
|
||||
const led = await ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } });
|
||||
if (led.length > 0) return { all: false, teamIds: led.map((t) => t.id), canSchedule: false, canManageTeams: false };
|
||||
}
|
||||
throw new ServiceError("forbidden", "planning not allowed");
|
||||
}
|
||||
|
||||
/** Recommendations and scheduling are backoffice functions (all teams + assign right). */
|
||||
export async function assertCanPlanAll(ctx: ServiceCtx): Promise<PlanningAccess> {
|
||||
const access = await planningAccess(ctx);
|
||||
if (!access.all || !can(ctx, "work_order:assign")) throw new ServiceError("forbidden", "missing permission work_order:assign");
|
||||
return access;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
assigneeDoubleBookings,
|
||||
computeTeamDay,
|
||||
isConflict,
|
||||
type ConflictKind,
|
||||
type PlanningConflict,
|
||||
type TeamDay,
|
||||
} from "@/lib/planning/capacity";
|
||||
import { addDays, dayBounds, dayKeyOf, dayRange, isDayKey, startOfWeek, weekdayIndex } from "@/lib/planning/days";
|
||||
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { planningAccess, type PlanningAccess } from "@/server/services/planning/access";
|
||||
import {
|
||||
loadPlannedOrders,
|
||||
loadTeams,
|
||||
loadUnplannedOrders,
|
||||
toBoardOrder,
|
||||
toPlanningOrder,
|
||||
toPlanningTeam,
|
||||
type BoardOrder,
|
||||
} from "@/server/services/planning/data";
|
||||
import { evaluateDelays } from "@/server/services/planning/watch";
|
||||
import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
|
||||
/**
|
||||
* Planning board (L13, Plantafel): per crew (team) and day planned orders, crew capacity, utilization,
|
||||
* conflicts (+ crew_incomplete hints) and — when today is in range — delay/at-risk markers.
|
||||
*/
|
||||
|
||||
export const MAX_BOARD_DAYS = 42;
|
||||
|
||||
const dayKey = z.string().refine(isDayKey, "invalid_day");
|
||||
const boardSchema = z.object({
|
||||
from: dayKey,
|
||||
to: dayKey,
|
||||
teamIds: z.array(z.string().min(1).max(64)).max(100).optional(),
|
||||
orderTypeId: z.string().min(1).max(64).nullish(),
|
||||
priority: z.enum(WORK_ORDER_PRIORITIES).nullish(),
|
||||
});
|
||||
export type BoardInput = z.input<typeof boardSchema>;
|
||||
|
||||
export type BoardTeamDay = TeamDay;
|
||||
|
||||
export type BoardTeam = {
|
||||
id: string;
|
||||
name: string;
|
||||
leaderName: string | null;
|
||||
dailyCapacityMinutes: number;
|
||||
workingDays: number;
|
||||
members: { userId: string; name: string }[];
|
||||
days: BoardTeamDay[];
|
||||
};
|
||||
|
||||
export type PlanningBoard = {
|
||||
from: string;
|
||||
to: string;
|
||||
today: string;
|
||||
timeZone: string;
|
||||
days: { key: string; weekday: number }[];
|
||||
teams: BoardTeam[];
|
||||
orders: Record<string, BoardOrder>;
|
||||
unplanned: BoardOrder[];
|
||||
/** Conflicts and hints (severity); `conflictCount` counts conflicts only. */
|
||||
conflicts: PlanningConflict[];
|
||||
conflictCount: number;
|
||||
access: PlanningAccess;
|
||||
};
|
||||
|
||||
export async function getPlanningBoard(ctx: ServiceCtx, raw: BoardInput, opts: { now?: Date } = {}): Promise<PlanningBoard> {
|
||||
const input = parseInput(boardSchema, raw);
|
||||
if (input.to < input.from || dayRange(input.from, input.to).length > MAX_BOARD_DAYS) throw new ServiceError("invalid", "invalid_range");
|
||||
const access = await planningAccess(ctx);
|
||||
const timeZone = await tenantTimezone(ctx);
|
||||
const now = opts.now ?? new Date();
|
||||
const today = dayKeyOf(now, timeZone);
|
||||
|
||||
let teamIds = access.teamIds;
|
||||
if (input.teamIds?.length) teamIds = teamIds ? teamIds.filter((id) => input.teamIds!.includes(id)) : input.teamIds;
|
||||
const days = dayRange(input.from, input.to);
|
||||
const start = dayBounds(input.from, timeZone).start;
|
||||
const end = dayBounds(input.to, timeZone).end;
|
||||
|
||||
const teams = await loadTeams(ctx, teamIds);
|
||||
const [rows, unplannedRows] = await Promise.all([
|
||||
loadPlannedOrders(ctx, { teamIds: teams.map((t) => t.id), start, end }),
|
||||
loadUnplannedOrders(ctx, access.teamIds),
|
||||
]);
|
||||
const planningOrders = rows.map(toPlanningOrder);
|
||||
const visible = (r: { orderTypeId: string | null; priority: string }) =>
|
||||
(!input.orderTypeId || r.orderTypeId === input.orderTypeId) && (!input.priority || r.priority === input.priority);
|
||||
const visibleIds = new Set(rows.filter(visible).map((r) => r.id));
|
||||
|
||||
const doubleBookings = days.flatMap((day) => assigneeDoubleBookings(planningOrders, day, timeZone));
|
||||
const conflicts: PlanningConflict[] = [...doubleBookings];
|
||||
|
||||
const boardTeams: BoardTeam[] = teams.map((team) => {
|
||||
const pt = toPlanningTeam(team);
|
||||
const teamOrders = planningOrders.filter((o) => o.teamId === team.id);
|
||||
const members = new Map<string, string>();
|
||||
for (const m of team.members) {
|
||||
if (m.user.status === "ACTIVE" && m.validFrom < end && (!m.validTo || m.validTo > start)) members.set(m.userId, m.user.name);
|
||||
}
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
leaderName: team.leader?.name ?? null,
|
||||
dailyCapacityMinutes: team.dailyCapacityMinutes,
|
||||
workingDays: team.workingDays,
|
||||
members: [...members].map(([userId, name]) => ({ userId, name })),
|
||||
days: days.map((day) => {
|
||||
const td = computeTeamDay(pt, day, teamOrders, timeZone);
|
||||
conflicts.push(...td.conflicts);
|
||||
return {
|
||||
...td,
|
||||
orderIds: td.orderIds.filter((id) => visibleIds.has(id)),
|
||||
conflicts: [...td.conflicts, ...doubleBookings.filter((c) => c.day === day && c.teamIds.includes(team.id))],
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const kindsByOrder = new Map<string, Set<ConflictKind>>();
|
||||
for (const c of conflicts.filter(isConflict)) {
|
||||
for (const id of c.orderIds) {
|
||||
const set = kindsByOrder.get(id) ?? new Set<ConflictKind>();
|
||||
set.add(c.kind);
|
||||
kindsByOrder.set(id, set);
|
||||
}
|
||||
}
|
||||
const orders: Record<string, BoardOrder> = Object.fromEntries(rows.filter(visible).map((r) => [r.id, toBoardOrder(r, timeZone, [...(kindsByOrder.get(r.id) ?? [])])]));
|
||||
|
||||
// Delay (≥ 80 %) and endangered follow-up orders — only meaningful when today is shown.
|
||||
if (today >= input.from && today <= input.to && teams.length > 0) {
|
||||
const { delays, followups } = await evaluateDelays(ctx, { now, teamIds: teams.map((t) => t.id) });
|
||||
for (const d of delays) {
|
||||
if (d.level !== "ok" && orders[d.workOrderId]) orders[d.workOrderId].delay = { level: d.level, percent: d.percent, workedMinutes: d.workedMinutes };
|
||||
}
|
||||
for (const f of followups) {
|
||||
if (orders[f.workOrderId]) orders[f.workOrderId].atRisk = { byOrderId: f.blockedById, byNumber: f.blockedByNumber, reason: f.reason };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: input.from,
|
||||
to: input.to,
|
||||
today,
|
||||
timeZone,
|
||||
days: days.map((key) => ({ key, weekday: weekdayIndex(key) })),
|
||||
teams: boardTeams,
|
||||
orders,
|
||||
unplanned: unplannedRows.filter(visible).map((r) => toBoardOrder(r, timeZone)),
|
||||
conflicts,
|
||||
conflictCount: conflicts.filter(isConflict).length,
|
||||
access,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mon–Sun of the week containing `day` (default: today in the tenant timezone). */
|
||||
export async function weekRange(ctx: ServiceCtx, day?: string | null): Promise<{ from: string; to: string; today: string }> {
|
||||
const today = dayKeyOf(new Date(), await tenantTimezone(ctx));
|
||||
const from = startOfWeek(day && isDayKey(day) ? day : today);
|
||||
return { from, to: addDays(from, 6), today };
|
||||
}
|
||||
|
||||
/** Dashboard tile "Konflikte diese Woche" (Mon–Sun of the current week in the tenant timezone). */
|
||||
export async function countWeekConflicts(ctx: ServiceCtx): Promise<{ count: number; from: string } | null> {
|
||||
try {
|
||||
const today = dayKeyOf(new Date(), await tenantTimezone(ctx));
|
||||
const from = startOfWeek(today);
|
||||
const board = await getPlanningBoard(ctx, { from, to: addDays(from, 6) });
|
||||
return { count: board.conflictCount, from };
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "forbidden") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { isValidLatLng } from "@/lib/geo/distance";
|
||||
import { effectiveDurationMinutes, hasTimeOfDay, orderDays, type PlanningOrder, type PlanningTeam } from "@/lib/planning/capacity";
|
||||
import { STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { customerDisplayName } from "@/server/services/work-orders/options";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/** Shared loaders + DTOs of the planning services (tenant via ctx.db, visibility via workOrderScope). */
|
||||
|
||||
/** Unplanned = open planning statuses without team or without planned start (+ assigned/accepted without date). */
|
||||
export const UNPLANNED_STATUSES: WorkOrderStatus[] = ["draft", "review_required", "planned", "assigned", "accepted"];
|
||||
const NEEDS_TEAM_STATUSES: WorkOrderStatus[] = ["draft", "review_required", "planned"];
|
||||
|
||||
export const ORDER_SELECT = {
|
||||
id: true,
|
||||
number: true,
|
||||
title: true,
|
||||
status: true,
|
||||
priority: true,
|
||||
version: true,
|
||||
plannedStart: true,
|
||||
plannedEnd: true,
|
||||
plannedDurationMinutes: true,
|
||||
assignedTeamId: true,
|
||||
orderTypeId: true,
|
||||
isEmergency: true,
|
||||
orderType: { select: { name: true, defaultDurationMinutes: true } },
|
||||
customer: { select: { companyName: true, firstName: true, lastName: true } },
|
||||
site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, latitude: true, longitude: true, geocodeStatus: true } },
|
||||
assignees: { select: { userId: true } },
|
||||
} as const satisfies Prisma.WorkOrderSelect;
|
||||
|
||||
export type OrderRow = Prisma.WorkOrderGetPayload<{ select: typeof ORDER_SELECT }>;
|
||||
|
||||
export type BoardOrder = {
|
||||
id: string;
|
||||
number: string;
|
||||
title: string;
|
||||
status: WorkOrderStatus;
|
||||
statusGroup: StatusGroup;
|
||||
priority: "low" | "normal" | "high" | "urgent";
|
||||
version: number;
|
||||
isEmergency: boolean;
|
||||
customerName: string;
|
||||
siteId: string | null;
|
||||
siteName: string | null;
|
||||
siteAddress: string | null;
|
||||
hasCoordinates: boolean;
|
||||
orderTypeId: string | null;
|
||||
orderTypeName: string | null;
|
||||
teamId: string | null;
|
||||
plannedStart: string | null;
|
||||
plannedEnd: string | null;
|
||||
plannedDurationMinutes: number | null;
|
||||
durationMinutes: number;
|
||||
allDay: boolean;
|
||||
multiDay: boolean;
|
||||
assigneeIds: string[];
|
||||
conflictKinds: string[];
|
||||
/** Running order: recorded crew work time vs. planned duration (≥ 80 % → risk, ≥ 100 % → overrun). */
|
||||
delay: { level: "risk" | "overrun"; percent: number; workedMinutes: number } | null;
|
||||
/** Planned follow-up order endangered by the delay of another order of the same crew. */
|
||||
atRisk: { byOrderId: string; byNumber: string; reason: "start" | "capacity" } | null;
|
||||
};
|
||||
|
||||
export function toPlanningOrder(r: OrderRow): PlanningOrder {
|
||||
return {
|
||||
id: r.id,
|
||||
teamId: r.assignedTeamId,
|
||||
plannedStart: r.plannedStart,
|
||||
plannedEnd: r.plannedEnd,
|
||||
plannedDurationMinutes: r.plannedDurationMinutes,
|
||||
orderTypeDefaultMinutes: r.orderType?.defaultDurationMinutes ?? null,
|
||||
assigneeIds: r.assignees.map((a) => a.userId),
|
||||
};
|
||||
}
|
||||
|
||||
export function siteAddress(s: { street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null } | null): string | null {
|
||||
if (!s) return null;
|
||||
const line = [[s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
|
||||
return line || null;
|
||||
}
|
||||
|
||||
export function toBoardOrder(r: OrderRow, timeZone: string, conflictKinds: string[] = []): BoardOrder {
|
||||
const po = toPlanningOrder(r);
|
||||
return {
|
||||
id: r.id,
|
||||
number: r.number,
|
||||
title: r.title,
|
||||
status: r.status as WorkOrderStatus,
|
||||
statusGroup: STATUS_GROUP[r.status as WorkOrderStatus],
|
||||
priority: r.priority,
|
||||
version: r.version,
|
||||
isEmergency: r.isEmergency,
|
||||
customerName: customerDisplayName(r.customer),
|
||||
siteId: r.site?.id ?? null,
|
||||
siteName: r.site?.name ?? null,
|
||||
siteAddress: siteAddress(r.site),
|
||||
hasCoordinates: isValidLatLng(r.site),
|
||||
orderTypeId: r.orderTypeId,
|
||||
orderTypeName: r.orderType?.name ?? null,
|
||||
teamId: r.assignedTeamId,
|
||||
plannedStart: r.plannedStart?.toISOString() ?? null,
|
||||
plannedEnd: r.plannedEnd?.toISOString() ?? null,
|
||||
plannedDurationMinutes: r.plannedDurationMinutes,
|
||||
durationMinutes: effectiveDurationMinutes(po, timeZone),
|
||||
allDay: !r.plannedStart || !hasTimeOfDay(r.plannedStart, timeZone),
|
||||
multiDay: orderDays(po, timeZone).length > 1,
|
||||
assigneeIds: po.assigneeIds,
|
||||
conflictKinds,
|
||||
delay: null,
|
||||
atRisk: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadTeams(ctx: ServiceCtx, teamIds: string[] | null) {
|
||||
return ctx.db.team.findMany({
|
||||
where: { deletedAt: null, status: "active", ...(teamIds ? { id: { in: teamIds } } : {}) },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
leaderUserId: true,
|
||||
dailyCapacityMinutes: true,
|
||||
workingDays: true,
|
||||
leader: { select: { id: true, name: true, status: true } },
|
||||
members: { select: { userId: true, validFrom: true, validTo: true, user: { select: { name: true, status: true } } }, orderBy: { validFrom: "asc" } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export type TeamRow = Awaited<ReturnType<typeof loadTeams>>[number];
|
||||
|
||||
export function toPlanningTeam(t: TeamRow): PlanningTeam {
|
||||
return {
|
||||
id: t.id,
|
||||
dailyCapacityMinutes: t.dailyCapacityMinutes,
|
||||
workingDays: t.workingDays,
|
||||
members: t.members.map((m) => ({ userId: m.userId, validFrom: m.validFrom, validTo: m.validTo, active: m.user.status === "ACTIVE" })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Orders of the teams planned in [start, end) incl. multi-day orders overlapping the range. */
|
||||
export async function loadPlannedOrders(ctx: ServiceCtx, opts: { teamIds: string[]; start: Date; end: Date; excludeId?: string }): Promise<OrderRow[]> {
|
||||
if (opts.teamIds.length === 0) return [];
|
||||
return ctx.db.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
await workOrderScope(ctx),
|
||||
{
|
||||
assignedTeamId: { in: opts.teamIds },
|
||||
status: { not: "cancelled" },
|
||||
plannedStart: { lt: opts.end, not: null },
|
||||
OR: [{ plannedEnd: null, plannedStart: { gte: opts.start } }, { plannedEnd: { gt: opts.start } }],
|
||||
},
|
||||
opts.excludeId ? { id: { not: opts.excludeId } } : {},
|
||||
],
|
||||
},
|
||||
select: ORDER_SELECT,
|
||||
orderBy: [{ plannedStart: "asc" }, { number: "asc" }],
|
||||
take: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadUnplannedOrders(ctx: ServiceCtx, teamIds: string[] | null, take = 200): Promise<OrderRow[]> {
|
||||
return ctx.db.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
await workOrderScope(ctx),
|
||||
{ status: { in: UNPLANNED_STATUSES } },
|
||||
{ OR: [{ plannedStart: null }, { assignedTeamId: null, status: { in: NEEDS_TEAM_STATUSES } }] },
|
||||
teamIds ? { assignedTeamId: { in: teamIds } } : {},
|
||||
],
|
||||
},
|
||||
select: ORDER_SELECT,
|
||||
orderBy: [{ priority: "desc" }, { createdAt: "asc" }],
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadOrder(ctx: ServiceCtx, workOrderId: string): Promise<OrderRow | null> {
|
||||
return ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, await workOrderScope(ctx)] }, select: ORDER_SELECT });
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { z } from "zod";
|
||||
import { isValidLatLng } from "@/lib/geo/distance";
|
||||
import { dayBounds, dayKeyOf } from "@/lib/planning/days";
|
||||
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { planningAccess } from "@/server/services/planning/access";
|
||||
import { loadTeams, siteAddress } from "@/server/services/planning/data";
|
||||
import { trackedSessionWhere } from "@/server/services/planning/time-tracking";
|
||||
import { evaluateDelays, getFreedCapacity, type FreedTeam } from "@/server/services/planning/watch";
|
||||
import { customerDisplayName } from "@/server/services/work-orders/options";
|
||||
import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/**
|
||||
* Live situation (L13, Live-Lage) WITHOUT GPS: a technician is shown at the site of the order of
|
||||
* his active WorkSession; the status comes from that session (en_route → Unterwegs, running → In
|
||||
* Arbeit, paused → Pause, none → frei). Teams are crews ("Kolonnen"): ONE entry/marker per crew at
|
||||
* the site of its running order with the individual status of each member; technicians without a
|
||||
* team are shown individually. Device positions are never read: WorkSession.startLat/startLng,
|
||||
* deviceInfo and photo coordinates are not selected and never part of the result (privacy decision).
|
||||
*/
|
||||
|
||||
export const LIVE_STATUSES = ["en_route", "working", "paused", "free"] as const;
|
||||
export type LiveStatus = (typeof LIVE_STATUSES)[number];
|
||||
|
||||
const SESSION_STATUS: Record<string, LiveStatus> = { en_route: "en_route", running: "working", paused: "paused" };
|
||||
const RANK: Record<LiveStatus, number> = { working: 3, en_route: 2, paused: 1, free: 0 };
|
||||
|
||||
const liveSchema = z.object({
|
||||
teamId: z.string().min(1).max(64).nullish(),
|
||||
status: z.enum(LIVE_STATUSES).nullish(),
|
||||
});
|
||||
|
||||
export type LiveOrderRef = {
|
||||
id: string;
|
||||
number: string;
|
||||
title: string;
|
||||
customerName: string;
|
||||
siteName: string | null;
|
||||
address: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
};
|
||||
|
||||
export type LiveDelay = { level: "risk" | "overrun"; percent: number; workedMinutes: number; plannedMinutes: number };
|
||||
|
||||
export type LiveTechnician = {
|
||||
userId: string;
|
||||
name: string;
|
||||
teams: { id: string; name: string }[];
|
||||
/** Crew the person currently belongs to (team of the running session, else first team). */
|
||||
crewId: string | null;
|
||||
status: LiveStatus;
|
||||
since: string | null;
|
||||
/** null = no active session; `visible=false` = session on an order outside the caller's scope */
|
||||
current: (LiveOrderRef & { visible: true }) | { visible: false } | null;
|
||||
delay: LiveDelay | null;
|
||||
next: { id: string; number: string; title: string; plannedStart: string; customerName: string; city: string | null }[];
|
||||
};
|
||||
|
||||
export type LiveCrew = {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
/** Highest member status: In Arbeit > Unterwegs > Pause > frei */
|
||||
status: LiveStatus;
|
||||
current: LiveOrderRef | null;
|
||||
delay: LiveDelay | null;
|
||||
members: { userId: string; name: string; status: LiveStatus; since: string | null; orderNumber: string | null }[];
|
||||
freedMinutes: number | null;
|
||||
};
|
||||
|
||||
export type LiveSituation = {
|
||||
generatedAt: string;
|
||||
timeZone: string;
|
||||
teams: { id: string; name: string }[];
|
||||
crews: LiveCrew[];
|
||||
technicians: LiveTechnician[];
|
||||
counts: Record<LiveStatus, number>;
|
||||
attention: { id: string; number: string; title: string; plannedStart: string; teamName: string | null; customerName: string; reason: "not_accepted" | "overdue" }[];
|
||||
freed: FreedTeam[];
|
||||
withoutLocation: number;
|
||||
};
|
||||
|
||||
export async function getLiveSituation(ctx: ServiceCtx, raw: z.input<typeof liveSchema> = {}, opts: { now?: Date } = {}): Promise<LiveSituation> {
|
||||
const access = await planningAccess(ctx);
|
||||
const input = parseInput(liveSchema, raw);
|
||||
const timeZone = await tenantTimezone(ctx);
|
||||
const now = opts.now ?? new Date();
|
||||
const today = dayBounds(dayKeyOf(now, timeZone), timeZone);
|
||||
|
||||
const allTeams = await loadTeams(ctx, access.teamIds);
|
||||
const teams = input.teamId ? allTeams.filter((tm) => tm.id === input.teamId) : allTeams;
|
||||
|
||||
// People: active members today + team leaders of the visible teams.
|
||||
const people = new Map<string, { name: string; teams: { id: string; name: string }[] }>();
|
||||
const addPerson = (userId: string, name: string, team: { id: string; name: string }) => {
|
||||
const p = people.get(userId) ?? { name, teams: [] };
|
||||
if (!p.teams.some((x) => x.id === team.id)) p.teams.push(team);
|
||||
people.set(userId, p);
|
||||
};
|
||||
for (const team of teams) {
|
||||
const ref = { id: team.id, name: team.name };
|
||||
for (const m of team.members) {
|
||||
if (m.user.status === "ACTIVE" && m.validFrom <= now && (!m.validTo || m.validTo > now)) addPerson(m.userId, m.user.name, ref);
|
||||
}
|
||||
if (team.leader && team.leader.status === "ACTIVE") addPerson(team.leader.id, team.leader.name, ref);
|
||||
}
|
||||
|
||||
// Active clock sessions — explicit select: NEVER startLat/startLng/deviceInfo.
|
||||
const sessionWhere = { AND: [trackedSessionWhere, { status: { in: ["en_route", "running", "paused"] as ("en_route" | "running" | "paused")[] } }] };
|
||||
const sessions = await ctx.db.workSession.findMany({
|
||||
where: access.all && !input.teamId ? sessionWhere : { AND: [sessionWhere, { userId: { in: [...people.keys()] } }] },
|
||||
select: { id: true, userId: true, teamId: true, status: true, startedAt: true, workOrderId: true },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 1000,
|
||||
});
|
||||
// Backoffice without team filter: also technicians with a session outside any team.
|
||||
const unknown = [...new Set(sessions.map((s) => s.userId).filter((id) => !people.has(id)))];
|
||||
if (unknown.length) {
|
||||
const users = await ctx.db.user.findMany({ where: { id: { in: unknown }, status: "ACTIVE" }, select: { id: true, name: true } });
|
||||
for (const u of users) people.set(u.id, { name: u.name, teams: [] });
|
||||
}
|
||||
const sessionByUser = new Map<string, (typeof sessions)[number]>();
|
||||
for (const s of sessions) if (!sessionByUser.has(s.userId) && people.has(s.userId)) sessionByUser.set(s.userId, s);
|
||||
|
||||
const activeSessions = [...sessionByUser.values()];
|
||||
const openSegments = activeSessions.length
|
||||
? await ctx.db.timeEntry.findMany({
|
||||
where: { workSessionId: { in: activeSessions.map((s) => s.id) }, endedAt: null },
|
||||
select: { workSessionId: true, startedAt: true },
|
||||
orderBy: { startedAt: "desc" },
|
||||
})
|
||||
: [];
|
||||
const segmentStart = new Map<string, Date>();
|
||||
for (const e of openSegments) if (!segmentStart.has(e.workSessionId)) segmentStart.set(e.workSessionId, e.startedAt);
|
||||
|
||||
const scope = await workOrderScope(ctx);
|
||||
const teamIds = teams.map((tm) => tm.id);
|
||||
const userIds = [...people.keys()];
|
||||
const [currentOrders, todays, delayEval, freed] = await Promise.all([
|
||||
activeSessions.length
|
||||
? ctx.db.workOrder.findMany({
|
||||
where: { AND: [scope, { id: { in: [...new Set(activeSessions.map((s) => s.workOrderId))] } }] },
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
title: true,
|
||||
customer: { select: { companyName: true, firstName: true, lastName: true } },
|
||||
site: { select: { name: true, street: true, houseNumber: true, postalCode: true, city: true, latitude: true, longitude: true } },
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
ctx.db.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
scope,
|
||||
{
|
||||
plannedStart: { gte: today.start, lt: today.end },
|
||||
status: { in: ["planned", "assigned", "accepted"] },
|
||||
OR: [...(teamIds.length ? [{ assignedTeamId: { in: teamIds } }] : []), ...(userIds.length ? [{ assignees: { some: { userId: { in: userIds } } } }] : [])],
|
||||
},
|
||||
access.all && !input.teamId ? {} : { assignedTeamId: { in: teamIds } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
title: true,
|
||||
status: true,
|
||||
plannedStart: true,
|
||||
assignedTeamId: true,
|
||||
team: { select: { name: true } },
|
||||
customer: { select: { companyName: true, firstName: true, lastName: true } },
|
||||
site: { select: { city: true } },
|
||||
assignees: { select: { userId: true } },
|
||||
},
|
||||
orderBy: { plannedStart: "asc" },
|
||||
take: 500,
|
||||
}),
|
||||
evaluateDelays(ctx, { now, teamIds: access.all && !input.teamId ? null : teamIds }),
|
||||
getFreedCapacity(ctx, { now }),
|
||||
]);
|
||||
const orderById = new Map(currentOrders.map((o) => [o.id, o]));
|
||||
const delayById = new Map(
|
||||
delayEval.delays
|
||||
.filter((d) => d.level !== "ok")
|
||||
.map((d) => [d.workOrderId, { level: d.level as "risk" | "overrun", percent: d.percent, workedMinutes: d.workedMinutes, plannedMinutes: d.plannedMinutes }]),
|
||||
);
|
||||
|
||||
const technicians: LiveTechnician[] = [...people].map(([userId, p]) => {
|
||||
const session = sessionByUser.get(userId);
|
||||
const status: LiveStatus = session ? SESSION_STATUS[session.status] ?? "free" : "free";
|
||||
const order = session ? orderById.get(session.workOrderId) : undefined;
|
||||
let current: LiveTechnician["current"] = null;
|
||||
if (session && order) {
|
||||
const coords = isValidLatLng(order.site) ? { latitude: order.site!.latitude as number, longitude: order.site!.longitude as number } : { latitude: null, longitude: null };
|
||||
current = {
|
||||
visible: true,
|
||||
id: order.id,
|
||||
number: order.number,
|
||||
title: order.title,
|
||||
customerName: customerDisplayName(order.customer),
|
||||
siteName: order.site?.name ?? null,
|
||||
address: siteAddress(order.site),
|
||||
...coords,
|
||||
};
|
||||
} else if (session) {
|
||||
current = { visible: false };
|
||||
}
|
||||
const myTeams = new Set(p.teams.map((x) => x.id));
|
||||
const crewId = session?.teamId && myTeams.has(session.teamId) ? session.teamId : p.teams[0]?.id ?? null;
|
||||
const next = todays
|
||||
.filter((o) => (o.assignees.length ? o.assignees.some((a) => a.userId === userId) : !!o.assignedTeamId && myTeams.has(o.assignedTeamId)))
|
||||
.slice(0, 3)
|
||||
.map((o) => ({ id: o.id, number: o.number, title: o.title, plannedStart: o.plannedStart!.toISOString(), customerName: customerDisplayName(o.customer), city: o.site?.city ?? null }));
|
||||
const since = session ? (segmentStart.get(session.id) ?? session.startedAt).toISOString() : null;
|
||||
const delay = current?.visible ? delayById.get(current.id) ?? null : null;
|
||||
return { userId, name: p.name, teams: p.teams, crewId, status, since, current, delay, next };
|
||||
});
|
||||
|
||||
const counts = Object.fromEntries(LIVE_STATUSES.map((s) => [s, technicians.filter((x) => x.status === s).length])) as Record<LiveStatus, number>;
|
||||
const matches = (x: { status: LiveStatus }) => !input.status || x.status === input.status;
|
||||
const freedByTeam = new Map(freed.teams.map((f) => [f.teamId, f.earlyMinutes]));
|
||||
|
||||
// One entry per crew (team): members with their own status, crew order = order of the most active member.
|
||||
const crews: LiveCrew[] = teams
|
||||
.map((team) => {
|
||||
const members = technicians.filter((x) => x.crewId === team.id).sort((a, b) => RANK[b.status] - RANK[a.status] || a.name.localeCompare(b.name, "de"));
|
||||
const lead = members.find((m) => m.current?.visible);
|
||||
const current = lead?.current?.visible ? (({ visible: _v, ...rest }) => (void _v, rest))(lead.current) : null;
|
||||
return {
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
status: members[0]?.status ?? ("free" as LiveStatus),
|
||||
current,
|
||||
delay: current ? delayById.get(current.id) ?? null : null,
|
||||
members: members.map((m) => ({ userId: m.userId, name: m.name, status: m.status, since: m.since, orderNumber: m.current?.visible ? m.current.number : null })),
|
||||
freedMinutes: freedByTeam.get(team.id) ?? null,
|
||||
};
|
||||
})
|
||||
.filter((c) => c.members.length > 0 && matches(c));
|
||||
|
||||
const filtered = technicians
|
||||
.filter(matches)
|
||||
.sort((a, b) => RANK[b.status] - RANK[a.status] || a.name.localeCompare(b.name, "de"));
|
||||
|
||||
const attention = todays
|
||||
.filter((o) => o.plannedStart! < now)
|
||||
.map((o) => ({
|
||||
id: o.id,
|
||||
number: o.number,
|
||||
title: o.title,
|
||||
plannedStart: o.plannedStart!.toISOString(),
|
||||
teamName: o.team?.name ?? null,
|
||||
customerName: customerDisplayName(o.customer),
|
||||
reason: ((o.status as WorkOrderStatus) === "accepted" ? "overdue" : "not_accepted") as "overdue" | "not_accepted",
|
||||
}));
|
||||
|
||||
return {
|
||||
generatedAt: now.toISOString(),
|
||||
timeZone,
|
||||
teams: allTeams.map((tm) => ({ id: tm.id, name: tm.name })),
|
||||
crews,
|
||||
technicians: filtered,
|
||||
counts,
|
||||
attention,
|
||||
freed: input.teamId ? freed.teams.filter((f) => f.teamId === input.teamId) : freed.teams,
|
||||
// running orders (distinct) whose site has no coordinates — shown in the list only
|
||||
withoutLocation: new Set(filtered.flatMap((x) => (x.current?.visible && x.current.latitude === null ? [x.current.id] : []))).size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { z } from "zod";
|
||||
import deMessages from "../../../../messages/de/planning.json";
|
||||
import enMessages from "../../../../messages/en/planning.json";
|
||||
import { formatDistance, haversineKm, isValidLatLng } from "@/lib/geo/distance";
|
||||
import { computeTeamDay, effectiveDurationMinutes, minutesOnDay, timeWindow } from "@/lib/planning/capacity";
|
||||
import { addDays, dayBounds, dayKeyOf, dayRange, isDayKey, minutesOfDay } from "@/lib/planning/days";
|
||||
import { fillTemplate, formatDayShort, formatMinutes } from "@/lib/planning/text";
|
||||
import { wallTimeToUtc } from "@/lib/work-orders/time";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { requestSiteGeocoding } from "@/server/services/geo/dispatch";
|
||||
import { assertCanPlanAll } from "@/server/services/planning/access";
|
||||
import {
|
||||
loadOrder,
|
||||
loadPlannedOrders,
|
||||
loadTeams,
|
||||
loadUnplannedOrders,
|
||||
toBoardOrder,
|
||||
toPlanningOrder,
|
||||
toPlanningTeam,
|
||||
type OrderRow,
|
||||
} from "@/server/services/planning/data";
|
||||
import { SCHEDULABLE_STATUSES } from "@/server/services/planning/schedule";
|
||||
import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
|
||||
/**
|
||||
* Assignment suggestions (L13): straight-line distance to orders a team already has on a day +
|
||||
* free capacity that day. Suggestions only — the human always schedules (no auto-save).
|
||||
* Score: distance dominates (0.6), then free capacity (0.2), early date (weight by priority), tight −0.25.
|
||||
*/
|
||||
|
||||
const MESSAGES = { de: deMessages, en: enMessages } as const;
|
||||
type Locale = keyof typeof MESSAGES;
|
||||
|
||||
const PRIORITY_DATE_WEIGHT = { low: 0.05, normal: 0.15, high: 0.25, urgent: 0.35 } as const;
|
||||
const DAY_START_MINUTES = 8 * 60;
|
||||
const LATEST_START_MINUTES = 18 * 60;
|
||||
|
||||
const recommendSchema = z.object({
|
||||
workOrderId: z.string().min(1).max(64),
|
||||
days: z.coerce.number().int().min(1).max(31).default(10),
|
||||
radiusKm: z.coerce.number().min(0.5).max(200).default(25),
|
||||
from: z.string().refine(isDayKey).optional(),
|
||||
locale: z.enum(["de", "en"]).default("de"),
|
||||
});
|
||||
export type RecommendInput = z.input<typeof recommendSchema>;
|
||||
|
||||
export type Recommendation = {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
day: string;
|
||||
distanceKm: number;
|
||||
nearOrder: { id: string; number: string };
|
||||
freeMinutes: number;
|
||||
requiredMinutes: number;
|
||||
tight: boolean;
|
||||
score: number;
|
||||
suggestedStart: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type RecommendationResult = {
|
||||
workOrderId: string;
|
||||
number: string;
|
||||
status: "ok" | "no_coordinates" | "not_schedulable";
|
||||
geocodeStatus: string | null;
|
||||
requiredMinutes: number;
|
||||
radiusKm: number;
|
||||
recommendations: Recommendation[];
|
||||
hint: string | null;
|
||||
};
|
||||
|
||||
async function loadTarget(ctx: ServiceCtx, workOrderId: string): Promise<OrderRow> {
|
||||
const row = await loadOrder(ctx, workOrderId);
|
||||
if (!row) throw new ServiceError("not_found", "work_order_not_found");
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Ask for geocoding when the target site has no coordinates (not for confirmed "not found"). */
|
||||
function nudgeGeocoding(ctx: ServiceCtx, row: OrderRow) {
|
||||
if (row.site && row.site.geocodeStatus !== "not_found") void requestSiteGeocoding(ctx.tenantId, row.site.id);
|
||||
}
|
||||
|
||||
export async function recommendAssignments(ctx: ServiceCtx, raw: RecommendInput): Promise<RecommendationResult> {
|
||||
await assertCanPlanAll(ctx);
|
||||
const input = parseInput(recommendSchema, raw);
|
||||
const timeZone = await tenantTimezone(ctx);
|
||||
const t = MESSAGES[input.locale as Locale].recommend;
|
||||
const row = await loadTarget(ctx, input.workOrderId);
|
||||
const target = toPlanningOrder(row);
|
||||
const requiredMinutes = effectiveDurationMinutes({ ...target, plannedEnd: null }, timeZone);
|
||||
const base = {
|
||||
workOrderId: row.id,
|
||||
number: row.number,
|
||||
geocodeStatus: row.site?.geocodeStatus ?? null,
|
||||
requiredMinutes,
|
||||
radiusKm: input.radiusKm,
|
||||
recommendations: [] as Recommendation[],
|
||||
};
|
||||
|
||||
if (!SCHEDULABLE_STATUSES.includes(row.status)) return { ...base, status: "not_schedulable", hint: t.notSchedulable };
|
||||
if (!row.site || !isValidLatLng(row.site)) {
|
||||
nudgeGeocoding(ctx, row);
|
||||
return { ...base, status: "no_coordinates", hint: row.site?.geocodeStatus === "not_found" ? t.addressNotFound : t.noCoordinates };
|
||||
}
|
||||
const origin = { latitude: row.site.latitude as number, longitude: row.site.longitude as number };
|
||||
|
||||
const today = dayKeyOf(new Date(), timeZone);
|
||||
const from = input.from && input.from > today ? input.from : today;
|
||||
const days = dayRange(from, addDays(from, input.days - 1));
|
||||
const teams = await loadTeams(ctx, null);
|
||||
const rows = await loadPlannedOrders(ctx, {
|
||||
teamIds: teams.map((tm) => tm.id),
|
||||
start: dayBounds(days[0], timeZone).start,
|
||||
end: dayBounds(days[days.length - 1], timeZone).end,
|
||||
excludeId: row.id,
|
||||
});
|
||||
const byId = new Map(rows.map((r) => [r.id, r]));
|
||||
const orders = rows.map(toPlanningOrder);
|
||||
|
||||
const candidates: Omit<Recommendation, "text">[] = [];
|
||||
for (const team of teams) {
|
||||
const pt = toPlanningTeam(team);
|
||||
const teamOrders = orders.filter((o) => o.teamId === team.id);
|
||||
days.forEach((day, idx) => {
|
||||
const td = computeTeamDay(pt, day, teamOrders, timeZone);
|
||||
if (!td.workingDay || td.capacityMinutes <= 0) return;
|
||||
const freeMinutes = td.capacityMinutes - td.plannedMinutes;
|
||||
if (freeMinutes <= 0) return;
|
||||
let near: { id: string; number: string; km: number } | null = null;
|
||||
for (const o of teamOrders) {
|
||||
if (minutesOnDay(o, day, timeZone) <= 0) continue;
|
||||
const site = byId.get(o.id)?.site;
|
||||
if (!site || !isValidLatLng(site)) continue;
|
||||
const km = haversineKm(origin, { latitude: site.latitude as number, longitude: site.longitude as number });
|
||||
if (!near || km < near.km) near = { id: o.id, number: byId.get(o.id)!.number, km };
|
||||
}
|
||||
if (!near || near.km > input.radiusKm) return;
|
||||
const tight = freeMinutes < requiredMinutes;
|
||||
const score =
|
||||
0.6 * (1 - near.km / input.radiusKm) +
|
||||
0.2 * (Math.min(freeMinutes / requiredMinutes, 2) / 2) +
|
||||
PRIORITY_DATE_WEIGHT[row.priority] * (1 - idx / days.length) -
|
||||
(tight ? 0.25 : 0);
|
||||
|
||||
// Suggested start: 08:00 or right after the team's last timed order that day (15-min grid).
|
||||
const lastEnd = teamOrders
|
||||
.filter((o) => minutesOnDay(o, day, timeZone) > 0)
|
||||
.map((o) => timeWindow(o, timeZone)?.end)
|
||||
.filter((v): v is number => typeof v === "number")
|
||||
.sort((a, b) => b - a)[0];
|
||||
let startMinutes = DAY_START_MINUTES;
|
||||
if (lastEnd !== undefined) {
|
||||
const m = Math.ceil(minutesOfDay(new Date(lastEnd), timeZone) / 15) * 15;
|
||||
if (m > startMinutes && m <= LATEST_START_MINUTES) startMinutes = m;
|
||||
}
|
||||
const hh = String(Math.floor(startMinutes / 60)).padStart(2, "0");
|
||||
const mm = String(startMinutes % 60).padStart(2, "0");
|
||||
const suggestedStart = wallTimeToUtc(`${day}T${hh}:${mm}`, timeZone) ?? dayBounds(day, timeZone).start;
|
||||
|
||||
candidates.push({
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
day,
|
||||
distanceKm: Math.round(near.km * 10) / 10,
|
||||
nearOrder: { id: near.id, number: near.number },
|
||||
freeMinutes,
|
||||
requiredMinutes,
|
||||
tight,
|
||||
score: Math.round(score * 1000) / 1000,
|
||||
suggestedStart: suggestedStart.toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const locale = input.locale;
|
||||
const recommendations = candidates
|
||||
.sort((a, b) => b.score - a.score || a.distanceKm - b.distanceKm || a.day.localeCompare(b.day))
|
||||
.slice(0, 5)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
text: fillTemplate(c.tight ? t.reasonTight : t.reason, {
|
||||
distance: formatDistance(c.distanceKm, locale),
|
||||
number: c.nearOrder.number,
|
||||
team: c.teamName,
|
||||
day: formatDayShort(c.day, locale),
|
||||
free: formatMinutes(c.freeMinutes, locale),
|
||||
required: formatMinutes(c.requiredMinutes, locale),
|
||||
}),
|
||||
}));
|
||||
|
||||
return { ...base, status: "ok", recommendations, hint: recommendations.length ? null : fillTemplate(t.none, { radius: input.radiusKm, days: input.days }) };
|
||||
}
|
||||
|
||||
const nearbySchema = z.object({
|
||||
workOrderId: z.string().min(1).max(64),
|
||||
radiusKm: z.coerce.number().min(0.5).max(100).default(5),
|
||||
});
|
||||
|
||||
export type NearbyOrder = ReturnType<typeof toBoardOrder> & { distanceKm: number };
|
||||
|
||||
/** Other unplanned orders around the target ("zusammen einplanen?"). */
|
||||
export async function findNearbyUnplanned(
|
||||
ctx: ServiceCtx,
|
||||
raw: z.input<typeof nearbySchema>,
|
||||
): Promise<{ status: "ok" | "no_coordinates"; radiusKm: number; items: NearbyOrder[] }> {
|
||||
await assertCanPlanAll(ctx);
|
||||
const input = parseInput(nearbySchema, raw);
|
||||
const timeZone = await tenantTimezone(ctx);
|
||||
const row = await loadTarget(ctx, input.workOrderId);
|
||||
if (!row.site || !isValidLatLng(row.site)) {
|
||||
nudgeGeocoding(ctx, row);
|
||||
return { status: "no_coordinates", radiusKm: input.radiusKm, items: [] };
|
||||
}
|
||||
const origin = { latitude: row.site.latitude as number, longitude: row.site.longitude as number };
|
||||
const items = (await loadUnplannedOrders(ctx, null, 500))
|
||||
.filter((r) => r.id !== row.id && r.site && isValidLatLng(r.site))
|
||||
.map((r) => ({ ...toBoardOrder(r, timeZone), distanceKm: Math.round(haversineKm(origin, { latitude: r.site!.latitude as number, longitude: r.site!.longitude as number }) * 10) / 10 }))
|
||||
.filter((r) => r.distanceKm <= input.radiusKm)
|
||||
.sort((a, b) => a.distanceKm - b.distanceKm)
|
||||
.slice(0, 10);
|
||||
return { status: "ok", radiusKm: input.radiusKm, items };
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { z } from "zod";
|
||||
import { orderDays, SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/planning/capacity";
|
||||
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board";
|
||||
import { assignWorkOrder } from "@/server/services/work-orders/assign";
|
||||
import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared";
|
||||
import { updateWorkOrder } from "@/server/services/work-orders/update";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/**
|
||||
* Schedule a work order from the planning board: team + planned start/end (+ duration) in ONE
|
||||
* transaction on top of the L2 services (assignWorkOrder → work_order.assigned, updateWorkOrder →
|
||||
* work_order.changed). The human always confirms — nothing is scheduled automatically.
|
||||
*/
|
||||
|
||||
export { SCHEDULABLE_STATUSES };
|
||||
export const VERSION_CONFLICT_MESSAGE = "Auftrag wurde zwischenzeitlich geändert";
|
||||
|
||||
const scheduleSchema = z.object({
|
||||
workOrderId: z.string().min(1).max(64),
|
||||
teamId: z.string().min(1).max(64),
|
||||
plannedStart: z.coerce.date(),
|
||||
plannedEnd: z.coerce.date().nullish(),
|
||||
plannedDurationMinutes: z.coerce.number().int().min(15).max(14 * 24 * 60).nullish(),
|
||||
baseVersion: z.coerce.number().int().positive(),
|
||||
});
|
||||
export type ScheduleInput = z.input<typeof scheduleSchema>;
|
||||
|
||||
export type ScheduleResult = {
|
||||
id: string;
|
||||
number: string;
|
||||
version: number;
|
||||
status: WorkOrderStatus;
|
||||
teamId: string;
|
||||
plannedStart: string;
|
||||
plannedEnd: string | null;
|
||||
plannedDurationMinutes: number | null;
|
||||
/** Conflicts of the target team on the target day(s), computed after the commit. */
|
||||
conflicts: PlanningConflict[];
|
||||
};
|
||||
|
||||
export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise<ScheduleResult> {
|
||||
assertCan(ctx, "work_order:assign");
|
||||
assertCan(ctx, "work_order:write");
|
||||
const input = parseInput(scheduleSchema, raw);
|
||||
if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]);
|
||||
|
||||
const saved = await inTransaction(ctx, async (tx) => {
|
||||
const wo = await tx.db.workOrder.findFirst({
|
||||
where: { AND: [{ id: input.workOrderId }, await workOrderScope(tx)] },
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
status: true,
|
||||
version: true,
|
||||
assignedTeamId: true,
|
||||
teamLeadUserId: true,
|
||||
plannedStart: true,
|
||||
plannedEnd: true,
|
||||
plannedDurationMinutes: true,
|
||||
assignees: { select: { userId: true } },
|
||||
},
|
||||
});
|
||||
if (!wo) throw new ServiceError("not_found", "work_order_not_found");
|
||||
if (wo.version !== input.baseVersion) {
|
||||
throw new ServiceError("conflict", VERSION_CONFLICT_MESSAGE, { reason: "version_conflict", currentVersion: wo.version, baseVersion: input.baseVersion });
|
||||
}
|
||||
if (!SCHEDULABLE_STATUSES.includes(wo.status as WorkOrderStatus)) throw new ServiceError("invalid", "not_schedulable", { status: wo.status });
|
||||
|
||||
// Keep the order's length when only the start moves and no new end was given.
|
||||
const plannedEnd =
|
||||
input.plannedEnd !== undefined
|
||||
? input.plannedEnd ?? null
|
||||
: wo.plannedStart && wo.plannedEnd
|
||||
? new Date(input.plannedStart.getTime() + (wo.plannedEnd.getTime() - wo.plannedStart.getTime()))
|
||||
: null;
|
||||
const duration = input.plannedDurationMinutes !== undefined ? input.plannedDurationMinutes ?? null : wo.plannedDurationMinutes;
|
||||
|
||||
let version = wo.version;
|
||||
let status = wo.status as WorkOrderStatus;
|
||||
const teamChanged = wo.assignedTeamId !== input.teamId;
|
||||
if (teamChanged || ["draft", "review_required", "planned"].includes(wo.status)) {
|
||||
let userIds = wo.assignees.map((a) => a.userId);
|
||||
if (teamChanged && userIds.length) {
|
||||
const now = new Date();
|
||||
const stay = await tx.db.teamMember.findMany({
|
||||
where: { teamId: input.teamId, userId: { in: userIds }, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] },
|
||||
select: { userId: true },
|
||||
});
|
||||
userIds = [...new Set(stay.map((s) => s.userId))];
|
||||
}
|
||||
const res = await assignWorkOrder(tx, {
|
||||
workOrderId: wo.id,
|
||||
teamId: input.teamId,
|
||||
userIds,
|
||||
teamLeadUserId: teamChanged ? null : wo.teamLeadUserId,
|
||||
baseVersion: version,
|
||||
});
|
||||
version = res.version;
|
||||
status = res.status;
|
||||
}
|
||||
|
||||
const datesChanged = wo.plannedStart?.getTime() !== input.plannedStart.getTime() || (wo.plannedEnd?.getTime() ?? null) !== (plannedEnd?.getTime() ?? null);
|
||||
if (datesChanged) {
|
||||
const res = await updateWorkOrder(tx, wo.id, { plannedStart: input.plannedStart, plannedEnd }, version);
|
||||
version = res.version;
|
||||
}
|
||||
if (duration !== wo.plannedDurationMinutes) {
|
||||
version = await writeWithVersion(tx, { id: wo.id, version }, { plannedDurationMinutes: duration });
|
||||
}
|
||||
|
||||
if (version !== wo.version) {
|
||||
await writeAuditLog({
|
||||
tenantId: tx.tenantId,
|
||||
actorId: tx.userId,
|
||||
action: "update",
|
||||
entity: "work_order",
|
||||
entityId: wo.id,
|
||||
before: snapshot({ assignedTeamId: wo.assignedTeamId, plannedStart: wo.plannedStart, plannedEnd: wo.plannedEnd, plannedDurationMinutes: wo.plannedDurationMinutes, status: wo.status }),
|
||||
after: snapshot({ source: "planning", assignedTeamId: input.teamId, plannedStart: input.plannedStart, plannedEnd, plannedDurationMinutes: duration, status, version }),
|
||||
});
|
||||
}
|
||||
return { id: wo.id, number: wo.number, version, status, plannedEnd, duration };
|
||||
});
|
||||
|
||||
let conflicts: PlanningConflict[] = [];
|
||||
try {
|
||||
const days = orderDays({ plannedStart: input.plannedStart, plannedEnd: saved.plannedEnd }, await tenantTimezone(ctx)).slice(0, MAX_BOARD_DAYS);
|
||||
const board = await getPlanningBoard(ctx, { from: days[0], to: days[days.length - 1], teamIds: [input.teamId] });
|
||||
conflicts = board.conflicts.filter((c) => c.teamIds.includes(input.teamId));
|
||||
} catch (err) {
|
||||
if (!(err instanceof ServiceError)) throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
id: saved.id,
|
||||
number: saved.number,
|
||||
version: saved.version,
|
||||
status: saved.status,
|
||||
teamId: input.teamId,
|
||||
plannedStart: input.plannedStart.toISOString(),
|
||||
plannedEnd: saved.plannedEnd?.toISOString() ?? null,
|
||||
plannedDurationMinutes: saved.duration,
|
||||
conflicts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { dayKeyOf } from "@/lib/planning/days";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { getPlanningBoard } from "@/server/services/planning/board";
|
||||
import { getLiveSituation } from "@/server/services/planning/live";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
|
||||
/** Dashboard tile "Planung heute" (L13): crews on the job, conflicts today, unplanned orders. Null without planning access. */
|
||||
export async function getPlanningToday(ctx: ServiceCtx): Promise<{ day: string; teamsTotal: number; teamsWorking: number; conflicts: number; unplanned: number } | null> {
|
||||
try {
|
||||
const day = dayKeyOf(new Date(), await tenantTimezone(ctx));
|
||||
const [board, live] = await Promise.all([getPlanningBoard(ctx, { from: day, to: day }), getLiveSituation(ctx)]);
|
||||
return {
|
||||
day,
|
||||
teamsTotal: board.teams.length,
|
||||
teamsWorking: live.crews.filter((c) => c.status !== "free").length,
|
||||
conflicts: board.conflictCount,
|
||||
unplanned: board.unplanned.length,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "forbidden") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from "zod";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { parseInput } from "@/server/services/work-orders/_shared";
|
||||
|
||||
/** Planning settings of a team = crew (L13): crew working day in minutes + working days bit mask. */
|
||||
|
||||
const settingsSchema = z.object({
|
||||
dailyCapacityMinutes: z.coerce.number().int().min(0).max(24 * 60),
|
||||
workingDays: z.coerce.number().int().min(0).max(127),
|
||||
});
|
||||
export type TeamPlanningSettingsInput = z.input<typeof settingsSchema>;
|
||||
|
||||
export async function updateTeamPlanningSettings(ctx: ServiceCtx, teamId: string, raw: TeamPlanningSettingsInput) {
|
||||
assertCan(ctx, "team:manage");
|
||||
const input = parseInput(settingsSchema, raw);
|
||||
const before = await ctx.db.team.findFirst({
|
||||
where: { id: teamId, deletedAt: null },
|
||||
select: { id: true, dailyCapacityMinutes: true, workingDays: true },
|
||||
});
|
||||
if (!before) throw new ServiceError("not_found", "team_not_found");
|
||||
const after = await ctx.db.team.update({
|
||||
where: { id: teamId },
|
||||
data: input,
|
||||
select: { id: true, dailyCapacityMinutes: true, workingDays: true },
|
||||
});
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "team", entityId: teamId, before, after });
|
||||
return after;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Adapter to the time tracking model of lane L12 (read-only use in planning).
|
||||
*
|
||||
* L12 adds `TimeEntry.source` (tracked | manual), `TimeEntry.approvalStatus` (approved | pending |
|
||||
* rejected) and `WorkSession.manual`. Planning rules:
|
||||
* - live situation / delay evaluation: only `source = tracked` entries of non-manual sessions,
|
||||
* - actual duration of completed orders: only `approvalStatus = approved` entries.
|
||||
* The filters are enabled automatically as soon as the generated Prisma client knows the fields
|
||||
* (after the L12 merge); before that they are no-ops, so this lane compiles against both schemas.
|
||||
*/
|
||||
|
||||
const entryFields = new Set<string>(Object.values(Prisma.TimeEntryScalarFieldEnum));
|
||||
const sessionFields = new Set<string>(Object.values(Prisma.WorkSessionScalarFieldEnum));
|
||||
|
||||
export const TIME_TRACKING_FIELDS = {
|
||||
entrySource: entryFields.has("source"),
|
||||
entryApproval: entryFields.has("approvalStatus"),
|
||||
sessionManual: sessionFields.has("manual"),
|
||||
} as const;
|
||||
|
||||
/** Time entries recorded by the running clock (no manual additions). */
|
||||
export const trackedEntryWhere = (TIME_TRACKING_FIELDS.entrySource ? { source: "tracked" } : {}) as unknown as Prisma.TimeEntryWhereInput;
|
||||
|
||||
/** Approved time entries (actual duration of completed orders). */
|
||||
export const approvedEntryWhere = (TIME_TRACKING_FIELDS.entryApproval ? { approvalStatus: "approved" } : {}) as unknown as Prisma.TimeEntryWhereInput;
|
||||
|
||||
/** Sessions started by the clock (manual after-the-fact sessions excluded). */
|
||||
export const trackedSessionWhere = (TIME_TRACKING_FIELDS.sessionManual ? { manual: false } : {}) as unknown as Prisma.WorkSessionWhereInput;
|
||||
@@ -0,0 +1,391 @@
|
||||
import { haversineKm, isValidLatLng } from "@/lib/geo/distance";
|
||||
import {
|
||||
computeTeamDay,
|
||||
effectiveDurationMinutes,
|
||||
estimateTravelMinutes,
|
||||
hasTimeOfDay,
|
||||
overrunBucket,
|
||||
unionMinutes,
|
||||
} from "@/lib/planning/capacity";
|
||||
import { addDays, dayBounds, dayKeyOf, isDayKey } from "@/lib/planning/days";
|
||||
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { PERMISSIONS } from "@/server/rbac";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { planningAccess } from "@/server/services/planning/access";
|
||||
import {
|
||||
loadPlannedOrders,
|
||||
loadTeams,
|
||||
loadUnplannedOrders,
|
||||
ORDER_SELECT,
|
||||
toBoardOrder,
|
||||
toPlanningOrder,
|
||||
toPlanningTeam,
|
||||
type BoardOrder,
|
||||
type OrderRow,
|
||||
} from "@/server/services/planning/data";
|
||||
import { approvedEntryWhere, trackedEntryWhere, trackedSessionWhere } from "@/server/services/planning/time-tracking";
|
||||
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/**
|
||||
* L13 Planung — watch functions:
|
||||
* - evaluateDelays: running orders, recorded crew work time (union of tracked `work` segments of all
|
||||
* sessions of the order — parallel crew members count once) vs. planned duration → ≥ 80 % "risk",
|
||||
* ≥ 100 % "overrun"; next planned order of the same crew today → endangered when remaining time +
|
||||
* estimated travel passes its start, or the overrun pushes the crew day over capacity.
|
||||
* - getFreedCapacity: orders of the day completed before their planned end → free crew minutes +
|
||||
* suggestions (pull forward later/next orders of the crew, nearby unplanned orders). Never changes data.
|
||||
* - runPlanningWatch: the only place that emits planning.* events (job `planning-watch`, every 5 min);
|
||||
* duplicates are prevented by an append-only ledger in the audit log (entity `planning_alert`).
|
||||
*/
|
||||
|
||||
export const DELAY_RISK_RATIO = 0.8;
|
||||
export const RUNNING_STATUSES: WorkOrderStatus[] = ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"];
|
||||
export const COMPLETED_STATUSES: WorkOrderStatus[] = ["technically_completed", "signature_pending", "in_review", "released_for_billing", "billed"];
|
||||
const UPCOMING_STATUSES: WorkOrderStatus[] = ["planned", "assigned", "accepted"];
|
||||
const ACTIVE_SESSION_STATUSES: ("en_route" | "running" | "paused")[] = ["en_route", "running", "paused"];
|
||||
export const MIN_FREED_MINUTES = 15;
|
||||
const PULL_FORWARD_DAYS = 5;
|
||||
const MAX_SUGGESTIONS = 3;
|
||||
|
||||
const coords = (row: Pick<OrderRow, "site"> | undefined) =>
|
||||
row?.site && isValidLatLng(row.site) ? { latitude: row.site.latitude as number, longitude: row.site.longitude as number } : null;
|
||||
|
||||
const roundUpQuarter = (ms: number) => Math.ceil(ms / (15 * 60_000)) * 15 * 60_000;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Delays
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export type OrderDelay = {
|
||||
workOrderId: string;
|
||||
number: string;
|
||||
teamId: string | null;
|
||||
plannedMinutes: number;
|
||||
workedMinutes: number;
|
||||
remainingMinutes: number;
|
||||
ratio: number;
|
||||
percent: number;
|
||||
level: "ok" | "risk" | "overrun";
|
||||
};
|
||||
|
||||
export type FollowupRisk = {
|
||||
workOrderId: string;
|
||||
number: string;
|
||||
teamId: string;
|
||||
day: string;
|
||||
blockedById: string;
|
||||
blockedByNumber: string;
|
||||
reason: "start" | "capacity";
|
||||
etaAt: string;
|
||||
travelMinutes: number;
|
||||
};
|
||||
|
||||
export async function evaluateDelays(ctx: ServiceCtx, opts: { now?: Date; teamIds?: string[] | null } = {}): Promise<{ delays: OrderDelay[]; followups: FollowupRisk[] }> {
|
||||
const now = opts.now ?? new Date();
|
||||
const timeZone = await tenantTimezone(ctx);
|
||||
const scope = await workOrderScope(ctx);
|
||||
const running = await ctx.db.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
scope,
|
||||
{ status: { in: RUNNING_STATUSES } },
|
||||
{ workSessions: { some: { AND: [trackedSessionWhere, { status: { in: ACTIVE_SESSION_STATUSES } }] } } },
|
||||
opts.teamIds ? { assignedTeamId: { in: opts.teamIds } } : {},
|
||||
],
|
||||
},
|
||||
select: ORDER_SELECT,
|
||||
take: 500,
|
||||
});
|
||||
if (running.length === 0) return { delays: [], followups: [] };
|
||||
|
||||
const entries = await ctx.db.timeEntry.findMany({
|
||||
where: { AND: [trackedEntryWhere, { type: "work", startedAt: { lt: now }, workSession: { AND: [trackedSessionWhere, { workOrderId: { in: running.map((r) => r.id) } }] } }] },
|
||||
select: { startedAt: true, endedAt: true, workSession: { select: { workOrderId: true } } },
|
||||
});
|
||||
const segments = new Map<string, { start: number; end: number }[]>();
|
||||
for (const e of entries) {
|
||||
const list = segments.get(e.workSession.workOrderId) ?? [];
|
||||
list.push({ start: e.startedAt.getTime(), end: Math.min((e.endedAt ?? now).getTime(), now.getTime()) });
|
||||
segments.set(e.workSession.workOrderId, list);
|
||||
}
|
||||
|
||||
const delays: OrderDelay[] = running.map((r) => {
|
||||
const plannedMinutes = effectiveDurationMinutes(toPlanningOrder(r), timeZone);
|
||||
const workedMinutes = unionMinutes(segments.get(r.id) ?? []);
|
||||
const ratio = plannedMinutes > 0 ? workedMinutes / plannedMinutes : 0;
|
||||
return {
|
||||
workOrderId: r.id,
|
||||
number: r.number,
|
||||
teamId: r.assignedTeamId,
|
||||
plannedMinutes,
|
||||
workedMinutes,
|
||||
remainingMinutes: Math.max(0, plannedMinutes - workedMinutes),
|
||||
ratio,
|
||||
percent: Math.round(ratio * 100),
|
||||
level: ratio >= 1 ? "overrun" : ratio >= DELAY_RISK_RATIO ? "risk" : "ok",
|
||||
};
|
||||
});
|
||||
|
||||
const today = dayKeyOf(now, timeZone);
|
||||
const { start, end } = dayBounds(today, timeZone);
|
||||
const teamIds = [...new Set(delays.map((d) => d.teamId).filter((t): t is string => !!t))];
|
||||
const [teams, dayOrders] = await Promise.all([loadTeams(ctx, teamIds), loadPlannedOrders(ctx, { teamIds, start, end })]);
|
||||
const runningById = new Map(running.map((r) => [r.id, r]));
|
||||
|
||||
const followups: FollowupRisk[] = [];
|
||||
for (const d of delays) {
|
||||
const team = d.teamId ? teams.find((t) => t.id === d.teamId) : undefined;
|
||||
if (!team) continue;
|
||||
const next = dayOrders
|
||||
.filter((o) => o.assignedTeamId === team.id && o.id !== d.workOrderId && UPCOMING_STATUSES.includes(o.status as WorkOrderStatus) && o.plannedStart && o.plannedStart.getTime() >= start.getTime())
|
||||
.sort((a, b) => a.plannedStart!.getTime() - b.plannedStart!.getTime())[0];
|
||||
if (!next) continue;
|
||||
const from = coords(runningById.get(d.workOrderId));
|
||||
const to = coords(next);
|
||||
const travelMinutes = estimateTravelMinutes(from && to ? haversineKm(from, to) : null);
|
||||
const eta = now.getTime() + (d.remainingMinutes + travelMinutes) * 60_000;
|
||||
const overrunExtra = Math.max(0, d.workedMinutes - d.plannedMinutes);
|
||||
let reason: FollowupRisk["reason"] | null = null;
|
||||
if (hasTimeOfDay(next.plannedStart!, timeZone) && eta > next.plannedStart!.getTime()) {
|
||||
reason = "start";
|
||||
} else if (overrunExtra > 0) {
|
||||
const td = computeTeamDay(toPlanningTeam(team), today, dayOrders.filter((o) => o.assignedTeamId === team.id).map(toPlanningOrder), timeZone);
|
||||
if (td.capacityMinutes > 0 && td.plannedMinutes + overrunExtra + travelMinutes > td.capacityMinutes) reason = "capacity";
|
||||
}
|
||||
if (reason) {
|
||||
followups.push({
|
||||
workOrderId: next.id,
|
||||
number: next.number,
|
||||
teamId: team.id,
|
||||
day: today,
|
||||
blockedById: d.workOrderId,
|
||||
blockedByNumber: d.number,
|
||||
reason,
|
||||
etaAt: new Date(eta).toISOString(),
|
||||
travelMinutes,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { delays, followups };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Freed capacity ("früher fertig")
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export type FreedSuggestion = {
|
||||
kind: "pull_forward" | "nearby_unplanned";
|
||||
order: BoardOrder;
|
||||
distanceKm: number | null;
|
||||
day: string;
|
||||
suggestedStart: string;
|
||||
};
|
||||
|
||||
export type FreedTeam = {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
day: string;
|
||||
earlyMinutes: number;
|
||||
availableMinutes: number;
|
||||
availableFrom: string;
|
||||
orders: { id: string; number: string; earlyMinutes: number; finishedAt: string; actualMinutes: number | null }[];
|
||||
suggestions: FreedSuggestion[];
|
||||
};
|
||||
|
||||
export async function getFreedCapacity(
|
||||
ctx: ServiceCtx,
|
||||
raw: { date?: string | null; now?: Date; radiusKm?: number; withSuggestions?: boolean } = {},
|
||||
): Promise<{ date: string; teams: FreedTeam[] }> {
|
||||
const access = await planningAccess(ctx);
|
||||
const now = raw.now ?? new Date();
|
||||
const timeZone = await tenantTimezone(ctx);
|
||||
const today = dayKeyOf(now, timeZone);
|
||||
const date = raw.date && isDayKey(raw.date) ? raw.date : today;
|
||||
const radiusKm = raw.radiusKm ?? 15;
|
||||
const { start, end } = dayBounds(date, timeZone);
|
||||
|
||||
const rows = await ctx.db.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
await workOrderScope(ctx),
|
||||
{ status: { in: COMPLETED_STATUSES }, plannedStart: { gte: start, lt: end } },
|
||||
access.teamIds ? { assignedTeamId: { in: access.teamIds } } : { assignedTeamId: { not: null } },
|
||||
],
|
||||
},
|
||||
select: ORDER_SELECT,
|
||||
take: 500,
|
||||
});
|
||||
if (rows.length === 0) return { date, teams: [] };
|
||||
const ids = rows.map((r) => r.id);
|
||||
|
||||
// Session end (clock sessions only) and approved work time (actual duration after L12 approval).
|
||||
const [sessions, approved] = await Promise.all([
|
||||
ctx.db.workSession.findMany({
|
||||
where: { AND: [trackedSessionWhere, { workOrderId: { in: ids }, status: "ended", endedAt: { not: null } }] },
|
||||
select: { workOrderId: true, endedAt: true },
|
||||
}),
|
||||
ctx.db.timeEntry.findMany({
|
||||
where: { AND: [approvedEntryWhere, { type: "work", endedAt: { not: null }, workSession: { workOrderId: { in: ids } } }] },
|
||||
select: { startedAt: true, endedAt: true, workSession: { select: { workOrderId: true } } },
|
||||
}),
|
||||
]);
|
||||
const lastEnd = new Map<string, number>();
|
||||
for (const s of sessions) lastEnd.set(s.workOrderId, Math.max(lastEnd.get(s.workOrderId) ?? 0, s.endedAt!.getTime()));
|
||||
const approvedSegments = new Map<string, { start: number; end: number }[]>();
|
||||
for (const e of approved) {
|
||||
const list = approvedSegments.get(e.workSession.workOrderId) ?? [];
|
||||
list.push({ start: e.startedAt.getTime(), end: e.endedAt!.getTime() });
|
||||
approvedSegments.set(e.workSession.workOrderId, list);
|
||||
}
|
||||
|
||||
type Early = { row: OrderRow; finishedAt: number; plannedEndAt: number; earlyMinutes: number };
|
||||
const byTeam = new Map<string, Early[]>();
|
||||
for (const row of rows) {
|
||||
const finishedAt = lastEnd.get(row.id);
|
||||
if (!finishedAt || !row.plannedStart || !row.assignedTeamId || !hasTimeOfDay(row.plannedStart, timeZone)) continue;
|
||||
const plannedEndAt = row.plannedStart.getTime() + effectiveDurationMinutes(toPlanningOrder(row), timeZone) * 60_000;
|
||||
const earlyMinutes = Math.floor((plannedEndAt - finishedAt) / 60_000);
|
||||
if (earlyMinutes < MIN_FREED_MINUTES) continue;
|
||||
const list = byTeam.get(row.assignedTeamId) ?? [];
|
||||
list.push({ row, finishedAt, plannedEndAt, earlyMinutes });
|
||||
byTeam.set(row.assignedTeamId, list);
|
||||
}
|
||||
if (byTeam.size === 0) return { date, teams: [] };
|
||||
|
||||
const teams = await loadTeams(ctx, [...byTeam.keys()]);
|
||||
const unplanned = raw.withSuggestions === false || date !== today ? [] : await loadUnplannedOrders(ctx, access.teamIds, 300);
|
||||
const result: FreedTeam[] = [];
|
||||
for (const team of teams) {
|
||||
const early = byTeam.get(team.id)!;
|
||||
const availableFromMs = Math.max(now.getTime(), ...early.map((e) => e.finishedAt));
|
||||
const availableMinutes = early.reduce((sum, e) => sum + Math.max(0, Math.floor((e.plannedEndAt - Math.max(e.finishedAt, now.getTime())) / 60_000)), 0);
|
||||
const suggestions: FreedSuggestion[] = [];
|
||||
|
||||
if (raw.withSuggestions !== false && date === today && availableMinutes >= MIN_FREED_MINUTES) {
|
||||
const suggestedStart = new Date(roundUpQuarter(availableFromMs)).toISOString();
|
||||
const later = await loadPlannedOrders(ctx, { teamIds: [team.id], start: new Date(availableFromMs), end: dayBounds(addDays(date, PULL_FORWARD_DAYS), timeZone).end });
|
||||
for (const o of later
|
||||
.filter((o) => UPCOMING_STATUSES.includes(o.status as WorkOrderStatus) && o.plannedStart && o.plannedStart.getTime() > availableFromMs)
|
||||
.filter((o) => effectiveDurationMinutes(toPlanningOrder(o), timeZone) <= availableMinutes)
|
||||
.slice(0, MAX_SUGGESTIONS)) {
|
||||
suggestions.push({ kind: "pull_forward", order: toBoardOrder(o, timeZone), distanceKm: null, day: date, suggestedStart });
|
||||
}
|
||||
const latest = [...early].sort((a, b) => b.finishedAt - a.finishedAt)[0];
|
||||
const origin = coords(latest.row);
|
||||
if (origin) {
|
||||
unplanned
|
||||
.map((o) => ({ o, c: coords(o) }))
|
||||
.filter((x): x is { o: OrderRow; c: { latitude: number; longitude: number } } => !!x.c)
|
||||
.map(({ o, c }) => ({ o, km: haversineKm(origin, c) }))
|
||||
.filter(({ o, km }) => km <= radiusKm && effectiveDurationMinutes(toPlanningOrder(o), timeZone) <= availableMinutes)
|
||||
.sort((a, b) => a.km - b.km)
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
.forEach(({ o, km }) => suggestions.push({ kind: "nearby_unplanned", order: toBoardOrder(o, timeZone), distanceKm: Math.round(km * 10) / 10, day: date, suggestedStart }));
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
day: date,
|
||||
earlyMinutes: early.reduce((sum, e) => sum + e.earlyMinutes, 0),
|
||||
availableMinutes,
|
||||
availableFrom: new Date(availableFromMs).toISOString(),
|
||||
orders: early.map((e) => ({
|
||||
id: e.row.id,
|
||||
number: e.row.number,
|
||||
earlyMinutes: e.earlyMinutes,
|
||||
finishedAt: new Date(e.finishedAt).toISOString(),
|
||||
actualMinutes: approvedSegments.has(e.row.id) ? unionMinutes(approvedSegments.get(e.row.id)!) : null,
|
||||
})),
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
return { date, teams: result };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Watch job (events)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export const WATCH_ACTOR_ID = "system:planning-watch";
|
||||
|
||||
/** System context of the watch job: all permissions within ONE tenant (dbForTenant + RLS). */
|
||||
export function watchContext(tenantId: string): ServiceCtx {
|
||||
return { db: dbForTenant(tenantId), tenantId, userId: WATCH_ACTOR_ID, permissions: new Set<string>(PERMISSIONS) };
|
||||
}
|
||||
|
||||
export type AlertLedger = { claim(ctx: ServiceCtx, key: string, facts: Record<string, unknown>): Promise<boolean> };
|
||||
|
||||
/** Dedupe ledger in the append-only audit log (entity `planning_alert`, entityId = dedupe key). */
|
||||
export const auditAlertLedger: AlertLedger = {
|
||||
async claim(ctx, key, facts) {
|
||||
const seen = await ctx.db.auditLog.findFirst({ where: { entity: "planning_alert", entityId: key }, select: { id: true } });
|
||||
if (seen) return false;
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, action: "create", entity: "planning_alert", entityId: key, after: facts });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
export type WatchResult = { overrun: number; followupAtRisk: number; capacityFreed: number };
|
||||
|
||||
export async function runPlanningWatch(
|
||||
tenantId: string,
|
||||
opts: { now?: Date; emit?: typeof emitEvent; ledger?: AlertLedger } = {},
|
||||
): Promise<WatchResult> {
|
||||
const ctx = watchContext(tenantId);
|
||||
const now = opts.now ?? new Date();
|
||||
const emit = opts.emit ?? emitEvent;
|
||||
const ledger = opts.ledger ?? auditAlertLedger;
|
||||
const result: WatchResult = { overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
|
||||
|
||||
const { delays, followups } = await evaluateDelays(ctx, { now });
|
||||
for (const d of delays) {
|
||||
const bucket = overrunBucket(d.ratio);
|
||||
if (bucket < 0) continue;
|
||||
if (!(await ledger.claim(ctx, `planning.overrun:${d.workOrderId}:${bucket}`, { percent: d.percent, teamId: d.teamId }))) continue;
|
||||
await emit(ctx, { type: "planning.overrun", entityType: "work_order", entityId: d.workOrderId, data: { percent: d.percent, teamId: d.teamId, occurrenceId: bucket } });
|
||||
result.overrun++;
|
||||
}
|
||||
for (const f of followups) {
|
||||
if (!(await ledger.claim(ctx, `planning.followup_at_risk:${f.workOrderId}:${f.day}`, { blockedBy: f.blockedByNumber, reason: f.reason }))) continue;
|
||||
await emit(ctx, {
|
||||
type: "planning.followup_at_risk",
|
||||
entityType: "work_order",
|
||||
entityId: f.workOrderId,
|
||||
data: { blockerNumber: f.blockedByNumber, teamId: f.teamId, reason: f.reason, occurrenceId: f.day },
|
||||
});
|
||||
result.followupAtRisk++;
|
||||
}
|
||||
const freed = await getFreedCapacity(ctx, { now, withSuggestions: false });
|
||||
for (const team of freed.teams) {
|
||||
for (const o of team.orders) {
|
||||
if (!(await ledger.claim(ctx, `planning.capacity_freed:${team.teamId}:${freed.date}:${o.id}`, { minutes: o.earlyMinutes }))) continue;
|
||||
await emit(ctx, { type: "planning.capacity_freed", entityType: "work_order", entityId: o.id, data: { teamId: team.teamId, minutes: o.earlyMinutes, occurrenceId: freed.date } });
|
||||
result.capacityFreed++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** All tenants (job payload tenantId "*"); a failing tenant never stops the others. */
|
||||
export async function runPlanningWatchAllTenants(opts: { now?: Date } = {}): Promise<{ tenants: number; failed: number } & WatchResult> {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true } });
|
||||
const total = { tenants: tenants.length, failed: 0, overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
|
||||
for (const t of tenants) {
|
||||
try {
|
||||
const r = await runPlanningWatch(t.id, opts);
|
||||
total.overrun += r.overrun;
|
||||
total.followupAtRisk += r.followupAtRisk;
|
||||
total.capacityFreed += r.capacityFreed;
|
||||
} catch (err) {
|
||||
total.failed++;
|
||||
console.error(`[planning-watch] tenant ${t.id} failed:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
Reference in New Issue
Block a user