Integration: Stubs von L2/L3/L4/L5 gegen echte Services getauscht

- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2
- Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes,
  readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage
- Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als
  imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts
- Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf
- L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay
- PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF
- Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned
- Dateilinks in Auftragsdetail auf /files/[documentId]
- proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect
- ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams)

Gate: tsc, lint, build, 42/42 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:49:19 +02:00
co-authored by Claude Opus 5
parent 2984cc18d3
commit 3883296300
37 changed files with 216 additions and 798 deletions
+36 -5
View File
@@ -4,8 +4,39 @@ import { allowedDocumentVisibility, requireVisibleWorkOrder, workOrderScope } fr
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";
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. */
@@ -208,8 +239,8 @@ export async function getFieldOrderDetail(ctx: ServiceCtx, workOrderId: string):
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),
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
@@ -268,7 +299,7 @@ export async function getFieldBundle(ctx: ServiceCtx, since?: Date | null) {
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)),
await Promise.all(siteIds.map(async (id) => [id, await fieldSiteHistory(ctx, id, 5).catch(() => [])] as const)),
);
return {
serverTime: serverTime.toISOString(),