Files
craftvia/scripts/planning-demo.ts
T
msolarczekandClaude Opus 5 1e1c154a8a Plantafel: Beispielwoche relativ zu heute; Live-Lage mit Kachelansicht
Neues Skript scripts/planning-demo.ts plant über createWorkOrder + scheduleWorkOrder eine
Woche für beide Kolonnen (Auslastung, Überbuchung, Überschneidung, Mehrtagesauftrag,
ungeplante Aufträge); die Demo-Termine aus dem Seed liegen relativ zum Seed-Tag und wandern
sonst aus dem Standardzeitraum.

Live-Lage: Umschalter Karte · Kacheln · Liste, Kachelansicht mit Status, Auftrag und Ort;
LiveMap meldet nicht erreichbare Kartenkacheln und die Ansicht wechselt automatisch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:44:24 +02:00

186 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Beispielbelegung der Plantafel (L13) für die visuelle Prüfung — relativ zu HEUTE.
*
* Die Demo-Fachdaten aus `scripts/lib/demo-seed.ts` liegen relativ zum Tag des Seed-Laufs; einen Tag
* später ist die Plantafel im Standardzeitraum (heute + 4 Werktage) deshalb fast leer. Dieses Skript
* plant eine sprechende Woche über die echten Services (createWorkOrder + scheduleWorkOrder), sodass
* Auslastung, Konflikte, Mehrtagesaufträge und die Spalte „Ungeplante Aufträge" sichtbar sind.
*
* npx tsx scripts/planning-demo.ts # anlegen bzw. auf heute umplanen (idempotent)
* npx tsx scripts/planning-demo.ts --tenant=demo # anderer Mandant (Slug)
* npx tsx scripts/planning-demo.ts --reset # Beispiele wieder entfernen (Soft Delete)
*
* Kennzeichen aller Beispiele: externe Auftragsnummer `PLAN-…`.
*/
import "dotenv/config";
import { dbForTenant, prisma } from "../src/server/db";
import { ROLE_DEFS } from "../src/server/rbac";
import type { ServiceCtx } from "../src/server/services/context";
import { scheduleWorkOrder } from "../src/server/services/planning/schedule";
import { createWorkOrder } from "../src/server/services/work-orders/create";
const args = process.argv.slice(2);
const RESET = args.includes("--reset");
const SLUG = args.find((a) => a.startsWith("--tenant="))?.slice("--tenant=".length) ?? "demo";
const PREFIX = "PLAN-";
/** Lokaler Zeitpunkt am n-ten Werktag ab heute (0 = heute bzw. der nächste Werktag). */
function workingDays(count: number): Date[] {
const out: Date[] = [];
const d = new Date();
d.setHours(0, 0, 0, 0);
while (out.length < count) {
if (d.getDay() >= 1 && d.getDay() <= 5) out.push(new Date(d));
d.setDate(d.getDate() + 1);
}
return out;
}
function at(day: Date, h: number, m = 0): Date {
const d = new Date(day);
d.setHours(h, m, 0, 0);
return d;
}
type Example = {
ext: string;
title: string;
typeKey: string;
crew: "nord" | "sued";
/** Index im Werktagsfenster (0 = heute) */
dayIndex: number;
from: [number, number];
to: [number, number];
/** Mehrtägig: Ende an einem späteren Werktag */
untilDayIndex?: number;
priority?: "low" | "normal" | "high" | "urgent";
note?: string;
};
/**
* Bewusst gewähltes Bild: hohe Auslastung, eine Überbuchung, eine Überschneidung, ein
* Mehrtagesauftrag und ruhige Tage — damit alle Zustände der Plantafel sichtbar werden.
*/
const EXAMPLES: Example[] = [
// Heute: Nord gut ausgelastet (7,5 h / 8 h), Süd halber Tag
{ ext: `${PREFIX}01`, title: "Heizungswartung Mehrfamilienhaus", typeKey: "wartung", crew: "nord", dayIndex: 0, from: [8, 0], to: [12, 0] },
{ ext: `${PREFIX}02`, title: "Thermostatventile tauschen", typeKey: "reparatur", crew: "nord", dayIndex: 0, from: [13, 0], to: [16, 30] },
{ ext: `${PREFIX}03`, title: "Rückstauklappe prüfen", typeKey: "wartung", crew: "sued", dayIndex: 0, from: [9, 0], to: [12, 0] },
// Morgen: Nord überbucht (9 h > 8 h Kolonnentag), Süd zwei Termine hintereinander
{ ext: `${PREFIX}04`, title: "Badsanierung – Rohinstallation", typeKey: "montage", crew: "nord", dayIndex: 1, from: [7, 30], to: [16, 30], priority: "high" },
{ ext: `${PREFIX}05`, title: "Warmwasserspeicher entkalken", typeKey: "wartung", crew: "sued", dayIndex: 1, from: [8, 0], to: [11, 0] },
{ ext: `${PREFIX}06`, title: "Waschtisch-Armatur erneuern", typeKey: "reparatur", crew: "sued", dayIndex: 1, from: [11, 30], to: [14, 0] },
// Tag 3: Überschneidung bei Nord (zwei Aufträge zur selben Zeit)
{ ext: `${PREFIX}07`, title: "Gastherme Störung beheben", typeKey: "stoerung", crew: "nord", dayIndex: 2, from: [8, 0], to: [12, 0], priority: "urgent" },
{ ext: `${PREFIX}08`, title: "Sicherheitsventil wechseln", typeKey: "reparatur", crew: "nord", dayIndex: 2, from: [10, 0], to: [14, 0] },
// Tag 4/5: Mehrtagesauftrag Nord, Süd mit vollem Tag
{ ext: `${PREFIX}09`, title: "Strangsanierung Steigleitung (2 Tage)", typeKey: "montage", crew: "nord", dayIndex: 3, from: [7, 0], to: [16, 0], untilDayIndex: 4 },
{ ext: `${PREFIX}10`, title: "Lüftungsanlage Filterwechsel", typeKey: "wartung", crew: "sued", dayIndex: 3, from: [8, 0], to: [15, 0] },
{ ext: `${PREFIX}11`, title: "Abnahme Trinkwasserprobe", typeKey: "abnahme", crew: "sued", dayIndex: 4, from: [8, 0], to: [12, 0] },
];
/** Ungeplant: füllt die Spalte „Ungeplante Aufträge" (Drag-&-Drop-Material für die Prüfung). */
const UNPLANNED: { ext: string; title: string; typeKey: string; priority?: "low" | "normal" | "high" | "urgent" }[] = [
{ ext: `${PREFIX}20`, title: "Neue Duscharmatur montieren", typeKey: "montage" },
{ ext: `${PREFIX}21`, title: "Tropfender Außenhahn", typeKey: "reparatur", priority: "low" },
{ ext: `${PREFIX}22`, title: "Jahreswartung Wärmepumpe", typeKey: "wartung" },
{ ext: `${PREFIX}23`, title: "Heizkörper entlüften – Notdienstnachlauf", typeKey: "nacharbeit", priority: "high" },
];
async function main() {
const tenant = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true, name: true } });
if (!tenant) throw new Error(`Mandant „${SLUG}" nicht gefunden.`);
const db = dbForTenant(tenant.id);
if (RESET) {
const res = await prisma.workOrder.updateMany({
where: { tenantId: tenant.id, externalOrderNumber: { startsWith: PREFIX }, deletedAt: null },
data: { deletedAt: new Date() },
});
console.log(`✔ ${res.count} Beispielauftrag/-aufträge entfernt (${tenant.name}).`);
return;
}
const backoffice = await db.user.findFirst({ where: { email: { contains: "backoffice" }, status: "ACTIVE" }, select: { id: true } });
if (!backoffice) throw new Error("Kein Backoffice-Nutzer im Mandanten gefunden.");
const ctx: ServiceCtx = { db, tenantId: tenant.id, userId: backoffice.id, permissions: new Set(ROLE_DEFS.backoffice.permissions) };
const teams = await db.team.findMany({ where: { deletedAt: null, status: "active" }, select: { id: true, name: true, members: { select: { userId: true, validTo: true } } }, orderBy: { name: "asc" } });
if (teams.length < 2) throw new Error("Mindestens zwei aktive Teams (Kolonnen) nötig — bitte zuerst `npx prisma db seed` ausführen.");
const crews = { nord: teams[0], sued: teams[1] };
const types = new Map((await db.orderType.findMany({ select: { id: true, key: true } })).map((t) => [t.key, t.id]));
// Objekte mit Koordinaten zuerst: so haben Karte und Empfehlungen (Luftlinie) etwas zu zeigen.
const sites = await db.site.findMany({
where: { deletedAt: null, customer: { deletedAt: null } },
select: { id: true, name: true, customerId: true, latitude: true },
orderBy: [{ latitude: "desc" }, { name: "asc" }],
});
if (!sites.length) throw new Error("Keine Objekte im Mandanten — bitte zuerst `npx prisma db seed` ausführen.");
const site = (i: number) => sites[i % sites.length];
const days = workingDays(6);
let created = 0;
let planned = 0;
/** Legt den Auftrag an (oder findet den vorhandenen) — immer ohne Termin, geplant wird danach. */
async function ensureOrder(ext: string, title: string, typeKey: string, index: number, priority?: Example["priority"], note?: string) {
const existing = await db.workOrder.findFirst({ where: { externalOrderNumber: ext, deletedAt: null }, select: { id: true, version: true, status: true } });
if (existing) return existing;
const s = site(index);
const wo = await createWorkOrder(ctx, {
title,
customerId: s.customerId,
siteId: s.id,
orderTypeId: types.get(typeKey) ?? null,
status: "planned",
priority: priority ?? "normal",
externalOrderNumber: ext,
technicianNotes: note,
applyTemplate: false,
});
created++;
return { id: wo.id, version: wo.version, status: wo.status };
}
for (const [i, ex] of EXAMPLES.entries()) {
const wo = await ensureOrder(ex.ext, ex.title, ex.typeKey, i, ex.priority, ex.note);
const startDay = days[ex.dayIndex];
const endDay = days[ex.untilDayIndex ?? ex.dayIndex];
const crew = crews[ex.crew];
const current = await db.workOrder.findFirstOrThrow({ where: { id: wo.id }, select: { version: true, status: true } });
// Laufende oder abgeschlossene Aufträge verschiebt die Plantafel nicht — solche Beispiele überspringen.
if (!["draft", "review_required", "planned", "assigned", "accepted"].includes(current.status)) continue;
await scheduleWorkOrder(ctx, {
workOrderId: wo.id,
teamId: crew.id,
plannedStart: at(startDay, ex.from[0], ex.from[1]),
plannedEnd: at(endDay, ex.to[0], ex.to[1]),
baseVersion: current.version,
});
planned++;
}
for (const [i, u] of UNPLANNED.entries()) {
await ensureOrder(u.ext, u.title, u.typeKey, EXAMPLES.length + i, u.priority);
}
const from = days[0].toLocaleDateString("de-DE");
const to = days[4].toLocaleDateString("de-DE");
console.log(`✔ ${tenant.name}: ${created} Auftrag/Aufträge neu angelegt, ${planned} eingeplant (${from} – ${to}).`);
console.log(` Kolonnen: ${crews.nord.name} · ${crews.sued.name} — Beispiele enthalten Überbuchung, Überschneidung und einen Mehrtagesauftrag.`);
console.log(` Ungeplant: ${UNPLANNED.length} Aufträge in der Spalte „Ungeplante Aufträge".`);
}
main()
.then(async () => {
await prisma.$disconnect();
// Ereignisse der Services halten Queue-Verbindungen offen → Prozess bewusst beenden.
process.exit(0);
})
.catch(async (err) => {
console.error(err instanceof Error ? err.message : err);
await prisma.$disconnect().catch(() => undefined);
process.exit(1);
});