From 604f2bfdc5780c61ee0f597c405397e8eaae69fe Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 10:13:18 +0200 Subject: [PATCH 1/5] =?UTF-8?q?L13=20Planung:=20Datenmodell=20(Kolonnenkap?= =?UTF-8?q?azit=C3=A4t,=20Dauer,=20Geocoding-Cache)=20und=20Planungsregeln?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 20260915120000_planung (nur Spalten): Team.dailyCapacityMinutes/workingDays, OrderType.defaultDurationMinutes (+ Backfill Standardarten), WorkOrder.plannedDurationMinutes, Site.geocodedAt/geocodeStatus/geocodeQuery. Client-sichere Regeln: Haversine, Tage/Werktage, Dauer, Kolonnenkapazität, Konflikte inkl. Hinweis crew_incomplete, Vereinigung von Arbeitssegmenten, Fahrzeitschätzung. Co-Authored-By: Claude Opus 5 --- .../20260915120000_planung/migration.sql | 32 +++ prisma/schema.prisma | 15 +- src/lib/geo/distance.ts | 37 +++ src/lib/planning/capacity.ts | 216 ++++++++++++++++++ src/lib/planning/days.ts | 91 ++++++++ src/lib/planning/text.ts | 24 ++ src/lib/work-orders/defaults.ts | 19 +- 7 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20260915120000_planung/migration.sql create mode 100644 src/lib/geo/distance.ts create mode 100644 src/lib/planning/capacity.ts create mode 100644 src/lib/planning/days.ts create mode 100644 src/lib/planning/text.ts diff --git a/prisma/migrations/20260915120000_planung/migration.sql b/prisma/migrations/20260915120000_planung/migration.sql new file mode 100644 index 0000000..f48a6d3 --- /dev/null +++ b/prisma/migrations/20260915120000_planung/migration.sql @@ -0,0 +1,32 @@ +-- L13 Planung: capacity planning + geocoding cache. Columns only (no new table → RLS unchanged, +-- no TENANT_MODELS change). No personal references (no DSGVO pii-fields change). + +-- AlterTable +ALTER TABLE "order_types" ADD COLUMN "default_duration_minutes" INTEGER; + +-- AlterTable +ALTER TABLE "sites" ADD COLUMN "geocode_query" TEXT, +ADD COLUMN "geocode_status" TEXT, +ADD COLUMN "geocoded_at" TIMESTAMP(3); + +-- AlterTable +-- Team = crew (Kolonne): capacity is the crew's working day in minutes, not person-hours. +ALTER TABLE "teams" ADD COLUMN "daily_capacity_minutes" INTEGER NOT NULL DEFAULT 480, +ADD COLUMN "working_days" INTEGER NOT NULL DEFAULT 31; + +-- AlterTable +ALTER TABLE "work_orders" ADD COLUMN "planned_duration_minutes" INTEGER; + +-- Backfill: default durations of the standard order types (spec §10.2) for existing tenants. +UPDATE "order_types" SET "default_duration_minutes" = CASE "key" + WHEN 'montage' THEN 480 + WHEN 'reparatur' THEN 180 + WHEN 'wartung' THEN 120 + WHEN 'stoerung' THEN 120 + WHEN 'notdienst' THEN 120 + WHEN 'besichtigung' THEN 60 + WHEN 'abnahme' THEN 60 + WHEN 'nacharbeit' THEN 120 + END +WHERE "default_duration_minutes" IS NULL + AND "key" IN ('montage', 'reparatur', 'wartung', 'stoerung', 'notdienst', 'besichtigung', 'abnahme', 'nacharbeit'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc29a6c..2e10223 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -472,6 +472,8 @@ model OrderType { active Boolean @default(true) signatureRequired Boolean @default(true) @map("signature_required") sortOrder Int @default(0) @map("sort_order") + // L13 Planung: default duration for capacity planning when the order has no own duration + defaultDurationMinutes Int? @map("default_duration_minutes") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -597,6 +599,11 @@ model Site { status SiteStatus @default(active) latitude Float? longitude Float? + // L13 Planung: geocoding cache (services/geo) — status ok | not_found | failed | skipped, + // geocodeQuery = normalized address the coordinates belong to (change detection) + geocodedAt DateTime? @map("geocoded_at") + geocodeStatus String? @map("geocode_status") + geocodeQuery String? @map("geocode_query") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") @@ -627,6 +634,10 @@ model Team { vehicle String? area String? notes String? + // L13 Planung: a team is a crew (Kolonne) working together — capacity = crew working day in minutes + // (not person-hours); workingDays = bit mask Mon=1 … Sun=64 (31 = Mo–Fr) + dailyCapacityMinutes Int @default(480) @map("daily_capacity_minutes") + workingDays Int @default(31) @map("working_days") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") @@ -700,7 +711,9 @@ model WorkOrder { scope String? // Leistungsumfang plannedStart DateTime? @map("planned_start") plannedEnd DateTime? @map("planned_end") - assignedTeamId String? @map("assigned_team_id") + // L13 Planung: explicit duration; fallback plannedStart/End → OrderType.defaultDurationMinutes → 120 + plannedDurationMinutes Int? @map("planned_duration_minutes") + assignedTeamId String? @map("assigned_team_id") teamLeadUserId String? @map("team_lead_user_id") signatureRequired Boolean @default(true) @map("signature_required") billingType String? @map("billing_type") // fixed | time_material | maintenance_contract | warranty diff --git a/src/lib/geo/distance.ts b/src/lib/geo/distance.ts new file mode 100644 index 0000000..3dff175 --- /dev/null +++ b/src/lib/geo/distance.ts @@ -0,0 +1,37 @@ +/** + * Geo helpers (client-safe, no server imports). Straight-line ("Luftlinie") distances only — + * no routing, no device positions (L13 Planung). + */ + +export type LatLng = { latitude: number; longitude: number }; + +const EARTH_RADIUS_KM = 6371.0088; + +export function isValidLatLng(p: { latitude?: number | null; longitude?: number | null } | null | undefined): p is LatLng { + return ( + !!p && + typeof p.latitude === "number" && + typeof p.longitude === "number" && + Number.isFinite(p.latitude) && + Number.isFinite(p.longitude) && + Math.abs(p.latitude) <= 90 && + Math.abs(p.longitude) <= 180 + ); +} + +const rad = (deg: number) => (deg * Math.PI) / 180; + +/** Great-circle distance in kilometres (haversine formula). */ +export function haversineKm(a: LatLng, b: LatLng): number { + const dLat = rad(b.latitude - a.latitude); + const dLng = rad(b.longitude - a.longitude); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.latitude)) * Math.cos(rad(b.latitude)) * Math.sin(dLng / 2) ** 2; + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** "3,2 km" / "850 m" in the given locale. */ +export function formatDistance(km: number, locale: string): string { + const tag = locale === "en" ? "en-GB" : "de-DE"; + if (km < 1) return `${new Intl.NumberFormat(tag, { maximumFractionDigits: 0 }).format(Math.round(km * 1000))} m`; + return `${new Intl.NumberFormat(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(km)} km`; +} diff --git a/src/lib/planning/capacity.ts b/src/lib/planning/capacity.ts new file mode 100644 index 0000000..0e7aee9 --- /dev/null +++ b/src/lib/planning/capacity.ts @@ -0,0 +1,216 @@ +import { dayBounds, dayKeyOf, dayRange, isWorkingDay, minutesOfDay } from "@/lib/planning/days"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; + +/** + * Capacity, utilization and conflict rules of the planning board (client-safe, pure functions, + * unit-tested in scripts/test-planung-core.ts). + * + * A team is a crew ("Kolonne", 2+ people travelling and working together). Capacity is the crew's + * working day in minutes (`Team.dailyCapacityMinutes`, default 480) on its working days — NOT + * person-hours. Order durations are crew time as well. + * + * Duration of an order (minutes): plannedDurationMinutes → plannedStart/End on the same day with a + * time of day → OrderType.defaultDurationMinutes → 120. + * Multi-day orders (plannedEnd on a later day) occupy every day of the range: an explicit duration is + * split evenly, otherwise each day counts with the order type default (or a full day of 480 min). + */ + +/** Statuses the planning board may (re)schedule — running or finished orders are never moved. */ +export const SCHEDULABLE_STATUSES: readonly WorkOrderStatus[] = ["draft", "review_required", "planned", "assigned", "accepted"]; + +export const FALLBACK_DURATION_MINUTES = 120; +export const FULL_DAY_MINUTES = 480; +/** Below this number of active members a crew is flagged as incomplete (hint, not a conflict). */ +export const MIN_CREW_SIZE = 2; + +/** Travel time estimate: straight line × 1.3 detour at 50 km/h, at least 10 min. */ +export const TRAVEL_DETOUR_FACTOR = 1.3; +export const TRAVEL_SPEED_KMH = 50; +export const MIN_TRAVEL_MINUTES = 10; + +export type PlanningOrder = { + id: string; + teamId: string | null; + plannedStart: Date | null; + plannedEnd: Date | null; + plannedDurationMinutes: number | null; + orderTypeDefaultMinutes: number | null; + assigneeIds: string[]; +}; + +export type PlanningTeam = { + id: string; + dailyCapacityMinutes: number; + workingDays: number; + members: { userId: string; validFrom: Date; validTo: Date | null; active: boolean }[]; +}; + +export const CONFLICT_KINDS = ["overbooked", "overlap", "assignee_double_booked", "outside_working_days", "crew_incomplete"] as const; +export type ConflictKind = (typeof CONFLICT_KINDS)[number]; + +export type PlanningConflict = { + kind: ConflictKind; + /** "hint" (crew_incomplete) is shown but not counted as a conflict. */ + severity: "conflict" | "hint"; + day: string; + teamIds: string[]; + orderIds: string[]; + userId?: string; + minutesOver?: number; + memberCount?: number; +}; + +export const isConflict = (c: Pick) => c.severity === "conflict"; + +/** An instant at exactly local midnight counts as "date only" (no time of day entered). */ +export function hasTimeOfDay(date: Date, timeZone: string): boolean { + return minutesOfDay(date, timeZone) !== 0; +} + +/** Days (keys) the order occupies; an end at exactly local midnight belongs to the previous day. */ +export function orderDays(o: Pick, timeZone: string): string[] { + if (!o.plannedStart) return []; + const first = dayKeyOf(o.plannedStart, timeZone); + if (!o.plannedEnd || o.plannedEnd <= o.plannedStart) return [first]; + const endMinusOne = new Date(o.plannedEnd.getTime() - (hasTimeOfDay(o.plannedEnd, timeZone) ? 0 : 1)); + const last = dayKeyOf(endMinusOne, timeZone); + return last < first ? [first] : dayRange(first, last); +} + +export function effectiveDurationMinutes(o: PlanningOrder, timeZone: string): number { + if (o.plannedDurationMinutes && o.plannedDurationMinutes > 0) return o.plannedDurationMinutes; + if (o.plannedStart && o.plannedEnd && o.plannedEnd > o.plannedStart && hasTimeOfDay(o.plannedStart, timeZone) && orderDays(o, timeZone).length === 1) { + return Math.round((o.plannedEnd.getTime() - o.plannedStart.getTime()) / 60_000); + } + if (o.orderTypeDefaultMinutes && o.orderTypeDefaultMinutes > 0) return o.orderTypeDefaultMinutes; + return FALLBACK_DURATION_MINUTES; +} + +/** Minutes the order occupies on `day` (0 if it is not planned on that day). */ +export function minutesOnDay(o: PlanningOrder, day: string, timeZone: string): number { + const days = orderDays(o, timeZone); + if (!days.includes(day)) return 0; + if (days.length === 1) return effectiveDurationMinutes(o, timeZone); + if (o.plannedDurationMinutes && o.plannedDurationMinutes > 0) return Math.ceil(o.plannedDurationMinutes / days.length); + return o.orderTypeDefaultMinutes && o.orderTypeDefaultMinutes > 0 ? o.orderTypeDefaultMinutes : FULL_DAY_MINUTES; +} + +/** Time window in ms for single-day orders with a time of day, otherwise null (no overlap check). */ +export function timeWindow(o: PlanningOrder, timeZone: string): { start: number; end: number } | null { + if (!o.plannedStart || !hasTimeOfDay(o.plannedStart, timeZone) || orderDays(o, timeZone).length !== 1) return null; + const start = o.plannedStart.getTime(); + const end = o.plannedEnd && o.plannedEnd > o.plannedStart ? o.plannedEnd.getTime() : start + effectiveDurationMinutes(o, timeZone) * 60_000; + return { start, end }; +} + +const overlaps = (a: { start: number; end: number }, b: { start: number; end: number }) => a.start < b.end && b.start < a.end; + +/** Distinct active members (user active, membership valid on the day). */ +export function activeMemberCount(team: PlanningTeam, day: string, timeZone: string): number { + const { start, end } = dayBounds(day, timeZone); + return new Set(team.members.filter((m) => m.active && m.validFrom < end && (!m.validTo || m.validTo > start)).map((m) => m.userId)).size; +} + +/** Crew capacity: the crew working day on working days if anyone is active, otherwise 0. */ +export function teamCapacityMinutes(team: PlanningTeam, day: string, timeZone: string): number { + if (!isWorkingDay(team.workingDays, day)) return 0; + return activeMemberCount(team, day, timeZone) > 0 ? team.dailyCapacityMinutes : 0; +} + +export function utilizationPercent(plannedMinutes: number, capacityMinutes: number): number | null { + return capacityMinutes > 0 ? Math.round((plannedMinutes / capacityMinutes) * 100) : null; +} + +export type TeamDay = { + day: string; + workingDay: boolean; + memberCount: number; + capacityMinutes: number; + plannedMinutes: number; + utilization: number | null; + orderIds: string[]; + conflicts: PlanningConflict[]; +}; + +/** Load + team-local conflicts (overbooked, overlap, outside_working_days) + crew_incomplete hint of one team on one day. */ +export function computeTeamDay(team: PlanningTeam, day: string, teamOrders: PlanningOrder[], timeZone: string): TeamDay { + const onDay = teamOrders.filter((o) => o.teamId === team.id && minutesOnDay(o, day, timeZone) > 0); + const workingDay = isWorkingDay(team.workingDays, day); + const memberCount = activeMemberCount(team, day, timeZone); + const capacityMinutes = teamCapacityMinutes(team, day, timeZone); + const plannedMinutes = onDay.reduce((sum, o) => sum + minutesOnDay(o, day, timeZone), 0); + const ids = onDay.map((o) => o.id); + const conflicts: PlanningConflict[] = []; + if (!workingDay && onDay.length > 0) { + conflicts.push({ kind: "outside_working_days", severity: "conflict", day, teamIds: [team.id], orderIds: ids }); + } else if (workingDay && plannedMinutes > capacityMinutes && onDay.length > 0) { + conflicts.push({ kind: "overbooked", severity: "conflict", day, teamIds: [team.id], orderIds: ids, minutesOver: plannedMinutes - capacityMinutes }); + } + const timed = onDay.map((o) => ({ o, w: timeWindow(o, timeZone) })).filter((x): x is { o: PlanningOrder; w: { start: number; end: number } } => !!x.w); + for (let i = 0; i < timed.length; i++) { + for (let j = i + 1; j < timed.length; j++) { + if (overlaps(timed[i].w, timed[j].w)) conflicts.push({ kind: "overlap", severity: "conflict", day, teamIds: [team.id], orderIds: [timed[i].o.id, timed[j].o.id] }); + } + } + if (workingDay && memberCount < MIN_CREW_SIZE) { + conflicts.push({ kind: "crew_incomplete", severity: "hint", day, teamIds: [team.id], orderIds: ids, memberCount }); + } + return { day, workingDay, memberCount, capacityMinutes, plannedMinutes, utilization: utilizationPercent(plannedMinutes, capacityMinutes), orderIds: ids, conflicts }; +} + +/** A technician individually assigned to two orders (any team) whose time windows overlap on `day`. */ +export function assigneeDoubleBookings(orders: PlanningOrder[], day: string, timeZone: string): PlanningConflict[] { + const byUser = new Map(); + for (const o of orders) { + if (!orderDays(o, timeZone).includes(day)) continue; + const w = timeWindow(o, timeZone); + if (!w) continue; + for (const userId of new Set(o.assigneeIds)) { + const list = byUser.get(userId) ?? []; + list.push({ o, w }); + byUser.set(userId, list); + } + } + const out: PlanningConflict[] = []; + for (const [userId, list] of byUser) { + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) { + if (overlaps(list[i].w, list[j].w)) { + const teamIds = [...new Set([list[i].o.teamId, list[j].o.teamId].filter((t): t is string => !!t))]; + out.push({ kind: "assignee_double_booked", severity: "conflict", day, teamIds, orderIds: [list[i].o.id, list[j].o.id], userId }); + } + } + } + } + return out; +} + +/** Length of the union of time intervals in minutes (crew time: parallel segments count once). */ +export function unionMinutes(intervals: { start: number; end: number }[]): number { + const sorted = intervals.filter((i) => i.end > i.start).sort((a, b) => a.start - b.start); + let total = 0; + let curStart = -Infinity; + let curEnd = -Infinity; + for (const i of sorted) { + if (i.start > curEnd) { + if (curEnd > curStart) total += curEnd - curStart; + curStart = i.start; + curEnd = i.end; + } else if (i.end > curEnd) { + curEnd = i.end; + } + } + if (curEnd > curStart) total += curEnd - curStart; + return Math.round(total / 60_000); +} + +/** Estimated travel minutes for a straight-line distance (null = unknown → minimum). */ +export function estimateTravelMinutes(km: number | null): number { + if (km === null || !Number.isFinite(km)) return MIN_TRAVEL_MINUTES; + return Math.max(MIN_TRAVEL_MINUTES, Math.round(((km * TRAVEL_DETOUR_FACTOR) / TRAVEL_SPEED_KMH) * 60)); +} + +/** Overrun notification bucket: 0 at ≥ 100 %, 1 at ≥ 150 %, 2 at ≥ 200 % … (-1 below 100 %). */ +export function overrunBucket(ratio: number): number { + return ratio < 1 ? -1 : Math.floor((ratio - 1) / 0.5 + 1e-9); +} diff --git a/src/lib/planning/days.ts b/src/lib/planning/days.ts new file mode 100644 index 0000000..0aff969 --- /dev/null +++ b/src/lib/planning/days.ts @@ -0,0 +1,91 @@ +import { safeTimeZone, wallTimeToUtc } from "@/lib/work-orders/time"; + +/** + * Calendar-day helpers for the planning board (client-safe). A "day key" is `YYYY-MM-DD` in the + * tenant timezone; weekday index 0 = Monday … 6 = Sunday. Team working days are a bit mask + * (Mon = 1, Tue = 2, … Sun = 64; default 31 = Mon–Fri). + */ + +export const DEFAULT_WORKING_DAYS = 31; +export const WEEKDAY_BITS = [1, 2, 4, 8, 16, 32, 64] as const; + +const DAY_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; + +export function isDayKey(value: unknown): value is string { + if (typeof value !== "string") return false; + const m = DAY_KEY_RE.exec(value); + if (!m) return false; + const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); + return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3]; +} + +/** Day key of an instant in the given timezone. */ +export function dayKeyOf(date: Date, timeZone: string): string { + return new Intl.DateTimeFormat("en-CA", { timeZone: safeTimeZone(timeZone), year: "numeric", month: "2-digit", day: "2-digit" }).format(date); +} + +/** Pure calendar arithmetic on day keys (no timezone involved). */ +export function addDays(dayKey: string, days: number): string { + const [y, m, d] = dayKey.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10); +} + +/** 0 = Monday … 6 = Sunday. */ +export function weekdayIndex(dayKey: string): number { + const [y, m, d] = dayKey.split("-").map(Number); + return (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7; +} + +export function startOfWeek(dayKey: string): string { + return addDays(dayKey, -weekdayIndex(dayKey)); +} + +/** Inclusive list of day keys. */ +export function dayRange(from: string, to: string): string[] { + const out: string[] = []; + for (let k = from; k <= to && out.length < 400; k = addDays(k, 1)) out.push(k); + return out; +} + +/** [start, end) instants of a calendar day in the timezone (DST-safe). */ +export function dayBounds(dayKey: string, timeZone: string): { start: Date; end: Date } { + const start = wallTimeToUtc(dayKey, timeZone); + const end = wallTimeToUtc(addDays(dayKey, 1), timeZone); + if (!start || !end) throw new Error(`invalid day key ${dayKey}`); + return { start, end }; +} + +export function isWorkingDay(mask: number, dayKey: string): boolean { + return (mask & WEEKDAY_BITS[weekdayIndex(dayKey)]) !== 0; +} + +/** `start` (always, even on a weekend) followed by the next working days — `count` keys in total. */ +export function nextWorkingDays(start: string, count: number, mask = DEFAULT_WORKING_DAYS): string[] { + const out = [start]; + for (let k = addDays(start, 1); out.length < count && out.length < 400; k = addDays(k, 1)) if (isWorkingDay(mask, k)) out.push(k); + return out; +} + +/** Move by `n` working days (negative = backwards); non-working start days count from the next one. */ +export function addWorkingDays(day: string, n: number, mask = DEFAULT_WORKING_DAYS): string { + let k = day; + const step = n < 0 ? -1 : 1; + for (let left = Math.abs(n); left > 0; ) { + k = addDays(k, step); + if (isWorkingDay(mask, k)) left--; + } + return k; +} + +/** Minutes since local midnight of an instant in the timezone. */ +export function minutesOfDay(date: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat("en-GB", { timeZone: safeTimeZone(timeZone), hourCycle: "h23", hour: "2-digit", minute: "2-digit" }).formatToParts(date); + const n = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? 0); + return n("hour") * 60 + n("minute"); +} + +/** Local wall time "HH:mm" of an instant. */ +export function wallClock(date: Date, timeZone: string): string { + const m = minutesOfDay(date, timeZone); + return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; +} diff --git a/src/lib/planning/text.ts b/src/lib/planning/text.ts new file mode 100644 index 0000000..fe83d6a --- /dev/null +++ b/src/lib/planning/text.ts @@ -0,0 +1,24 @@ +/** Small client-safe text helpers of the planning module (no ICU runtime needed on the server). */ + +const tag = (locale: string) => (locale === "en" ? "en-GB" : "de-DE"); + +/** Replaces `{name}` placeholders (simple message templates from messages//planning.json). */ +export function fillTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in vars ? String(vars[key]) : match)); +} + +/** "45 min", "4 h", "4,5 h". */ +export function formatMinutes(minutes: number, locale: string): string { + const m = Math.round(minutes); + if (Math.abs(m) < 60) return `${m} min`; + return `${new Intl.NumberFormat(tag(locale), { maximumFractionDigits: 1 }).format(m / 60)} h`; +} + +/** "Di 16.09." (de) / "Tue 16 Sep" (en) for a day key. */ +export function formatDayShort(dayKey: string, locale: string): string { + const [y, m, d] = dayKey.split("-").map(Number); + const date = new Date(Date.UTC(y, m - 1, d, 12)); + const weekday = new Intl.DateTimeFormat(tag(locale), { weekday: "short", timeZone: "UTC" }).format(date).replace(/\.$/, ""); + if (locale === "en") return `${weekday} ${new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", timeZone: "UTC" }).format(date)}`; + return `${weekday} ${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.`; +} diff --git a/src/lib/work-orders/defaults.ts b/src/lib/work-orders/defaults.ts index 2fc1b77..6349c14 100644 --- a/src/lib/work-orders/defaults.ts +++ b/src/lib/work-orders/defaults.ts @@ -14,15 +14,16 @@ export const DEFAULT_NUMBER_PREFIX: Record<"customer" | "work_order" | "emergenc }; /** Spec §10.2 — created by ensureDefaultOrderTypes on first use per tenant. */ -export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number }> = [ - { key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10 }, - { key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20 }, - { key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30 }, - { key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40 }, - { key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50 }, - { key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60 }, - { key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70 }, - { key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80 }, +// defaultDurationMinutes: L13 Planung (capacity planning default per order type) +export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number; defaultDurationMinutes: number }> = [ + { key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10, defaultDurationMinutes: 480 }, + { key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20, defaultDurationMinutes: 180 }, + { key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30, defaultDurationMinutes: 120 }, + { key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40, defaultDurationMinutes: 120 }, + { key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50, defaultDurationMinutes: 120 }, + { key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60, defaultDurationMinutes: 60 }, + { key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70, defaultDurationMinutes: 60 }, + { key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80, defaultDurationMinutes: 120 }, ]; /** Spec §12.4 — suggested checklist items. */ From 213bcca3a172bac3971f4db358eae46c89ef8dc2 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 10:13:26 +0200 Subject: [PATCH 2/5] =?UTF-8?q?L13=20Planung:=20Geocoding=20der=20Objekte?= =?UTF-8?q?=20=C3=BCber=20OpenStreetMap=20Nominatim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider-Interface (nominatim|none), strukturierte Suche mit User-Agent und Accept-Language, Drosselung 1/s je Prozess, Job geocode-site (Worker: Concurrency 1 + Limiter), Cache am Objekt, manuelle Koordinaten bleiben, Auslöser Anlage/Adressänderung/Import-Bestätigung nur per Queue, Backfill-Skript, Env-Beispiele. Tests mit Fake-Provider, Nominatim nie aufgerufen. Co-Authored-By: Claude Opus 5 --- .env.example | 12 ++ scripts/craftvia-worker.ts | 5 +- scripts/geocode-backfill.ts | 81 ++++++++++ scripts/test-planung-geocode.ts | 178 +++++++++++++++++++++ src/server/jobs/processors/geocode-site.ts | 12 ++ src/server/services/geo/config.ts | 42 +++++ src/server/services/geo/dispatch.ts | 24 +++ src/server/services/geo/geocode-site.ts | 92 +++++++++++ src/server/services/geo/nominatim.ts | 58 +++++++ src/server/services/geo/normalize.ts | 27 ++++ src/server/services/geo/provider.ts | 41 +++++ src/server/services/geo/rate-limit.ts | 24 +++ src/server/services/imports/confirm.ts | 2 + src/server/services/sites/sites.ts | 4 + 14 files changed, 600 insertions(+), 2 deletions(-) create mode 100644 scripts/geocode-backfill.ts create mode 100644 scripts/test-planung-geocode.ts create mode 100644 src/server/jobs/processors/geocode-site.ts create mode 100644 src/server/services/geo/config.ts create mode 100644 src/server/services/geo/dispatch.ts create mode 100644 src/server/services/geo/geocode-site.ts create mode 100644 src/server/services/geo/nominatim.ts create mode 100644 src/server/services/geo/normalize.ts create mode 100644 src/server/services/geo/provider.ts create mode 100644 src/server/services/geo/rate-limit.ts diff --git a/.env.example b/.env.example index a81f2c4..bf2fbe5 100644 --- a/.env.example +++ b/.env.example @@ -129,6 +129,18 @@ AI_GENERATION_RETENTION_DAYS=180 # Import-Extraktion ab. 0 = unbegrenzt. AI_MONTHLY_TOKEN_LIMIT=0 +# --- Craftvia: Planung – Karten & Geocoding (L13) --- +# Adresse → Koordinaten nur serverseitig im Worker (Job geocode-site, max. 1 Anfrage/s), +# Ergebnis wird am Objekt gespeichert. nominatim | none (none = keine Verortung, Objekte +# erscheinen „ohne Ortsangabe“). Für Produktion eigenen/vertraglichen Dienst verwenden. +GEOCODING_PROVIDER=nominatim +# GEOCODING_URL=https://nominatim.openstreetmap.org +# Eindeutiger User-Agent (Nominatim-Richtlinie); leer = "Craftvia/ (+APP_BASE_URL)". +# GEOCODING_USER_AGENT= +# Kartenkacheln der Live-Lage; der Host wird beim Build in die CSP (img-src) übernommen. +# MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png +# MAP_ATTRIBUTION=© OpenStreetMap-Mitwirkende + # --- Optionale Fundament-Variablen (Default leer) --- # MFA_ENC_KEY: Schlüssel für TOTP-Secrets at-rest (leer = aus AUTH_SECRET abgeleitet). # ⚠ Nach dem Setzen nicht mehr ändern. diff --git a/scripts/craftvia-worker.ts b/scripts/craftvia-worker.ts index 649e67e..f2a53de 100644 --- a/scripts/craftvia-worker.ts +++ b/scripts/craftvia-worker.ts @@ -18,8 +18,9 @@ async function main() { continue; } const processor = await load(); - const concurrency = name === JOB_QUEUES.reportPdf ? 2 : 4; - const w = new Worker(name, async (job) => processor(job.data), { connection, concurrency }); + const geocode = name === JOB_QUEUES.geocodeSite; // L13: OSM Nominatim policy — max. 1 request/s + const concurrency = name === JOB_QUEUES.reportPdf ? 2 : geocode ? 1 : 4; + const w = new Worker(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) }); w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message)); workers.push(w); console.info(`[worker] listening on ${name}`); diff --git a/scripts/geocode-backfill.ts b/scripts/geocode-backfill.ts new file mode 100644 index 0000000..0d68ab6 --- /dev/null +++ b/scripts/geocode-backfill.ts @@ -0,0 +1,81 @@ +/** + * L13 Planung — geocode sites that have no (or outdated) coordinates, respecting the OSM Nominatim + * usage policy: strictly sequential, max. 1 request per second (process-wide limiter in the + * provider), identifying User-Agent, results cached on the site (unchanged addresses are skipped). + * Manually entered coordinates are never overwritten. + * + * Usage: + * npx tsx scripts/geocode-backfill.ts # all tenants + * npx tsx scripts/geocode-backfill.ts --tenant=demo --limit=50 + * npx tsx scripts/geocode-backfill.ts --dry-run # only list what would be geocoded + * npx tsx scripts/geocode-backfill.ts --retry-not-found # also retry addresses marked not_found + * + * For larger data sets use a self-hosted or contracted geocoding service (GEOCODING_URL). + */ +import "dotenv/config"; +import { isValidLatLng } from "../src/lib/geo/distance"; +import { dbForTenant, prisma } from "../src/server/db"; +import { geocodingConfig } from "../src/server/services/geo/config"; +import { geocodeSite, type GeocodeOutcome } from "../src/server/services/geo/geocode-site"; +import { geocodeQueryFor } from "../src/server/services/geo/normalize"; + +const arg = (name: string) => process.argv.find((a) => a.startsWith(`--${name}=`))?.split("=")[1]; +const flag = (name: string) => process.argv.includes(`--${name}`); + +async function main() { + const cfg = geocodingConfig(); + const dryRun = flag("dry-run"); + const retryNotFound = flag("retry-not-found"); + const limit = Number(arg("limit") ?? Infinity); + const slug = arg("tenant"); + if (cfg.provider === "none" && !dryRun) { + console.log("GEOCODING_PROVIDER=none — nichts zu tun."); + return; + } + console.log(`Provider ${cfg.provider} (${cfg.url}), User-Agent "${cfg.userAgent}", ≤ 1 Anfrage/s${dryRun ? " — Probelauf" : ""}`); + + const tenants = await prisma.tenant.findMany({ where: slug ? { slug } : {}, select: { id: true, slug: true }, orderBy: { slug: "asc" } }); + const totals: Record = {}; + let done = 0; + for (const tenant of tenants) { + const db = dbForTenant(tenant.id); + const sites = await db.site.findMany({ + where: { deletedAt: null }, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, country: true, latitude: true, longitude: true, geocodedAt: true, geocodeStatus: true, geocodeQuery: true }, + orderBy: { createdAt: "asc" }, + }); + const pending = sites.filter((s) => { + const query = geocodeQueryFor(s); + if (!query) return false; + if (isValidLatLng(s) && !s.geocodedAt) return false; // manual coordinates + if (s.geocodeQuery !== query) return true; + if (s.geocodeStatus === "ok") return false; + if (s.geocodeStatus === "not_found") return retryNotFound; + return true; + }); + if (!pending.length) continue; + console.log(`\n${tenant.slug}: ${pending.length} Objekt(e)`); + for (const site of pending) { + if (done >= limit) break; + done++; + if (dryRun) { + console.log(` · ${site.name} (${[site.postalCode, site.city].filter(Boolean).join(" ")})`); + continue; + } + if (retryNotFound && site.geocodeStatus === "not_found") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: null } }); + const outcome: GeocodeOutcome = await geocodeSite(tenant.id, site.id); + totals[outcome] = (totals[outcome] ?? 0) + 1; + console.log(` ${outcome.padEnd(9)} ${site.name}`); + } + } + console.log(`\nFertig: ${done} Objekt(e) ${JSON.stringify(totals)}`); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/test-planung-geocode.ts b/scripts/test-planung-geocode.ts new file mode 100644 index 0000000..8f834dc --- /dev/null +++ b/scripts/test-planung-geocode.ts @@ -0,0 +1,178 @@ +// L13 Planung — Geocoding: Normalisierung/Änderungserkennung, Nominatim-Anfrage (URL, User-Agent, +// Accept-Language, Auswertung, Fehler) mit injiziertem fetch, Drosselung 1/s mit Fake-Uhr, Processor mit +// Fake-Provider (Cache bei unveränderter Adresse, not_found, failed → Retry, manuelle Koordinaten, +// Provider none, Mandantentrennung), Hook beim Anlegen ohne Inline-Aufruf. Nominatim wird NIE aufgerufen. +// Lauf: npx tsx scripts/test-planung-geocode.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture"; +import { geocodingConfig, mapConfig } from "../src/server/services/geo/config"; +import { requestSiteGeocoding } from "../src/server/services/geo/dispatch"; +import { geocodeSite } from "../src/server/services/geo/geocode-site"; +import { buildNominatimUrl, createNominatimProvider } from "../src/server/services/geo/nominatim"; +import { addressChanged, geocodeQueryFor } from "../src/server/services/geo/normalize"; +import { GeocodingError, setGeocodingProvider, type GeocodeAddress, type GeocodingProvider } from "../src/server/services/geo/provider"; +import { createIntervalLimiter } from "../src/server/services/geo/rate-limit"; +import { process as geocodeProcessor } from "../src/server/jobs/processors/geocode-site"; +import { PROCESSORS } from "../src/server/jobs/processors"; +import { createSite } from "../src/server/services/sites/sites"; + +const SLUG_A = "zz-planung-geo-a"; +const SLUG_B = "zz-planung-geo-b"; + +// Guard: any request to a Nominatim host fails the suite. +let nominatimHits = 0; +const realFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (/nominatim|openstreetmap/i.test(url)) { + nominatimHits++; + throw new Error("Nominatim must never be called from tests"); + } + return realFetch(input, init); +}) as typeof fetch; + +function fakeProvider(answer: (a: GeocodeAddress) => { status: "ok"; latitude: number; longitude: number } | { status: "not_found" } | "throw") { + const calls: GeocodeAddress[] = []; + const provider: GeocodingProvider = { + name: "fake", + async geocode(a) { + calls.push(a); + const r = answer(a); + if (r === "throw") throw new GeocodingError("http 503"); + return r; + }, + }; + return { provider, calls }; +} + +runSuite("L13 Planung – Geocoding", [SLUG_A, SLUG_B], async () => { + section("Normalisierung & Änderungserkennung"); + const addr = { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" }; + ok(geocodeQueryFor(addr) === "hafenstraße 12|20457|hamburg|de", `normierte Adresse (${geocodeQueryFor(addr)})`); + ok(geocodeQueryFor({ ...addr, street: " HAFENSTRASSE ".replace("SS", "ß"), city: "hamburg " }) === geocodeQueryFor(addr), "Groß-/Kleinschreibung und Leerzeichen egal"); + ok(geocodeQueryFor({ street: "Hafenstraße", houseNumber: "12" }) === null, "ohne PLZ und Ort → nicht verortbar"); + ok(!addressChanged(addr, { ...addr, city: "HAMBURG" }) && addressChanged(addr, { ...addr, houseNumber: "14" }), "nur relevante Adressänderungen zählen"); + + section("Nominatim-Anfrage (ohne Netz)"); + const url = buildNominatimUrl("https://nominatim.example/", addr); + ok( + url.pathname === "/search" && url.searchParams.get("format") === "jsonv2" && url.searchParams.get("limit") === "1" && url.searchParams.get("street") === "12 Hafenstraße" && url.searchParams.get("postalcode") === "20457" && url.searchParams.get("city") === "Hamburg" && url.searchParams.get("countrycodes") === "de", + `strukturierte Suche (${url.search})`, + ); + const cfg = geocodingConfig({ APP_BASE_URL: "https://app.craftvia.example" }); + ok(/^Craftvia\/\d+\.\d+\.\d+ \(\+https:\/\/app\.craftvia\.example\)$/.test(cfg.userAgent) && cfg.url === "https://nominatim.openstreetmap.org" && cfg.provider === "nominatim", `Standard-User-Agent „${cfg.userAgent}“`); + ok(geocodingConfig({ GEOCODING_USER_AGENT: "Betrieb/1 (+mailto:it@example.org)", GEOCODING_PROVIDER: "none", GEOCODING_URL: "https://geo.example/" }).userAgent === "Betrieb/1 (+mailto:it@example.org)", "User-Agent per GEOCODING_USER_AGENT überschreibbar"); + ok(geocodingConfig({ GEOCODING_PROVIDER: "none" }).provider === "none" && geocodingConfig({ GEOCODING_URL: "https://geo.example/" }).url === "https://geo.example", "Provider none / eigene GEOCODING_URL"); + ok(mapConfig({}).tileUrl === "https://tile.openstreetmap.org/{z}/{x}/{y}.png" && mapConfig({}).attribution.includes("OpenStreetMap"), "Kartenkacheln: OSM-Standard mit Namensnennung"); + + const seen: { url: string; headers: Headers }[] = []; + let reply: () => Response = () => Response.json([{ lat: "53.5413", lon: "9.9849" }]); + const acquired: number[] = []; + const nominatim = createNominatimProvider( + { url: "https://nominatim.example", userAgent: "Craftvia/test (+https://app.example)", timeoutMs: 1000 }, + { + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(input), headers: new Headers(init?.headers) }); + return reply(); + }) as typeof fetch, + limiter: { acquire: async () => (acquired.push(Date.now()), Date.now()) }, + }, + ); + const hit = await nominatim.geocode(addr); + ok(hit.status === "ok" && hit.latitude === 53.5413 && hit.longitude === 9.9849, "Treffer ausgewertet"); + ok(seen[0]?.headers.get("user-agent") === "Craftvia/test (+https://app.example)" && seen[0].headers.get("accept-language") === "de", "User-Agent und Accept-Language de gesetzt"); + ok(acquired.length === 1, "jede Anfrage läuft über den Drosselungs-Limiter"); + reply = () => Response.json([]); + ok((await nominatim.geocode(addr)).status === "not_found", "leere Antwort → not_found"); + reply = () => new Response("busy", { status: 503 }); + let threw = false; + try { + await nominatim.geocode(addr); + } catch (err) { + threw = err instanceof GeocodingError; + } + ok(threw, "HTTP-Fehler → GeocodingError (wiederholbar)"); + + section("Drosselung 1 Anfrage/s (Fake-Uhr)"); + let clock = 10_000; + const sleeps: number[] = []; + const limiter = createIntervalLimiter(1000, { now: () => clock, sleep: async (ms) => void sleeps.push(ms) }); + const slots = [await limiter.acquire(), await limiter.acquire(), await limiter.acquire()]; + ok(slots[0] === 10_000 && slots[1] === 11_000 && slots[2] === 12_000 && sleeps.join(",") === "1000,2000", `Slots im Sekundenabstand (${slots.join(",")}), Wartezeiten ${sleeps.join(",")}`); + clock = 20_000; + ok((await limiter.acquire()) === 20_000 && sleeps.length === 2, "nach Pause sofort wieder frei"); + + section("geocodeSite mit Fake-Provider"); + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const fake = fakeProvider((a) => (a.city === "Nirgendwo" ? { status: "not_found" } : { status: "ok", latitude: 53.55, longitude: a.houseNumber === "14" ? 10.01 : 10.0 })); + setGeocodingProvider(fake.provider); + const site = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo 1", ...addr } }); + + ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 1, "neue Adresse → ok, 1 Provider-Aufruf"); + const s1 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } }); + ok(s1.latitude === 53.55 && s1.longitude === 10.0 && s1.geocodeStatus === "ok" && s1.geocodeQuery === geocodeQueryFor(addr) && !!s1.geocodedAt, "Koordinaten, Status, Adresse und Zeitpunkt am Objekt gespeichert"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "site", entityId: site.id, action: "update" } })) === 1, "Koordinatenänderung auditiert"); + ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "unveränderte Adresse → Cache, kein weiterer Aufruf"); + await prisma.site.update({ where: { id: site.id }, data: { city: "hamburg " } }); + ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "nur Schreibweise geändert → Cache"); + await prisma.site.update({ where: { id: site.id }, data: { houseNumber: "14" } }); + ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 2 && (await prisma.site.findUniqueOrThrow({ where: { id: site.id } })).longitude === 10.01, "geänderte Adresse → neu verortet"); + await prisma.site.update({ where: { id: site.id }, data: { city: "Nirgendwo", postalCode: "99999" } }); + ok((await geocodeSite(A.tenantId, site.id)) === "not_found" && fake.calls.length === 3, "unbekannte Adresse → not_found"); + const s2 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } }); + ok(s2.geocodeStatus === "not_found" && s2.latitude === null && s2.longitude === null, "not_found entfernt veraltete automatische Koordinaten"); + ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 3, "not_found wird für dieselbe Adresse nicht wiederholt"); + + const failing = fakeProvider(() => "throw"); + const siteFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Fehler", ...addr } }); + ok((await geocodeSite(A.tenantId, siteFail.id, { provider: failing.provider })) === "failed" && (await prisma.site.findUniqueOrThrow({ where: { id: siteFail.id } })).geocodeStatus === "failed", "Providerfehler → Status failed"); + ok((await geocodeSite(A.tenantId, siteFail.id)) === "ok", "failed wird beim nächsten Lauf erneut versucht"); + + const manual = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo manuell", ...addr, latitude: 50.1, longitude: 8.6 } }); + const callsBefore = fake.calls.length; + ok((await geocodeSite(A.tenantId, manual.id)) === "manual" && fake.calls.length === callsBefore, "manuelle Koordinaten → kein Aufruf"); + const m = await prisma.site.findUniqueOrThrow({ where: { id: manual.id } }); + ok(m.latitude === 50.1 && m.longitude === 8.6 && m.geocodeStatus === "skipped", "manuelle Koordinaten bleiben unverändert (Status skipped)"); + + const none = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo none", ...addr } }); + ok((await geocodeSite(A.tenantId, none.id, { provider: null })) === "skipped" && (await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).latitude === null, "Provider none → skipped, ohne Ortsangabe, kein Fehler"); + const noAddr = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo ohne Adresse", street: "Irgendwo" } }); + ok((await geocodeSite(A.tenantId, noAddr.id)) === "skipped" && fake.calls.length === callsBefore, "unvollständige Adresse → skipped ohne Aufruf"); + + const bCalls = fake.calls.length; + ok((await geocodeSite(B.tenantId, none.id)) === "missing" && fake.calls.length === bCalls, "Mandant B: Objekt von A nicht gefunden, kein Aufruf"); + ok((await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).geocodeStatus === "skipped", "Objekt von A unverändert"); + + section("Processor & Hook"); + ok(typeof PROCESSORS["geocode-site"] === "function", "Processor geocode-site registriert"); + const viaJob = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job", ...addr } }); + await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJob.id }); + ok((await prisma.site.findUniqueOrThrow({ where: { id: viaJob.id } })).geocodeStatus === "ok", "Processor verortet das Objekt"); + setGeocodingProvider(failing.provider); + const viaJobFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job Fehler", ...addr } }); + let jobThrew = false; + try { + await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJobFail.id }); + } catch { + jobThrew = true; + } + ok(jobThrew, "Processor wirft bei failed → BullMQ wiederholt"); + setGeocodingProvider(fake.provider); + + const prev = process.env.GEOCODING_PROVIDER; + process.env.GEOCODING_PROVIDER = "none"; + ok((await requestSiteGeocoding(A.tenantId, viaJob.id)) === false, "GEOCODING_PROVIDER=none → kein Job"); + process.env.GEOCODING_PROVIDER = "nominatim"; + const hookCalls = fake.calls.length; + const created = await createSite(A.ctx.backoffice, { customerId: A.customerId, name: "Geo Hook", street: "Am Kaiserkai", houseNumber: "1", postalCode: "20457", city: "Hamburg" }); + await new Promise((resolve) => setTimeout(resolve, 100)); + ok(!!created.id && fake.calls.length === hookCalls, "Anlage eines Objekts: Verortung nur per Queue, nie inline im Request"); + if (prev === undefined) delete process.env.GEOCODING_PROVIDER; + else process.env.GEOCODING_PROVIDER = prev; + setGeocodingProvider(undefined); + + ok(nominatimHits === 0, "Nominatim wurde in keinem Test aufgerufen"); +}); diff --git a/src/server/jobs/processors/geocode-site.ts b/src/server/jobs/processors/geocode-site.ts new file mode 100644 index 0000000..a114c33 --- /dev/null +++ b/src/server/jobs/processors/geocode-site.ts @@ -0,0 +1,12 @@ +import { geocodeSite } from "@/server/services/geo/geocode-site"; +import type { JobPayload } from "../queues"; + +/** + * Queue "geocode-site" (L13 Planung): address → coordinates for one site, cached on the row. + * The worker runs this queue with concurrency 1 + BullMQ limiter 1/s; the Nominatim provider + * additionally throttles process-wide. `failed` is rethrown so BullMQ retries with backoff. + */ +export async function process(payload: JobPayload): Promise { + const outcome = await geocodeSite(payload.tenantId, payload.entityId); + if (outcome === "failed") throw new Error("geocoding failed (retry scheduled)"); +} diff --git a/src/server/services/geo/config.ts b/src/server/services/geo/config.ts new file mode 100644 index 0000000..f5031d0 --- /dev/null +++ b/src/server/services/geo/config.ts @@ -0,0 +1,42 @@ +import packageJson from "../../../../package.json"; + +/** + * Geocoding + map configuration (L13 Planung). Defaults follow the OpenStreetMap usage policies: + * Nominatim only server side, max. 1 request/s, identifying User-Agent, results cached per site. + * For production with more than a handful of addresses a self-hosted / contracted geocoding and + * tile service is recommended (see docs/craftvia/lanes/planung.md). + */ + +export const GEOCODING_PROVIDERS = ["nominatim", "none"] as const; +export type GeocodingProviderName = (typeof GEOCODING_PROVIDERS)[number]; + +export const DEFAULT_NOMINATIM_URL = "https://nominatim.openstreetmap.org"; +export const DEFAULT_MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; +export const DEFAULT_MAP_ATTRIBUTION = "© OpenStreetMap-Mitwirkende"; + +type Env = Record; +const val = (v: string | undefined) => (v && v.trim() ? v.trim() : undefined); + +export function geocodingProviderName(env: Env = process.env): GeocodingProviderName { + const v = val(env.GEOCODING_PROVIDER)?.toLowerCase(); + return v === "none" ? "none" : "nominatim"; +} + +export function geocodingConfig(env: Env = process.env) { + const baseUrl = val(env.APP_BASE_URL) ?? val(env.AUTH_URL) ?? "http://localhost:3000"; + return { + provider: geocodingProviderName(env), + url: (val(env.GEOCODING_URL) ?? DEFAULT_NOMINATIM_URL).replace(/\/+$/, ""), + userAgent: val(env.GEOCODING_USER_AGENT) ?? `Craftvia/${packageJson.version} (+${baseUrl})`, + timeoutMs: 8_000, + minIntervalMs: 1_000, + }; +} + +/** Tile URL + attribution for the live map (passed from server pages to the client). */ +export function mapConfig(env: Env = process.env) { + return { + tileUrl: val(env.MAP_TILE_URL) ?? DEFAULT_MAP_TILE_URL, + attribution: val(env.MAP_ATTRIBUTION) ?? DEFAULT_MAP_ATTRIBUTION, + }; +} diff --git a/src/server/services/geo/dispatch.ts b/src/server/services/geo/dispatch.ts new file mode 100644 index 0000000..642885f --- /dev/null +++ b/src/server/services/geo/dispatch.ts @@ -0,0 +1,24 @@ +import { enqueueJob } from "@/server/jobs/queues"; +import { geocodingProviderName } from "@/server/services/geo/config"; + +/** + * Ask the worker to geocode a site (after create / address change / import confirmation). + * Never runs inline: no external request inside a user request (OSM policy: no bulk geocoding in + * requests). If the queue is not reachable yet (lazy Redis connection) one delayed retry is made; + * otherwise the site stays without coordinates ("ohne Ortsangabe") until the next change or + * `scripts/geocode-backfill.ts`. Never throws. + */ +export async function requestSiteGeocoding(tenantId: string, siteId: string): Promise { + if (geocodingProviderName() === "none") return false; + const payload = { tenantId, entityId: siteId }; + try { + if (await enqueueJob("geocode-site", payload)) return true; + } catch (err) { + console.error("[geo] enqueue geocode-site failed:", (err as Error).message); + } + const timer = setTimeout(() => { + enqueueJob("geocode-site", payload).catch(() => undefined); + }, 1_500); + timer.unref?.(); + return false; +} diff --git a/src/server/services/geo/geocode-site.ts b/src/server/services/geo/geocode-site.ts new file mode 100644 index 0000000..b088e26 --- /dev/null +++ b/src/server/services/geo/geocode-site.ts @@ -0,0 +1,92 @@ +import { isValidLatLng } from "@/lib/geo/distance"; +import { writeAuditLog } from "@/server/audit"; +import { dbForTenant } from "@/server/db"; +import { geocodeQueryFor } from "@/server/services/geo/normalize"; +import { getGeocodingProvider, type GeocodingProvider } from "@/server/services/geo/provider"; + +/** + * Geocode one site and cache the result on the row (job `geocode-site`, backfill script). + * + * Rules: + * - Manually entered coordinates (coordinates without `geocodedAt`) are never overwritten. + * - Unchanged address (`geocodeQuery`) with status ok/not_found → no provider call ("cached"). + * - No usable address or provider `none` → status `skipped` (retried once enabled/complete). + * - Transport/HTTP error → status `failed` (the job rethrows so BullMQ retries). + * - Address changed and not found → previous automatic coordinates are removed (they would be wrong). + */ + +export type GeocodeOutcome = "ok" | "not_found" | "failed" | "skipped" | "cached" | "manual" | "missing"; + +const SITE_SELECT = { + id: true, + street: true, + houseNumber: true, + postalCode: true, + city: true, + country: true, + latitude: true, + longitude: true, + geocodedAt: true, + geocodeStatus: true, + geocodeQuery: true, +} as const; + +export async function geocodeSite( + tenantId: string, + siteId: string, + opts: { provider?: GeocodingProvider | null; now?: Date } = {}, +): Promise { + const db = dbForTenant(tenantId); + const site = await db.site.findFirst({ where: { id: siteId, deletedAt: null }, select: SITE_SELECT }); + if (!site) return "missing"; + const now = opts.now ?? new Date(); + const query = geocodeQueryFor(site); + + if (isValidLatLng(site) && !site.geocodedAt) { + if (site.geocodeStatus !== "skipped" || site.geocodeQuery !== query) { + await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "skipped", geocodeQuery: query } }); + } + return "manual"; + } + + if (!query) { + const clearAuto = site.geocodedAt !== null && (site.latitude !== null || site.longitude !== null); + await db.site.update({ + where: { id: site.id }, + data: { geocodeStatus: "skipped", geocodeQuery: null, ...(clearAuto ? { latitude: null, longitude: null } : {}) }, + }); + return "skipped"; + } + + if (site.geocodeQuery === query && (site.geocodeStatus === "ok" || site.geocodeStatus === "not_found")) return "cached"; + + const provider = opts.provider === undefined ? await getGeocodingProvider() : opts.provider; + if (!provider) { + if (site.geocodeStatus !== "skipped") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "skipped" } }); + return "skipped"; + } + + let result; + try { + result = await provider.geocode({ street: site.street, houseNumber: site.houseNumber, postalCode: site.postalCode, city: site.city, country: site.country }); + } catch (err) { + console.error(`[geo] geocoding site ${site.id} failed:`, (err as Error).message); + await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "failed", geocodeQuery: query, geocodedAt: now } }); + return "failed"; + } + + const before = { latitude: site.latitude, longitude: site.longitude, geocodeStatus: site.geocodeStatus }; + const coords = result.status === "ok" ? { latitude: result.latitude, longitude: result.longitude } : { latitude: null, longitude: null }; + await db.site.update({ where: { id: site.id }, data: { ...coords, geocodeStatus: result.status, geocodeQuery: query, geocodedAt: now } }); + if (before.latitude !== coords.latitude || before.longitude !== coords.longitude) { + await writeAuditLog({ + tenantId, + action: "update", + entity: "site", + entityId: site.id, + before, + after: { ...coords, geocodeStatus: result.status, source: "geocoding", provider: provider.name }, + }); + } + return result.status; +} diff --git a/src/server/services/geo/nominatim.ts b/src/server/services/geo/nominatim.ts new file mode 100644 index 0000000..d1f3070 --- /dev/null +++ b/src/server/services/geo/nominatim.ts @@ -0,0 +1,58 @@ +import { GeocodingError, type GeocodeAddress, type GeocodeResult, type GeocodingProvider } from "@/server/services/geo/provider"; +import { nominatimLimiter } from "@/server/services/geo/rate-limit"; + +/** + * Nominatim (OpenStreetMap) provider. Usage policy: server side only, max. 1 request/s process-wide, + * identifying User-Agent, results are cached in Site.latitude/longitude by the caller, no bulk + * geocoding inside requests (only the geocode-site job and scripts/geocode-backfill.ts call this). + */ + +type Config = { url: string; userAgent: string; timeoutMs: number }; +type Deps = { fetch: typeof fetch; limiter: { acquire(): Promise } }; + +/** Structured search URL (no free text → fewer ambiguous hits). */ +export function buildNominatimUrl(baseUrl: string, address: GeocodeAddress): URL { + const url = new URL(`${baseUrl.replace(/\/+$/, "")}/search`); + url.searchParams.set("format", "jsonv2"); + url.searchParams.set("limit", "1"); + url.searchParams.set("addressdetails", "0"); + const street = [address.houseNumber, address.street].map((s) => s?.trim()).filter(Boolean).join(" "); + if (street) url.searchParams.set("street", street); + if (address.postalCode?.trim()) url.searchParams.set("postalcode", address.postalCode.trim()); + if (address.city?.trim()) url.searchParams.set("city", address.city.trim()); + const country = (address.country ?? "DE").trim().toLowerCase(); + if (/^[a-z]{2}$/.test(country)) url.searchParams.set("countrycodes", country); + return url; +} + +export function createNominatimProvider(config: Config, deps: Deps = { fetch: globalThis.fetch, limiter: nominatimLimiter }): GeocodingProvider { + return { + name: "nominatim", + async geocode(address: GeocodeAddress): Promise { + await deps.limiter.acquire(); + let res: Response; + try { + res = await deps.fetch(buildNominatimUrl(config.url, address), { + headers: { "User-Agent": config.userAgent, "Accept-Language": "de", Accept: "application/json" }, + signal: AbortSignal.timeout(config.timeoutMs), + }); + } catch (err) { + throw new GeocodingError(`request failed: ${(err as Error).name}`); + } + if (!res.ok) throw new GeocodingError(`http ${res.status}`); + let body: unknown; + try { + body = await res.json(); + } catch { + throw new GeocodingError("invalid response"); + } + const hit = Array.isArray(body) ? (body[0] as { lat?: unknown; lon?: unknown } | undefined) : undefined; + const latitude = Number(hit?.lat); + const longitude = Number(hit?.lon); + if (!hit || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) { + return { status: "not_found" }; + } + return { status: "ok", latitude, longitude }; + }, + }; +} diff --git a/src/server/services/geo/normalize.ts b/src/server/services/geo/normalize.ts new file mode 100644 index 0000000..0081fef --- /dev/null +++ b/src/server/services/geo/normalize.ts @@ -0,0 +1,27 @@ +/** + * Normalized address string of a site ("hafenstraße 12|20457|hamburg|de"). Stored in + * Site.geocodeQuery next to the coordinates: an unchanged address is never geocoded twice. + * Returns null when the address is too incomplete to geocode (neither postal code nor city). + */ +export type SiteAddress = { + street?: string | null; + houseNumber?: string | null; + postalCode?: string | null; + city?: string | null; + country?: string | null; +}; + +const clean = (v: string | null | undefined) => (v ?? "").normalize("NFC").trim().replace(/\s+/g, " ").toLowerCase(); + +export function geocodeQueryFor(site: SiteAddress): string | null { + const postal = clean(site.postalCode); + const city = clean(site.city); + if (!postal && !city) return null; + const street = [clean(site.street), clean(site.houseNumber)].filter(Boolean).join(" "); + return [street, postal, city, clean(site.country) || "de"].join("|"); +} + +/** True when the geocoding-relevant part of the address changed. */ +export function addressChanged(before: SiteAddress, after: SiteAddress): boolean { + return geocodeQueryFor(before) !== geocodeQueryFor(after); +} diff --git a/src/server/services/geo/provider.ts b/src/server/services/geo/provider.ts new file mode 100644 index 0000000..ebe95fa --- /dev/null +++ b/src/server/services/geo/provider.ts @@ -0,0 +1,41 @@ +import { geocodingConfig, geocodingProviderName } from "@/server/services/geo/config"; + +/** Provider-neutral geocoding contract (L13 Planung) — swap Nominatim for another service via env. */ + +export type GeocodeAddress = { + street: string | null; + houseNumber: string | null; + postalCode: string | null; + city: string | null; + country: string | null; +}; + +export type GeocodeResult = { status: "ok"; latitude: number; longitude: number } | { status: "not_found" }; + +export interface GeocodingProvider { + readonly name: string; + /** Resolves ok/not_found; throws GeocodingError for transport/HTTP failures (retryable). */ + geocode(address: GeocodeAddress): Promise; +} + +export class GeocodingError extends Error { + constructor(message: string) { + super(message); + this.name = "GeocodingError"; + } +} + +let override: GeocodingProvider | null | undefined; + +/** Tests inject a fake provider (never Nominatim); `undefined` restores the env-based provider. */ +export function setGeocodingProvider(provider: GeocodingProvider | null | undefined): void { + override = provider; +} + +/** Configured provider, or null when geocoding is disabled (`GEOCODING_PROVIDER=none`). */ +export async function getGeocodingProvider(): Promise { + if (override !== undefined) return override; + if (geocodingProviderName() === "none") return null; + const { createNominatimProvider } = await import("@/server/services/geo/nominatim"); + return createNominatimProvider(geocodingConfig()); +} diff --git a/src/server/services/geo/rate-limit.ts b/src/server/services/geo/rate-limit.ts new file mode 100644 index 0000000..e857ba8 --- /dev/null +++ b/src/server/services/geo/rate-limit.ts @@ -0,0 +1,24 @@ +/** + * Minimum-interval limiter: callers are released one after another with at least `intervalMs` + * between two slots (Nominatim policy: max. 1 request per second). Clock is injectable for tests. + */ +export type Clock = { now: () => number; sleep: (ms: number) => Promise }; + +const realClock: Clock = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }; + +export function createIntervalLimiter(intervalMs: number, clock: Clock = realClock) { + let next = 0; + return { + /** Waits for the next free slot and returns its timestamp. */ + async acquire(): Promise { + const now = clock.now(); + const slot = Math.max(now, next); + next = slot + intervalMs; + if (slot > now) await clock.sleep(slot - now); + return slot; + }, + }; +} + +/** Process-wide limiter for all Nominatim requests of this process (worker, backfill script). */ +export const nominatimLimiter = createIntervalLimiter(1_000); diff --git a/src/server/services/imports/confirm.ts b/src/server/services/imports/confirm.ts index 9104cd2..0ffab91 100644 --- a/src/server/services/imports/confirm.ts +++ b/src/server/services/imports/confirm.ts @@ -8,6 +8,7 @@ import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imp import { createWorkOrder } from "@/server/services/work-orders/create"; import { startExtraction, type Dispatch } from "./upload"; import { dispatchJob } from "@/server/jobs/dispatch"; +import { requestSiteGeocoding } from "@/server/services/geo/dispatch"; export type ConfirmResult = { workOrderId: string; workOrderNumber: string; customerId: string; siteId: string | null; contactId: string | null }; @@ -168,6 +169,7 @@ export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm: if (result.createdCustomer) await writeAuditLog({ ...base, action: "create", entity: "customer", entityId: result.customerId, after: { source: "import", importId: job.id } }); if (result.contactId) await writeAuditLog({ ...base, action: "create", entity: "contact", entityId: result.contactId, after: { customerId: result.customerId, source: "import" } }); if (result.createdSite && result.siteId) await writeAuditLog({ ...base, action: "create", entity: "site", entityId: result.siteId, after: { customerId: result.customerId, source: "import" } }); + if (result.createdSite && result.siteId) void requestSiteGeocoding(ctx.tenantId, result.siteId); // L13 Planung: queued geocoding of the new site // work order creation is audited by services/work-orders/create (entity work_order) await writeAuditLog({ ...base, diff --git a/src/server/services/sites/sites.ts b/src/server/services/sites/sites.ts index c0380d4..dc1ebb0 100644 --- a/src/server/services/sites/sites.ts +++ b/src/server/services/sites/sites.ts @@ -4,6 +4,8 @@ import { writeAuditLog } from "@/server/audit"; import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; import { siteScope } from "@/server/services/work-orders/visibility"; import { optStr } from "@/server/services/customers/schemas"; +import { requestSiteGeocoding } from "@/server/services/geo/dispatch"; +import { addressChanged } from "@/server/services/geo/normalize"; export const SITE_STATUSES = ["active", "inactive", "provisional"] as const; @@ -121,6 +123,7 @@ export async function createSite(ctx: ServiceCtx, input: SiteCreateInput) { await assertCustomerAndContact(ctx, data.customerId, data.contactId); const site = await ctx.db.site.create({ data: { ...data, tenantId: ctx.tenantId, country: data.country ?? "DE" } }); await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "site", entityId: site.id, after: site }); + void requestSiteGeocoding(ctx.tenantId, site.id); // L13 Planung: queued, never inline return site; } @@ -134,6 +137,7 @@ export async function updateSite(ctx: ServiceCtx, id: string, patch: SitePatchIn await assertCustomerAndContact(ctx, customerId, contactId); const after = await ctx.db.site.update({ where: { id }, data: { ...data, contactId } }); await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "site", entityId: id, before, after }); + if (addressChanged(before, after)) void requestSiteGeocoding(ctx.tenantId, id); // L13 Planung return after; } From 5e10523df6325a6d2f2b93d1f1942d0b11996a00 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 10:13:39 +0200 Subject: [PATCH 3/5] L13 Planung: Plantafel-, Einplan-, Empfehlungs-, Live- und Verzugsdienste mit API und Meldungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/craftvia/API.md | 4 + messages/de/notifications.json | 13 +- messages/en/notifications.json | 13 +- scripts/test-planung-core.ts | 302 ++++++++++++++ scripts/test-planung-live.ts | 127 ++++++ scripts/test-planung-recommend.ts | 134 ++++++ scripts/test-planung-watch.ts | 176 ++++++++ src/app/api/v1/planning/board/route.ts | 24 ++ src/app/api/v1/planning/live/route.ts | 19 + .../api/v1/planning/recommendations/route.ts | 25 ++ src/app/api/v1/planning/schedule/route.ts | 15 + src/lib/api/openapi.ts | 134 ++++++ src/lib/events.ts | 4 + src/server/actions/work_orders/planning.ts | 27 ++ src/server/jobs/processors/index.ts | 2 + src/server/jobs/processors/planning-watch.ts | 17 + src/server/jobs/queues.ts | 13 + .../services/notifications/handle-event.ts | 5 + .../services/notifications/recipients.ts | 18 + src/server/services/planning/access.ts | 38 ++ src/server/services/planning/board.ts | 177 ++++++++ src/server/services/planning/data.ts | 184 +++++++++ src/server/services/planning/live.ts | 271 ++++++++++++ src/server/services/planning/recommend.ts | 222 ++++++++++ src/server/services/planning/schedule.ts | 148 +++++++ src/server/services/planning/summary.ts | 23 ++ src/server/services/planning/team-settings.ts | 29 ++ src/server/services/planning/time-tracking.ts | 30 ++ src/server/services/planning/watch.ts | 391 ++++++++++++++++++ 29 files changed, 2579 insertions(+), 6 deletions(-) create mode 100644 scripts/test-planung-core.ts create mode 100644 scripts/test-planung-live.ts create mode 100644 scripts/test-planung-recommend.ts create mode 100644 scripts/test-planung-watch.ts create mode 100644 src/app/api/v1/planning/board/route.ts create mode 100644 src/app/api/v1/planning/live/route.ts create mode 100644 src/app/api/v1/planning/recommendations/route.ts create mode 100644 src/app/api/v1/planning/schedule/route.ts create mode 100644 src/server/actions/work_orders/planning.ts create mode 100644 src/server/jobs/processors/planning-watch.ts create mode 100644 src/server/services/planning/access.ts create mode 100644 src/server/services/planning/board.ts create mode 100644 src/server/services/planning/data.ts create mode 100644 src/server/services/planning/live.ts create mode 100644 src/server/services/planning/recommend.ts create mode 100644 src/server/services/planning/schedule.ts create mode 100644 src/server/services/planning/summary.ts create mode 100644 src/server/services/planning/team-settings.ts create mode 100644 src/server/services/planning/time-tracking.ts create mode 100644 src/server/services/planning/watch.ts diff --git a/docs/craftvia/API.md b/docs/craftvia/API.md index 02b2096..5ab762a 100644 --- a/docs/craftvia/API.md +++ b/docs/craftvia/API.md @@ -91,4 +91,8 @@ Die Pfade sind relativ zu `/api/v1`. „Recht“ nennt das Gate der Route. Mit | POST | `/uploads` | field | `field:execute` | Foto/Sprachnotiz hochladen → `documentId` | | GET | `/field/bundle` | field | `field:execute` | Offline-Pull (`?since=`, max. 200 Aufträge) | | GET | `/field/documents/{id}` | field | Service: `document:read` + Sichtbarkeit/Scope | Dokument für die Mobile-App (`?variant=preview`) | +| GET | `/planning/board` | work_orders | Service: `work_order:read_all` oder Teamleiter (eigene Teams) | Plantafel: Aufträge je Team/Tag, Kapazität, Auslastung, Konflikte (`from`, `to` ≤ 42 Tage, `teamId`, `orderTypeId`, `priority`) | +| POST | `/planning/schedule` | work_orders | `work_order:assign` + Service: `work_order:write` | Einplanen (Team + Termin + Dauer, atomar, `baseVersion` Pflicht, 409 „Auftrag wurde zwischenzeitlich geändert“) | +| GET | `/planning/recommendations` | work_orders | `work_order:assign` | Einsatz-Empfehlungen (Luftlinie + freie Kapazität, Top 5) + nahe ungeplante Aufträge; nur Vorschläge | +| GET | `/planning/live` | work_orders | Service: `work_order:read_all` oder Teamleiter | Live-Lage: Status aus aktiver WorkSession, Standort = Objekt des Auftrags, keine Geräte-Koordinaten | | GET | `/openapi.json` | – | angemeldet | OpenAPI-3.1-Dokument | diff --git a/messages/de/notifications.json b/messages/de/notifications.json index 3689c31..b11cf65 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -45,7 +45,10 @@ "emergency_completed": "Notdiensteinsatz abgeschlossen", "import_ready_for_review": "Import zur Prüfung", "import_failed": "Fehler bei Dokumentverarbeitung", - "sync_failed": "Synchronisationsfehler" + "sync_failed": "Synchronisationsfehler", + "planning_capacity_freed": "Früher fertig", + "planning_overrun": "Geplante Dauer überschritten", + "planning_followup_at_risk": "Folgeauftrag gefährdet" }, "events": { "work_order_assigned": { "title": "Neuer Auftrag {number}", "message": "{title} – {customer} wurde Ihrem Team zugewiesen." }, @@ -64,7 +67,10 @@ "emergency_completed": { "title": "Notdiensteinsatz {number} abgeschlossen", "message": "{customer}: zur Prüfung und Abrechnung." }, "import_ready_for_review": { "title": "Import zur Prüfung bereit", "message": "Die Daten aus {fileName} liegen zur Prüfung vor." }, "import_failed": { "title": "Fehler bei Dokumentverarbeitung", "message": "{fileName} konnte nicht verarbeitet werden." }, - "sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({reason})." } + "sync_failed": { "title": "Synchronisation fehlgeschlagen", "message": "Eine Änderung konnte nicht übernommen werden ({reason})." }, + "planning_capacity_freed": { "title": "{team} früher fertig: {number}", "message": "{title} – {customer}: {minutes} min vor dem geplanten Ende abgeschlossen. Freie Zeit auf der Plantafel nutzen." }, + "planning_overrun": { "title": "Geplante Dauer überschritten: {number}", "message": "{team} bei {customer}: {percent} % der geplanten Dauer erfasst." }, + "planning_followup_at_risk": { "title": "Folgeauftrag gefährdet: {number}", "message": "{title} – {customer} ({team}) ist gefährdet durch Verzug {blocker}." } }, "fallback": { "unknown": "unbekannt", @@ -178,7 +184,8 @@ "voice_note": "Sprachnotiz", "notification": "Benachrichtigung", "notification_settings": "Benachrichtigungseinstellungen", - "sync_operation": "Synchronisation" + "sync_operation": "Synchronisation", + "planning_alert": "Planungshinweis" } } } diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 3206fdf..2bb02d6 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -45,7 +45,10 @@ "emergency_completed": "Emergency call-out completed", "import_ready_for_review": "Import ready for review", "import_failed": "Document processing failed", - "sync_failed": "Sync failed" + "sync_failed": "Sync failed", + "planning_capacity_freed": "Finished early", + "planning_overrun": "Planned duration exceeded", + "planning_followup_at_risk": "Follow-up order at risk" }, "events": { "work_order_assigned": { "title": "New work order {number}", "message": "{title} – {customer} was assigned to your team." }, @@ -64,7 +67,10 @@ "emergency_completed": { "title": "Emergency call-out {number} completed", "message": "{customer}: ready for review and billing." }, "import_ready_for_review": { "title": "Import ready for review", "message": "The data from {fileName} is ready for review." }, "import_failed": { "title": "Document processing failed", "message": "{fileName} could not be processed." }, - "sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({reason})." } + "sync_failed": { "title": "Sync failed", "message": "A change could not be applied ({reason})." }, + "planning_capacity_freed": { "title": "{team} finished early: {number}", "message": "{title} – {customer}: completed {minutes} min before the planned end. Use the free time on the planning board." }, + "planning_overrun": { "title": "Planned duration exceeded: {number}", "message": "{team} at {customer}: {percent} % of the planned duration recorded." }, + "planning_followup_at_risk": { "title": "Follow-up order at risk: {number}", "message": "{title} – {customer} ({team}) is at risk due to the delay of {blocker}." } }, "fallback": { "unknown": "unknown", @@ -178,7 +184,8 @@ "voice_note": "Voice note", "notification": "Notification", "notification_settings": "Notification settings", - "sync_operation": "Sync" + "sync_operation": "Sync", + "planning_alert": "Planning alert" } } } diff --git a/scripts/test-planung-core.ts b/scripts/test-planung-core.ts new file mode 100644 index 0000000..07a88b1 --- /dev/null +++ b/scripts/test-planung-core.ts @@ -0,0 +1,302 @@ +// L13 Planung — Kern: Haversine, Arbeitstage/Kapazität (Mitgliedschaft gültig ab/bis), Auslastung, +// Konflikte (overbooked, overlap, assignee_double_booked, outside_working_days), Plantafel mit Rollen +// und Mandantentrennung, scheduleWorkOrder (Version, Scope, Recht, Event, Audit, atomar). +// Lauf: npx tsx scripts/test-planung-core.ts (eigene zz-Mandanten, räumt auf) + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture"; +import { formatDistance, haversineKm, isValidLatLng } from "../src/lib/geo/distance"; +import { addDays, dayBounds, dayKeyOf, isDayKey, isWorkingDay, startOfWeek, weekdayIndex } from "../src/lib/planning/days"; +import { + activeMemberCount, + assigneeDoubleBookings, + computeTeamDay, + effectiveDurationMinutes, + estimateTravelMinutes, + isConflict, + minutesOnDay, + overrunBucket, + teamCapacityMinutes, + unionMinutes, + utilizationPercent, + type PlanningOrder, +} from "../src/lib/planning/capacity"; +import { addWorkingDays, nextWorkingDays } from "../src/lib/planning/days"; +import { formatDayShort, formatMinutes } from "../src/lib/planning/text"; +import { wallTimeToUtc } from "../src/lib/work-orders/time"; +import { ServiceError } from "../src/server/services/context"; +import { countWeekConflicts, getPlanningBoard } from "../src/server/services/planning/board"; +import { scheduleWorkOrder, VERSION_CONFLICT_MESSAGE } from "../src/server/services/planning/schedule"; +import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { ensureDefaultOrderTypes } from "../src/server/services/work-orders/settings"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; + +const TZ = "Europe/Berlin"; +const SLUG_A = "zz-planung-core-a"; +const SLUG_B = "zz-planung-core-b"; +const at = (day: string, hm: string) => wallTimeToUtc(`${day}T${hm}`, TZ)!; +const po = (p: Partial & { id: string }): PlanningOrder => ({ + teamId: null, + plannedStart: null, + plannedEnd: null, + plannedDurationMinutes: null, + orderTypeDefaultMinutes: null, + assigneeIds: [], + ...p, +}); + +runSuite("L13 Planung – Kern", [SLUG_A, SLUG_B], async () => { + process.env.GEOCODING_PROVIDER = "none"; + + section("Haversine & Formatierung"); + const hh = { latitude: 53.5503, longitude: 9.992 }; + const ber = { latitude: 52.5163, longitude: 13.3777 }; + const d = haversineKm(hh, ber); + ok(d > 250 && d < 262, `Hamburg–Berlin ≈ 255 km (${d.toFixed(1)} km)`); + ok(haversineKm(hh, hh) === 0, "gleicher Punkt → 0 km"); + ok(Math.abs(haversineKm(hh, ber) - haversineKm(ber, hh)) < 1e-9, "symmetrisch"); + const eq = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 0, longitude: 1 }); + ok(Math.abs(eq - 111.195) < 0.1, `1° Länge am Äquator ≈ 111,2 km (${eq.toFixed(3)})`); + ok(formatDistance(3.24, "de") === "3,2 km" && formatDistance(0.85, "de") === "850 m" && formatDistance(3.24, "en") === "3.2 km", "„3,2 km“ / „850 m“ / en „3.2 km“"); + ok(!isValidLatLng({ latitude: null, longitude: 9 }) && !isValidLatLng({ latitude: 91, longitude: 0 }) && isValidLatLng(hh), "Koordinaten-Validierung"); + ok(formatMinutes(240, "de") === "4 h" && formatMinutes(270, "de") === "4,5 h" && formatMinutes(45, "de") === "45 min", "Dauer „4 h“ / „4,5 h“ / „45 min“"); + ok(formatDayShort("2026-09-15", "de") === "Di 15.09.", `Tag kurz „Di 15.09.“ (${formatDayShort("2026-09-15", "de")})`); + + section("Tage, Dauer, Kapazität, Konflikte (reine Regeln)"); + const mon = startOfWeek(addDays(dayKeyOf(new Date(), TZ), 14)); + const tue = addDays(mon, 1); + const wed = addDays(mon, 2); + const sat = addDays(mon, 5); + ok(weekdayIndex(mon) === 0 && weekdayIndex(addDays(mon, 6)) === 6, "Wochentag Mo = 0 … So = 6"); + ok(isWorkingDay(31, mon) && !isWorkingDay(31, sat) && isWorkingDay(31 | 32, sat), "Arbeitstage-Bitmaske (Mo–Fr, mit Sa)"); + ok(isDayKey("2026-02-28") && !isDayKey("2026-02-30") && !isDayKey("28.02.2026"), "Tagesschlüssel validiert"); + const dst = dayBounds("2026-10-25", TZ); + ok(dst.end.getTime() - dst.start.getTime() === 25 * 3600_000, "Zeitumstellung: 25-Stunden-Tag"); + + ok(effectiveDurationMinutes(po({ id: "a", plannedStart: at(mon, "08:00"), plannedEnd: at(mon, "09:00"), plannedDurationMinutes: 90, orderTypeDefaultMinutes: 480 }), TZ) === 90, "Dauer: explizit vor Beginn/Ende und Auftragsart"); + ok(effectiveDurationMinutes(po({ id: "b", plannedStart: at(mon, "08:00"), plannedEnd: at(mon, "11:30"), orderTypeDefaultMinutes: 480 }), TZ) === 210, "Dauer: aus Beginn/Ende (3,5 h)"); + ok(effectiveDurationMinutes(po({ id: "c", plannedStart: at(mon, "08:00"), orderTypeDefaultMinutes: 180 }), TZ) === 180, "Dauer: Standard der Auftragsart"); + ok(effectiveDurationMinutes(po({ id: "d", plannedStart: at(mon, "08:00") }), TZ) === 120, "Dauer: Fallback 120 min"); + const multi = po({ id: "e", plannedStart: at(mon, "08:00"), plannedEnd: at(tue, "16:00") }); + ok(minutesOnDay(multi, mon, TZ) === 480 && minutesOnDay(multi, tue, TZ) === 480 && minutesOnDay(multi, wed, TZ) === 0, "mehrtägig ohne Dauer: je Tag 480 min"); + ok(minutesOnDay({ ...multi, plannedDurationMinutes: 600 }, tue, TZ) === 300, "mehrtägig mit Dauer: gleichmäßig verteilt"); + const dateOnly = po({ id: "f", plannedStart: at(mon, "00:00"), plannedEnd: at(tue, "00:00") }); + ok(minutesOnDay(dateOnly, mon, TZ) === 120 && minutesOnDay(dateOnly, tue, TZ) === 0, "Ende um Mitternacht gehört zum Vortag"); + + const longAgo = new Date(Date.now() - 400 * 86400_000); + const team = { + id: "t", + dailyCapacityMinutes: 480, + workingDays: 31, + members: [ + { userId: "u1", validFrom: longAgo, validTo: null, active: true }, + { userId: "u2", validFrom: longAgo, validTo: dayBounds(mon, TZ).start, active: true }, // endet vor Montag + { userId: "u3", validFrom: dayBounds(tue, TZ).start, validTo: null, active: true }, // ab Dienstag + { userId: "u4", validFrom: longAgo, validTo: null, active: false }, // deaktiviert + { userId: "u1", validFrom: longAgo, validTo: null, active: true }, // doppelte Zeile zählt einmal + ], + }; + ok(activeMemberCount(team, mon, TZ) === 1 && activeMemberCount(team, tue, TZ) === 2, "aktive Mitglieder: gültig ab/bis, inaktiv, doppelt (Mo 1, Di 2)"); + ok(teamCapacityMinutes(team, mon, TZ) === 480 && teamCapacityMinutes(team, tue, TZ) === 480, "Kolonnenkapazität = Arbeitstag der Kolonne (480), nicht × Personen"); + ok(teamCapacityMinutes(team, sat, TZ) === 0, "Kapazität Sa: kein Arbeitstag → 0"); + ok(teamCapacityMinutes({ ...team, members: [] }, tue, TZ) === 0, "Kolonne ohne aktive Mitglieder → 0"); + ok(utilizationPercent(360, 960) === 38 && utilizationPercent(60, 0) === null, "Auslastung 6 h / 16 h = 38 %; ohne Kapazität null"); + const hintMon = computeTeamDay(team, mon, [], TZ).conflicts.find((c) => c.kind === "crew_incomplete"); + ok(!!hintMon && hintMon.severity === "hint" && hintMon.memberCount === 1, "crew_incomplete: Hinweis bei weniger als 2 aktiven Personen"); + ok(!computeTeamDay(team, tue, [], TZ).conflicts.some((c) => c.kind === "crew_incomplete") && !computeTeamDay(team, sat, [], TZ).conflicts.some((c) => c.kind === "crew_incomplete"), "kein Hinweis bei 2 Personen bzw. an Nicht-Arbeitstagen"); + ok(nextWorkingDays("2026-09-18", 5).join(",") === "2026-09-18,2026-09-21,2026-09-22,2026-09-23,2026-09-24" && nextWorkingDays("2026-09-19", 2).join(",") === "2026-09-19,2026-09-21", "Standardansicht: heute + nächste Werktage (Wochenende übersprungen)"); + ok(addWorkingDays("2026-09-21", -5) === "2026-09-14" && addWorkingDays("2026-09-18", 1) === "2026-09-21", "Werktage vor/zurück"); + + const crew = { ...team, dailyCapacityMinutes: 960 }; + const orders = [ + po({ id: "o1", teamId: "t", plannedStart: at(tue, "08:00"), plannedEnd: at(tue, "12:00") }), + po({ id: "o2", teamId: "t", plannedStart: at(tue, "11:00"), plannedEnd: at(tue, "13:00") }), + po({ id: "o3", teamId: "t", plannedStart: at(tue, "13:00"), plannedDurationMinutes: 600 }), + ]; + const td = computeTeamDay(crew, tue, orders, TZ); + ok(td.plannedMinutes === 960 && td.capacityMinutes === 960 && td.utilization === 100, "Tageslast 16 h bei 16 h Kolonnenkapazität = 100 %"); + ok(!td.conflicts.some((c) => c.kind === "overbooked"), "genau ausgelastet ist nicht überbucht"); + const overlaps = td.conflicts.filter((c) => c.kind === "overlap"); + ok(overlaps.length === 1 && overlaps[0].orderIds.includes("o1") && overlaps[0].orderIds.includes("o2"), "Überschneidung o1/o2; o3 beginnt genau am Ende von o2 → keine"); + const td2 = computeTeamDay(crew, tue, [...orders, po({ id: "o4", teamId: "t", plannedStart: at(tue, "00:00"), plannedDurationMinutes: 30 })], TZ); + ok(td2.conflicts.some((c) => c.kind === "overbooked" && c.minutesOver === 30), "überbucht um 30 min"); + ok(td2.conflicts.filter((c) => c.kind === "overlap").length === 1, "Auftrag ohne Uhrzeit erzeugt keine Überschneidung"); + const tdSat = computeTeamDay(crew, sat, [po({ id: "o5", teamId: "t", plannedStart: at(sat, "09:00") })], TZ); + ok(tdSat.conflicts.length === 1 && tdSat.conflicts[0].kind === "outside_working_days", "Auftrag am Samstag → outside_working_days (nicht zusätzlich überbucht)"); + const dbl = assigneeDoubleBookings( + [ + po({ id: "x1", teamId: "t", plannedStart: at(tue, "09:00"), plannedEnd: at(tue, "10:00"), assigneeIds: ["u1"] }), + po({ id: "x2", teamId: "s", plannedStart: at(tue, "09:30"), plannedEnd: at(tue, "11:00"), assigneeIds: ["u1"] }), + po({ id: "x3", teamId: "s", plannedStart: at(tue, "10:00"), plannedEnd: at(tue, "11:00"), assigneeIds: ["u3"] }), + po({ id: "x4", teamId: "s", plannedStart: at(wed, "09:00"), plannedEnd: at(wed, "11:00"), assigneeIds: ["u1"] }), + ], + tue, + TZ, + ); + ok(dbl.length === 1 && dbl[0].userId === "u1" && dbl[0].teamIds.length === 2, "Person in zwei Kolonnen gleichzeitig → assignee_double_booked (nur überlappende Zeitfenster)"); + + section("Kolonnenzeit, Fahrzeit, Überschreitungsstufen (rein)"); + const hm = (v: string) => at(tue, v).getTime(); + ok(unionMinutes([{ start: hm("08:00"), end: hm("10:00") }, { start: hm("09:00"), end: hm("11:00") }, { start: hm("12:00"), end: hm("13:00") }]) === 240, "Arbeitssegmente zeitlich vereinigt (3 h + 1 h), nicht über Personen summiert"); + ok(unionMinutes([]) === 0 && unionMinutes([{ start: hm("10:00"), end: hm("09:00") }]) === 0, "leere/ungültige Segmente → 0"); + ok(estimateTravelMinutes(null) === 10 && estimateTravelMinutes(1) === 10 && estimateTravelMinutes(50) === 78 && estimateTravelMinutes(20) === 31, "Fahrzeit = Luftlinie × 1,3 / 50 km/h, mind. 10 min (50 km → 78 min)"); + ok(overrunBucket(0.99) === -1 && overrunBucket(1) === 0 && overrunBucket(1.49) === 0 && overrunBucket(1.5) === 1 && overrunBucket(2) === 2, "Meldestufen: 100 %, danach je weitere +50 %"); + + section("Plantafel (Datenbank)"); + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + await ensureDefaultOrderTypes(bo.db, A.tenantId); + const montage = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "montage" } }); + const besichtigung = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "besichtigung" } }); + ok(montage.defaultDurationMinutes === 480 && besichtigung.defaultDurationMinutes === 60, "Standard-Auftragsarten mit Dauer (Montage 480, Besichtigung 60)"); + + const mk = (title: string, extra: Record = {}) => createWorkOrder(bo, { title, customerId: A.customerId, siteId: A.siteId, ...extra }); + const version = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).version; + const place = async (id: string, teamId: string, start: Date, extra: { plannedEnd?: Date | null; plannedDurationMinutes?: number | null } = {}) => + scheduleWorkOrder(bo, { workOrderId: id, teamId, plannedStart: start, baseVersion: await version(id), ...extra }); + + const w1 = await mk("Planung 1"); + await assignWorkOrder(bo, { workOrderId: w1.id, teamId: A.teamId, userIds: [A.users.tech.id] }); + await place(w1.id, A.teamId, at(mon, "08:00"), { plannedEnd: at(mon, "12:00") }); + const w2 = await mk("Planung 2"); + await place(w2.id, A.teamId, at(mon, "10:00"), { plannedEnd: at(mon, "14:00") }); + const w3 = await mk("Planung 3"); + await place(w3.id, A.teamId, at(mon, "14:00"), { plannedDurationMinutes: 600 }); + const w4 = await mk("Planung Samstag"); + await place(w4.id, A.team2Id, at(sat, "09:00"), { plannedEnd: at(sat, "11:00") }); + const w5 = await mk("Planung Süd mit Max", { priority: "high" }); + await assignWorkOrder(bo, { workOrderId: w5.id, teamId: A.team2Id, userIds: [A.users.tech.id] }); + await place(w5.id, A.team2Id, at(mon, "09:00"), { plannedEnd: at(mon, "11:00") }); + const draft = await mk("Noch ungeplant"); + + const board = await getPlanningBoard(bo, { from: mon, to: addDays(mon, 6) }); + const nord = board.teams.find((t) => t.id === A.teamId)!; + const sued = board.teams.find((t) => t.id === A.team2Id)!; + const nMon = nord.days.find((x) => x.day === mon)!; + ok(board.days.length === 7 && board.timeZone === TZ, "Woche Mo–So in Mandanten-Zeitzone"); + ok(nMon.capacityMinutes === 480 && nMon.plannedMinutes === 1080 && nMon.utilization === 225 && nMon.memberCount === 2, `Kolonne Nord Mo: 18 h / 8 h · 225 % (${nMon.plannedMinutes}/${nMon.capacityMinutes}/${nMon.utilization})`); + ok(nMon.conflicts.some((c) => c.kind === "overbooked" && c.minutesOver === 600), "Kolonne Nord Mo überbucht um 10 h"); + ok( + !nMon.conflicts.some((c) => c.kind === "crew_incomplete") && sued.days.find((x) => x.day === mon)!.conflicts.some((c) => c.kind === "crew_incomplete" && c.severity === "hint" && c.memberCount === 1), + "Hinweis „Kolonne unvollständig“ nur bei Team Süd (1 Person)", + ); + ok(nMon.conflicts.some((c) => c.kind === "overlap" && c.orderIds.includes(w1.id) && c.orderIds.includes(w2.id)), "Überschneidung zweier Aufträge desselben Teams"); + ok(!nMon.conflicts.some((c) => c.kind === "overlap" && c.orderIds.includes(w3.id)), "anschließender Auftrag (14:00) überschneidet nicht"); + ok(sued.days.find((x) => x.day === sat)!.conflicts.some((c) => c.kind === "outside_working_days" && c.orderIds.includes(w4.id)), "Samstag bei Team Süd → außerhalb der Arbeitstage"); + const dblC = board.conflicts.find((c) => c.kind === "assignee_double_booked"); + ok(!!dblC && dblC.userId === A.users.tech.id && dblC.orderIds.includes(w1.id) && dblC.orderIds.includes(w5.id) && dblC.teamIds.length === 2, "Monteur Nord + Einzelzuweisung Süd gleichzeitig → doppelt eingeplant"); + ok( + nMon.conflicts.some((c) => c.kind === "assignee_double_booked") && sued.days.find((x) => x.day === mon)!.conflicts.some((c) => c.kind === "assignee_double_booked"), + "Doppelbuchung in beiden Teamzeilen sichtbar", + ); + ok(board.orders[w1.id]?.conflictKinds.includes("overlap") && board.orders[w1.id].conflictKinds.includes("assignee_double_booked"), "Auftragskarte trägt ihre Konfliktarten"); + ok(nMon.orderIds.length === 3 && board.orders[w3.id].durationMinutes === 600 && board.orders[w1.id].assigneeIds.includes(A.users.tech.id), "Zelle zeigt Aufträge mit Dauer und Einzelzuweisung"); + ok(nord.days.find((x) => x.day === tue)!.utilization === 0 && nord.members.length === 2, "Di ohne Aufträge: 0 %; Mitglieder aufklappbar (2)"); + ok(board.unplanned.some((o) => o.id === draft.id) && !board.unplanned.some((o) => o.id === w1.id), "Seitenleiste: ungeplanter Entwurf ja, eingeplanter Auftrag nein"); + ok(board.conflictCount >= 4 && board.conflictCount === board.conflicts.filter(isConflict).length && board.conflicts.some((c) => c.severity === "hint"), `Konfliktzähler ohne Hinweise (${board.conflictCount})`); + ok(!board.orders[w4.id].conflictKinds.includes("crew_incomplete"), "Hinweise färben keine Auftragskarte rot"); + ok(board.access.all && board.access.canSchedule, "Backoffice: alle Teams, darf einplanen"); + + await prisma.teamMember.updateMany({ where: { teamId: A.teamId, userId: A.users.tech2.id }, data: { validTo: dayBounds(tue, TZ).start } }); + const b2 = await getPlanningBoard(bo, { from: mon, to: tue, teamIds: [A.teamId] }); + ok( + b2.teams.length === 1 && b2.teams[0].days[0].memberCount === 2 && b2.teams[0].days[1].memberCount === 1 && b2.teams[0].days[1].capacityMinutes === 480 && b2.teams[0].days[1].conflicts.some((c) => c.kind === "crew_incomplete"), + "Mitgliedschaft „gültig bis Di“ → Di 1 Person: Kapazität bleibt, Hinweis Kolonne unvollständig", + ); + await prisma.teamMember.updateMany({ where: { teamId: A.teamId, userId: A.users.tech2.id }, data: { validTo: null } }); + + const settings = await updateTeamPlanningSettings(A.ctx.admin, A.team2Id, { dailyCapacityMinutes: 360, workingDays: 63 }); + const b3 = await getPlanningBoard(bo, { from: sat, to: sat, teamIds: [A.team2Id] }); + ok(settings.workingDays === 63 && b3.teams[0].days[0].capacityMinutes === 360 && !b3.teams[0].days[0].conflicts.some((c) => c.kind === "outside_working_days"), "Kolonnenkapazität 6 h + Samstag als Arbeitstag wirkt sofort"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "team", entityId: A.team2Id, action: "update" } })) === 1, "Kolonnenkapazität auditiert"); + await expectCode(() => updateTeamPlanningSettings(A.ctx.lead, A.team2Id, { dailyCapacityMinutes: 1, workingDays: 1 }), "forbidden", "Kolonnenkapazität: Teamleiter → forbidden"); + await expectCode(() => updateTeamPlanningSettings(B.ctx.admin, A.team2Id, { dailyCapacityMinutes: 1, workingDays: 1 }), "not_found", "Kolonnenkapazität: Mandant B → not_found"); + await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.team2Id, { dailyCapacityMinutes: 480, workingDays: 200 }), "invalid", "Kolonnenkapazität: ungültige Bitmaske → invalid"); + + const bf = await getPlanningBoard(bo, { from: mon, to: addDays(mon, 6), priority: "high" }); + ok(!!bf.orders[w5.id] && !bf.orders[w1.id] && bf.teams.find((t) => t.id === A.teamId)!.days[0].plannedMinutes === 1080, "Filter Priorität blendet Karten aus, Auslastung bleibt vollständig"); + ok(!bf.unplanned.some((o) => o.id === draft.id), "Filter wirkt auch auf die Seitenleiste"); + + section("Plantafel: Rollen & Mandantentrennung"); + const leadBoard = await getPlanningBoard(A.ctx.lead, { from: mon, to: addDays(mon, 6) }); + ok(leadBoard.teams.length === 1 && leadBoard.teams[0].id === A.teamId && !leadBoard.access.canSchedule, "Teamleiter: nur eigenes Team, nur lesend"); + ok(!!leadBoard.orders[w1.id] && !leadBoard.orders[w5.id] && !leadBoard.unplanned.some((o) => o.id === draft.id), "Teamleiter: keine Aufträge anderer Teams, keine teamlosen Aufträge"); + await expectCode(() => getPlanningBoard(A.ctx.tech, { from: mon, to: mon }), "forbidden", "Monteur → forbidden"); + const bB = await getPlanningBoard(B.ctx.backoffice, { from: mon, to: addDays(mon, 6) }); + ok( + bB.teams.every((t) => t.id !== A.teamId && t.id !== A.team2Id) && Object.keys(bB.orders).length === 0 && !bB.unplanned.some((o) => [w1.id, draft.id].includes(o.id)), + "Mandant B sieht keine Teams und Aufträge von A", + ); + const bBforeign = await getPlanningBoard(B.ctx.backoffice, { from: mon, to: mon, teamIds: [A.teamId] }); + ok(bBforeign.teams.length === 0, "Mandant B: fremde Team-ID liefert nichts"); + await expectCode(() => getPlanningBoard(bo, { from: mon, to: addDays(mon, 60) }), "invalid", "Zeitraum > 42 Tage → invalid"); + await expectCode(() => getPlanningBoard(bo, { from: tue, to: mon }), "invalid", "Ende vor Beginn → invalid"); + ok((await countWeekConflicts(B.ctx.backoffice))?.count === 0, "Dashboard-Zähler Mandant B: 0 Konflikte diese Woche"); + ok((await countWeekConflicts(A.ctx.tech)) === null, "Dashboard-Zähler für Monteur: keine Kachel"); + + section("scheduleWorkOrder"); + const s1 = await mk("Einplanen Entwurf"); + const s1before = await prisma.workOrder.findUniqueOrThrow({ where: { id: s1.id } }); + const r1 = await scheduleWorkOrder(bo, { workOrderId: s1.id, teamId: A.teamId, plannedStart: at(mon, "15:00"), plannedDurationMinutes: 60, baseVersion: s1before.version }); + const s1after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s1.id } }); + ok( + s1after.status === "assigned" && s1after.assignedTeamId === A.teamId && s1after.plannedStart?.getTime() === at(mon, "15:00").getTime() && s1after.plannedDurationMinutes === 60, + "Entwurf → zugewiesen mit Team, Beginn und Dauer", + ); + ok(r1.version === s1after.version && s1after.version > s1before.version, "neue Version zurückgegeben"); + ok(r1.conflicts.some((c) => c.kind === "overbooked") && r1.conflicts.every((c) => c.teamIds.includes(A.teamId)), "Antwort enthält Konflikte des Zielteams am Zieltag"); + ok((await prisma.notification.count({ where: { tenantId: A.tenantId, entityId: s1.id, type: "work_order.assigned" } })) > 0, "Event work_order.assigned → Benachrichtigung ans Team"); + const audits = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "work_order", entityId: s1.id } }); + const planningAudit = audits.find((a) => (a.after as { source?: string } | null)?.source === "planning"); + ok(!!planningAudit && planningAudit.actorId === A.users.backoffice.id && (planningAudit.before as { assignedTeamId?: string | null }).assignedTeamId === null, "Audit „planning“ mit before/after und Akteur"); + + const s2 = await mk("Verschieben"); + await place(s2.id, A.teamId, at(tue, "08:00"), { plannedEnd: at(tue, "10:30") }); + const v2 = await version(s2.id); + await scheduleWorkOrder(bo, { workOrderId: s2.id, teamId: A.team2Id, plannedStart: at(wed, "13:00"), baseVersion: v2 }); + const s2after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s2.id } }); + ok(s2after.assignedTeamId === A.team2Id && s2after.plannedEnd?.getTime() === at(wed, "15:30").getTime(), "Verschieben in anderes Team/Tag behält die Länge (2,5 h)"); + + let conflictErr: unknown = null; + try { + await scheduleWorkOrder(bo, { workOrderId: s2.id, teamId: A.teamId, plannedStart: at(tue, "08:00"), baseVersion: v2 }); + } catch (err) { + conflictErr = err; + } + ok(conflictErr instanceof ServiceError && conflictErr.code === "conflict" && conflictErr.message === VERSION_CONFLICT_MESSAGE, "veraltete Version → conflict „Auftrag wurde zwischenzeitlich geändert“"); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: s2.id } })).assignedTeamId === A.team2Id, "bei Konflikt nichts überschrieben"); + + const s3 = await mk("Atomar"); + await expectCode( + () => scheduleWorkOrder(bo, { workOrderId: s3.id, teamId: A.teamId, plannedStart: at(tue, "12:00"), plannedEnd: at(tue, "09:00"), baseVersion: s3.version }), + "invalid", + "Ende vor Beginn → invalid (Prüfung im Auftragsservice nach der Zuweisung)", + ); + const s3after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s3.id } }); + ok(s3after.status === "draft" && s3after.assignedTeamId === null && s3after.version === s3.version, "atomar: Zuweisung zurückgerollt (Entwurf, ohne Team, gleiche Version)"); + ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "work_order", entityId: s3.id, action: "update" } })) === 0, "atomar: kein Audit aus dem zurückgerollten Versuch"); + ok((await prisma.workOrderStatusChange.count({ where: { tenantId: A.tenantId, workOrderId: s3.id, toStatus: "assigned" } })) === 0, "atomar: keine Statushistorie „zugewiesen“"); + + const s3input = { workOrderId: s3.id, teamId: A.teamId, plannedStart: at(tue, "12:00"), baseVersion: s3.version }; + await expectCode(() => scheduleWorkOrder(A.ctx.lead, s3input), "forbidden", "Teamleiter darf nicht einplanen"); + await expectCode(() => scheduleWorkOrder(A.ctx.tech, s3input), "forbidden", "Monteur darf nicht einplanen"); + await expectCode(() => scheduleWorkOrder(B.ctx.backoffice, s3input), ["not_found", "invalid"], "Mandant B: Auftrag von A → abgewiesen"); + const bOrder = await createWorkOrder(B.ctx.backoffice, { title: "B-Auftrag", customerId: B.customerId, siteId: B.siteId }); + await expectCode( + () => scheduleWorkOrder(B.ctx.backoffice, { workOrderId: bOrder.id, teamId: A.teamId, plannedStart: at(tue, "08:00"), baseVersion: bOrder.version }), + "invalid", + "Mandant B: Team von A → invalid (team_not_found)", + ); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: s3.id } })).status === "draft", "Auftrag von A unverändert"); + await expectCode(() => scheduleWorkOrder(bo, { ...s3input, plannedStart: "kein Datum" }), "invalid", "ungültiges Datum → invalid"); + + const s4 = await mk("Läuft schon"); + await place(s4.id, A.teamId, at(tue, "08:00")); + await transitionWorkOrder(A.ctx.tech, { workOrderId: s4.id, to: "in_progress" }); + const v4 = await version(s4.id); + await expectCode(() => scheduleWorkOrder(bo, { workOrderId: s4.id, teamId: A.team2Id, plannedStart: at(wed, "08:00"), baseVersion: v4 }), "invalid", "laufender Auftrag → invalid (not_schedulable)"); +}); diff --git a/scripts/test-planung-live.ts b/scripts/test-planung-live.ts new file mode 100644 index 0000000..abb7eb3 --- /dev/null +++ b/scripts/test-planung-live.ts @@ -0,0 +1,127 @@ +// L13 Planung — Live-Lage ohne GPS: Status aus der aktiven WorkSession (Unterwegs/In Arbeit/Pause/frei), +// „seit“ aus dem offenen Zeitabschnitt, Standort = Objekt des laufenden Auftrags, KEINE Geräte-Koordinaten +// im Ergebnis, Teamleiter nur eigenes Team, Monteur forbidden, Mandantentrennung, heutige Aufträge ohne Beginn. +// Lauf: npx tsx scripts/test-planung-live.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture"; +import { dayBounds, dayKeyOf } from "../src/lib/planning/days"; +import { getLiveSituation } from "../src/server/services/planning/live"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; + +const TZ = "Europe/Berlin"; +const SLUG_A = "zz-planung-live-a"; +const SLUG_B = "zz-planung-live-b"; +const DEVICE_LAT = 12.3456789; +const DEVICE_LNG = 98.7654321; +const MIN = 60_000; + +runSuite("L13 Planung – Live-Lage", [SLUG_A, SLUG_B], async () => { + process.env.GEOCODING_PROVIDER = "none"; + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const now = new Date(); + const t = A.tenantId; + + await prisma.site.update({ where: { id: A.siteId }, data: { latitude: 53.5413, longitude: 9.9849, geocodeStatus: "ok", geocodedAt: now } }); + const noCoordSite = await prisma.site.create({ data: { tenantId: t, customerId: A.customerId, name: "Baustelle ohne Ortsangabe", city: "Hamburg" } }); + + const mk = async (title: string, data: Record, siteId = A.siteId) => { + const wo = await createWorkOrder(A.ctx.backoffice, { title, customerId: A.customerId, siteId }); + await prisma.workOrder.update({ where: { id: wo.id }, data }); + return wo; + }; + + // tech: running on the site with coordinates; the session carries (test) device coordinates that must never leak. + const oRun = await mk("Laufender Einsatz", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: new Date(now.getTime() - 120 * MIN) }); + const sRun = await prisma.workSession.create({ + data: { tenantId: t, workOrderId: oRun.id, userId: A.users.tech.id, teamId: A.teamId, status: "running", startedAt: new Date(now.getTime() - 120 * MIN), startLat: DEVICE_LAT, startLng: DEVICE_LNG, deviceInfo: "ZZ-Geraet-4711" }, + }); + await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sRun.id, userId: A.users.tech.id, type: "travel", startedAt: new Date(now.getTime() - 120 * MIN), endedAt: new Date(now.getTime() - 30 * MIN) } }); + const workSince = new Date(now.getTime() - 30 * MIN); + await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sRun.id, userId: A.users.tech.id, type: "work", startedAt: workSince } }); + // tech2: en route to a site without coordinates. + const oRoute = await mk("Anfahrt", { status: "en_route", assignedTeamId: A.teamId }, noCoordSite.id); + await prisma.workSession.create({ data: { tenantId: t, workOrderId: oRoute.id, userId: A.users.tech2.id, teamId: A.teamId, status: "en_route", startedAt: new Date(now.getTime() - 10 * MIN), startLat: DEVICE_LAT, startLng: DEVICE_LNG } }); + // outsider (Team Süd): paused. + const oPause = await mk("Pausiert", { status: "paused", assignedTeamId: A.team2Id }); + const sPause = await prisma.workSession.create({ data: { tenantId: t, workOrderId: oPause.id, userId: A.users.outsider.id, teamId: A.team2Id, status: "paused", startedAt: new Date(now.getTime() - 60 * MIN) } }); + const pauseSince = new Date(now.getTime() - 5 * MIN); + await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sPause.id, userId: A.users.outsider.id, type: "break", startedAt: pauseSince } }); + // an ended session must not count + await prisma.workSession.create({ data: { tenantId: t, workOrderId: oRun.id, userId: A.users.lead.id, teamId: A.teamId, status: "ended", startedAt: new Date(now.getTime() - 300 * MIN), endedAt: new Date(now.getTime() - 200 * MIN) } }); + + // today's orders without start + const today = dayBounds(dayKeyOf(now, TZ), TZ); + const early = new Date(Math.max(today.start.getTime() + MIN, now.getTime() - 60 * MIN)); + const later = new Date(now.getTime() + 120 * MIN); + const canEarly = early < now; + const canLater = later < today.end; + const oNotAccepted = canEarly ? await mk("Nicht angenommen", { status: "assigned", assignedTeamId: A.teamId, plannedStart: early }) : null; + const oOverdue = canEarly ? await mk("Überfällig", { status: "accepted", assignedTeamId: A.teamId, plannedStart: early }) : null; + const oNext = canLater ? await mk("Heute später", { status: "assigned", assignedTeamId: A.teamId, plannedStart: later }) : null; + + section("Status aus der WorkSession"); + const live = await getLiveSituation(A.ctx.backoffice); + const byUser = (id: string) => live.technicians.find((x) => x.userId === id); + const tech = byUser(A.users.tech.id); + ok(tech?.status === "working" && tech.since === workSince.toISOString(), "Monteur mit laufender Session → In Arbeit, seit Beginn des offenen Abschnitts"); + ok(tech?.current?.visible === true && tech.current.id === oRun.id && tech.current.latitude === 53.5413 && tech.current.longitude === 9.9849, "Standort = Koordinaten des Objekts des laufenden Auftrags"); + ok(tech?.current?.visible === true && tech.current.number === oRun.number && tech.current.customerName.includes("Hausverwaltung") && !!tech.current.address, "aktueller Auftrag mit Nummer, Kunde und Adresse"); + const tech2 = byUser(A.users.tech2.id); + ok(tech2?.status === "en_route" && tech2.current?.visible === true && tech2.current.latitude === null, "Unterwegs; Objekt ohne Koordinaten → ohne Ortsangabe"); + ok(live.withoutLocation === 1, "Zähler „ohne Ortsangabe“ = 1"); + const outsider = byUser(A.users.outsider.id); + ok(outsider?.status === "paused" && outsider.since === pauseSince.toISOString(), "Pause, seit Beginn der Pause"); + const lead = byUser(A.users.lead.id); + ok(lead?.status === "free" && lead.current === null && lead.since === null, "ohne aktive Session (beendete zählt nicht) → frei"); + ok(live.counts.working === 1 && live.counts.en_route === 1 && live.counts.paused === 1 && live.counts.free >= 2, `Zähler je Status ${JSON.stringify(live.counts)}`); + if (oNext) ok(!!tech?.next.some((n) => n.id === oNext.id) && !tech?.next.some((n) => n.id === oRun.id), "heute noch geplante Aufträge je Monteur"); + if (oNotAccepted && oOverdue) { + ok(live.attention.some((a) => a.id === oNotAccepted.id && a.reason === "not_accepted") && live.attention.some((a) => a.id === oOverdue.id && a.reason === "overdue"), "heute ohne Beginn: nicht angenommen / Beginn überschritten"); + ok(!live.attention.some((a) => a.id === oRun.id) && (!oNext || !live.attention.some((a) => a.id === oNext.id)), "laufende und spätere Aufträge nicht in „ohne Beginn“"); + } + + section("Kolonnen: ein Eintrag je Team"); + const nordCrew = live.crews.find((c) => c.teamId === A.teamId); + const suedCrew = live.crews.find((c) => c.teamId === A.team2Id); + ok( + live.crews.filter((c) => c.teamId === A.teamId).length === 1 && nordCrew?.status === "working" && nordCrew.current?.id === oRun.id && nordCrew.current.latitude === 53.5413, + "Kolonne Nord: ein Eintrag am Einsatzort des laufenden Auftrags, Status In Arbeit", + ); + const nordMembers = new Map(nordCrew?.members.map((m) => [m.userId, m]) ?? []); + ok( + nordMembers.get(A.users.tech.id)?.status === "working" && nordMembers.get(A.users.tech2.id)?.status === "en_route" && nordMembers.get(A.users.lead.id)?.status === "free" && nordMembers.get(A.users.tech2.id)?.orderNumber === oRoute.number, + "Mitglieder mit individuellem Status aus der eigenen Session", + ); + ok(suedCrew?.status === "paused" && suedCrew.members.some((m) => m.userId === A.users.outsider.id && m.status === "paused"), "Kolonne Süd: Pause"); + ok(live.technicians.find((x) => x.userId === A.users.tech.id)?.crewId === A.teamId, "Monteure mit Team gehören zu ihrer Kolonne (keine Einzelmarker)"); + ok(nordCrew?.delay === null, "kein Verzug bei 30 min von 2 h"); + + section("Datenschutz: keine Geräte-Koordinaten"); + for (const [label, result] of [ + ["Backoffice", live], + ["Filter Status", await getLiveSituation(A.ctx.backoffice, { status: "en_route" })], + ["Teamleiter", await getLiveSituation(A.ctx.lead)], + ] as const) { + const json = JSON.stringify(result); + ok(!json.includes(String(DEVICE_LAT)) && !json.includes(String(DEVICE_LNG)) && !/startLat|startLng|deviceInfo|ZZ-Geraet/.test(json), `${label}: keine Start-Koordinaten/Geräteinfo im Ergebnis`); + } + + section("Filter, Rollen, Mandantentrennung"); + const onlyWorking = await getLiveSituation(A.ctx.backoffice, { status: "working" }); + ok(onlyWorking.technicians.length === 1 && onlyWorking.technicians[0].userId === A.users.tech.id, "Filter Status „In Arbeit“"); + const onlySued = await getLiveSituation(A.ctx.backoffice, { teamId: A.team2Id }); + ok(onlySued.technicians.every((x) => x.teams.some((tm) => tm.id === A.team2Id)) && onlySued.technicians.some((x) => x.userId === A.users.outsider.id) && !onlySued.technicians.some((x) => x.userId === A.users.tech.id), "Filter Team Süd"); + const leadView = await getLiveSituation(A.ctx.lead); + const leadUsers = leadView.technicians.map((x) => x.userId); + ok(leadUsers.includes(A.users.tech.id) && leadUsers.includes(A.users.tech2.id) && !leadUsers.includes(A.users.outsider.id) && !leadUsers.includes(A.users.lead2.id), "Teamleiter sieht nur sein Team"); + ok(leadView.teams.length === 1 && leadView.teams[0].id === A.teamId && !leadView.attention.some((a) => a.teamName === "Team Süd"), "Teamleiter: nur eigenes Team in Auswahl und Hinweisen"); + ok(leadView.crews.length === 1 && leadView.crews[0].teamId === A.teamId, "Teamleiter: nur die eigene Kolonne"); + await expectCode(() => getLiveSituation(A.ctx.tech), "forbidden", "Monteur → forbidden"); + const liveB = await getLiveSituation(B.ctx.backoffice); + const aUsers = Object.values(A.users).map((u) => u.id); + const jsonB = JSON.stringify(liveB); + ok(liveB.technicians.every((x) => !aUsers.includes(x.userId)) && !jsonB.includes(oRun.number) && !jsonB.includes(A.teamId), "Mandant B sieht keine Monteure, Aufträge oder Teams von A"); +}); diff --git a/scripts/test-planung-recommend.ts b/scripts/test-planung-recommend.ts new file mode 100644 index 0000000..7f0a38c --- /dev/null +++ b/scripts/test-planung-recommend.ts @@ -0,0 +1,134 @@ +// L13 Planung — Einsatz-Empfehlungen (Luftlinie + Terminlage) und nahe ungeplante Aufträge: +// nächster Kandidat mit Kapazität gewinnt, Team ohne Kapazität ausgeschlossen, außerhalb des Radius +// ausgeschlossen, „knapp“ markiert, ohne Koordinaten → Hinweis, Rollen, Mandantentrennung. +// Lauf: npx tsx scripts/test-planung-recommend.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { createTenant, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; +import { addDays, dayKeyOf, startOfWeek } from "../src/lib/planning/days"; +import { wallTimeToUtc } from "../src/lib/work-orders/time"; +import { findNearbyUnplanned, recommendAssignments } from "../src/server/services/planning/recommend"; +import { scheduleWorkOrder } from "../src/server/services/planning/schedule"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; + +const TZ = "Europe/Berlin"; +const SLUG_A = "zz-planung-rec-a"; +const SLUG_B = "zz-planung-rec-b"; +const at = (day: string, hm: string) => wallTimeToUtc(`${day}T${hm}`, TZ)!; + +const TARGET = { latitude: 53.56, longitude: 10.0 }; +const NEAR3 = { latitude: 53.587, longitude: 10.0 }; // ≈ 3,0 km +const VERY_NEAR = { latitude: 53.565, longitude: 10.005 }; // ≈ 0,65 km +const FAR = { latitude: 53.87, longitude: 10.69 }; // Lübeck, ≈ 57 km + +async function site(f: TenantFixture, name: string, coords: { latitude: number; longitude: number } | null, geocodeStatus: string | null = coords ? "ok" : null) { + return prisma.site.create({ + data: { tenantId: f.tenantId, customerId: f.customerId, name, street: "Teststraße", houseNumber: "1", postalCode: "20457", city: "Hamburg", ...(coords ?? {}), geocodeStatus, geocodedAt: coords ? new Date() : null }, + }); +} + +async function order(f: TenantFixture, siteId: string, title: string, extra: Record = {}) { + return createWorkOrder(f.ctx.backoffice, { title, customerId: f.customerId, siteId, ...extra }); +} + +async function place(f: TenantFixture, id: string, teamId: string, start: Date, plannedDurationMinutes: number) { + const { version } = await prisma.workOrder.findUniqueOrThrow({ where: { id } }); + return scheduleWorkOrder(f.ctx.backoffice, { workOrderId: id, teamId, plannedStart: start, plannedDurationMinutes, baseVersion: version }); +} + +runSuite("L13 Planung – Empfehlungen", [SLUG_A, SLUG_B], async () => { + process.env.GEOCODING_PROVIDER = "none"; // never contact Nominatim from tests + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const bo = A.ctx.backoffice; + const D1 = startOfWeek(addDays(dayKeyOf(new Date(), TZ), 14)); + const D2 = addDays(D1, 1); + const D3 = addDays(D1, 2); + + // Third team "Ost" (1 member) far away. + const ost = await prisma.team.create({ data: { tenantId: A.tenantId, name: "Team Ost", leaderUserId: A.users.admin.id } }); + await prisma.teamMember.create({ data: { tenantId: A.tenantId, teamId: ost.id, userId: A.users.admin.id, validFrom: new Date(Date.now() - 30 * 86400_000) } }); + + const sTarget = await site(A, "Ziel", TARGET); + const sNear3 = await site(A, "Nachbar 3 km", NEAR3); + const sVeryNear = await site(A, "Nachbar 650 m", VERY_NEAR); + const sFar = await site(A, "Lübeck", FAR); + + section("Kandidaten"); + const nordD1 = await order(A, sNear3.id, "Nord D1 3 km"); + await place(A, nordD1.id, A.teamId, at(D1, "08:00"), 240); // Kolonne Nord: 480 − 240 = 240 frei + const suedD1 = await order(A, sVeryNear.id, "Süd D1 voll"); + await place(A, suedD1.id, A.team2Id, at(D1, "08:00"), 480); // Süd: 480 − 480 = 0 frei + const ostD1 = await order(A, sFar.id, "Ost D1 Lübeck"); + await place(A, ostD1.id, ost.id, at(D1, "08:00"), 120); + + const target = await order(A, sTarget.id, "Zielauftrag", { plannedDurationMinutes: undefined }); + const r1 = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3 }); + ok(r1.status === "ok" && r1.requiredMinutes === 120, "Status ok, benötigte Dauer 2 h (Fallback)"); + const top = r1.recommendations[0]; + ok(!!top && top.teamId === A.teamId && top.day === D1 && top.nearOrder.id === nordD1.id, "Team Nord am D1 (Auftrag in 3 km) wird vorgeschlagen"); + ok(!!top && top.distanceKm > 2.5 && top.distanceKm < 3.5 && top.freeMinutes === 240 && !top.tight, `Begründung strukturiert: ${top?.distanceKm} km, 4 h freie Kolonnenzeit`); + ok(!!top && top.text.includes("km von") && top.text.includes(nordD1.number) && top.text.includes("Team Nord") && top.text.includes("noch 4 h frei"), `Begründungstext: „${top?.text}“`); + ok(!r1.recommendations.some((r) => r.teamId === A.team2Id), "Kolonne Süd ohne freie Kapazität ausgeschlossen (obwohl näher)"); + ok(!r1.recommendations.some((r) => r.teamId === ost.id), "Team Ost außerhalb des Radius ausgeschlossen"); + ok(!!top && new Date(top.suggestedStart).getTime() === at(D1, "12:00").getTime(), "Vorgeschlagener Beginn direkt nach dem letzten Auftrag des Tages (12:00)"); + + const nordD2 = await order(A, sVeryNear.id, "Nord D2 650 m"); + await place(A, nordD2.id, A.teamId, at(D2, "08:00"), 120); + const nordD3 = await order(A, sNear3.id, "Nord D3 fast voll"); + await place(A, nordD3.id, A.teamId, at(D3, "07:00"), 420); // 480 − 420 = 60 min frei < 120 → knapp + const r2 = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3 }); + ok(r2.recommendations[0]?.teamId === A.teamId && r2.recommendations[0]?.day === D2 && r2.recommendations[0].nearOrder.id === nordD2.id, "nächster Kandidat mit Kapazität gewinnt (650 m am D2 vor 3 km am D1)"); + const tight = r2.recommendations.find((r) => r.day === D3); + ok(!!tight && tight.tight && tight.text.includes("knapp") && r2.recommendations.indexOf(tight) > 0, "knappe Kapazität markiert und nachrangig"); + ok(r2.recommendations.length <= 5 && r2.recommendations.every((r, i, arr) => i === 0 || arr[i - 1].score >= r.score), "höchstens 5, nach Score sortiert"); + const r2en = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3, locale: "en" }); + ok(r2en.recommendations[0]?.text.includes("from") && r2en.recommendations[0].text.includes("free"), `englischer Text: „${r2en.recommendations[0]?.text}“`); + const r3 = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3, radiusKm: 0.5 }); + ok(r3.recommendations.length === 0 && !!r3.hint && r3.hint.includes("0.5 km"), `kleiner Radius → keine Vorschläge + Hinweis („${r3.hint}“)`); + + section("Ohne Koordinaten"); + const sNone = await site(A, "Ohne Koordinaten", null); + const noCoords = await order(A, sNone.id, "Auftrag ohne Verortung"); + const r4 = await recommendAssignments(bo, { workOrderId: noCoords.id, from: D1, days: 3 }); + ok(r4.status === "no_coordinates" && r4.recommendations.length === 0 && !!r4.hint?.includes("nicht verortet"), `ohne Koordinaten → Hinweis „${r4.hint}“`); + const sNotFound = await site(A, "Unbekannte Adresse", null, "not_found"); + const notFound = await order(A, sNotFound.id, "Adresse nicht gefunden"); + const r5 = await recommendAssignments(bo, { workOrderId: notFound.id, from: D1, days: 3 }); + ok(r5.status === "no_coordinates" && !!r5.hint?.includes("nicht gefunden"), "Adresse nicht gefunden → eigener Hinweis"); + const withoutSite = await createWorkOrder(bo, { title: "Ohne Objekt", customerId: A.customerId }); + ok((await recommendAssignments(bo, { workOrderId: withoutSite.id })).status === "no_coordinates", "Auftrag ohne Objekt → no_coordinates"); + ok((await findNearbyUnplanned(bo, { workOrderId: noCoords.id })).status === "no_coordinates", "Umkreis ohne Koordinaten → no_coordinates"); + + section("Nahe ungeplante Aufträge"); + const n1 = await order(A, sVeryNear.id, "Ungeplant nah"); + const n2 = await order(A, sFar.id, "Ungeplant fern"); + const nearby = await findNearbyUnplanned(bo, { workOrderId: target.id, radiusKm: 5 }); + ok(nearby.items.some((i) => i.id === n1.id) && !nearby.items.some((i) => i.id === n2.id) && !nearby.items.some((i) => i.id === target.id), "Umkreis 5 km: nahen Auftrag ja, fernen und sich selbst nein"); + ok(nearby.items.every((i, idx, arr) => idx === 0 || arr[idx - 1].distanceKm <= i.distanceKm) && (nearby.items.find((i) => i.id === n1.id)?.distanceKm ?? 99) < 1, "nach Entfernung sortiert (650 m)"); + + section("Rollen & Mandantentrennung"); + await expectCode(() => recommendAssignments(A.ctx.lead, { workOrderId: target.id }), "forbidden", "Teamleiter → forbidden"); + await expectCode(() => recommendAssignments(A.ctx.tech, { workOrderId: target.id }), "forbidden", "Monteur → forbidden"); + await expectCode(() => findNearbyUnplanned(A.ctx.tech, { workOrderId: target.id }), "forbidden", "Umkreis: Monteur → forbidden"); + await expectCode(() => recommendAssignments(B.ctx.backoffice, { workOrderId: target.id }), "not_found", "Mandant B: Auftrag von A → not_found"); + await expectCode(() => findNearbyUnplanned(B.ctx.backoffice, { workOrderId: target.id }), "not_found", "Mandant B: Umkreis für Auftrag von A → not_found"); + + // B has a team with free capacity and an order at identical coordinates + an unplanned order nearby. + const bVeryNear = await site(B, "B nah", VERY_NEAR); + const bTargetSite = await site(B, "B Ziel", TARGET); + const bPlanned = await order(B, bVeryNear.id, "B geplant"); + await place(B, bPlanned.id, B.teamId, at(D1, "08:00"), 60); + const bUnplanned = await order(B, bVeryNear.id, "B ungeplant"); + const aAgain = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3 }); + const aNearby = await findNearbyUnplanned(bo, { workOrderId: target.id, radiusKm: 5 }); + ok(aAgain.recommendations.every((r) => r.teamId !== B.teamId && r.nearOrder.id !== bPlanned.id), "A: nie Teams/Aufträge von Mandant B in Vorschlägen"); + ok(!aNearby.items.some((i) => i.id === bUnplanned.id), "A: nie ungeplante Aufträge von Mandant B im Umkreis"); + const bTarget = await order(B, bTargetSite.id, "B Zielauftrag"); + const bRec = await recommendAssignments(B.ctx.backoffice, { workOrderId: bTarget.id, from: D1, days: 3 }); + const aTeamIds = [A.teamId, A.team2Id, ost.id]; + ok(bRec.recommendations.length > 0 && bRec.recommendations.every((r) => !aTeamIds.includes(r.teamId) && r.nearOrder.id === bPlanned.id), "B: Vorschläge nur aus eigenen Teams/Aufträgen"); + const bNear = await findNearbyUnplanned(B.ctx.backoffice, { workOrderId: bTarget.id, radiusKm: 5 }); + ok(bNear.items.length === 1 && bNear.items[0].id === bUnplanned.id, "B: Umkreis nur eigene ungeplante Aufträge"); +}); diff --git a/scripts/test-planung-watch.ts b/scripts/test-planung-watch.ts new file mode 100644 index 0000000..4222788 --- /dev/null +++ b/scripts/test-planung-watch.ts @@ -0,0 +1,176 @@ +// L13 Planung — Verzug und „früher fertig“: erfasste Kolonnenzeit (vereinigt), Warnung ab 80 %, Meldung ab +// 100 % mit Dedupe und erneuter Meldung je weitere +50 %, gefährdeter Folgeauftrag (Beginn inkl. Fahrzeit, +// Tageskapazität) mit Dedupe, freie Kolonnenzeit bei früherem Abschluss + Vorzieh-/Umkreis-Vorschläge, +// Event planning.capacity_freed, Empfänger (Backoffice + Teamleiter, nur In-App), Plantafel/Live-Lage, +// Rollen, Mandantentrennung, L12-Adapter. Alle Zeiten relativ zu „heute 12:00“ (unabhängig von der Uhrzeit). +// Lauf: npx tsx scripts/test-planung-watch.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { berlinAt, createTenant, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture"; +import { dayKeyOf } from "../src/lib/planning/days"; +import { getPlanningBoard } from "../src/server/services/planning/board"; +import { getLiveSituation } from "../src/server/services/planning/live"; +import { approvedEntryWhere, TIME_TRACKING_FIELDS, trackedEntryWhere, trackedSessionWhere } from "../src/server/services/planning/time-tracking"; +import { auditAlertLedger, evaluateDelays, getFreedCapacity, runPlanningWatch, watchContext } from "../src/server/services/planning/watch"; +import { PROCESSORS } from "../src/server/jobs/processors"; +import { JOB_QUEUES } from "../src/server/jobs/queues"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; + +const TZ = "Europe/Berlin"; +const SLUG_A = "zz-planung-watch-a"; +const SLUG_B = "zz-planung-watch-b"; +const T = (hh: number, mm = 0) => berlinAt(0, hh, mm); +const S1 = { latitude: 53.55, longitude: 10.0 }; +const S2 = { latitude: 53.73, longitude: 10.0 }; // ≈ 20 km nördlich +const S3 = { latitude: 53.559, longitude: 10.0 }; // ≈ 1 km +const FAR = { latitude: 53.87, longitude: 10.69 }; + +async function site(f: TenantFixture, name: string, c: { latitude: number; longitude: number } | null) { + return prisma.site.create({ data: { tenantId: f.tenantId, customerId: f.customerId, name, city: "Hamburg", postalCode: "20457", ...(c ?? {}), geocodeStatus: c ? "ok" : null, geocodedAt: c ? new Date() : null } }); +} + +async function order(f: TenantFixture, siteId: string | null, title: string, data: Record) { + const wo = await createWorkOrder(f.ctx.backoffice, { title, customerId: f.customerId, ...(siteId ? { siteId } : {}) }); + await prisma.workOrder.update({ where: { id: wo.id }, data }); + return wo; +} + +async function session(f: TenantFixture, workOrderId: string, userId: string, teamId: string, status: "running" | "paused" | "en_route" | "ended", startedAt: Date, endedAt: Date | null = null) { + return prisma.workSession.create({ data: { tenantId: f.tenantId, workOrderId, userId, teamId, status, startedAt, endedAt } }); +} + +async function entry(f: TenantFixture, workSessionId: string, userId: string, startedAt: Date, endedAt: Date | null, type: "work" | "break" | "travel" = "work") { + return prisma.timeEntry.create({ data: { tenantId: f.tenantId, workSessionId, userId, type, startedAt, endedAt } }); +} + +const alerts = (f: TenantFixture) => prisma.auditLog.findMany({ where: { tenantId: f.tenantId, entity: "planning_alert" }, select: { entityId: true } }); + +runSuite("L13 Planung – Verzug & früher fertig", [SLUG_A, SLUG_B], async () => { + process.env.GEOCODING_PROVIDER = "none"; + const A = await createTenant(SLUG_A); + const B = await createTenant(SLUG_B); + const today = dayKeyOf(T(12), TZ); + + section("Job & Adapter"); + ok(JOB_QUEUES.planningWatch === "planning-watch" && typeof PROCESSORS["planning-watch"] === "function", "Queue planning-watch + Processor registriert"); + ok( + Object.keys(trackedEntryWhere).length === (TIME_TRACKING_FIELDS.entrySource ? 1 : 0) && + Object.keys(approvedEntryWhere).length === (TIME_TRACKING_FIELDS.entryApproval ? 1 : 0) && + Object.keys(trackedSessionWhere).length === (TIME_TRACKING_FIELDS.sessionManual ? 1 : 0), + `L12-Filter aktiv je nach Schema (source=${TIME_TRACKING_FIELDS.entrySource}, approvalStatus=${TIME_TRACKING_FIELDS.entryApproval}, manual=${TIME_TRACKING_FIELDS.sessionManual})`, + ); + + // Kolonne Nord (tech + tech2): laufender Auftrag, 2 h geplant. + const s1 = await site(A, "Baustelle 1", S1); + const s2 = await site(A, "Baustelle 20 km", S2); + const oRun = await order(A, s1.id, "Laufend Nord", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(8), plannedDurationMinutes: 120 }); + const sessTech = await session(A, oRun.id, A.users.tech.id, A.teamId, "running", T(8)); + await entry(A, sessTech.id, A.users.tech.id, T(8), T(9)); + await entry(A, sessTech.id, A.users.tech.id, T(9), T(9, 30), "break"); + await entry(A, sessTech.id, A.users.tech.id, T(9, 30), null); + const sessTech2 = await session(A, oRun.id, A.users.tech2.id, A.teamId, "running", T(8, 30)); + await entry(A, sessTech2.id, A.users.tech2.id, T(8, 30), null); + const oNext = await order(A, s2.id, "Folgeauftrag Nord", { status: "assigned", assignedTeamId: A.teamId, plannedStart: T(10, 45), plannedDurationMinutes: 60 }); + + section("Verzug: Kolonnenzeit und 80 %"); + const sys = watchContext(A.tenantId); + const e900 = await evaluateDelays(sys, { now: T(9, 0) }); + ok(e900.delays.find((d) => d.workOrderId === oRun.id)?.level === "ok" && e900.delays.find((d) => d.workOrderId === oRun.id)?.workedMinutes === 60, "09:00: 60 min (50 %) → kein Verzug"); + const e940 = await evaluateDelays(sys, { now: T(9, 40) }); + const d940 = e940.delays.find((d) => d.workOrderId === oRun.id); + ok(d940?.workedMinutes === 100, `erfasste Kolonnenzeit vereinigt 08:00–09:40 = 100 min (summiert wären 140) — ${d940?.workedMinutes}`); + ok(d940?.level === "risk" && d940.percent === 83 && d940.remainingMinutes === 20, "83 % → Verzug droht, Restzeit 20 min"); + ok(!e940.followups.some((f) => f.workOrderId === oNext.id), "09:40 + 20 min Rest + 31 min Fahrt (20 km) = 10:31 < Beginn 10:45 → Folgeauftrag nicht gefährdet"); + const e1020 = await evaluateDelays(sys, { now: T(10, 20) }); + const f1020 = e1020.followups.find((f) => f.workOrderId === oNext.id); + ok(!!f1020 && f1020.reason === "start" && f1020.travelMinutes === 31 && f1020.blockedByNumber === oRun.number, "10:20: Rest 0 + 31 min Fahrt = 10:51 > 10:45 → Folgeauftrag gefährdet (Beginn)"); + + const board = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(9, 40) }); + ok(board.orders[oRun.id]?.delay?.level === "risk" && board.orders[oRun.id].delay?.percent === 83 && !board.orders[oNext.id]?.atRisk, "Plantafel 09:40: Karte „Verzug droht · 83 %“, Folgeauftrag noch nicht gefährdet"); + const board1020 = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(10, 20) }); + ok(board1020.orders[oRun.id]?.delay?.level === "overrun" && board1020.orders[oNext.id]?.atRisk?.byNumber === oRun.number, "Plantafel 10:20: „Dauer überschritten“ + Folgeauftrag „gefährdet durch Verzug …“"); + const live940 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(9, 40) }); + ok(live940.crews.find((c) => c.teamId === A.teamId)?.delay?.level === "risk", "Live-Lage: Kolonne Nord mit Verzugswarnung"); + + section("Meldungen: 100 %, Dedupe, +50 %"); + const r940 = await runPlanningWatch(A.tenantId, { now: T(9, 40) }); + ok(r940.overrun === 0 && r940.followupAtRisk === 0, `09:40: keine Meldung bei 83 % (${JSON.stringify(r940)})`); + const r1010 = await runPlanningWatch(A.tenantId, { now: T(10, 10) }); + ok(r1010.overrun === 1 && r1010.followupAtRisk === 0, "10:10: 130 min = 108 % → planning.overrun"); + const r1020 = await runPlanningWatch(A.tenantId, { now: T(10, 20) }); + ok(r1020.overrun === 0 && r1020.followupAtRisk === 1, "10:20: keine erneute Überschreitungsmeldung, 1 × Folgeauftrag gefährdet"); + ok((await runPlanningWatch(A.tenantId, { now: T(10, 25) })).followupAtRisk === 0, "Folgeauftrag gefährdet wird je Auftrag/Tag nur einmal gemeldet"); + ok((await runPlanningWatch(A.tenantId, { now: T(10, 40) })).overrun === 0, "10:40 (133 %): keine erneute Meldung"); + ok((await runPlanningWatch(A.tenantId, { now: T(11, 0) })).overrun === 1, "11:00: 180 min = 150 % → erneute Meldung (+50 %)"); + ok((await runPlanningWatch(A.tenantId, { now: T(11, 10) })).overrun === 0, "11:10: keine weitere Meldung vor 200 %"); + const keys = (await alerts(A)).map((a) => a.entityId); + ok(keys.includes(`planning.overrun:${oRun.id}:0`) && keys.includes(`planning.overrun:${oRun.id}:1`) && keys.includes(`planning.followup_at_risk:${oNext.id}:${today}`), "Dedupe-Einträge im Audit-Protokoll (planning_alert)"); + + const overrunNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.overrun", entityId: oRun.id }, select: { userId: true, emailedAt: true, title: true, message: true } }); + const noteUsers = new Set(overrunNotes.map((n) => n.userId)); + ok(noteUsers.has(A.users.backoffice.id) && noteUsers.has(A.users.admin.id) && noteUsers.has(A.users.lead.id), "Empfänger: Backoffice + Teamleiter der Kolonne"); + ok(!noteUsers.has(A.users.tech.id) && !noteUsers.has(A.users.lead2.id), "nicht: Monteure, Teamleiter anderer Kolonnen"); + ok(overrunNotes.every((n) => n.emailedAt === null) && overrunNotes.some((n) => n.message.includes("150 %") && n.title.includes(oRun.number)), "nur In-App (keine E-Mail), Text mit Prozent und Nummer"); + const followNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.followup_at_risk", entityId: oNext.id }, select: { message: true } }); + ok(followNotes.length > 0 && followNotes.every((n) => n.message.includes(oRun.number)), "Folgeauftrag-Meldung nennt den verursachenden Auftrag"); + + section("Folgeauftrag gefährdet durch Tageskapazität"); + await prisma.team.update({ where: { id: A.team2Id }, data: { dailyCapacityMinutes: 180 } }); + const oSud = await order(A, null, "Laufend Süd", { status: "in_progress", assignedTeamId: A.team2Id, plannedStart: T(7), plannedDurationMinutes: 120 }); + const sessSud = await session(A, oSud.id, A.users.outsider.id, A.team2Id, "running", T(7)); + await entry(A, sessSud.id, A.users.outsider.id, T(7), null); + const oSudNext = await order(A, null, "Folgeauftrag Süd ohne Uhrzeit", { status: "assigned", assignedTeamId: A.team2Id, plannedStart: berlinAt(0, 0), plannedDurationMinutes: 60 }); + const eSud = await evaluateDelays(sys, { now: T(9, 40) }); + const fSud = eSud.followups.find((f) => f.workOrderId === oSudNext.id); + ok(!!fSud && fSud.reason === "capacity" && fSud.travelMinutes === 10, "160 min statt 120 + Folgeauftrag 60 + 10 min Fahrt > 180 min Kolonnentag → gefährdet (Kapazität)"); + + section("Früher fertig: freie Kolonnenzeit und Vorschläge"); + const ost = await prisma.team.create({ data: { tenantId: A.tenantId, name: "Team Ost", leaderUserId: A.users.lead.id } }); + await prisma.teamMember.createMany({ data: [A.users.admin.id, A.users.backoffice.id].map((userId) => ({ tenantId: A.tenantId, teamId: ost.id, userId, validFrom: new Date(Date.now() - 30 * 86400_000) })) }); + const s3 = await site(A, "Nachbar 1 km", S3); + const sFar = await site(A, "Lübeck", FAR); + const oDone = await order(A, s1.id, "Früher fertig Ost", { status: "technically_completed", assignedTeamId: ost.id, plannedStart: T(8), plannedDurationMinutes: 240 }); + await session(A, oDone.id, A.users.admin.id, ost.id, "ended", T(8), T(10)); + const oLater = await order(A, s2.id, "Ost später heute", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(14), plannedDurationMinutes: 60 }); + const oTooLong = await order(A, s2.id, "Ost zu lang", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(15), plannedDurationMinutes: 180 }); + const oNear = await order(A, s3.id, "Ungeplant nah", { status: "draft", plannedDurationMinutes: 60 }); + const oFar = await order(A, sFar.id, "Ungeplant fern", { status: "draft", plannedDurationMinutes: 60 }); + + const freed = await getFreedCapacity(A.ctx.backoffice, { now: T(10, 30) }); + const fo = freed.teams.find((x) => x.teamId === ost.id); + ok(!!fo && fo.earlyMinutes === 120 && fo.availableMinutes === 90 && fo.availableFrom === T(10, 30).toISOString(), "Ost: 2 h früher fertig (Ende 10:00 statt 12:00), 90 min ab jetzt frei"); + ok(!freed.teams.some((x) => x.teamId === A.teamId), "laufende Kolonne Nord nicht als früher fertig"); + const kinds = new Map(fo?.suggestions.map((s) => [s.order.id, s]) ?? []); + ok(kinds.get(oLater.id)?.kind === "pull_forward" && !kinds.has(oTooLong.id), "Vorziehen: späterer Auftrag der Kolonne, der in die freie Zeit passt (nicht der zu lange)"); + ok(kinds.get(oNear.id)?.kind === "nearby_unplanned" && (kinds.get(oNear.id)?.distanceKm ?? 99) < 2 && !kinds.has(oFar.id), "ungeplanter Auftrag im Umkreis (1 km) ja, ferner nein"); + ok(kinds.get(oLater.id)?.suggestedStart === T(10, 30).toISOString() && kinds.get(oLater.id)?.day === today, "Vorschlag vorbelegt ab 10:30 heute — keine automatische Änderung"); + ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: oLater.id } })).plannedStart?.getTime() === T(14).getTime(), "geplanter Auftrag bleibt unverändert"); + const live1030 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(10, 30) }); + ok(live1030.freed.some((x) => x.teamId === ost.id && x.suggestions.length >= 2), "Live-Lage zeigt „früher fertig“ mit Vorschlägen"); + + const rFreed = await runPlanningWatch(A.tenantId, { now: T(10, 30) }); + ok(rFreed.capacityFreed === 1, "planning.capacity_freed einmal gemeldet"); + ok((await runPlanningWatch(A.tenantId, { now: T(10, 50) })).capacityFreed === 0, "Dedupe je Kolonne/Tag/Auftrag"); + const freedNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.capacity_freed", entityId: oDone.id }, select: { userId: true, message: true } }); + ok(freedNotes.some((n) => n.userId === A.users.lead.id) && freedNotes.some((n) => n.message.includes("120 min")), "Meldung an Teamleiter der Kolonne mit 120 min"); + + section("Rollen & Mandantentrennung"); + await expectCode(() => getFreedCapacity(A.ctx.tech, { now: T(10, 30) }), "forbidden", "Monteur: früher fertig → forbidden"); + const leadFreed = await getFreedCapacity(A.ctx.lead2, { now: T(10, 30) }); + ok(leadFreed.teams.length === 0, "Teamleiter Süd sieht die Kolonne Ost nicht"); + const bSys = watchContext(B.tenantId); + const bEval = await evaluateDelays(bSys, { now: T(11, 0) }); + ok(bEval.delays.length === 0 && bEval.followups.length === 0, "Mandant B: keine Aufträge/Sessions von A in der Auswertung"); + ok((await getFreedCapacity(B.ctx.backoffice, { now: T(10, 30) })).teams.length === 0, "Mandant B: kein „früher fertig“ von A"); + const rB = await runPlanningWatch(B.tenantId, { now: T(11, 0) }); + ok(rB.overrun + rB.followupAtRisk + rB.capacityFreed === 0 && (await alerts(B)).length === 0, "Mandant B: keine Meldungen, keine Dedupe-Einträge"); + ok((await prisma.notification.count({ where: { tenantId: B.tenantId, type: { startsWith: "planning." } } })) === 0, "Mandant B: keine Planungs-Benachrichtigungen"); + + // Ein Dedupe-Schlüssel in B blockiert A nicht (Ledger je Mandant). + const oRun2 = await order(A, s1.id, "Laufend Nord 2", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(6), plannedDurationMinutes: 60 }); + const sess2 = await session(A, oRun2.id, A.users.lead.id, A.teamId, "running", T(6)); + await entry(A, sess2.id, A.users.lead.id, T(6), null); + ok(await auditAlertLedger.claim(bSys, `planning.overrun:${oRun2.id}:0`, {}), "B beansprucht (künstlich) den Schlüssel eines A-Auftrags"); + ok((await runPlanningWatch(A.tenantId, { now: T(11, 20) })).overrun >= 1, "A meldet trotzdem (Dedupe mandantengetrennt)"); +}); diff --git a/src/app/api/v1/planning/board/route.ts b/src/app/api/v1/planning/board/route.ts new file mode 100644 index 0000000..3c00ba5 --- /dev/null +++ b/src/app/api/v1/planning/board/route.ts @@ -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, + }), + ); +}); diff --git a/src/app/api/v1/planning/live/route.ts b/src/app/api/v1/planning/live/route.ts new file mode 100644 index 0000000..58256a8 --- /dev/null +++ b/src/app/api/v1/planning/live/route.ts @@ -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, + }), + ); +}); diff --git a/src/app/api/v1/planning/recommendations/route.ts b/src/app/api/v1/planning/recommendations/route.ts new file mode 100644 index 0000000..7a25c94 --- /dev/null +++ b/src/app/api/v1/planning/recommendations/route.ts @@ -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 }); +}); diff --git a/src/app/api/v1/planning/schedule/route.ts b/src/app/api/v1/planning/schedule/route.ts new file mode 100644 index 0000000..714483b --- /dev/null +++ b/src/app/api/v1/planning/schedule/route.ts @@ -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)); +}); diff --git a/src/lib/api/openapi.ts b/src/lib/api/openapi.ts index eebd29d..0dfbdc8 100644 --- a/src/lib/api/openapi.ts +++ b/src/lib/api/openapi.ts @@ -1106,6 +1106,139 @@ const paths: Record> = { 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, diff --git a/src/lib/events.ts b/src/lib/events.ts index 0a05092..1b5cea3 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -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]; diff --git a/src/server/actions/work_orders/planning.ts b/src/server/actions/work_orders/planning.ts new file mode 100644 index 0000000..755ad7b --- /dev/null +++ b/src/server/actions/work_orders/planning.ts @@ -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 { + 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"); +} diff --git a/src/server/jobs/processors/index.ts b/src/server/jobs/processors/index.ts index bc7653b..204dd5c 100644 --- a/src/server/jobs/processors/index.ts +++ b/src/server/jobs/processors/index.ts @@ -13,6 +13,8 @@ export const PROCESSORS: Partial Promise 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). */ diff --git a/src/server/jobs/processors/planning-watch.ts b/src/server/jobs/processors/planning-watch.ts new file mode 100644 index 0000000..1b43a3e --- /dev/null +++ b/src/server/jobs/processors/planning-watch.ts @@ -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 { + 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); +} diff --git a/src/server/jobs/queues.ts b/src/server/jobs/queues.ts index f310555..d62cfdb 100644 --- a/src/server/jobs/queues.ts +++ b/src/server/jobs/queues.ts @@ -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 { } finally { await q.close(); } + // L13 Planung: planning watch every 5 minutes (all tenants) + const watch = new Queue(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 { diff --git a/src/server/services/notifications/handle-event.ts b/src/server/services/notifications/handle-event.ts index aa09d5e..77c7a8f 100644 --- a/src/server/services/notifications/handle-event.ts +++ b/src/server/services/notifications/handle-event.ts @@ -136,6 +136,11 @@ function textVars(locale: Locale, f: EventFacts): Record { + 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 { + const access = await planningAccess(ctx); + if (!access.all || !can(ctx, "work_order:assign")) throw new ServiceError("forbidden", "missing permission work_order:assign"); + return access; +} diff --git a/src/server/services/planning/board.ts b/src/server/services/planning/board.ts new file mode 100644 index 0000000..830832e --- /dev/null +++ b/src/server/services/planning/board.ts @@ -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; + +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; + 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 { + 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(); + 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>(); + for (const c of conflicts.filter(isConflict)) { + for (const id of c.orderIds) { + const set = kindsByOrder.get(id) ?? new Set(); + set.add(c.kind); + kindsByOrder.set(id, set); + } + } + const orders: Record = 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; + } +} diff --git a/src/server/services/planning/data.ts b/src/server/services/planning/data.ts new file mode 100644 index 0000000..e4b239e --- /dev/null +++ b/src/server/services/planning/data.ts @@ -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>[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 { + 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 { + 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 { + return ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, await workOrderScope(ctx)] }, select: ORDER_SELECT }); +} diff --git a/src/server/services/planning/live.ts b/src/server/services/planning/live.ts new file mode 100644 index 0000000..6936d24 --- /dev/null +++ b/src/server/services/planning/live.ts @@ -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 = { en_route: "en_route", running: "working", paused: "paused" }; +const RANK: Record = { 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; + 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 = {}, opts: { now?: Date } = {}): Promise { + 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(); + 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(); + 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(); + 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; + 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, + }; +} diff --git a/src/server/services/planning/recommend.ts b/src/server/services/planning/recommend.ts new file mode 100644 index 0000000..96ac15d --- /dev/null +++ b/src/server/services/planning/recommend.ts @@ -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; + +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 { + 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 { + 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[] = []; + 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 & { distanceKm: number }; + +/** Other unplanned orders around the target ("zusammen einplanen?"). */ +export async function findNearbyUnplanned( + ctx: ServiceCtx, + raw: z.input, +): 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 }; +} diff --git a/src/server/services/planning/schedule.ts b/src/server/services/planning/schedule.ts new file mode 100644 index 0000000..998d21d --- /dev/null +++ b/src/server/services/planning/schedule.ts @@ -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; + +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 { + 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, + }; +} diff --git a/src/server/services/planning/summary.ts b/src/server/services/planning/summary.ts new file mode 100644 index 0000000..117a073 --- /dev/null +++ b/src/server/services/planning/summary.ts @@ -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; + } +} diff --git a/src/server/services/planning/team-settings.ts b/src/server/services/planning/team-settings.ts new file mode 100644 index 0000000..4d9a35f --- /dev/null +++ b/src/server/services/planning/team-settings.ts @@ -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; + +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; +} diff --git a/src/server/services/planning/time-tracking.ts b/src/server/services/planning/time-tracking.ts new file mode 100644 index 0000000..47d5af1 --- /dev/null +++ b/src/server/services/planning/time-tracking.ts @@ -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(Object.values(Prisma.TimeEntryScalarFieldEnum)); +const sessionFields = new Set(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; diff --git a/src/server/services/planning/watch.ts b/src/server/services/planning/watch.ts new file mode 100644 index 0000000..9221c38 --- /dev/null +++ b/src/server/services/planning/watch.ts @@ -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 | 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(); + 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(); + for (const s of sessions) lastEnd.set(s.workOrderId, Math.max(lastEnd.get(s.workOrderId) ?? 0, s.endedAt!.getTime())); + const approvedSegments = new Map(); + 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(); + 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(PERMISSIONS) }; +} + +export type AlertLedger = { claim(ctx: ServiceCtx, key: string, facts: Record): Promise }; + +/** 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 { + 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; +} From ee17134863091cefcf053bee1c3c6ae2b1ce872f Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 10:13:50 +0200 Subject: [PATCH 4/5] =?UTF-8?q?L13=20Planung:=20Men=C3=BCpunkt=20Planung?= =?UTF-8?q?=20mit=20Plantafel=20und=20Live-Lage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plantafel: Standard heute + nächste 4 Werktage (Kolonnen als Zeilen, heutige Spalte mit Live-Status), Heute mit Kolonnen als parallelen Spalten (6–20 Uhr), Woche/nächste Woche, Auslastung, Konflikte/Hinweise, Verzugs- und Gefährdungs-Badges, Drag & Drop (@dnd-kit/core) mit Bestätigungs-Popover und Tastatur-Alternative, ungeplante Aufträge mit Vorschlägen, früher fertig mit Vorziehen, Kolonnenkapazität-Popup. Live-Lage: Leaflet/OSM-Karte mit einem Marker je Kolonne, Liste, Polling 30 s, Hinweis keine GPS-Ortung; CSP img-src für Kacheln. Dashboard-Kacheln Planung heute und Konflikte diese Woche, Einsatz-Vorschläge im Auftragsdetail, Navigation mit Unterpunkten, Smoke-Prüfungen. Co-Authored-By: Claude Opus 5 --- messages/de/nav.json | 3 + messages/de/planning.json | 199 +++++ messages/en/nav.json | 3 + messages/en/planning.json | 199 +++++ next.config.ts | 11 +- package-lock.json | 65 ++ package.json | 3 + scripts/smoke-auth.ts | 19 + src/app/(app)/dashboard/page.tsx | 2 + src/app/(app)/layout.tsx | 3 +- src/app/(app)/planning/layout.tsx | 7 + src/app/(app)/planning/live/page.tsx | 34 + src/app/(app)/planning/page.tsx | 295 ++++++++ src/app/(app)/work-orders/[id]/page.tsx | 4 + src/components/nav-link.tsx | 5 +- src/components/planning/capacity-form.tsx | 52 ++ src/components/planning/conflicts-tile.tsx | 47 ++ src/components/planning/live-map.tsx | 112 +++ src/components/planning/live-situation.tsx | 361 +++++++++ src/components/planning/live-tone.ts | 13 + src/components/planning/planning-board.tsx | 689 ++++++++++++++++++ .../planning/recommendations-panel.tsx | 148 ++++ src/components/planning/schedule-dialog.tsx | 177 +++++ .../planning/suggestions-section.tsx | 98 +++ src/lib/nav.ts | 11 + 25 files changed, 2557 insertions(+), 3 deletions(-) create mode 100644 messages/de/planning.json create mode 100644 messages/en/planning.json create mode 100644 src/app/(app)/planning/layout.tsx create mode 100644 src/app/(app)/planning/live/page.tsx create mode 100644 src/app/(app)/planning/page.tsx create mode 100644 src/components/planning/capacity-form.tsx create mode 100644 src/components/planning/conflicts-tile.tsx create mode 100644 src/components/planning/live-map.tsx create mode 100644 src/components/planning/live-situation.tsx create mode 100644 src/components/planning/live-tone.ts create mode 100644 src/components/planning/planning-board.tsx create mode 100644 src/components/planning/recommendations-panel.tsx create mode 100644 src/components/planning/schedule-dialog.tsx create mode 100644 src/components/planning/suggestions-section.tsx diff --git a/messages/de/nav.json b/messages/de/nav.json index efed3cc..b207ef8 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -1,6 +1,9 @@ { "dashboard": "Dashboard", "workOrders": "Aufträge", + "planning": "Planung", + "planningBoard": "Plantafel", + "planningLive": "Live-Lage", "emergencyReview": "Notdienst-Prüfung", "imports": "Import", "customers": "Kunden", diff --git a/messages/de/planning.json b/messages/de/planning.json new file mode 100644 index 0000000..62e8dc6 --- /dev/null +++ b/messages/de/planning.json @@ -0,0 +1,199 @@ +{ + "crumb": "Planung", + "weekday": { "0": "Mo", "1": "Di", "2": "Mi", "3": "Do", "4": "Fr", "5": "Sa", "6": "So" }, + "board": { + "title": "Plantafel", + "sub": "Aufträge je Team und Tag einplanen – Auslastung und Überschneidungen auf einen Blick.", + "readOnly": "Nur Lesezugriff – Sie sehen Ihre eigenen Teams.", + "viewLabel": "Ansicht", + "view": { "today": "Heute", "days": "5 Tage", "week": "Woche", "nextWeek": "Nächste Woche" }, + "navLabel": "Zeitraum wechseln", + "prevWeek": "Vorige Woche", + "nextWeek": "Nächste Woche", + "prevDay": "Voriger Tag", + "nextDay": "Nächster Tag", + "prevDays": "Vorige 5 Werktage", + "nextDays": "Nächste 5 Werktage", + "todayLabel": "Heute · {day}", + "time": "Uhrzeit", + "today": "Heute", + "range": "{from} – {to}", + "team": "Team", + "noTeams": "Keine aktiven Teams in dieser Auswahl.", + "showMembers": "Mitglieder von {team} anzeigen", + "hideMembers": "Mitglieder von {team} ausblenden", + "memberHint": "Einzeln zugewiesen", + "load": "{planned} / {capacity} · {percent} %", + "loadNoCapacity": "{planned} · keine Kapazität", + "notWorkingDay": "Kein Arbeitstag", + "allDay": "Ganztägig", + "free": "frei", + "narrowHint": "Die Plantafel ist für Bildschirme ab 1024 px ausgelegt. Hier sehen Sie eine einfache Liste.", + "capacity": "Kapazität", + "capacityOf": "Kapazität von {team} bearbeiten", + "move": "Verschieben …", + "schedule": "Einplanen …", + "dragHint": "Karten mit der Maus auf einen Tag ziehen oder über „Einplanen …“ bzw. „Verschieben …“ per Tastatur einplanen.", + "open": "Auftrag öffnen", + "saved": "{number} eingeplant: {team}, {day}.", + "savedWithConflicts": "{number} eingeplant: {team}, {day}. Achtung: {count} Konflikt(e) am Zieltag.", + "priority": "Priorität: {priority}", + "emergency": "Notdienst", + "multiDay": "mehrtägig", + "duration": "Dauer {duration}", + "listDayEmpty": "Nichts geplant", + "crewCapacity": "Kolonne · {capacity} je Arbeitstag · {count} Personen", + "delayRisk": "Verzug droht · {percent} %", + "delayOverrun": "Dauer überschritten · {percent} %", + "atRisk": "gefährdet durch Verzug {number}" + }, + "freed": { + "title": "Früher fertig", + "badge": "{team} · {minutes} früher fertig", + "available": "{minutes} frei ab {time}", + "pullForward": "Vorziehen", + "kindPullForward": "Geplanter Auftrag von {team}", + "kindNearby": "Ungeplant, {distance} entfernt", + "noSuggestions": "Kein passender Auftrag für die freie Zeit.", + "hint": "Nur Hinweis – nichts wird automatisch verschoben." + }, + "conflicts": { + "label": "Konflikt", + "count": "{count, plural, =0 {Keine Konflikte} one {# Konflikt} other {# Konflikte}}", + "overbooked": "Überbucht um {over}", + "overlap": "Überschneidung: {a} und {b}", + "assignee_double_booked": "{user} doppelt eingeplant: {a} und {b}", + "outside_working_days": "Außerhalb der Arbeitstage", + "crew_incomplete": "Kolonne unvollständig: {count} aktive Person(en)", + "hintLabel": "Hinweis", + "short": { + "overbooked": "Überbucht", + "overlap": "Überschneidung", + "assignee_double_booked": "Doppelt eingeplant", + "outside_working_days": "Kein Arbeitstag", + "crew_incomplete": "Kolonne unvollständig" + } + }, + "filters": { + "title": "Filter", + "team": "Team", + "orderType": "Auftragsart", + "priority": "Priorität", + "any": "Alle", + "apply": "Anwenden", + "reset": "Zurücksetzen" + }, + "unplanned": { + "title": "Ungeplante Aufträge", + "empty": "Keine ungeplanten Aufträge.", + "noTeam": "ohne Team", + "noDate": "ohne Termin", + "noLocation": "ohne Ortsangabe", + "suggestions": "Vorschläge" + }, + "dialog": { + "title": "Auftrag einplanen", + "team": "Team", + "date": "Datum", + "time": "Beginn", + "noTime": "Ohne Uhrzeit (ganztägig)", + "duration": "Dauer in Minuten", + "durationHint": "Vorbelegt aus Auftrag bzw. Auftragsart ({duration}).", + "save": "Speichern", + "saving": "Speichert …", + "cancel": "Abbrechen", + "close": "Schließen" + }, + "errors": { + "conflict": "Auftrag wurde zwischenzeitlich geändert. Die Ansicht wurde aktualisiert – bitte erneut einplanen.", + "forbidden": "Dafür fehlt Ihnen die Berechtigung.", + "not_found": "Auftrag nicht gefunden.", + "invalid": "Bitte die Eingaben prüfen.", + "planned_end_before_start": "Das Ende liegt vor dem Beginn.", + "not_schedulable": "Dieser Auftrag läuft bereits und wird hier nicht verschoben.", + "team_not_found": "Team nicht gefunden oder nicht aktiv.", + "not_editable": "Dieser Auftrag kann nicht mehr geändert werden.", + "network": "Keine Verbindung. Bitte erneut versuchen.", + "generic": "Speichern war nicht möglich. Bitte erneut versuchen." + }, + "recommend": { + "title": "Vorschläge", + "sub": "Nur Vorschläge nach Luftlinie und Terminlage – die Zuweisung entscheiden Sie.", + "loading": "Vorschläge werden berechnet …", + "reason": "{distance} von {number} · {team} hat am {day} noch {free} frei", + "reasonTight": "{distance} von {number} · {team} am {day} knapp: {free} frei, {required} nötig", + "card": "{day} · {team} · {distance} von {number} · {free} frei", + "tight": "knapp", + "take": "Übernehmen", + "noCoordinates": "Adresse nicht verortet – die Verortung wurde angestoßen. Bitte später erneut öffnen.", + "addressNotFound": "Adresse nicht verortet – die Adresse wurde nicht gefunden. Bitte am Objekt prüfen.", + "notSchedulable": "Dieser Auftrag ist bereits in Ausführung.", + "none": "Kein Team hat in den nächsten {days} Tagen einen Auftrag im Umkreis von {radius} km mit freier Kapazität.", + "nearbyTitle": "In der Nähe, ebenfalls ungeplant", + "nearbyQuestion": "Diese Aufträge liegen im Umkreis von {radius} km – zusammen einplanen?", + "nearbyEmpty": "Keine weiteren ungeplanten Aufträge in der Nähe.", + "planTogether": "Gemeinsam einplanen", + "close": "Vorschläge schließen", + "failed": "Vorschläge konnten nicht geladen werden.", + "sectionTitle": "Einsatz-Vorschläge", + "openBoard": "Zur Plantafel" + }, + "live": { + "title": "Live-Lage", + "sub": "Wer ist gerade in welchem Einsatz?", + "privacy": "Standort = Einsatzort des laufenden Auftrags, keine GPS-Ortung.", + "status": { "en_route": "Unterwegs", "working": "In Arbeit", "paused": "Pause", "free": "Frei" }, + "freeHint": "kein laufender Einsatz", + "since": "seit {time}", + "updated": "Zuletzt aktualisiert: {time}", + "refresh": "Aktualisieren", + "refreshing": "Aktualisiert …", + "updateFailed": "Aktualisierung fehlgeschlagen – der letzte Stand wird angezeigt.", + "tabsLabel": "Darstellung", + "tabs": { "map": "Karte", "list": "Liste" }, + "filterTeam": "Team", + "filterStatus": "Status", + "all": "Alle", + "noLocation": "ohne Ortsangabe", + "hiddenOrder": "Einsatz außerhalb Ihres Bereichs", + "next": "Heute noch geplant", + "nothingNext": "Heute nichts weiter geplant", + "attentionTitle": "Heute ohne Beginn", + "attentionEmpty": "Alle heutigen Aufträge sind begonnen oder später geplant.", + "reason": { "not_accepted": "Nicht angenommen", "overdue": "Beginn überschritten" }, + "empty": "Keine Monteure in dieser Auswahl.", + "markerCount": "{count} Personen an diesem Ort", + "mapLabel": "Karte der laufenden Einsätze", + "mapLoading": "Karte wird geladen …", + "withoutLocation": "{count} laufende Einsätze ohne Ortsangabe (nur in der Liste)", + "noTeam": "ohne Team", + "crewsTitle": "Kolonnen", + "soloTitle": "Ohne Team", + "crewMembers": "Mitglieder", + "memberAt": "bei {number}", + "delayRisk": "Verzug droht · {percent} %", + "delayOverrun": "Dauer überschritten · {percent} %", + "worked": "{worked} von {planned} erfasst" + }, + "capacity": { + "title": "Teamkapazität", + "sub": "Grundlage für Auslastung und Vorschläge", + "minutes": "Arbeitstag der Kolonne in Minuten", + "minutesHint": "Die Kolonne arbeitet gemeinsam: Kapazität je Arbeitstag, nicht je Person.", + "days": "Arbeitstage", + "save": "Speichern", + "saving": "Speichert …", + "errors": { + "invalid": "Bitte die Eingaben prüfen.", + "forbidden": "Dafür fehlt Ihnen die Berechtigung.", + "not_found": "Team nicht gefunden.", + "generic": "Speichern war nicht möglich." + } + }, + "dashboardTile": { + "title": "Konflikte diese Woche", + "hint": "Überbuchung, Überschneidung, Doppelbuchung", + "todayTitle": "Planung heute", + "todayHint": "Teams im Einsatz · {conflicts} Konflikt(e) · {unplanned} ungeplante Aufträge" + } +} diff --git a/messages/en/nav.json b/messages/en/nav.json index 91f4ba6..73602b5 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -1,6 +1,9 @@ { "dashboard": "Dashboard", "workOrders": "Work orders", + "planning": "Planning", + "planningBoard": "Planning board", + "planningLive": "Live situation", "emergencyReview": "Emergency review", "imports": "Import", "customers": "Customers", diff --git a/messages/en/planning.json b/messages/en/planning.json new file mode 100644 index 0000000..aafe3b3 --- /dev/null +++ b/messages/en/planning.json @@ -0,0 +1,199 @@ +{ + "crumb": "Planning", + "weekday": { "0": "Mon", "1": "Tue", "2": "Wed", "3": "Thu", "4": "Fri", "5": "Sat", "6": "Sun" }, + "board": { + "title": "Planning board", + "sub": "Schedule orders per team and day – utilization and overlaps at a glance.", + "readOnly": "Read-only – you see your own teams.", + "viewLabel": "View", + "view": { "today": "Today", "days": "5 days", "week": "Week", "nextWeek": "Next week" }, + "navLabel": "Change period", + "prevWeek": "Previous week", + "nextWeek": "Next week", + "prevDay": "Previous day", + "nextDay": "Next day", + "prevDays": "Previous 5 working days", + "nextDays": "Next 5 working days", + "todayLabel": "Today · {day}", + "time": "Time", + "today": "Today", + "range": "{from} – {to}", + "team": "Team", + "noTeams": "No active teams in this selection.", + "showMembers": "Show members of {team}", + "hideMembers": "Hide members of {team}", + "memberHint": "Individually assigned", + "load": "{planned} / {capacity} · {percent} %", + "loadNoCapacity": "{planned} · no capacity", + "notWorkingDay": "Not a working day", + "allDay": "All day", + "free": "free", + "narrowHint": "The planning board is designed for screens from 1024 px. Here you see a simple list.", + "capacity": "Capacity", + "capacityOf": "Edit capacity of {team}", + "move": "Move …", + "schedule": "Schedule …", + "dragHint": "Drag cards onto a day with the mouse or use “Schedule …” / “Move …” with the keyboard.", + "open": "Open order", + "saved": "{number} scheduled: {team}, {day}.", + "savedWithConflicts": "{number} scheduled: {team}, {day}. Attention: {count} conflict(s) on the target day.", + "priority": "Priority: {priority}", + "emergency": "Emergency", + "multiDay": "multi-day", + "duration": "Duration {duration}", + "listDayEmpty": "Nothing planned", + "crewCapacity": "Crew · {capacity} per working day · {count} people", + "delayRisk": "Delay likely · {percent} %", + "delayOverrun": "Duration exceeded · {percent} %", + "atRisk": "at risk due to delay of {number}" + }, + "freed": { + "title": "Finished early", + "badge": "{team} · {minutes} early", + "available": "{minutes} free from {time}", + "pullForward": "Pull forward", + "kindPullForward": "Planned order of {team}", + "kindNearby": "Unplanned, {distance} away", + "noSuggestions": "No suitable order for the free time.", + "hint": "Hint only – nothing is moved automatically." + }, + "conflicts": { + "label": "Conflict", + "count": "{count, plural, =0 {No conflicts} one {# conflict} other {# conflicts}}", + "overbooked": "Overbooked by {over}", + "overlap": "Overlap: {a} and {b}", + "assignee_double_booked": "{user} double-booked: {a} and {b}", + "outside_working_days": "Outside working days", + "crew_incomplete": "Crew incomplete: {count} active person(s)", + "hintLabel": "Hint", + "short": { + "overbooked": "Overbooked", + "overlap": "Overlap", + "assignee_double_booked": "Double-booked", + "outside_working_days": "Not a working day", + "crew_incomplete": "Crew incomplete" + } + }, + "filters": { + "title": "Filter", + "team": "Team", + "orderType": "Order type", + "priority": "Priority", + "any": "All", + "apply": "Apply", + "reset": "Reset" + }, + "unplanned": { + "title": "Unplanned orders", + "empty": "No unplanned orders.", + "noTeam": "no team", + "noDate": "no date", + "noLocation": "no location", + "suggestions": "Suggestions" + }, + "dialog": { + "title": "Schedule order", + "team": "Team", + "date": "Date", + "time": "Start", + "noTime": "No time (all day)", + "duration": "Duration in minutes", + "durationHint": "Prefilled from the order or order type ({duration}).", + "save": "Save", + "saving": "Saving …", + "cancel": "Cancel", + "close": "Close" + }, + "errors": { + "conflict": "The order was changed in the meantime. The view was refreshed – please schedule again.", + "forbidden": "You are not allowed to do this.", + "not_found": "Order not found.", + "invalid": "Please check your input.", + "planned_end_before_start": "The end is before the start.", + "not_schedulable": "This order is already in progress and is not moved here.", + "team_not_found": "Team not found or not active.", + "not_editable": "This order can no longer be changed.", + "network": "No connection. Please try again.", + "generic": "Saving was not possible. Please try again." + }, + "recommend": { + "title": "Suggestions", + "sub": "Suggestions only, by straight-line distance and schedule – you decide the assignment.", + "loading": "Calculating suggestions …", + "reason": "{distance} from {number} · {team} still has {free} free on {day}", + "reasonTight": "{distance} from {number} · {team} on {day} tight: {free} free, {required} needed", + "card": "{day} · {team} · {distance} from {number} · {free} free", + "tight": "tight", + "take": "Use", + "noCoordinates": "Address not located – geocoding was requested. Please open again later.", + "addressNotFound": "Address not located – the address was not found. Please check the site.", + "notSchedulable": "This order is already in progress.", + "none": "No team has an order within {radius} km with free capacity in the next {days} days.", + "nearbyTitle": "Nearby, also unplanned", + "nearbyQuestion": "These orders are within {radius} km – schedule them together?", + "nearbyEmpty": "No other unplanned orders nearby.", + "planTogether": "Schedule together", + "close": "Close suggestions", + "failed": "Suggestions could not be loaded.", + "sectionTitle": "Assignment suggestions", + "openBoard": "Open planning board" + }, + "live": { + "title": "Live situation", + "sub": "Who is currently on which job?", + "privacy": "Location = site of the running order, no GPS tracking.", + "status": { "en_route": "En route", "working": "Working", "paused": "Break", "free": "Free" }, + "freeHint": "no running job", + "since": "since {time}", + "updated": "Last updated: {time}", + "refresh": "Refresh", + "refreshing": "Refreshing …", + "updateFailed": "Update failed – showing the last state.", + "tabsLabel": "Display", + "tabs": { "map": "Map", "list": "List" }, + "filterTeam": "Team", + "filterStatus": "Status", + "all": "All", + "noLocation": "no location", + "hiddenOrder": "Job outside your area", + "next": "Still planned today", + "nothingNext": "Nothing else planned today", + "attentionTitle": "Today without start", + "attentionEmpty": "All of today's orders have started or are planned later.", + "reason": { "not_accepted": "Not accepted", "overdue": "Start time passed" }, + "empty": "No technicians in this selection.", + "markerCount": "{count} people at this location", + "mapLabel": "Map of running jobs", + "mapLoading": "Loading map …", + "withoutLocation": "{count} running jobs without location (list only)", + "noTeam": "no team", + "crewsTitle": "Crews", + "soloTitle": "Without team", + "crewMembers": "Members", + "memberAt": "at {number}", + "delayRisk": "Delay likely · {percent} %", + "delayOverrun": "Duration exceeded · {percent} %", + "worked": "{worked} of {planned} recorded" + }, + "capacity": { + "title": "Team capacity", + "sub": "Basis for utilization and suggestions", + "minutes": "Crew working day in minutes", + "minutesHint": "The crew works together: capacity per working day, not per person.", + "days": "Working days", + "save": "Save", + "saving": "Saving …", + "errors": { + "invalid": "Please check your input.", + "forbidden": "You are not allowed to do this.", + "not_found": "Team not found.", + "generic": "Saving was not possible." + } + }, + "dashboardTile": { + "title": "Conflicts this week", + "hint": "Overbooking, overlap, double booking", + "todayTitle": "Planning today", + "todayHint": "Teams on the job · {conflicts} conflict(s) · {unplanned} unplanned orders" + } +} diff --git a/next.config.ts b/next.config.ts index fe81c08..ee17c7d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,15 @@ import createNextIntlPlugin from "next-intl/plugin"; // zum Dev-Server. Diese Lockerungen gelten NUR in der Entwicklung, nie im Build. const isDev = process.env.NODE_ENV !== "production"; +// L13 Planung: map tiles of the live map (MAP_TILE_URL, default OpenStreetMap). Only the tile host(s) +// are added to img-src — evaluated at build time, so set MAP_TILE_URL before `next build`. +function mapTileSources(): string { + const raw = process.env.MAP_TILE_URL?.trim(); + if (!raw) return "https://tile.openstreetmap.org https://*.tile.openstreetmap.org"; + const m = /^(https?):\/\/([^/?#]+)/i.exec(raw); + return m ? `${m[1].toLowerCase()}://${m[2].replace(/\{[a-z]\}/gi, "*")}` : ""; +} + // Content-Security-Policy (F-07). // // Hinweis Skripte: 'unsafe-inline' ist ein bewusster Zwischenstand. Next.js liefert @@ -16,7 +25,7 @@ const csp = [ // Next.js und Tailwind v4 setzen Inline-Styles. "style-src 'self' 'unsafe-inline'", // data: für den MFA-QR-Code; blob: für clientseitige Foto-Vorschauen (Einsatz-Fotos). - "img-src 'self' data: blob:", + `img-src 'self' data: blob: ${mapTileSources()}`.trim(), // blob: für lokale Wiedergabe von Sprachnotizen vor dem Upload. "media-src 'self' blob:", "font-src 'self' data:", diff --git a/package-lock.json b/package-lock.json index 62be9a7..31746df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@anthropic-ai/sdk": "^0.115.0", "@aws-sdk/client-s3": "^3.1103.0", "@base-ui/react": "^1.6.0", + "@dnd-kit/core": "^6.3.1", "@node-rs/argon2": "^2.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", @@ -21,6 +22,7 @@ "clsx": "^2.1.1", "dotenv": "^17.4.2", "ioredis": "^5.11.1", + "leaflet": "^1.9.4", "lucide-react": "^1.23.0", "next": "16.2.12", "next-auth": "5.0.0-beta.32", @@ -38,6 +40,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.22", "@types/node": "^20", "@types/nodemailer": "^8.0.1", "@types/qrcode": "^1.5.6", @@ -896,6 +899,45 @@ } } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -4832,6 +4874,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4846,6 +4895,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -9567,6 +9626,12 @@ "node": ">=0.10" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index 50fd72b..9570f39 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@anthropic-ai/sdk": "^0.115.0", "@aws-sdk/client-s3": "^3.1103.0", "@base-ui/react": "^1.6.0", + "@dnd-kit/core": "^6.3.1", "@node-rs/argon2": "^2.0.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", @@ -30,6 +31,7 @@ "clsx": "^2.1.1", "dotenv": "^17.4.2", "ioredis": "^5.11.1", + "leaflet": "^1.9.4", "lucide-react": "^1.23.0", "next": "16.2.12", "next-auth": "5.0.0-beta.32", @@ -47,6 +49,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/leaflet": "^1.9.22", "@types/node": "^20", "@types/nodemailer": "^8.0.1", "@types/qrcode": "^1.5.6", diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index 2ef0bca..694e374 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -99,6 +99,17 @@ function plans(x: Awaited>): Plan[] { { path: "/search?q=Elbblick", mustContain: "Elbblick", perf: true }, { path: "/teams" }, { path: "/settings/users", expect: [200, 307, 403] }, + // L13 Planung + { path: "/planning", mustContain: ["Plantafel", 'data-planning-view="days"', 'data-day-count="5"', "Heute", "Team Nord", "Team Süd", "Ungeplante Aufträge"], perf: true }, + { path: "/planning?view=today", mustContain: ['data-planning-view="today"', "Ganztägig", "Team Nord", "Team Süd"] }, + { path: "/planning?view=week", mustContain: ['data-planning-view="week"', 'data-day-count="7"'] }, + { path: "/dashboard", mustContain: ["Planung heute", "Teams im Einsatz"] }, + { path: `/planning?schedule=${x.d07.id}`, mustContain: "Plantafel" }, + { path: "/planning/live", mustContain: ["Live-Lage", "keine GPS-Ortung", "OpenStreetMap"], mustNotContain: ["startLat", "startLng"], perf: true }, + { path: "/api/v1/planning/board", mustContain: ['"teams"', '"conflictCount"'] }, + { path: "/api/v1/planning/live", mustContain: '"technicians"', mustNotContain: ["startLat", "startLng", "deviceInfo"] }, + { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, mustContain: '"recommendations"' }, + { path: "/dashboard", mustContain: "Konflikte diese Woche" }, ], }, { @@ -113,6 +124,9 @@ function plans(x: Awaited>): Plan[] { { path: "/reports" }, ...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []), { path: m(x.d08), expect: [404] }, + // L13 Planung: team lead read-only, own team only + { path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] }, + { path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] }, ], }, { @@ -141,6 +155,8 @@ function plans(x: Awaited>): Plan[] { { path: m(x.d08), expect: [404] }, { path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] }, { path: "/dashboard", expect: [307, 308], redirectTo: "/m" }, + { path: "/planning", expect: [404] }, + { path: "/planning/live", expect: [404] }, ], }, { @@ -167,6 +183,9 @@ function plans(x: Awaited>): Plan[] { ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []), ...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []), ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []), + { path: "/planning", mustNotContain: ["Team Nord", x.d07.number] }, + { path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] }, + { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] }, ], }, ]; diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 0261dee..46607b2 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -16,6 +16,7 @@ import { type LucideIcon, } from "lucide-react"; import { PageHead } from "@/components/mockup-ui"; +import { PlanningTiles } from "@/components/planning/conflicts-tile"; import { buttonCls } from "@/components/work-orders/button-cls"; import { pageContext } from "@/components/work-orders/page-context"; import { Field, inputCls } from "@/components/work-orders/ui"; @@ -162,6 +163,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr ); })} + )} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 8f77b5e..13ff9d7 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -67,7 +67,8 @@ export default async function AppLayout({