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 { const s = await ctx.db.tenantSettings.findFirst({ select: { timezone: true } }); return s?.timezone || "Europe/Berlin"; } const joinLines = (...parts: Array) => 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 { 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(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(); 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 = {}; const byPerson = new Map(); 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(); 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, }; }