L5 Berichte & Unterschrift: Berichtsinhalt, Services, Unterschrift und PDF

ReportContent-Vertrag, Content-Builder mit Tagesfilter, Services für Tages-/Abschlussbericht,
Bearbeiten, Absenden, Freigabe, Zurückweisen, neue Version, Unterschrift und PDF-Erzeugung
(playwright-core, Worker-Processor, Dockerfile-Stage worker). Stubs für L2-Transition/Blocker
und Dokumenten-Store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:40 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 8a6fdd8f7a
26 changed files with 2129 additions and 3 deletions
@@ -0,0 +1,270 @@
import type { Prisma } from "@prisma/client";
import {
emptyTexts,
type MaterialLine,
type ReportContent,
type ReportTexts,
type ReportType,
type SignatureBlock,
} from "@/lib/reports/content";
import { dayWindow, localDateKey } from "@/lib/reports/dates";
import type { ServiceCtx } from "@/server/services/context";
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
export type BuildContentInput = {
workOrderId: string;
type: ReportType;
/** YYYY-MM-DD in tenant time zone */
reportDate: string;
reportNumber: string;
version: number;
/** keep edited texts; when omitted texts are prefilled from activity notes */
texts?: ReportTexts;
/** id of the report whose Signature row feeds the signature block */
reportId?: string | null;
/** fallback when the report has no Signature row (e.g. copied into a new version) */
previousSignature?: SignatureBlock | null;
technicianUserId: string | null;
now?: Date;
};
export async function tenantTimeZone(ctx: ServiceCtx): Promise<string> {
const s = await ctx.db.tenantSettings.findFirst({ select: { timezone: true } });
return s?.timezone || "Europe/Berlin";
}
const joinLines = (...parts: Array<string | null | undefined>) => parts.filter((p) => p && p.trim()).join("\n");
const dec = (v: Prisma.Decimal | null | undefined) => (v == null ? null : v.toString());
function address(street?: string | null, houseNumber?: string | null, postalCode?: string | null, city?: string | null) {
const line1 = [street, houseNumber].filter(Boolean).join(" ") || null;
const line2 = [postalCode, city].filter(Boolean).join(" ") || null;
return { line1, line2 };
}
/**
* Build the report snapshot from the database (ARCHITEKTUR §4.7).
* Daily report: only time entries, notes, photos and material usages of `reportDate`.
* Completion report: the whole work order.
* Access: the work order must be visible to the caller (workOrderScope).
*/
export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInput): Promise<ReportContent> {
const now = input.now ?? new Date();
await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true });
const timeZone = await tenantTimeZone(ctx);
const daily = input.type === "daily";
const win = dayWindow(input.reportDate, timeZone);
const inDay = daily ? { gte: win.start, lt: win.end } : undefined;
const [wo, settings, tenant, entries, notes, photos, usages, plans, checklist, signature, technician] = await Promise.all([
ctx.db.workOrder.findFirstOrThrow({
where: { id: input.workOrderId },
include: {
customer: true,
site: true,
contact: true,
orderType: { select: { name: true } },
assignees: { include: { user: { select: { id: true, name: true } } } },
},
}),
ctx.db.tenantSettings.findFirst({ select: { orgName: true, address: true, phone: true, email: true } }),
ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { tenant: { select: { name: true } } } }),
ctx.db.timeEntry.findMany({
where: { workSession: { workOrderId: input.workOrderId }, ...(inDay ? { startedAt: inDay } : {}) },
orderBy: { startedAt: "asc" },
select: { userId: true, type: true, startedAt: true, endedAt: true },
}),
ctx.db.activityNote.findMany({
where: { workOrderId: input.workOrderId, deletedAt: null, ...(inDay ? { createdAt: inDay } : {}) },
orderBy: { createdAt: "asc" },
select: { kind: true, text: true },
}),
ctx.db.photo.findMany({
where: { workOrderId: input.workOrderId, includeInReport: true, ...(inDay ? { takenAt: inDay } : {}) },
orderBy: { takenAt: "asc" },
include: { photoRequirement: { select: { label: true } } },
}),
ctx.db.materialUsage.findMany({
where: { workOrderId: input.workOrderId, ...(inDay ? { createdAt: inDay } : {}) },
orderBy: { createdAt: "asc" },
include: { materialPlan: true },
}),
ctx.db.materialPlan.findMany({ where: { workOrderId: input.workOrderId }, orderBy: { sortOrder: "asc" } }),
ctx.db.checklistItem.findMany({ where: { workOrderId: input.workOrderId }, orderBy: { sortOrder: "asc" } }),
input.reportId ? ctx.db.signature.findFirst({ where: { reportId: input.reportId } }) : Promise.resolve(null),
input.technicianUserId ? ctx.db.user.findFirst({ where: { id: input.technicianUserId }, select: { id: true, name: true } }) : Promise.resolve(null),
]);
// ---- people & time ----
const userIds = new Set<string>(entries.map((e) => e.userId));
if (signature?.capturedById) userIds.add(signature.capturedById);
const users = await ctx.db.user.findMany({ where: { id: { in: [...userIds] } }, select: { id: true, name: true } });
const nameOf = new Map(users.map((u) => [u.id, u.name]));
for (const a of wo.assignees) nameOf.set(a.user.id, a.user.name);
const perKey = new Map<string, { userId: string; name: string; type: (typeof entries)[number]["type"]; minutes: number }>();
let hasRunningEntries = false;
for (const e of entries) {
if (!e.endedAt) hasRunningEntries = true;
const minutes = Math.max(0, Math.round(((e.endedAt ?? now).getTime() - e.startedAt.getTime()) / 60_000));
const key = `${e.userId}|${e.type}`;
const cur = perKey.get(key) ?? { userId: e.userId, name: nameOf.get(e.userId) ?? "—", type: e.type, minutes: 0 };
cur.minutes += minutes;
perKey.set(key, cur);
}
const timeLines = [...perKey.values()];
const totalsByType: Record<string, number> = {};
const byPerson = new Map<string, { userId: string; name: string; minutes: number }>();
let totalMinutes = 0;
for (const l of timeLines) {
totalsByType[l.type] = (totalsByType[l.type] ?? 0) + l.minutes;
if (l.type === "break") continue;
totalMinutes += l.minutes;
const p = byPerson.get(l.userId) ?? { userId: l.userId, name: l.name, minutes: 0 };
p.minutes += l.minutes;
byPerson.set(l.userId, p);
}
const staffIds = [...new Set(entries.map((e) => e.userId))];
const staff = (staffIds.length ? staffIds : wo.assignees.map((a) => a.userId)).map((id) => ({ userId: id, name: nameOf.get(id) ?? "—" }));
const workDates = daily
? [input.reportDate]
: [...new Set(entries.map((e) => localDateKey(e.startedAt, timeZone)))].sort();
// ---- texts (prefill from notes on create) ----
const byKind = (...kinds: string[]) => joinLines(...notes.filter((n) => kinds.includes(n.kind)).map((n) => n.text));
const texts: ReportTexts = input.texts ?? {
...emptyTexts(),
workPerformed: byKind("work_done", "general"),
deviations: byKind("deviation"),
additionalWork: byKind("additional_work"),
problems: byKind("problem", "not_executable"),
openItems: byKind("follow_up"),
nextSteps: "",
hints: byKind("recommendation", "customer_note"),
};
// ---- materials ----
const used: MaterialLine[] = [];
const notUsed: MaterialLine[] = [];
const additional: MaterialLine[] = [];
const plansWithUsage = new Set<string>();
for (const u of usages) {
if (u.materialPlanId) plansWithUsage.add(u.materialPlanId);
const planned = u.materialPlan ? dec(u.materialPlan.plannedQuantity) : null;
const quantityDiffers = u.materialPlan ? !u.materialPlan.plannedQuantity.equals(u.actualQuantity) : false;
const line: MaterialLine = {
usageId: u.id,
planId: u.materialPlanId,
name: u.name,
articleNumber: u.articleNumber,
plannedQuantity: planned,
actualQuantity: dec(u.actualQuantity),
unit: u.unit,
status: u.usageStatus,
deviation: u.usageStatus !== "fully_used" || quantityDiffers,
deviationReason: u.deviationReason,
notes: u.notes,
documented: true,
};
if (u.usageStatus === "additional" || !u.materialPlanId) additional.push({ ...line, deviation: true });
else if (u.usageStatus === "not_used") notUsed.push(line);
else used.push(line);
}
if (!daily) {
for (const p of plans) {
if (plansWithUsage.has(p.id)) continue;
notUsed.push({
usageId: null,
planId: p.id,
name: p.name,
articleNumber: p.articleNumber,
plannedQuantity: dec(p.plannedQuantity),
actualQuantity: null,
unit: p.unit,
status: null,
deviation: true,
deviationReason: null,
notes: p.notes,
documented: false,
});
}
}
// ---- signature ----
let signatureBlock: SignatureBlock | null = input.previousSignature ?? null;
if (signature) {
signatureBlock = {
outcome: signature.outcome,
signerName: signature.signerName,
signerRole: signature.signerRole,
signedAt: signature.signedAt.toISOString(),
reason: signature.reason,
confirmationText: signature.confirmationText,
imageDocumentId: signature.imageDocumentId,
capturedByName: signature.capturedById ? (nameOf.get(signature.capturedById) ?? null) : null,
};
}
const customerName =
wo.customer.companyName || [wo.customer.firstName, wo.customer.lastName].filter(Boolean).join(" ") || wo.customer.customerNumber || "—";
return {
schemaVersion: 1,
type: input.type,
reportNumber: input.reportNumber,
version: input.version,
reportDate: input.reportDate,
generatedAt: now.toISOString(),
tenant: {
name: settings?.orgName || tenant?.tenant.name || "—",
address: settings?.address ?? null,
phone: settings?.phone ?? null,
email: settings?.email ?? null,
logoDocumentId: null, // TODO(settings): TenantSettings.logoKey is not a Document yet
},
customer: {
id: wo.customer.id,
number: wo.customer.customerNumber,
name: customerName,
address: address(wo.customer.street, wo.customer.houseNumber, wo.customer.postalCode, wo.customer.city),
},
site: wo.site ? { id: wo.site.id, name: wo.site.name, address: address(wo.site.street, wo.site.houseNumber, wo.site.postalCode, wo.site.city) } : null,
contact: wo.contact
? { name: wo.contact.name, role: wo.contact.role, phone: wo.contact.phone ?? wo.contact.mobile, email: wo.contact.email }
: null,
workOrder: {
id: wo.id,
number: wo.number,
externalOrderNumber: wo.externalOrderNumber,
title: wo.title,
description: wo.description,
scope: wo.scope,
orderType: wo.orderType?.name ?? null,
signatureRequired: wo.signatureRequired,
},
workDates,
staff,
time: {
entries: timeLines,
totalsByType,
totalsByPerson: [...byPerson.values()],
totalMinutes,
hasRunningEntries,
},
texts,
materials: { used, notUsed, additional },
photos: photos.map((p) => ({
photoId: p.id,
documentId: p.documentId,
phase: p.phase,
comment: p.comment,
requirement: p.photoRequirement?.label ?? null,
takenAt: p.takenAt.toISOString(),
})),
checklist: checklist.map((c) => ({ label: c.label, required: c.required, checked: c.checked, comment: c.comment })),
signature: signatureBlock,
technician: technician ? { userId: technician.id, name: technician.name } : null,
};
}