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>
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|