L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests
- services/field: Einsatz-Sessions (Anfahrt/Arbeit/Pause als TimeEntry-Segmente, eine aktive Session je User+Auftrag), Zeitkorrektur mit Recht + Grund + Audit, Checkliste, Material (Abweichung nur mit Begründung, Zusatzmaterial), Notizen, Fotos, Sprachnotizen (ohne Transkriptions-Processor Status disabled), Uploads (idempotent je Mandant), autorisierte Dokument-Auslieferung, Lesemodelle + Bundle - services/sync: applyOperations mit Idempotenz, baseVersion-Konfliktprüfung, Registry für Ops anderer Lanes, lane-lokaler requireApiContext - /api/v1/sync, /api/v1/uploads, /api/v1/field/bundle, /api/v1/field/documents/[id] - lib/sync/ops.ts (Zod-Payloads je opType), lib/field/material-rules.ts - Stubs mit Vertragssignatur: transitionWorkOrder (L2), storeFile (§4.3), getSiteHistory (L1) - Processor image-derivatives + Registrierung, Audit-Entity-Labels - Tests: test-einsatz-field (48 Prüfungen), test-einsatz-sync (38 Prüfungen) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
// Shared fixture of the lane L4 tests (scripts/test-einsatz-*.ts): two zz test tenants with
|
||||
// users, customer, site, team and work orders. Not a test itself (the runner only picks up
|
||||
// scripts/test-*.ts at top level).
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma, dbForTenant } from "../../src/server/db";
|
||||
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
|
||||
import type { ServiceCtx } from "../../src/server/services/context";
|
||||
|
||||
export let failures = 0;
|
||||
export const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
/** Expects `fn` to throw a ServiceError with the given code. */
|
||||
export async function expectCode(fn: () => Promise<unknown>, code: string, msg: string) {
|
||||
try {
|
||||
await fn();
|
||||
ok(false, `${msg} — kein Fehler (erwartet ${code})`);
|
||||
} catch (err) {
|
||||
const actual = (err as { code?: string }).code;
|
||||
ok(actual === code, `${msg}${actual === code ? "" : ` — Code ${actual ?? (err as Error).message}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
|
||||
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
|
||||
}
|
||||
|
||||
async function cleanupSlug(slug: string, emailDomain: string) {
|
||||
const tenant = await prisma.tenant.findUnique({ where: { slug }, select: { id: true } });
|
||||
if (tenant) {
|
||||
const where = { tenantId: tenant.id };
|
||||
await prisma.auditLog.deleteMany({ where });
|
||||
await prisma.syncOperation.deleteMany({ where });
|
||||
await prisma.photo.deleteMany({ where });
|
||||
await prisma.voiceNote.deleteMany({ where });
|
||||
await prisma.document.deleteMany({ where });
|
||||
await prisma.workOrder.deleteMany({ where });
|
||||
await prisma.teamMember.deleteMany({ where });
|
||||
await prisma.team.deleteMany({ where });
|
||||
await prisma.site.deleteMany({ where });
|
||||
await prisma.contact.deleteMany({ where });
|
||||
await prisma.customer.deleteMany({ where });
|
||||
await prisma.numberSequence.deleteMany({ where });
|
||||
await prisma.userRole.deleteMany({ where: { user: { tenantId: tenant.id } } });
|
||||
await prisma.user.deleteMany({ where });
|
||||
await prisma.role.deleteMany({ where });
|
||||
await prisma.tenant.delete({ where: { id: tenant.id } });
|
||||
}
|
||||
await prisma.identity.deleteMany({ where: { email: { endsWith: emailDomain }, memberships: { none: {} } } });
|
||||
}
|
||||
|
||||
async function user(tenantId: string, email: string, name: string) {
|
||||
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
|
||||
return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name } });
|
||||
}
|
||||
|
||||
export type Fixture = Awaited<ReturnType<typeof createFixture>>;
|
||||
|
||||
export async function createFixture(prefix: string) {
|
||||
const slugA = `zz-${prefix}-a`;
|
||||
const slugB = `zz-${prefix}-b`;
|
||||
const domain = `@zz-${prefix}.test`;
|
||||
await cleanupSlug(slugA, domain);
|
||||
await cleanupSlug(slugB, domain);
|
||||
|
||||
const tenantA = await prisma.tenant.create({ data: { name: `L4 ${prefix} A`, slug: slugA } });
|
||||
const tenantB = await prisma.tenant.create({ data: { name: `L4 ${prefix} B`, slug: slugB } });
|
||||
|
||||
const tech = await user(tenantA.id, `tech${domain}`, "Tech A");
|
||||
const lead = await user(tenantA.id, `lead${domain}`, "Lead A");
|
||||
const outsider = await user(tenantA.id, `outsider${domain}`, "Outsider A");
|
||||
const techB = await user(tenantB.id, `techb${domain}`, "Tech B");
|
||||
|
||||
const customerA = await prisma.customer.create({ data: { tenantId: tenantA.id, companyName: "Kunde A GmbH", city: "Hamburg" } });
|
||||
const siteA = await prisma.site.create({ data: { tenantId: tenantA.id, customerId: customerA.id, name: "Halle 1", street: "Hafenstraße", houseNumber: "1", postalCode: "20457", city: "Hamburg", accessNotes: "Schlüssel beim Pförtner" } });
|
||||
const team = await prisma.team.create({ data: { tenantId: tenantA.id, name: `Team ${prefix}`, leaderUserId: lead.id } });
|
||||
|
||||
const now = new Date();
|
||||
const orderA = await prisma.workOrder.create({
|
||||
data: {
|
||||
tenantId: tenantA.id,
|
||||
number: `A-${prefix}-1`,
|
||||
customerId: customerA.id,
|
||||
siteId: siteA.id,
|
||||
title: "Heizung montieren",
|
||||
status: "assigned",
|
||||
plannedStart: new Date(now.getTime() - 60 * 60 * 1000),
|
||||
plannedEnd: new Date(now.getTime() + 60 * 60 * 1000),
|
||||
teamLeadUserId: lead.id,
|
||||
assignees: { create: [{ tenantId: tenantA.id, userId: tech.id }] },
|
||||
checklistItems: { create: [{ tenantId: tenantA.id, key: "safe", label: "Arbeitsbereich abgesichert", required: true }] },
|
||||
photoRequirements: { create: [{ tenantId: tenantA.id, key: "typenschild", label: "Typenschild" }] },
|
||||
materialPlans: { create: [{ tenantId: tenantA.id, name: "Kupferrohr 15 mm", plannedQuantity: new Prisma.Decimal(10), unit: "m" }] },
|
||||
},
|
||||
include: { checklistItems: true, photoRequirements: true, materialPlans: true },
|
||||
});
|
||||
|
||||
const customerB = await prisma.customer.create({ data: { tenantId: tenantB.id, companyName: "Kunde B AG" } });
|
||||
const orderB = await prisma.workOrder.create({
|
||||
data: {
|
||||
tenantId: tenantB.id,
|
||||
number: `A-${prefix}-B1`,
|
||||
customerId: customerB.id,
|
||||
title: "Wartung B",
|
||||
status: "assigned",
|
||||
assignees: { create: [{ tenantId: tenantB.id, userId: techB.id }] },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
tenantA,
|
||||
tenantB,
|
||||
tech,
|
||||
lead,
|
||||
outsider,
|
||||
techB,
|
||||
team,
|
||||
siteA,
|
||||
orderA,
|
||||
orderB,
|
||||
ctxTech: ctxFor(tenantA.id, tech.id, "technician"),
|
||||
ctxLead: ctxFor(tenantA.id, lead.id, "team-lead"),
|
||||
ctxOutsider: ctxFor(tenantA.id, outsider.id, "technician"),
|
||||
ctxB: ctxFor(tenantB.id, techB.id, "technician"),
|
||||
cleanup: async () => {
|
||||
await cleanupSlug(slugA, domain);
|
||||
await cleanupSlug(slugB, domain);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user