// L16 Lotse-Chat für Monteure – Bestätigen, Bearbeiten, Verwerfen, „Alle bestätigen“. // // Deckt ab: Ausführung ausschließlich über bestehende Services mit dem Monteur-ctx (Status, Session, Zeitnachtrag mit // Freigabepflicht, Material, Notiz, Berichtsvorschlag im L9-Mechanismus), Blocker (Pflichtfoto) → fehlgeschlagen mit // Sprungziel und ohne Teiländerung, Einmaligkeit (Doppeltipp, parallel), Ablauf, fremder Nutzer / Mandant B → not_found, // Monteur ohne Zuweisung → Service verweigert, manipulierte Nutzlast, Bearbeiten (nur erlaubte Felder), Audit mit Quelle, // Reihenfolge und Stopp beim ersten Fehler, Einstellung aus → blocked. // // Lauf: npx tsx scripts/test-lotse-chat-confirm.ts import "dotenv/config"; import { prisma, dbForTenant } from "../src/server/db"; import type { ProposalKind } from "../src/lib/lotse/chat"; import { sessionStartPayload } from "../src/lib/sync/ops"; import { toWallTimeInput } from "../src/lib/work-orders/time"; import { parseReportContent } from "../src/lib/reports/content"; import type { ServiceCtx } from "../src/server/services/context"; import { startSession } from "../src/server/services/field/sessions"; import { createDailyReport } from "../src/server/services/reports/create"; import { decideLotseSuggestion } from "../src/server/services/lotse/suggestions"; import { updateLotseSettings } from "../src/server/services/lotse/settings"; import { confirmAllProposals, confirmProposal, discardProposal } from "../src/server/services/lotse/chat/confirm"; import { createProposal, hashMatches } from "../src/server/services/lotse/chat/proposals"; import { getChatView } from "../src/server/services/lotse/chat/conversations"; import { cleanupChatTenants, createChatFixture, expectErr, failures, ok } from "./lib/lotse-chat-fixture"; const PREFIX = "lotsechat-confirm"; const TZ = "Europe/Berlin"; async function main() { const f = await createChatFixture(PREFIX); const { wo1, wo2, wo3, wo4 } = f.orders; const conversations = new Map(); async function conversationOf(ctx: ServiceCtx) { if (!conversations.has(ctx.userId)) { const c = await prisma.lotseConversation.create({ data: { tenantId: ctx.tenantId, userId: ctx.userId } }); conversations.set(ctx.userId, c.id); } return conversations.get(ctx.userId)!; } async function card(ctx: ServiceCtx, kind: ProposalKind, workOrderId: string, payload: Record, messageId?: string) { const conversationId = await conversationOf(ctx); const p = await createProposal(ctx, { conversationId, kind, payload: { workOrderId, ...payload }, workOrderId, now: new Date() }); if (messageId) await prisma.lotseActionProposal.update({ where: { id: p.id }, data: { messageId } }); return p; } const slot = (startMinutesAgo: number, endMinutesAgo: number) => { const s = toWallTimeInput(new Date(Date.now() - startMinutesAgo * 60_000), TZ); const e = toWallTimeInput(new Date(Date.now() - endMinutesAgo * 60_000), TZ); return { date: s.slice(0, 10), from: s.slice(11, 16), to: e.slice(11, 16), durationMinutes: startMinutesAgo - endMinutesAgo }; }; console.log("\n— Status über transitionWorkOrder / Sessions —"); const accept = await card(f.ctx.tech2, "transition_work_order", wo2.id, { number: "A-LC-2", action: "accept" }); const r1 = await confirmProposal(f.ctx.tech2, { proposalId: accept.id }); ok(r1.status === "confirmed", "Annehmen bestätigt"); ok((await prisma.workOrder.findUnique({ where: { id: wo2.id } }))?.status === "accepted", "Auftrag → accepted (Service)"); ok((await prisma.workOrderStatusChange.count({ where: { workOrderId: wo2.id, toStatus: "accepted", actorId: f.users.tech2.id } })) === 1, "Statuswechsel mit Monteur als Akteur"); const again = await confirmProposal(f.ctx.tech2, { proposalId: accept.id }); ok(again.status === "confirmed" && again.idempotent === true, "Doppeltipp → idempotent"); const auditRow = await prisma.auditLog.findFirst({ where: { tenantId: f.tA.id, entity: "lotse_action_proposal", entityId: accept.id } }); ok((auditRow?.after as { source?: string; status?: string } | null)?.source === "lotse_chat" && (auditRow?.before as { status?: string } | null)?.status === "proposed", "Audit before/after mit Quelle lotse_chat"); const start = await card(f.ctx.tech2, "transition_work_order", wo2.id, { number: "A-LC-2", action: "start_work" }); ok((await confirmProposal(f.ctx.tech2, { proposalId: start.id })).status === "confirmed", "Arbeit starten bestätigt"); ok((await prisma.workSession.count({ where: { workOrderId: wo2.id, userId: f.users.tech2.id, status: "running" } })) === 1, "laufende Session angelegt"); ok((await prisma.workOrder.findUnique({ where: { id: wo2.id } }))?.status === "in_progress", "Auftrag → in_progress"); console.log("\n— Zeitnachtrag nach L12-Regeln —"); const time = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(95, 65) }); ok((await confirmProposal(f.ctx.tech2, { proposalId: time.id })).status === "confirmed", "Zeit bestätigt"); const entry = await prisma.timeEntry.findFirst({ where: { userId: f.users.tech2.id, source: "manual" }, orderBy: { createdAt: "desc" } }); ok(entry?.approvalStatus === "pending" && entry.correctionReason === "Uhr vergessen", "Nachtrag pending (Freigabepflicht) mit Grund"); ok(((await prisma.lotseActionProposal.findUnique({ where: { id: time.id } }))?.result as { approvalStatus?: string } | null)?.approvalStatus === "pending", "Ergebnis der Karte: zur Freigabe"); const edited = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(160, 130) }); ok((await confirmProposal(f.ctx.tech2, { proposalId: edited.id, edits: { reason: "Kein Netz", workOrderId: wo1.id, number: "X" } })).status === "confirmed", "bearbeitete Zeit bestätigt"); const editedEntry = await prisma.timeEntry.findFirst({ where: { userId: f.users.tech2.id, source: "manual" }, orderBy: { createdAt: "desc" }, include: { workSession: { select: { workOrderId: true } } } }); ok(editedEntry?.correctionReason === "Kein Netz" && editedEntry.workSession.workOrderId === wo2.id, "Änderung übernommen, Auftrag nicht änderbar"); const editedRow = await prisma.lotseActionProposal.findUniqueOrThrow({ where: { id: edited.id } }); ok((editedRow.payload as { reason?: string }).reason === "Kein Netz" && hashMatches(editedRow), "Nutzlast + Hash nach Bearbeitung aktualisiert"); const editAudit = await prisma.auditLog.findFirst({ where: { entity: "lotse_action_proposal", entityId: edited.id } }); ok((editAudit?.after as { edited?: boolean } | null)?.edited === true, "Audit: bearbeitet"); const overlapCard = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(90, 70) }); const overlap = await confirmProposal(f.ctx.tech2, { proposalId: overlapCard.id }); ok(overlap.status === "failed" && overlap.code === "overlap", `Überschneidung beim Bestätigen → fehlgeschlagen (${overlap.code})`); console.log("\n— Material, Notiz, Einmaligkeit —"); const mat = await card(f.ctx.tech, "record_material", wo1.id, { number: "A-LC-1", materialPlanId: f.plan.id, name: "Filter", quantity: 1, unit: "Stk", usageStatus: "fully_used" }); ok((await confirmProposal(f.ctx.tech, { proposalId: mat.id })).status === "confirmed", "Material bestätigt"); const usage = await prisma.materialUsage.findFirst({ where: { workOrderId: wo1.id, materialPlanId: f.plan.id } }); ok(usage?.usageStatus === "fully_used" && Number(usage.actualQuantity) === 1 && usage.recordedById === f.users.tech.id, "MaterialUsage über Service erfasst"); const note = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "work_done", text: "Speicher installiert" }); const notesBefore = await prisma.activityNote.count({ where: { workOrderId: wo1.id } }); const parallel = await Promise.all([confirmProposal(f.ctx.tech, { proposalId: note.id }), confirmProposal(f.ctx.tech, { proposalId: note.id })]); ok(parallel.every((r) => r.status === "confirmed") && parallel.some((r) => r.idempotent), "paralleler Doppeltipp: einmal ausgeführt, einmal idempotent"); ok((await prisma.activityNote.count({ where: { workOrderId: wo1.id } })) === notesBefore + 1, "genau eine Notiz angelegt"); console.log("\n— Blocker → Rückfrage mit Sprungziel —"); await startSession(f.ctx.tech, sessionStartPayload.parse({ workOrderId: wo3.id, mode: "work" })); const complete = await card(f.ctx.tech, "transition_work_order", wo3.id, { number: "A-LC-3", action: "complete" }); const blocked = await confirmProposal(f.ctx.tech, { proposalId: complete.id }); ok(blocked.status === "failed" && blocked.code === "blocked", `Abschluss mit fehlendem Pflichtfoto → fehlgeschlagen (${blocked.code})`); ok((await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "in_progress", "Auftrag unverändert"); ok((await prisma.workSession.count({ where: { workOrderId: wo3.id, userId: f.users.tech.id, status: "running" } })) === 1, "Session nicht beendet (Blocker vor jeder Änderung geprüft)"); const view = await getChatView(f.ctx.tech, { conversationId: await conversationOf(f.ctx.tech) }); const failNotice = view.messages.find((m) => m.role === "tool" && m.content.noticeKey === "failed"); ok(Boolean(failNotice?.content.links?.some((l) => l.href === `/m/orders/${wo3.id}/photos` && l.label === "Fertige Montage")), "Lotse-Antwort mit Sprungziel zum Pflichtfoto"); await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: complete.id }), "conflict", "fehlgeschlagene Karte nicht erneut ausführbar", "already_decided"); console.log("\n— Ablauf, fremde Nutzer, Manipulation, Verwerfen —"); const late = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "zu spät" }); await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: late.id }, new Date(Date.now() + 31 * 60_000)), "conflict", "nach 30 Minuten abgelaufen", "expired"); ok((await prisma.lotseActionProposal.findUnique({ where: { id: late.id } }))?.status === "expired", "Status expired gespeichert"); const foreign = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "fremd" }); await expectErr(() => confirmProposal(f.ctx.tech2, { proposalId: foreign.id }), "not_found", "anderer Monteur (gleicher Auftrag) → not_found"); await expectErr(() => confirmProposal(f.ctx.admin, { proposalId: foreign.id }), "not_found", "Admin bestätigt keine fremde Karte"); await expectErr(() => confirmProposal(f.ctx.techB, { proposalId: foreign.id }), "not_found", "Mandant B → not_found"); await expectErr(() => discardProposal(f.ctx.techB, { proposalId: foreign.id }), "not_found", "Mandant B verwirft nicht"); ok((await dbForTenant(f.tB.id).lotseActionProposal.findFirst({ where: { id: foreign.id } })) === null, "B-Client sieht die Karte nicht"); await prisma.lotseActionProposal.update({ where: { id: foreign.id }, data: { payload: { ...(foreign.payload as object), text: "manipuliert" } } }); await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: foreign.id }), "conflict", "veränderte Nutzlast → nicht ausgeführt", "tampered"); ok((await prisma.activityNote.count({ where: { text: "manipuliert" } })) === 0, "keine manipulierte Notiz"); const discard = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "verwerfen" }); ok((await discardProposal(f.ctx.tech, { proposalId: discard.id })).status === "discarded", "verworfen"); ok((await discardProposal(f.ctx.tech, { proposalId: discard.id })).status === "discarded", "Verwerfen idempotent"); await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: discard.id }), "conflict", "verworfene Karte nicht bestätigbar", "already_decided"); ok((await prisma.activityNote.count({ where: { text: "verwerfen" } })) === 0, "nichts ausgeführt"); const notAssigned = await card(f.ctx.outsider, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "ohne Zuweisung" }); const outsiderResult = await confirmProposal(f.ctx.outsider, { proposalId: notAssigned.id }); ok(outsiderResult.status === "failed" && outsiderResult.code === "not_found", "Monteur ohne Zuweisung: Service verweigert (not_found)"); ok((await prisma.activityNote.count({ where: { text: "ohne Zuweisung" } })) === 0, "keine Notiz ohne Zuweisung"); console.log("\n— Alle bestätigen —"); const conv = await conversationOf(f.ctx.tech); const msg = await prisma.lotseMessage.create({ data: { tenantId: f.tA.id, conversationId: conv, role: "assistant", text: "Drei Karten" } }); const pause = await card(f.ctx.tech, "transition_work_order", wo3.id, { number: "A-LC-3", action: "pause" }, msg.id); const noteAll = await card(f.ctx.tech, "add_note", wo3.id, { number: "A-LC-3", kind: "work_done", text: "Pumpe gesetzt" }, msg.id); const badMaterial = await card(f.ctx.tech, "record_material", wo3.id, { number: "A-LC-3", name: "Dichtung", quantity: 2, unit: "Stk", usageStatus: "additional" }, msg.id); const all1 = await confirmAllProposals(f.ctx.tech, { messageId: msg.id }); ok(all1.stopped && all1.results.length === 1 && all1.results[0].proposalId === badMaterial.id && all1.remaining === 2, "Reihenfolge (Material vor Notiz vor Status), Stopp beim ersten Fehler"); ok((await prisma.lotseActionProposal.findUnique({ where: { id: noteAll.id } }))?.status === "proposed" && (await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "in_progress", "nach Fehler nichts weiter ausgeführt"); const stoppedNotice = await prisma.lotseMessage.findFirst({ where: { conversationId: conv, role: "tool" }, orderBy: { createdAt: "desc" } }); ok((stoppedNotice?.content as { noticeKey?: string } | null)?.noticeKey === "stopped", "Klartext-Hinweis „gestoppt“"); const all2 = await confirmAllProposals(f.ctx.tech, { messageId: msg.id }); ok(!all2.stopped && all2.results.map((r) => r.proposalId).join() === [noteAll.id, pause.id].join(), "zweiter Lauf: Notiz, dann Pause"); ok((await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "paused", "Auftrag pausiert"); await expectErr(() => confirmAllProposals(f.ctx.tech2, { messageId: msg.id }), "not_found", "„Alle bestätigen“ fremder Nachricht → not_found"); console.log("\n— Berichtsfelder als Lotse-Vorschlag (L9) —"); const { report } = await createDailyReport(f.ctx.tech, { workOrderId: wo4.id }); const fields = await card(f.ctx.tech, "suggest_report_fields", wo4.id, { number: "A-LC-4", reportId: report.id, reportType: "daily", fields: [{ field: "workPerformed", text: "Regler eingestellt und geprüft" }] }); ok((await confirmProposal(f.ctx.tech, { proposalId: fields.id })).status === "confirmed", "Berichtsvorschlag bestätigt"); const updated = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); const content = parseReportContent(updated.content); ok(updated.aiDrafted && content.lotse?.suggestions.some((s) => s.field === "workPerformed" && s.state === "pending" && s.text === "Regler eingestellt und geprüft") === true, "Vorschlag in content.lotse (pending), aiDrafted"); ok(content.texts.workPerformed === "", "Berichtstext selbst unverändert"); await decideLotseSuggestion(f.ctx.tech, { reportId: report.id, field: "workPerformed", decision: "accept" }); ok(parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content).texts.workPerformed === "Regler eingestellt und geprüft", "im Bericht über den L9-Mechanismus übernommen"); console.log("\n— Einstellung aus —"); const offCard = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "aus" }); await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: false }); await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: offCard.id }), "blocked", "Lotse-Chat aus → Bestätigen blocked", "chat_disabled"); await updateLotseSettings(f.ctx.admin, { enabled: false, addressForm: "neutral", chatEnabled: true }); await expectErr(() => discardProposal(f.ctx.tech, { proposalId: offCard.id }), "blocked", "Lotse-Modul aus → Verwerfen blocked", "disabled"); await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: true }); await cleanupChatTenants(PREFIX); console.log(failures ? `\n${failures} Fehler` : "\nAlle Prüfungen grün."); await prisma.$disconnect(); process.exit(failures ? 1 : 0); } main().catch(async (err) => { console.error(err); await cleanupChatTenants(PREFIX).catch(() => undefined); await prisma.$disconnect(); process.exit(1); });