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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user