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:
2026-09-15 10:13:39 +02:00
co-authored by Claude Opus 5
parent 213bcca3a1
commit 5e10523df6
29 changed files with 2579 additions and 6 deletions
+24
View File
@@ -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,
}),
);
});
+19
View File
@@ -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 });
});
+15
View File
@@ -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));
});
+134
View File
@@ -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,
+4
View File
@@ -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");
}
+2
View File
@@ -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
View File
@@ -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;
}
+38
View File
@@ -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;
}
+177
View File
@@ -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;
}
}
+184
View File
@@ -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 });
}
+271
View File
@@ -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,
};
}
+222
View File
@@ -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 };
}
+148
View File
@@ -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,
};
}
+23
View File
@@ -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;
+391
View File
@@ -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;
}