L13 Planung: Plantafel-, Einplan-, Empfehlungs-, Live- und Verzugsdienste mit API und Meldungen
Services board/schedule (L2 assign+update in inTransaction, Versionskonflikt, Audit), recommend (Luftlinie + freie Kolonnenzeit), live (Kolonnen, ohne Geräte-Koordinaten), watch (Verzug ab 80 %, Überschreitung, gefährdete Folgeaufträge, früher fertig mit Vorschlägen), Kolonnenkapazität. Job planning-watch alle 5 min, Events planning.capacity_freed/overrun/followup_at_risk (In-App an Backoffice + Teamleiter, Dedupe im Audit-Log), L12-Adapter für source/approvalStatus/manual. API /api/v1/planning/{board,schedule,recommendations,live} + OpenAPI. Tests core, recommend, live, watch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user