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>
178 lines
6.9 KiB
TypeScript
178 lines
6.9 KiB
TypeScript
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;
|
||
}
|
||
}
|