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>
186 lines
9.4 KiB
TypeScript
186 lines
9.4 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|