Files
craftvia/src/server/services/field/queries.ts
T
msolarczekandClaude Opus 5 85bae832d0 L10b Betrieb & Aufräumen: Sync – Berichts-Ops, Konflikt-Übernahme, eigene Session im Bundle
- Aufräumpunkt j: report.save_draft und report.submit mit Zod-Schemas (lib/sync/ops.ts) und
  Registry-Einträgen → services/reports/sync-ops.ts. report.submit reicht baseVersion als
  expectedWorkOrderVersion und aiReviewed an submitReport durch; Lotse-Entwürfe ohne Bestätigung →
  rejected invalid. signature.capture bleibt unregistriert (Upload-Art für Unterschriftsbild fehlt).
- Aufräumpunkt b: „Übernehmen" in der Konfliktliste delegiert an den Sync-Dispatcher
  (apply.ts#reapplyOperation, ohne baseVersion) statt des L2-Stubs; unterstützt
  work_order.transition und report.submit. Hinweistext der Konfliktliste angepasst.
- Aufräumpunkt c: getFieldBundle liefert je Auftrag mySession (eigene aktive WorkSession); die
  Offline-Ansicht leitet den Zeitstatus daraus ab (alte Bundles: Näherung über Auftragsstatus).
- scripts/test-betrieb-sync.ts (Bundle, clientId je Mandant, Berichts-Ops, Konflikt-Übernahme,
  Mandant B, Monteur ohne Zuweisung); test-einsatz-sync.ts prüft „nicht verfügbare Op" jetzt mit
  signature.capture, weil report.save_draft registriert ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:19:19 +02:00

328 lines
14 KiB
TypeScript

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<SiteHistoryEntry[]> {
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<OrderTab, WorkOrderStatus[]> = {
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<OrderCard[]> {
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<OrderCard[]> {
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<FieldOrderDetail> {
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 = <T extends { lineageId: string; version: number }>(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<string, { id: string; status: string; startedAt: string }>();
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,
})),
};
}