- 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>
328 lines
14 KiB
TypeScript
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,
|
|
})),
|
|
};
|
|
}
|