Files
craftvia/src/server/services/planning/board.ts
T
msolarczekandClaude Opus 5 5e10523df6 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>
2026-09-15 10:13:39 +02:00

178 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}