L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests
- services/field: Einsatz-Sessions (Anfahrt/Arbeit/Pause als TimeEntry-Segmente, eine aktive Session je User+Auftrag), Zeitkorrektur mit Recht + Grund + Audit, Checkliste, Material (Abweichung nur mit Begründung, Zusatzmaterial), Notizen, Fotos, Sprachnotizen (ohne Transkriptions-Processor Status disabled), Uploads (idempotent je Mandant), autorisierte Dokument-Auslieferung, Lesemodelle + Bundle - services/sync: applyOperations mit Idempotenz, baseVersion-Konfliktprüfung, Registry für Ops anderer Lanes, lane-lokaler requireApiContext - /api/v1/sync, /api/v1/uploads, /api/v1/field/bundle, /api/v1/field/documents/[id] - lib/sync/ops.ts (Zod-Payloads je opType), lib/field/material-rules.ts - Stubs mit Vertragssignatur: transitionWorkOrder (L2), storeFile (§4.3), getSiteHistory (L1) - Processor image-derivatives + Registrierung, Audit-Entity-Labels - Tests: test-einsatz-field (48 Prüfungen), test-einsatz-sync (38 Prüfungen) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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 { completionBlockers } from "./stubs/work-order-transition";
|
||||
import { getSiteHistory, type SiteHistoryEntry } from "./stubs/site-history";
|
||||
|
||||
/** 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 } },
|
||||
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 ? getSiteHistory(ctx, wo.siteId, { onlyApproved: true }).catch((err) => (err instanceof ServiceError ? [] : Promise.reject(err))) : Promise.resolve([]),
|
||||
completionBlockers(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 getSiteHistory(ctx, id, { onlyApproved: true, limit: 5 }).catch(() => [])] as const)),
|
||||
);
|
||||
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] ?? [] : [] })),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user