import type { Prisma } from "@prisma/client"; import { ServiceError, type ServiceCtx } from "@/server/services/context"; import { allowedDocumentVisibility, requireVisibleWorkOrder, workOrderScope } from "@/server/services/work-orders/visibility"; import { STATUS_GROUP, type CompletionBlocker, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status"; import { ACTIVE_SESSION_STATUSES } from "./sessions"; // TODO(merge L2/L1): replace with the lane implementations import { getCompletionBlockers } from "@/server/services/work-orders/completion"; import { getSiteHistory } from "@/server/services/sites/history"; /** One released report at the site, as shown in the mobile order detail (US-005). */ export type SiteHistoryEntry = { reportId: string; reportType: "daily" | "completion"; reportDate: Date; workOrderId: string; workOrderNumber: string; workOrderTitle: string; /** Immutable PDF of the approved report (lane reports API; requires report:read). */ pdfHref: string; }; /** Adapter over the site history service (lane master data): flattens approved reports, newest first. */ async function fieldSiteHistory(ctx: ServiceCtx, siteId: string, limit = 20): Promise { const { items } = await getSiteHistory(ctx, siteId, { onlyApproved: true, pageSize: Math.min(100, limit * 2) }); return items .flatMap((i) => i.approvedReports.map((r) => ({ reportId: r.id, reportType: r.type, reportDate: r.reportDate, workOrderId: i.workOrderId, workOrderNumber: i.number, workOrderTitle: i.title, pdfHref: `/api/v1/reports/${encodeURIComponent(r.id)}/pdf`, })), ) .sort((a, b) => b.reportDate.getTime() - a.reportDate.getTime()) .slice(0, limit); } /** Read models of the mobile app (Spec §11.2, §22, US-005). All reads go through workOrderScope. */ export const ORDER_TABS = ["upcoming", "running", "to_complete", "past"] as const; export type OrderTab = (typeof ORDER_TABS)[number]; const TAB_STATUSES: Record = { upcoming: ["planned", "assigned", "accepted"], running: ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"], to_complete: ["technically_completed", "signature_pending"], past: ["in_review", "released_for_billing", "billed", "cancelled"], }; /** Categories shown in the photo/voice sections instead of the document list. */ const MEDIA_CATEGORIES = ["photo", "voice_note", "signature"] as const; export type OrderCard = { id: string; number: string; title: string; status: WorkOrderStatus; statusGroup: StatusGroup; priority: string; isEmergency: boolean; customerName: string; siteName: string | null; address: string | null; mapsUrl: string | null; plannedStart: Date | null; plannedEnd: Date | null; version: number; }; const CARD_SELECT = { id: true, number: true, title: true, status: true, priority: true, isEmergency: true, plannedStart: true, plannedEnd: true, version: true, customer: { select: { companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true } }, site: { select: { name: true, street: true, houseNumber: true, postalCode: true, city: true } }, } satisfies Prisma.WorkOrderSelect; type Addressable = { street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null }; export function formatAddress(a: Addressable | null | undefined): string | null { if (!a) return null; const line1 = [a.street, a.houseNumber].filter(Boolean).join(" "); const line2 = [a.postalCode, a.city].filter(Boolean).join(" "); const s = [line1, line2].filter(Boolean).join(", "); return s || null; } export function mapsUrl(address: string | null): string | null { return address ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}` : null; } export function customerDisplayName(c: { companyName: string | null; firstName: string | null; lastName: string | null }): string { return c.companyName?.trim() || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—"; } function toCard(wo: Prisma.WorkOrderGetPayload<{ select: typeof CARD_SELECT }>): OrderCard { const address = formatAddress(wo.site) ?? formatAddress(wo.customer); return { id: wo.id, number: wo.number, title: wo.title, status: wo.status, statusGroup: STATUS_GROUP[wo.status], priority: wo.priority, isEmergency: wo.isEmergency, customerName: customerDisplayName(wo.customer), siteName: wo.site?.name ?? null, address, mapsUrl: mapsUrl(address), plannedStart: wo.plannedStart, plannedEnd: wo.plannedEnd, version: wo.version, }; } export async function listFieldOrders(ctx: ServiceCtx, tab: OrderTab): Promise { const scope = await workOrderScope(ctx); const rows = await ctx.db.workOrder.findMany({ where: { AND: [scope, { status: { in: TAB_STATUSES[tab] } }] }, orderBy: tab === "past" ? [{ updatedAt: "desc" }] : [{ plannedStart: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }], take: tab === "past" ? 50 : 200, select: CARD_SELECT, }); return rows.map(toCard); } /** "Heute": orders planned for today (not yet done) plus all running and paused ones. */ export async function listTodayOrders(ctx: ServiceCtx, now = new Date()): Promise { const start = new Date(now); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(end.getDate() + 1); const scope = await workOrderScope(ctx); const rows = await ctx.db.workOrder.findMany({ where: { AND: [ scope, { OR: [ { status: { in: TAB_STATUSES.running } }, { status: { in: [...TAB_STATUSES.upcoming, ...TAB_STATUSES.to_complete] }, plannedStart: { lt: end }, OR: [{ plannedEnd: { gte: start } }, { plannedEnd: null, plannedStart: { gte: start } }], }, ], }, ], }, orderBy: [{ plannedStart: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }], take: 100, select: CARD_SELECT, }); return rows.map(toCard); } const DETAIL_SELECT = { ...CARD_SELECT, externalOrderNumber: true, description: true, scope: true, technicianNotes: true, signatureRequired: true, siteId: true, orderType: { select: { name: true } }, customer: { select: { id: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true, phone: true, mobile: true, email: true }, }, contact: { select: { name: true, role: true, phone: true, mobile: true, email: true } }, site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, phone: true, onSiteContact: true, accessNotes: true, parkingNotes: true, safetyNotes: true, technicalNotes: true, contact: { select: { name: true, role: true, phone: true, mobile: true, email: true } }, }, }, team: { select: { name: true } }, checklistItems: { orderBy: { sortOrder: "asc" }, select: { id: true, label: true, required: true, requiresPhoto: true, checked: true, checkedAt: true, comment: true } }, photoRequirements: { orderBy: { sortOrder: "asc" }, select: { id: true, key: true, label: true, _count: { select: { photos: true } } } }, materialPlans: { orderBy: { sortOrder: "asc" }, select: { id: true, name: true, articleNumber: true, plannedQuantity: true, unit: true, notes: true } }, materialUsages: { orderBy: { createdAt: "asc" }, select: { id: true, materialPlanId: true, name: true, articleNumber: true, actualQuantity: true, unit: true, usageStatus: true, deviationReason: true, notes: true, clientId: true }, }, notes: { where: { deletedAt: null }, orderBy: { createdAt: "desc" }, take: 100, select: { id: true, kind: true, text: true, createdAt: true, authorId: true, voiceNoteId: true } }, photos: { orderBy: { takenAt: "desc" }, select: { id: true, documentId: true, phase: true, comment: true, takenAt: true, photoRequirementId: true, checklistItemId: true, takenById: true }, }, voiceNotes: { orderBy: { recordedAt: "desc" }, select: { id: true, documentId: true, durationSeconds: true, transcript: true, transcriptionStatus: true, recordedAt: true } }, workSessions: { orderBy: { startedAt: "desc" }, select: { id: true, userId: true, status: true, startedAt: true, endedAt: true, startedOffline: true, user: { select: { name: true } }, entries: { orderBy: { startedAt: "asc" }, select: { id: true, type: true, startedAt: true, endedAt: true, corrected: true, correctionReason: true } }, }, }, } satisfies Prisma.WorkOrderSelect; export type FieldOrderDetail = Prisma.WorkOrderGetPayload<{ select: typeof DETAIL_SELECT }> & { card: OrderCard; documents: Array<{ id: string; title: string | null; fileName: string; category: string; mimeType: string; fileSize: number; createdAt: Date; source: "order" | "site" }>; siteHistory: SiteHistoryEntry[]; blockers: CompletionBlocker[]; mySession: { id: string; status: "en_route" | "running" | "paused" | "ended" } | null; }; export async function getFieldOrderDetail(ctx: ServiceCtx, workOrderId: string): Promise { const wo = (await requireVisibleWorkOrder(ctx, workOrderId, DETAIL_SELECT)) as unknown as Prisma.WorkOrderGetPayload<{ select: typeof DETAIL_SELECT }>; const visibility = allowedDocumentVisibility(ctx); const docWhere = { deletedAt: null, uploadStatus: "uploaded" as const, visibility: { in: visibility }, category: { notIn: [...MEDIA_CATEGORIES] } }; const docSelect = { id: true, title: true, fileName: true, category: true, mimeType: true, fileSize: true, createdAt: true, lineageId: true, version: true } as const; const [orderDocs, siteDocs, siteHistory, blockers] = await Promise.all([ ctx.db.document.findMany({ where: { ...docWhere, workOrderId: wo.id }, orderBy: { createdAt: "desc" }, select: docSelect }), wo.siteId ? ctx.db.document.findMany({ where: { ...docWhere, siteId: wo.siteId, workOrderId: null }, orderBy: { createdAt: "desc" }, select: docSelect }) : Promise.resolve([]), wo.siteId ? fieldSiteHistory(ctx, wo.siteId).catch((err) => (err instanceof ServiceError ? [] : Promise.reject(err))) : Promise.resolve([]), getCompletionBlockers(ctx, wo.id), ]); // only the newest version per lineage const latest = (docs: T[]) => docs.filter((d) => !docs.some((o) => o.lineageId === d.lineageId && o.version > d.version)); const mine = wo.workSessions.find((s) => s.userId === ctx.userId && ACTIVE_SESSION_STATUSES.includes(s.status)); return { ...wo, card: toCard(wo), documents: [ ...latest(orderDocs).map((d) => ({ ...d, source: "order" as const })), ...latest(siteDocs).map((d) => ({ ...d, source: "site" as const })), ], siteHistory, blockers, mySession: mine ? { id: mine.id, status: mine.status } : null, }; } /** * Offline pull bundle (ARCHITEKTUR §4.6): open orders in scope (optionally only those changed * since `since`) with customer, site, contacts, checklist, material plan, photo requirements, * document metadata and the approved reports at the site. Blobs are fetched separately. */ export async function getFieldBundle(ctx: ServiceCtx, since?: Date | null) { const scope = await workOrderScope(ctx); const statuses: WorkOrderStatus[] = [...TAB_STATUSES.upcoming, ...TAB_STATUSES.running, ...TAB_STATUSES.to_complete]; const serverTime = new Date(); const orders = await ctx.db.workOrder.findMany({ where: { AND: [scope, { status: { in: statuses } }, since ? { updatedAt: { gt: since } } : {}] }, orderBy: [{ plannedStart: { sort: "asc", nulls: "last" } }], take: 200, select: { ...CARD_SELECT, externalOrderNumber: true, description: true, scope: true, technicianNotes: true, signatureRequired: true, updatedAt: true, orderType: DETAIL_SELECT.orderType, customer: DETAIL_SELECT.customer, contact: DETAIL_SELECT.contact, site: DETAIL_SELECT.site, checklistItems: DETAIL_SELECT.checklistItems, photoRequirements: DETAIL_SELECT.photoRequirements, materialPlans: DETAIL_SELECT.materialPlans, materialUsages: DETAIL_SELECT.materialUsages, documents: { where: { deletedAt: null, uploadStatus: "uploaded", visibility: { in: allowedDocumentVisibility(ctx) }, category: { notIn: [...MEDIA_CATEGORIES] } }, select: { id: true, title: true, fileName: true, category: true, mimeType: true, fileSize: true, checksum: true, version: true, lineageId: true }, }, }, }); const siteIds = [...new Set(orders.map((o) => o.site?.id).filter((id): id is string => !!id))]; const histories = Object.fromEntries( await Promise.all(siteIds.map(async (id) => [id, await fieldSiteHistory(ctx, id, 5).catch(() => [])] as const)), ); // L10b (L7 offene Punkte 3/4): the caller's own running session per order, so the offline view // shows the correct time actions on team orders with several technicians. const ownSessions = orders.length ? await ctx.db.workSession.findMany({ where: { workOrderId: { in: orders.map((o) => o.id) }, userId: ctx.userId, status: { in: ACTIVE_SESSION_STATUSES } }, orderBy: { startedAt: "desc" }, select: { id: true, workOrderId: true, status: true, startedAt: true }, }) : []; const mySessions = new Map(); for (const s of ownSessions) { if (!mySessions.has(s.workOrderId)) mySessions.set(s.workOrderId, { id: s.id, status: s.status, startedAt: s.startedAt.toISOString() }); } return { serverTime: serverTime.toISOString(), since: since?.toISOString() ?? null, orders: orders.map((o) => ({ ...o, statusGroup: STATUS_GROUP[o.status], siteHistory: o.site ? histories[o.site.id] ?? [] : [], mySession: mySessions.get(o.id) ?? null, })), }; }