Fake-Provider mit geskripteten Tool-Aufrufen: Zuordnung, Vorschlag ohne Schreibzugriff, Ausführung über Services, Blocker, Einmaligkeit/Ablauf/fremder Nutzer, Scope, Mandantentrennung, Datenminimierung, Einstellung aus, Budget- und Rundengrenze. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
16 KiB
TypeScript
187 lines
16 KiB
TypeScript
// 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<string, string>();
|
||
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<string, unknown>, 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);
|
||
});
|