diff --git a/prisma/seed.ts b/prisma/seed.ts index c068b18..4a47836 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -8,7 +8,9 @@ import { provisionTenant } from "../src/server/provision"; /** * Craftvia-Demo-Seed (Fundament): Plattform-Admin + Mandant `demo` mit je einem Nutzer * pro Standardrolle + zweiter Mandant `demo2` (Isolations-/Tenant-Switch-Tests) + - * Multi-Membership-Fixture. KEINE Fachdaten (Kunden/Aufträge liefern die Module). + * Multi-Membership-Fixture. Fachliche Demo-Daten (Teams, Kunden, Objekte, Aufträge, Berichte, + * Notdienst) kommen aus scripts/lib/demo-seed.ts — nur mit SEED_DEMO=true oder außerhalb von + * Production (SEED_DEMO=false schaltet sie ab). * * Idempotent (upserts) — beliebig oft ausführbar. Passwort: SEED_PASSWORD oder Default. */ @@ -66,8 +68,13 @@ async function main() { { email: "backoffice@demo.example", name: "Bernd Backoffice", roles: ["backoffice"] }, { email: "teamleiter@demo.example", name: "Tina Teamleiter", roles: ["team-lead"] }, { email: "monteur@demo.example", name: "Max Monteur", roles: ["technician"] }, + // L10a Demo-Teams: Team Nord (Tina + Max + Nora), Team Süd (Sven + Paul) + { email: "teamleiter2@demo.example", name: "Sven Südfeld", roles: ["team-lead"] }, + { email: "monteur2@demo.example", name: "Nora Nordmann", roles: ["technician"] }, + { email: "monteur3@demo.example", name: "Paul Petersen", roles: ["technician"] }, ]; - for (const u of demoUsers) await ensureMember(demo.id, u, passwordHash); + const members: Record = {}; + for (const u of demoUsers) members[u.email] = (await ensureMember(demo.id, u, passwordHash)).id; console.log(`✔ Mandant "demo" (${PERMISSIONS.length} Permissions) mit ${demoUsers.length} Nutzern (Passwort: ${DEMO_PASSWORD})`); // 2. Zweiter Mandant „demo2" — Grundlage für Isolations- und Tenant-Switch-Tests. @@ -93,10 +100,36 @@ async function main() { create: { email: "platform@demo.example", name: "Paula Plattform", passwordHash, status: "ACTIVE" }, }); console.log(`✔ Plattform-Admin platform@demo.example (/platform/login)`); + + // 5. L10a: fachliche Demo-Daten (Teams, Kunden, Objekte, 20 Aufträge, Berichte, Notdienst). + // Nur mit SEED_DEMO=true oder außerhalb von Production; SEED_DEMO=false schaltet sie ab. + const demoData = process.env.SEED_DEMO === "true" || (process.env.NODE_ENV !== "production" && process.env.SEED_DEMO !== "false"); + if (demoData) { + const { seedDemoTenant, seedDemo2Tenant, closeDemoSeed } = await import("../scripts/lib/demo-seed"); + const res = await seedDemoTenant(demo.id, { + admin: members["admin@demo.example"], + backoffice: members["backoffice@demo.example"], + lead: members["teamleiter@demo.example"], + lead2: members["teamleiter2@demo.example"], + tech: members["monteur@demo.example"], + tech2: members["monteur2@demo.example"], + tech3: members["monteur3@demo.example"], + }); + const admin2 = await prisma.user.findUniqueOrThrow({ where: { tenantId_email: { tenantId: demo2.id, email: "admin2@demo.example" } } }); + await seedDemo2Tenant(demo2.id, admin2.id); + await closeDemoSeed(); + console.log(`✔ Demo-Fachdaten: ${res.orders} Demo-Aufträge in "demo" (${res.created} neu angelegt), 1 Auftrag in "demo2"`); + } else { + console.log("• Demo-Fachdaten übersprungen (SEED_DEMO=false bzw. Production ohne SEED_DEMO=true)"); + } } main() - .then(() => prisma.$disconnect()) + .then(async () => { + await prisma.$disconnect(); + // Services may leave queue/redis handles open (events, image jobs) — end the seed explicitly. + process.exit(0); + }) .catch(async (e) => { console.error(e); await prisma.$disconnect(); diff --git a/scripts/lib/demo-seed.ts b/scripts/lib/demo-seed.ts new file mode 100644 index 0000000..768bc75 --- /dev/null +++ b/scripts/lib/demo-seed.ts @@ -0,0 +1,410 @@ +// 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 sharp from "sharp"; +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(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 { + const svg = `${label}`; + return sharp(Buffer.from(svg)).jpeg({ quality: 80 }).toBuffer(); +} + +async function signaturePng(): Promise { + const 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[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(); + 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(); + 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) => 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 & { ext: string; customer: string; site?: string }; + const order = async (seed: OrderSeed, drive: (id: string) => Promise) => { + 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" }); + }); + 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" }); + }); + 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 }); + }); + + // 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++; + } + + const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } }); + return { orders, created }; +} + +/** Isolation fixture for the second tenant: one customer + one work order. */ +export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise { + 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) }); + } +} + +/** Close the owner client used by the services (the seed has its own client). */ +export async function closeDemoSeed(): Promise { + await prisma.$disconnect(); +}