Files
craftvia/scripts/lib/demo-seed.ts
T
msolarczekandClaude Opus 5 06d320908f L17 Pakete: Stufen Basis/Profi, Lotse-Chat-Plätze und Kontingent
- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
  Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
  Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
  requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
  Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
  hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
  und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:13:27 +02:00

464 lines
39 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.
// L10a Demo-Seed: fachliche Demo-Daten für die Mandanten `demo` und `demo2`.
//
// Wird von prisma/seed.ts aufgerufen (nur mit SEED_DEMO=true oder außerhalb von Production).
// Alle Datensätze entstehen über die ECHTEN Fachservices (Statusmaschine, Zuweisung, Sessions,
// Berichte, Notdienst) – damit sind Statushistorie, Audit, Versionen und Berichts-Snapshots
// konsistent wie im Echtbetrieb. Idempotent: Kunden über feste Kundennummern (K-D…), Objekte über
// Kunde + Name, Aufträge über die externe Auftragsnummer DEMO-…, Notdienst über feste Client-IDs.
// Alle Namen, Adressen und Telefonnummern sind frei erfunden (keine echten personenbezogenen Daten).
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { FakeExtractionProvider } from "../../src/server/ai/extraction/fake";
import { createImport } from "../../src/server/services/imports/upload";
import { processImport } from "../../src/server/services/imports/process";
import { buildPdf } from "../make-sample-pdfs";
import { prisma, dbForTenant } from "../../src/server/db";
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
import type { ServiceCtx } from "../../src/server/services/context";
import { localDateKey } from "../../src/lib/reports/dates";
import {
checklistTogglePayload,
materialUpsertPayload,
noteCreatePayload,
photoAttachPayload,
sessionControlPayload,
sessionStartPayload,
type NoteKind,
} from "../../src/lib/sync/ops";
import { createCustomer } from "../../src/server/services/customers/customers";
import { createContact } from "../../src/server/services/customers/contacts";
import { createSite } from "../../src/server/services/sites/sites";
import { createTeam } from "../../src/server/services/teams/teams";
import { createChecklistTemplate, ensureDefaultOrderTypes } from "../../src/server/services/work-orders/settings";
import { createWorkOrder } from "../../src/server/services/work-orders/create";
import { assignWorkOrder } from "../../src/server/services/work-orders/assign";
import { transitionWorkOrder } from "../../src/server/services/work-orders/transition";
import { markBilled, releaseForBilling } from "../../src/server/services/work-orders/release-billing";
import { endSession, pauseSession, startSession } from "../../src/server/services/field/sessions";
import { createNote } from "../../src/server/services/field/notes";
import { upsertMaterialUsage } from "../../src/server/services/field/materials";
import { toggleChecklistItem } from "../../src/server/services/field/checklist";
import { storeFieldUpload } from "../../src/server/services/field/uploads";
import { attachPhoto } from "../../src/server/services/field/photos";
import { storeFile } from "../../src/server/services/documents/store";
import { createCompletionReport, createDailyReport } from "../../src/server/services/reports/create";
import { updateReportTexts } from "../../src/server/services/reports/edit";
import { captureSignature } from "../../src/server/services/reports/signature";
import { submitReport } from "../../src/server/services/reports/submit";
import { approveReport } from "../../src/server/services/reports/approve";
import { createEmergencyOrder } from "../../src/server/services/emergency/create";
import type { CreateWorkOrderInput } from "../../src/lib/work-orders/schemas";
export type DemoUsers = Record<"admin" | "backoffice" | "lead" | "lead2" | "tech" | "tech2" | "tech3", string>;
const TZ = "Europe/Berlin";
const noPdf = { dispatchPdf: async () => undefined };
function ctxOf(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
}
/** Local date relative to today (server time zone), e.g. day(-1, 7, 30) = yesterday 07:30. */
function day(offset: number, h = 8, m = 0): Date {
const x = new Date();
x.setDate(x.getDate() + offset);
x.setHours(h, m, 0, 0);
return x;
}
async function demoJpeg(label: string): Promise<Buffer> {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="768"><rect width="1024" height="768" fill="#dfe6ee"/><rect x="80" y="120" width="864" height="528" fill="#9fb3c8"/><text x="512" y="400" font-size="56" font-family="sans-serif" text-anchor="middle" fill="#1f2d3d">${label}</text></svg>`;
return sharp(Buffer.from(svg)).jpeg({ quality: 80 }).toBuffer();
}
async function signaturePng(): Promise<Buffer> {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="200"><rect width="600" height="200" fill="#ffffff"/><path d="M40 140 C 90 40, 140 180, 190 110 S 290 60, 330 130 S 450 90, 560 120" stroke="#1f2d3d" stroke-width="4" fill="none"/></svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promise<{ orders: number; created: number }> {
const admin = ctxOf(tenantId, users.admin, "tenant-admin");
const bo = ctxOf(tenantId, users.backoffice, "backoffice");
const lead = ctxOf(tenantId, users.lead, "team-lead");
const lead2 = ctxOf(tenantId, users.lead2, "team-lead");
const tech = ctxOf(tenantId, users.tech, "technician");
const tech2 = ctxOf(tenantId, users.tech2, "technician");
const tech3 = ctxOf(tenantId, users.tech3, "technician");
// ---------- Auftragsarten + Checklisten-Vorlagen ----------
await ensureDefaultOrderTypes(admin.db, tenantId);
const orderTypes = new Map((await admin.db.orderType.findMany({ select: { id: true, key: true } })).map((t) => [t.key, t.id]));
const type = (key: string) => orderTypes.get(key) ?? null;
const templates: Parameters<typeof createChecklistTemplate>[1][] = [
{
name: "Montage Heizung/Sanitär",
orderTypeId: type("montage"),
items: [
{ key: "arbeitsbereich_abgesichert", label: "Arbeitsbereich abgesichert", required: true },
{ key: "material_geprueft", label: "Material geprüft", required: false },
{ key: "dichtheit_geprueft", label: "Dichtheitsprüfung durchgeführt", required: true },
{ key: "funktionspruefung", label: "Funktionsprüfung durchgeführt", required: true },
{ key: "kunde_eingewiesen", label: "Kunde eingewiesen", required: false },
],
requiredPhotos: [
{ key: "ausgangszustand", label: "Ausgangszustand" },
{ key: "typenschild", label: "Typenschild" },
{ key: "fertige_montage", label: "Fertige Montage" },
],
},
{
name: "Wartung Heizungsanlage",
orderTypeId: type("wartung"),
items: [
{ key: "brenner_gereinigt", label: "Brenner gereinigt", required: true },
{ key: "abgaswerte_gemessen", label: "Abgaswerte gemessen", required: true },
{ key: "anlagendruck_geprueft", label: "Anlagendruck geprüft", required: true },
],
requiredPhotos: [{ key: "typenschild", label: "Typenschild" }],
},
];
for (const tpl of templates) {
if (!(await admin.db.checklistTemplate.findFirst({ where: { name: tpl.name }, select: { id: true } }))) await createChecklistTemplate(admin, tpl);
}
// ---------- Teams ----------
async function team(name: string, leaderUserId: string, memberIds: string[], extra: { vehicle: string; area: string; phone: string }) {
const existing = await admin.db.team.findFirst({ where: { name, deletedAt: null }, select: { id: true } });
if (existing) return existing.id;
const validFrom = day(-120, 0, 0).toISOString();
return (await createTeam(admin, { name, leaderUserId, ...extra, members: memberIds.map((userId) => ({ userId, validFrom })) })).id;
}
const nord = await team("Team Nord", users.lead, [users.tech, users.tech2], { vehicle: "HH-MB 101", area: "Hamburg Nord, Norderstedt", phone: "040 555 0110" });
const sued = await team("Team Süd", users.lead2, [users.tech3], { vehicle: "HH-MB 202", area: "Hamburg Süd, Harburg", phone: "040 555 0120" });
// ---------- Kunden, Ansprechpartner, Objekte ----------
type CustomerSeed = { number: string; companyName?: string; firstName?: string; lastName?: string; street: string; houseNumber: string; postalCode: string; city: string; phone: string; email?: string; contact?: { name: string; role: string; phone: string } };
const customerSeeds: CustomerSeed[] = [
{ number: "K-D001", companyName: "Hausverwaltung Elbblick GmbH", street: "Große Elbstraße", houseNumber: "140", postalCode: "22767", city: "Hamburg", phone: "040 555 0201", email: "service@elbblick-hv.example", contact: { name: "Frauke Albers", role: "Objektbetreuung", phone: "040 555 0202" } },
{ number: "K-D002", companyName: "Bäckerei Morgenrot KG", street: "Osterstraße", houseNumber: "38", postalCode: "20259", city: "Hamburg", phone: "040 555 0211", contact: { name: "Hannes Korn", role: "Inhaber", phone: "0171 555 0212" } },
{ number: "K-D003", companyName: "Wohnbau Alstertal eG", street: "Wellingsbütteler Landstraße", houseNumber: "7", postalCode: "22391", city: "Hamburg", phone: "040 555 0221", email: "technik@alstertal-eg.example", contact: { name: "Deniz Yilmaz", role: "Technik", phone: "040 555 0222" } },
{ number: "K-D004", companyName: "Praxis am Stadtpark", street: "Jarrestraße", houseNumber: "12", postalCode: "22303", city: "Hamburg", phone: "040 555 0231" },
{ number: "K-D005", firstName: "Greta", lastName: "Musterfrau", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg", phone: "040 555 0241", email: "g.musterfrau@example.org" },
{ number: "K-D006", companyName: "Logistikpark Harburg GmbH", street: "Seehafenstraße", houseNumber: "20", postalCode: "21079", city: "Hamburg", phone: "040 555 0251", contact: { name: "Sören Petersen", role: "Facility Management", phone: "0160 555 0252" } },
{ number: "K-D007", companyName: "Kita Sonnenblume e. V.", street: "Hallerstraße", houseNumber: "55", postalCode: "20146", city: "Hamburg", phone: "040 555 0261" },
];
const customers = new Map<string, { id: string; contactId: string | null }>();
for (const c of customerSeeds) {
let row = await bo.db.customer.findFirst({ where: { customerNumber: c.number }, select: { id: true } });
if (!row) {
row = await createCustomer(
bo,
{ customerNumber: c.number, companyName: c.companyName, firstName: c.firstName, lastName: c.lastName, street: c.street, houseNumber: c.houseNumber, postalCode: c.postalCode, city: c.city, phone: c.phone, email: c.email, notes: "Demo-Datensatz" },
{ acknowledgeDuplicates: true },
);
}
let contactId: string | null = null;
if (c.contact) {
const existing = await bo.db.contact.findFirst({ where: { customerId: row.id, name: c.contact.name }, select: { id: true } });
contactId = existing?.id ?? (await createContact(bo, row.id, { ...c.contact, preferredChannel: "phone" })).id;
}
customers.set(c.number, { id: row.id, contactId });
}
type SiteSeed = { customer: string; name: string; street: string; houseNumber: string; postalCode: string; city: string; accessNotes: string; parkingNotes?: string; safetyNotes?: string; technicalNotes?: string };
const siteSeeds: SiteSeed[] = [
{ customer: "K-D001", name: "Wohnanlage Elbblick – Haus A", street: "Große Elbstraße", houseNumber: "140a", postalCode: "22767", city: "Hamburg", accessNotes: "Schlüssel im Schlüsseltresor neben dem Eingang, Code beim Büro erfragen.", parkingNotes: "Besucherparkplätze im Hof.", technicalNotes: "Heizungsraum im Keller, Gas-Brennwertkessel Baujahr 2014." },
{ customer: "K-D001", name: "Wohnanlage Elbblick – Haus B", street: "Große Elbstraße", houseNumber: "140b", postalCode: "22767", city: "Hamburg", accessNotes: "Hausmeister Tel. 040 555 0203 (Mo–Fr 7–15 Uhr).", safetyNotes: "Asbestverdacht im Altbau-Keller – vor Bohrarbeiten Büro informieren." },
{ customer: "K-D002", name: "Backstube Osterstraße", street: "Osterstraße", houseNumber: "38", postalCode: "20259", city: "Hamburg", accessNotes: "Lieferanteneingang hinten, ab 4 Uhr besetzt.", parkingNotes: "Ladezone vor dem Haus maximal 30 Minuten." },
{ customer: "K-D002", name: "Filiale Eppendorf", street: "Eppendorfer Baum", houseNumber: "9", postalCode: "20249", city: "Hamburg", accessNotes: "Nur außerhalb der Öffnungszeiten (ab 18 Uhr).", technicalNotes: "Wasseraufbereitung unter der Theke." },
{ customer: "K-D003", name: "Quartier Poppenbüttel – Block 3", street: "Saseler Damm", houseNumber: "21", postalCode: "22391", city: "Hamburg", accessNotes: "Zugang über Tiefgarage, Transponder beim Hausmeister.", safetyNotes: "Dachzugang nur mit Absturzsicherung." },
{ customer: "K-D003", name: "Quartier Poppenbüttel – Block 5", street: "Saseler Damm", houseNumber: "25", postalCode: "22391", city: "Hamburg", accessNotes: "Hauptschlüssel im Büro der eG abholen." },
{ customer: "K-D004", name: "Praxisräume 2. OG", street: "Jarrestraße", houseNumber: "12", postalCode: "22303", city: "Hamburg", accessNotes: "Anmeldung am Empfang, Arbeiten nur mittwochs nachmittags.", safetyNotes: "Röntgenraum nicht betreten." },
{ customer: "K-D005", name: "Einfamilienhaus Lindenallee", street: "Lindenallee", houseNumber: "4", postalCode: "21073", city: "Hamburg", accessNotes: "Kundin ist vormittags zu Hause, Hund im Garten.", parkingNotes: "Einfahrt frei halten." },
{ customer: "K-D006", name: "Halle 1 – Kühllager", street: "Seehafenstraße", houseNumber: "20", postalCode: "21079", city: "Hamburg", accessNotes: "Anmeldung an der Pforte, Besucherausweis erforderlich.", safetyNotes: "Warnweste und Sicherheitsschuhe Pflicht, Staplerverkehr." },
{ customer: "K-D006", name: "Verwaltungsgebäude", street: "Seehafenstraße", houseNumber: "22", postalCode: "21079", city: "Hamburg", accessNotes: "Haustechnikraum im EG, Schlüssel an der Pforte.", technicalNotes: "Lüftungsanlage mit Wärmerückgewinnung." },
{ customer: "K-D007", name: "Kita-Gebäude Hallerstraße", street: "Hallerstraße", houseNumber: "55", postalCode: "20146", city: "Hamburg", accessNotes: "Nur nach Absprache mit der Leitung, Kinder im Haus.", safetyNotes: "Werkzeug nie unbeaufsichtigt lassen." },
{ customer: "K-D007", name: "Gartenhaus / Außenanlage", street: "Hallerstraße", houseNumber: "55", postalCode: "20146", city: "Hamburg", accessNotes: "Tor zum Garten mit Zahlenschloss, Code im Auftrag." },
];
const sites = new Map<string, string>();
for (const s of siteSeeds) {
const customer = customers.get(s.customer)!;
const existing = await bo.db.site.findFirst({ where: { customerId: customer.id, name: s.name, deletedAt: null }, select: { id: true } });
const { customer: _c, ...fields } = s;
void _c;
sites.set(s.name, existing?.id ?? (await createSite(bo, { ...fields, customerId: customer.id, contactId: customer.contactId })).id);
}
// ---------- Helfer für den Einsatz ----------
let created = 0;
const jpeg = await demoJpeg("Demo-Foto");
const signature = await signaturePng();
const at = (d: Date) => d.toISOString();
const work = (c: ServiceCtx, id: string, d: Date) => startSession(c, sessionStartPayload.parse({ workOrderId: id, mode: "work", at: at(d), deviceInfo: "Demo-Seed" }));
const travel = (c: ServiceCtx, id: string, d: Date) => startSession(c, sessionStartPayload.parse({ workOrderId: id, mode: "travel", at: at(d), deviceInfo: "Demo-Seed" }));
const pause = (c: ServiceCtx, id: string, d: Date) => pauseSession(c, sessionControlPayload.parse({ workOrderId: id, at: at(d) }));
const end = (c: ServiceCtx, id: string, d: Date) => endSession(c, sessionControlPayload.parse({ workOrderId: id, at: at(d) }));
const note = (c: ServiceCtx, id: string, kind: NoteKind, text: string) => createNote(c, noteCreatePayload.parse({ workOrderId: id, kind, text }));
const checkAll = async (c: ServiceCtx, id: string) => {
for (const item of await c.db.checklistItem.findMany({ where: { workOrderId: id, checked: false }, select: { id: true } })) {
await toggleChecklistItem(c, checklistTogglePayload.parse({ workOrderId: id, itemId: item.id, checked: true }));
}
};
const photo = async (c: ServiceCtx, id: string, opts: { phase: "before" | "during" | "after"; requirementKey?: string; comment?: string; takenAt?: Date }) => {
const upload = await storeFieldUpload(c, { clientId: randomUUID(), workOrderId: id, kind: "photo" }, { bytes: jpeg, name: `${opts.requirementKey ?? opts.phase}.jpg`, type: "image/jpeg" }, { bytes: jpeg, name: "thumb.jpg", type: "image/jpeg" });
const req = opts.requirementKey ? await c.db.photoRequirement.findFirst({ where: { workOrderId: id, key: opts.requirementKey }, select: { id: true } }) : null;
await attachPhoto(c, photoAttachPayload.parse({ workOrderId: id, documentId: upload.documentId, phase: opts.phase, photoRequirementId: req?.id ?? null, comment: opts.comment, takenAt: opts.takenAt ? at(opts.takenAt) : undefined }));
};
const material = (c: ServiceCtx, id: string, input: Record<string, unknown>) => upsertMaterialUsage(c, materialUpsertPayload.parse({ workOrderId: id, ...input }));
const planMaterial = async (c: ServiceCtx, id: string, index: number, quantity: number, usageStatus: "fully_used" | "partially_used" | "not_used", deviationReason?: string) => {
const plan = (await c.db.materialPlan.findMany({ where: { workOrderId: id }, orderBy: { sortOrder: "asc" }, select: { id: true, unit: true } }))[index];
if (plan) await material(c, id, { materialPlanId: plan.id, quantity, unit: plan.unit, usageStatus, deviationReason });
};
/** Completion report with signature outcome and optional approvals (team lead → backoffice). */
const complete = async (
c: ServiceCtx,
id: string,
opts: { reportDate: Date; workPerformed: string; outcome: "signed" | "customer_absent" | "later"; signer?: string; approve: "none" | "team" | "final"; teamLead: ServiceCtx },
) => {
const { report } = await createCompletionReport(c, { workOrderId: id, reportDate: localDateKey(opts.reportDate, TZ) });
await updateReportTexts(c, { reportId: report.id, texts: { workPerformed: opts.workPerformed, hints: "Anlage läuft störungsfrei.", nextSteps: "Nächste Wartung in 12 Monaten." } });
const confirmationText = "Ich bestätige die Ausführung der aufgeführten Arbeiten.";
if (opts.outcome === "signed") {
const img = await storeFile(c, { bytes: signature, fileName: "unterschrift.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: id } });
await captureSignature(c, { reportId: report.id, outcome: "signed", signerName: opts.signer ?? "Frauke Albers", signerRole: "Objektbetreuung", imageDocumentId: img.id, confirmationText });
} else {
await captureSignature(c, { reportId: report.id, outcome: opts.outcome, reason: opts.outcome === "later" ? "Ansprechpartnerin nicht vor Ort, Unterschrift wird nachgereicht." : "Kunde trotz Terminabsprache nicht angetroffen.", confirmationText });
}
await submitReport(c, { reportId: report.id });
if (opts.approve !== "none") await approveReport(opts.teamLead, { reportId: report.id }, noPdf);
if (opts.approve === "final") await approveReport(bo, { reportId: report.id }, noPdf);
return report.id;
};
type OrderSeed = Omit<CreateWorkOrderInput, "customerId" | "siteId" | "contactId" | "externalOrderNumber"> & { ext: string; customer: string; site?: string };
const order = async (seed: OrderSeed, drive: (id: string) => Promise<void>) => {
const existing = await bo.db.workOrder.findFirst({ where: { externalOrderNumber: seed.ext }, select: { id: true } });
if (existing) return existing.id;
const { ext, customer, site, ...input } = seed;
const cust = customers.get(customer)!;
const wo = await createWorkOrder(bo, { ...input, externalOrderNumber: ext, customerId: cust.id, siteId: site ? sites.get(site)! : null, contactId: cust.contactId });
await drive(wo.id);
created++;
return wo.id;
};
const assign = (id: string, teamId: string, userIds: string[]) => assignWorkOrder(bo, { workOrderId: id, teamId, userIds });
const heating = [
{ name: "Gas-Brennwerttherme 24 kW", articleNumber: "GBT-24", plannedQuantity: 1, unit: "Stk" },
{ name: "Kupferrohr 18 mm", articleNumber: "CU-18", plannedQuantity: 12, unit: "m" },
{ name: "Pressfitting 18 mm", articleNumber: "PF-18", plannedQuantity: 10, unit: "Stk" },
];
const service = [
{ name: "Wartungsset Brenner", articleNumber: "WS-B1", plannedQuantity: 1, unit: "Satz" },
{ name: "Dichtungssatz", articleNumber: "DS-12", plannedQuantity: 2, unit: "Stk" },
];
// ---------- 20 Aufträge über alle Statusgruppen ----------
// Neu
await order({ ext: "DEMO-01", customer: "K-D004", site: "Praxisräume 2. OG", title: "Angebot Waschtisch-Armaturen prüfen", orderTypeId: type("besichtigung"), description: "Besichtigung der Behandlungsräume, Aufnahme der Armaturen.", status: "draft", plannedStart: day(6, 14), plannedEnd: day(6, 16) }, async () => undefined);
await order({ ext: "DEMO-02", customer: "K-D003", site: "Quartier Poppenbüttel – Block 5", title: "Strangsanierung Trinkwasser – Aufmaß", orderTypeId: type("montage"), description: "Aus Auftragsbestätigung übernommen, Prüfung erforderlich.", status: "review_required", plannedStart: day(10, 7), plannedEnd: day(14, 16), materials: heating.slice(1) }, async () => undefined);
// Geplant / kommend
await order({ ext: "DEMO-03", customer: "K-D006", site: "Verwaltungsgebäude", title: "Filterwechsel Lüftungsanlage", orderTypeId: type("wartung"), status: "planned", plannedStart: day(1, 8), plannedEnd: day(1, 12), materials: service }, async () => undefined);
await order({ ext: "DEMO-10", customer: "K-D005", site: "Einfamilienhaus Lindenallee", title: "Heizkörper Wohnzimmer tauschen", orderTypeId: type("montage"), priority: "normal", plannedStart: day(2, 8), plannedEnd: day(2, 15), materials: [{ name: "Flachheizkörper 22/600/1200", articleNumber: "HK-22-12", plannedQuantity: 1, unit: "Stk" }] }, (id) => assign(id, sued, [users.tech3]).then(() => undefined));
await order({ ext: "DEMO-11", customer: "K-D007", site: "Kita-Gebäude Hallerstraße", title: "Jahreswartung Gasheizung", orderTypeId: type("wartung"), plannedStart: day(5, 9), plannedEnd: day(5, 13), materials: service, technicianNotes: "Arbeiten nur in der Mittagsruhe (12–14 Uhr) mit Lärm." }, (id) => assign(id, nord, [users.tech]).then(() => undefined));
// Heute
await order({ ext: "DEMO-04", customer: "K-D002", site: "Backstube Osterstraße", title: "Wasserenthärtung warten", orderTypeId: type("wartung"), plannedStart: day(0, 13), plannedEnd: day(0, 15), materials: service }, (id) => assign(id, nord, [users.tech]).then(() => undefined));
await order({ ext: "DEMO-05", customer: "K-D001", site: "Wohnanlage Elbblick – Haus B", title: "Zirkulationspumpe tauschen", orderTypeId: type("reparatur"), priority: "high", plannedStart: day(0, 10), plannedEnd: day(0, 12), materials: [{ name: "Zirkulationspumpe 25-40", articleNumber: "ZP-2540", plannedQuantity: 1, unit: "Stk" }] }, async (id) => {
await assign(id, nord, [users.tech2]);
await transitionWorkOrder(tech2, { workOrderId: id, to: "accepted" });
});
// DEMO-06/DEMO-07 (laufende Uhren von Sven Südfeld und Max Monteur) werden erst nach DEMO-19 angelegt: seit
// L12 darf ein Nutzer nur eine laufende Uhr haben – spätere Einsätze derselben Monteure würden sonst kollidieren.
await order({ ext: "DEMO-08", customer: "K-D002", site: "Filiale Eppendorf", title: "Undichte Leitung unter der Theke", orderTypeId: type("reparatur"), plannedStart: day(0, 7), plannedEnd: day(0, 11) }, async (id) => {
await assign(id, sued, [users.tech3]);
await work(tech3, id, day(0, 7, 5));
await note(tech3, id, "problem", "Leitung hinter Kühlvitrine schwer erreichbar.");
await pause(tech3, id, day(0, 9, 30));
});
await order({ ext: "DEMO-09", customer: "K-D007", site: "Gartenhaus / Außenanlage", title: "Außenwasserhahn frostsicher erneuern", orderTypeId: type("reparatur"), plannedStart: day(0, 8), plannedEnd: day(0, 12), materials: [{ name: "Frostsichere Außenarmatur", articleNumber: "FA-12", plannedQuantity: 1, unit: "Stk" }] }, async (id) => {
await assign(id, nord, [users.tech2]);
await work(tech2, id, day(0, 8, 15));
await note(tech2, id, "problem", "Falsche Armatur geliefert (Länge 150 statt 250 mm).");
await transitionWorkOrder(tech2, { workOrderId: id, to: "waiting_material" });
await pause(tech2, id, day(0, 9));
});
// Überfällig
await order({ ext: "DEMO-12", customer: "K-D001", site: "Wohnanlage Elbblick – Haus B", title: "Thermostatventile Treppenhaus", orderTypeId: type("reparatur"), plannedStart: day(-4, 8), plannedEnd: day(-3, 16) }, (id) => assign(id, nord, [users.tech]).then(() => undefined));
await order({ ext: "DEMO-13", customer: "K-D006", site: "Verwaltungsgebäude", title: "Druckminderer erneuern", orderTypeId: type("reparatur"), plannedStart: day(-3, 8), plannedEnd: day(-2, 12) }, async (id) => {
await assign(id, sued, [users.tech3]);
await transitionWorkOrder(tech3, { workOrderId: id, to: "accepted" });
});
// Mehrtägig mit Tagesbericht
await order({ ext: "DEMO-14", customer: "K-D003", site: "Quartier Poppenbüttel – Block 5", title: "Heizungsverteilung Tiefgarage erneuern (2 Tage)", orderTypeId: type("montage"), plannedStart: day(-1, 7), plannedEnd: day(0, 16), materials: heating.slice(1), applyTemplate: false, checklistItems: [{ label: "Anlage entleert", required: true }, { label: "Druckprobe durchgeführt", required: true }] }, async (id) => {
await assign(id, nord, [users.tech, users.tech2]);
await work(tech, id, day(-1, 7));
await note(tech, id, "work_done", "Tag 1: Alte Verteilung demontiert, neue Konsolen gesetzt.");
await planMaterial(tech, id, 0, 8, "partially_used", "Rest wird an Tag 2 verbaut");
await photo(tech, id, { phase: "during", comment: "Neue Konsolen", takenAt: day(-1, 14) });
await end(tech, id, day(-1, 15, 30));
const { report } = await createDailyReport(tech, { workOrderId: id, reportDate: localDateKey(day(-1), TZ) });
await updateReportTexts(tech, { reportId: report.id, texts: { workPerformed: "Alte Verteilung demontiert, neue Konsolen gesetzt, Rohrleitungen vorbereitet.", openItems: "Anschluss der Heizkreise an Tag 2." } });
await submitReport(tech, { reportId: report.id });
await approveReport(lead, { reportId: report.id }, noPdf);
});
// Dokumentation unvollständig / Zur Prüfung
await order({ ext: "DEMO-15", customer: "K-D004", site: "Praxisräume 2. OG", title: "Boiler Sterilisationsraum tauschen", orderTypeId: type("montage"), plannedStart: day(-2, 13), plannedEnd: day(-2, 17), applyTemplate: false, checklistItems: [{ label: "Funktionsprüfung durchgeführt", required: true }], materials: [{ name: "Untertischspeicher 10 l", articleNumber: "UTS-10", plannedQuantity: 1, unit: "Stk" }] }, async (id) => {
await assign(id, sued, [users.tech3]);
await work(tech3, id, day(-2, 13, 10));
await checkAll(tech3, id);
await planMaterial(tech3, id, 0, 1, "fully_used");
await photo(tech3, id, { phase: "after", comment: "Neuer Speicher montiert", takenAt: day(-2, 16) });
await note(tech3, id, "work_done", "Speicher getauscht, Funktion geprüft.");
await end(tech3, id, day(-2, 16, 30));
await complete(tech3, id, { reportDate: day(-2), workPerformed: "Untertischspeicher demontiert und neu montiert, Dichtheit und Funktion geprüft.", outcome: "later", approve: "none", teamLead: lead2 });
});
await order({ ext: "DEMO-16", customer: "K-D002", site: "Backstube Osterstraße", title: "Sicherheitsventil Warmwasserspeicher", orderTypeId: type("reparatur"), plannedStart: day(-1, 5), plannedEnd: day(-1, 8), applyTemplate: false, checklistItems: [{ label: "Anlage drucklos gemacht", required: true }] }, async (id) => {
await assign(id, nord, [users.tech2]);
await work(tech2, id, day(-1, 5, 10));
await checkAll(tech2, id);
await note(tech2, id, "work_done", "Sicherheitsventil getauscht und Ansprechdruck geprüft.");
await material(tech2, id, { clientId: randomUUID(), name: "Sicherheitsventil 6 bar", articleNumber: "SV-6", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Nicht in der Vorgabe enthalten" });
await end(tech2, id, day(-1, 7, 20));
await complete(tech2, id, { reportDate: day(-1), workPerformed: "Sicherheitsventil am Warmwasserspeicher getauscht, Funktion geprüft.", outcome: "customer_absent", approve: "team", teamLead: lead });
});
await order({ ext: "DEMO-17", customer: "K-D007", site: "Kita-Gebäude Hallerstraße", title: "Spülkasten Kinder-WC reparieren", orderTypeId: type("reparatur"), plannedStart: day(-1, 12), plannedEnd: day(-1, 14), applyTemplate: false, checklistItems: [{ label: "Spülung geprüft", required: true }] }, async (id) => {
await assign(id, nord, [users.tech]);
await work(tech, id, day(-1, 12, 5));
await checkAll(tech, id);
await note(tech, id, "work_done", "Füllventil und Dichtung ersetzt.");
await end(tech, id, day(-1, 13, 10));
await transitionWorkOrder(tech, { workOrderId: id, to: "technically_completed" });
});
// Zur Abrechnung / abgerechnet – beide mit freigegebenem Bericht in der Objekt-Historie von Haus A
const historySite = "Wohnanlage Elbblick – Haus A";
await order({ ext: "DEMO-18", customer: "K-D001", site: historySite, title: "Jahreswartung Gas-Brennwertkessel", orderTypeId: type("wartung"), plannedStart: day(-8, 8), plannedEnd: day(-8, 12), applyTemplate: false, checklistItems: [{ label: "Brenner gereinigt", required: true }, { label: "Abgaswerte gemessen", required: true }], materials: service }, async (id) => {
await assign(id, nord, [users.tech]);
await work(tech, id, day(-8, 8, 5));
await checkAll(tech, id);
await planMaterial(tech, id, 0, 1, "fully_used");
await planMaterial(tech, id, 1, 1, "partially_used", "Zweite Dichtung war noch in Ordnung");
await photo(tech, id, { phase: "after", comment: "Kessel nach Wartung", takenAt: day(-8, 11) });
await note(tech, id, "work_done", "Brenner gereinigt, Zündelektrode eingestellt, Abgaswerte im Sollbereich.");
await note(tech, id, "recommendation", "Ausdehnungsgefäß beim nächsten Termin prüfen.");
await end(tech, id, day(-8, 11, 30));
await complete(tech, id, { reportDate: day(-8), workPerformed: "Jahreswartung durchgeführt: Brenner gereinigt, Zündelektrode eingestellt, Abgasmessung ohne Befund.", outcome: "signed", approve: "final", teamLead: lead });
await releaseForBilling(bo, { workOrderId: id });
});
await order({ ext: "DEMO-19", customer: "K-D001", site: historySite, title: "Heizungspumpe erneuern", orderTypeId: type("reparatur"), plannedStart: day(-21, 9), plannedEnd: day(-21, 13), applyTemplate: false, checklistItems: [{ label: "Anlage entlüftet", required: true }], materials: [{ name: "Hocheffizienzpumpe 25-60", articleNumber: "HP-2560", plannedQuantity: 1, unit: "Stk" }] }, async (id) => {
await assign(id, sued, [users.tech3]);
await work(tech3, id, day(-21, 9, 10));
await checkAll(tech3, id);
await planMaterial(tech3, id, 0, 1, "fully_used");
await photo(tech3, id, { phase: "after", comment: "Neue Pumpe eingebaut", takenAt: day(-21, 12) });
await note(tech3, id, "work_done", "Heizungspumpe getauscht, Anlage entlüftet und hydraulisch abgeglichen.");
await note(tech3, id, "follow_up", "Thermostatköpfe im 3. OG klemmen – separaten Auftrag anlegen.");
await end(tech3, id, day(-21, 12, 40));
await complete(tech3, id, { reportDate: day(-21), workPerformed: "Heizungspumpe durch Hocheffizienzpumpe ersetzt, Anlage entlüftet.", outcome: "signed", signer: "Frauke Albers", approve: "final", teamLead: lead2 });
await releaseForBilling(bo, { workOrderId: id });
await markBilled(bo, { workOrderId: id });
});
// Heute laufende Uhren (nach allen vergangenen Einsätzen derselben Monteure, s. o.)
await order({ ext: "DEMO-06", customer: "K-D006", site: "Halle 1 – Kühllager", title: "Störung Kältemittelleitung", orderTypeId: type("stoerung"), priority: "urgent", plannedStart: day(0, 9), plannedEnd: day(0, 13) }, async (id) => {
await assign(id, sued, [users.tech3]);
await travel(tech3, id, day(0, 8, 30));
});
await order({ ext: "DEMO-07", customer: "K-D003", site: "Quartier Poppenbüttel – Block 3", title: "Montage Gas-Brennwerttherme", orderTypeId: type("montage"), plannedStart: day(0, 7), plannedEnd: day(0, 17), materials: heating, scope: "Demontage Altgerät, Montage Neugerät, Abgasanschluss, Inbetriebnahme." }, async (id) => {
await assign(id, nord, [users.tech]);
await travel(tech, id, day(0, 6, 45));
await work(tech, id, day(0, 7, 10));
await photo(tech, id, { phase: "before", requirementKey: "ausgangszustand", comment: "Altgerät vor Demontage" });
await note(tech, id, "work_done", "Altgerät demontiert, Anschlüsse verschlossen.");
await note(tech, id, "deviation", "Abgasrohr muss um 30 cm verlängert werden.");
await planMaterial(tech, id, 1, 14, "fully_used", "Leitungsführung um den Schacht");
await material(tech, id, { clientId: randomUUID(), name: "Abgasrohr-Verlängerung DN80", articleNumber: "AG-80-05", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Abgasrohr zu kurz" });
});
// Notdienst mit vorläufigem Kunden (feste Client-IDs → idempotent)
const emergencyExisting = await bo.db.workSession.findFirst({ where: { clientId: "7c1d6a0e-3b1f-4c55-9d2a-00000000d020" }, select: { workOrderId: true } });
if (!emergencyExisting) {
const r = await createEmergencyOrder(tech, {
clientIds: { workOrder: "7c1d6a0e-3b1f-4c55-9d2a-00000000a020", session: "7c1d6a0e-3b1f-4c55-9d2a-00000000d020", customer: "7c1d6a0e-3b1f-4c55-9d2a-00000000c020", site: "7c1d6a0e-3b1f-4c55-9d2a-00000000e020" },
customer: { mode: "new", firstName: "Jonas", lastName: "Beispielmann", phone: "040 555 0299", street: "Heußweg", houseNumber: "71", postalCode: "20255", city: "Hamburg" },
site: { mode: "new", name: "Mehrfamilienhaus Heußweg 71", street: "Heußweg", houseNumber: "71", postalCode: "20255", city: "Hamburg" },
onSiteContact: { name: "Jonas Beispielmann", phone: "040 555 0299" },
reason: "Wasserrohrbruch im Keller – Hauptabsperrung defekt",
startedAt: day(0, 6, 20).toISOString(),
deviceInfo: "Demo-Seed",
});
await note(tech, r.workOrderId, "work_done", "Wasser abgesperrt, Leck provisorisch abgedichtet.");
await material(tech, r.workOrderId, { clientId: randomUUID(), name: "Reparaturschelle 1/2 Zoll", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Notdienst" });
created++;
}
// Import in Prüfung (Prüfmaske): Musterdokument + Extraktion über den Fake-Provider (keine KI nötig)
if (!(await bo.db.importJob.findFirst({ where: { extractedText: { startsWith: "DEMO-IMPORT" } }, select: { id: true } }))) {
const samplePath = join(process.cwd(), "docs", "craftvia", "samples", "02-elbblick-wartungsauftrag.pdf");
const bytes = existsSync(samplePath) ? readFileSync(samplePath) : buildPdf([[{ text: "Wartungsauftrag Hausverwaltung Elbblick GmbH" }]]);
const job = await createImport(bo, { bytes, fileName: "wartungsauftrag-elbblick.pdf", mimeType: "application/pdf" }, { dispatch: async () => undefined });
await processImport(bo, job.id, {
provider: new FakeExtractionProvider({
text: "DEMO-IMPORT Wartungsauftrag Hausverwaltung Elbblick GmbH, Große Elbstraße 140, 22767 Hamburg",
extraction: {
orderNumber: { value: "WA-2026-117", confidence: 0.96 },
customerNumber: { value: "K-D001", confidence: 0.91 },
companyName: { value: "Hausverwaltung Elbblick GmbH", confidence: 0.95 },
customerAddress: { value: { street: "Große Elbstr.", houseNumber: "140", postalCode: "22767", city: "Hamburg", country: "DE" }, confidence: 0.9 },
siteName: { value: "Wohnanlage Elbblick – Haus B", confidence: 0.72, source: "Objekt: Haus B" },
siteAddress: { value: { street: "Große Elbstraße", houseNumber: "140b", postalCode: "22767", city: "Hamburg" }, confidence: 0.88 },
contactName: { value: "Frauke Albers", confidence: 0.83 },
title: { value: "Wartung Warmwasserbereitung", confidence: 0.86 },
plannedStart: { value: localDateKey(day(12), TZ), confidence: 0.64, source: "KW-Angabe unsicher" },
positions: { value: [{ name: "Wartungsset Speicher", articleNumber: "WS-S2", quantity: 1, unit: "Satz", isMaterial: true }, { name: "Arbeitszeit", quantity: 2, unit: "Std", isMaterial: false }], confidence: 0.8 },
},
}),
loadBytes: async () => bytes,
});
}
// ---------- L17 Pakete: Profi mit 3 Lotse-Chat-Plätzen für die Demo-Monteure (idempotent) ----------
await seedDemoPlan(tenantId, { tier: "PROFI", seats: 3, seatUserIds: [users.tech, users.tech2, users.tech3], assignedById: users.admin });
const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } });
return { orders, created };
}
/**
* L17 Pakete: package tier + chat seats of a demo tenant. Idempotent (seat rows per user are unique);
* existing seats of other users are left alone.
*/
export async function seedDemoPlan(tenantId: string, opts: { tier: "BASIS" | "PROFI"; seats: number; seatUserIds?: string[]; assignedById?: string }): Promise<void> {
await prisma.tenant.update({ where: { id: tenantId }, data: { tier: opts.tier, lotseChatSeats: opts.seats, lotseChatHardLimit: false } });
const db = dbForTenant(tenantId);
for (const userId of opts.seatUserIds ?? []) {
const existing = await db.lotseChatSeat.findFirst({ where: { userId }, select: { id: true } });
if (!existing) await db.lotseChatSeat.create({ data: { tenantId, userId, assignedById: opts.assignedById ?? null } });
}
}
/** Isolation fixture for the second tenant: one customer + one work order. */
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> {
const admin = ctxOf(tenantId, adminUserId, "tenant-admin");
let customer = await admin.db.customer.findFirst({ where: { customerNumber: "K-N001" }, select: { id: true } });
if (!customer) {
customer = await createCustomer(admin, { customerNumber: "K-N001", companyName: "Werft Nordlicht GmbH", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", phone: "0431 555 0101", notes: "Demo-Datensatz (Isolationsprüfung)" }, { acknowledgeDuplicates: true });
}
if (!(await admin.db.workOrder.findFirst({ where: { externalOrderNumber: "DEMO2-01" }, select: { id: true } }))) {
const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." });
await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) });
}
// L17 Pakete: demo2 = Basis (zeigt die Sperren: keine Planung, Abrechnung, Notdienst, Import, Lotse)
await seedDemoPlan(tenantId, { tier: "BASIS", seats: 0 });
}
/** Close the owner client used by the services (the seed has its own client). */
export async function closeDemoSeed(): Promise<void> {
await prisma.$disconnect();
}