Architektur: Craftvia-Domänenmodell, Verträge und Team-Schnitte
- Migration 0002_craftvia_domain: 27 Fachtabellen inkl. RLS (enable_tenant_rls) - TENANT_MODELS (db.ts, backup/topology.ts) um alle Fachmodelle ergänzt - moduleGuard liefert DB-autoritative Rechte; ServiceCtx für Domänen-Services - Verträge: Statusmaschine, Events, Nummernkreise, Sichtbarkeits-Scopes, Job-Queues + Worker, KI-Provider-Interfaces, Sync-Envelope - docs/craftvia/ARCHITEKTUR.md mit Lanes, Ownership und DoD Gate: tsc, lint, build, 22/22 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
// Domain event catalogue (client-safe). Lanes emit via src/server/events.ts#emitEvent;
|
||||
// the notifications lane maps events to recipients, in-app notifications and e-mails.
|
||||
|
||||
export const EVENT_TYPES = [
|
||||
"work_order.assigned",
|
||||
"work_order.changed",
|
||||
"work_order.cancelled",
|
||||
"work_order.started",
|
||||
"work_order.daily_report_created",
|
||||
"work_order.technically_completed",
|
||||
"work_order.signature_missing",
|
||||
"work_order.missing_required",
|
||||
"work_order.released_for_billing",
|
||||
"report.submitted",
|
||||
"report.approved",
|
||||
"report.rejected",
|
||||
"emergency.created",
|
||||
"emergency.completed",
|
||||
"import.ready_for_review",
|
||||
"import.failed",
|
||||
"sync.failed",
|
||||
] as const;
|
||||
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
export type DomainEvent = {
|
||||
type: EventType;
|
||||
entityType: "work_order" | "report" | "import_job" | "sync_operation";
|
||||
entityId: string;
|
||||
/** Short, human-readable facts for templates (no PII beyond what the recipient may see). */
|
||||
data?: Record<string, string | number | boolean | null>;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Offline sync envelope (ARCHITEKTUR §4.6). The per-op payload schemas live in
|
||||
* src/lib/sync/ops.ts (owned by lane field); this file only fixes the wire format.
|
||||
*/
|
||||
|
||||
export const SYNC_OP_TYPES = [
|
||||
"session.start",
|
||||
"session.pause",
|
||||
"session.resume",
|
||||
"session.end",
|
||||
"work_order.transition",
|
||||
"note.create",
|
||||
"checklist.toggle",
|
||||
"material.upsert",
|
||||
"photo.attach",
|
||||
"voice.attach",
|
||||
"report.save_draft",
|
||||
"report.submit",
|
||||
"signature.capture",
|
||||
"emergency.create",
|
||||
] as const;
|
||||
|
||||
export type SyncOpType = (typeof SYNC_OP_TYPES)[number];
|
||||
|
||||
/** Ops that compare WorkOrder.version and may produce a conflict. All others are additive. */
|
||||
export const CONFLICTING_OPS: readonly SyncOpType[] = ["work_order.transition", "report.submit"];
|
||||
|
||||
export const syncOperationSchema = z.object({
|
||||
clientOpId: z.string().uuid(),
|
||||
opType: z.enum(SYNC_OP_TYPES),
|
||||
entityType: z.string().max(40).optional(),
|
||||
entityId: z.string().max(64).optional(),
|
||||
baseVersion: z.number().int().positive().optional(),
|
||||
payload: z.record(z.string(), z.unknown()),
|
||||
clientCreatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type SyncOperationInput = z.infer<typeof syncOperationSchema>;
|
||||
|
||||
export const syncRequestSchema = z.object({
|
||||
deviceId: z.string().max(64),
|
||||
operations: z.array(syncOperationSchema).min(1).max(100),
|
||||
});
|
||||
|
||||
export type SyncOpResult = {
|
||||
clientOpId: string;
|
||||
status: "applied" | "duplicate" | "conflict" | "rejected";
|
||||
/** server ids created by the op, keyed by the client id they replace */
|
||||
idMap?: Record<string, string>;
|
||||
entityVersion?: number;
|
||||
errorCode?: "not_found" | "forbidden" | "invalid" | "conflict" | "blocked" | "internal";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type SyncResponse = { results: SyncOpResult[]; serverTime: string };
|
||||
@@ -0,0 +1,129 @@
|
||||
// Work order status machine (client-safe: no server imports).
|
||||
// Server enforcement lives in src/server/services/work-orders/transition.ts,
|
||||
// which MUST call canTransition()/requiredPermission() — never update status directly.
|
||||
|
||||
export const WORK_ORDER_STATUSES = [
|
||||
"draft",
|
||||
"review_required",
|
||||
"planned",
|
||||
"assigned",
|
||||
"accepted",
|
||||
"en_route",
|
||||
"in_progress",
|
||||
"paused",
|
||||
"waiting_material",
|
||||
"daily_report_created",
|
||||
"technically_completed",
|
||||
"signature_pending",
|
||||
"in_review",
|
||||
"released_for_billing",
|
||||
"billed",
|
||||
"cancelled",
|
||||
] as const;
|
||||
|
||||
export type WorkOrderStatus = (typeof WORK_ORDER_STATUSES)[number];
|
||||
|
||||
const T: Record<WorkOrderStatus, readonly WorkOrderStatus[]> = {
|
||||
draft: ["review_required", "planned", "assigned", "cancelled"],
|
||||
review_required: ["planned", "assigned", "cancelled"],
|
||||
planned: ["assigned", "cancelled"],
|
||||
assigned: ["accepted", "en_route", "in_progress", "planned", "cancelled"],
|
||||
accepted: ["en_route", "in_progress", "assigned", "cancelled"],
|
||||
en_route: ["in_progress", "cancelled"],
|
||||
in_progress: ["paused", "waiting_material", "daily_report_created", "technically_completed", "cancelled"],
|
||||
paused: ["in_progress", "en_route", "cancelled"],
|
||||
waiting_material: ["in_progress", "en_route", "cancelled"],
|
||||
daily_report_created: ["en_route", "in_progress", "cancelled"],
|
||||
technically_completed: ["signature_pending", "in_review", "in_progress", "cancelled"],
|
||||
signature_pending: ["in_review", "cancelled"],
|
||||
in_review: ["released_for_billing", "in_progress", "cancelled"],
|
||||
released_for_billing: ["billed", "in_review"],
|
||||
billed: [],
|
||||
cancelled: [],
|
||||
};
|
||||
|
||||
export function allowedTransitions(from: WorkOrderStatus): readonly WorkOrderStatus[] {
|
||||
return T[from];
|
||||
}
|
||||
|
||||
export function canTransition(from: WorkOrderStatus, to: WorkOrderStatus): boolean {
|
||||
return T[from].includes(to);
|
||||
}
|
||||
|
||||
/** Permission required for a transition (server additionally checks work order scope for field roles). */
|
||||
export function requiredPermission(from: WorkOrderStatus, to: WorkOrderStatus): string {
|
||||
if (to === "cancelled") return "work_order:cancel";
|
||||
if (to === "released_for_billing" || to === "billed") return "work_order:release_billing";
|
||||
if (from === "released_for_billing" && to === "in_review") return "work_order:release_billing";
|
||||
if (from === "in_review" && to === "in_progress") return "report:approve_team"; // or report:approve (checked server-side)
|
||||
if (["draft", "review_required", "planned"].includes(from)) return to === "assigned" ? "work_order:assign" : "work_order:write";
|
||||
if (from === "assigned" && to === "planned") return "work_order:assign";
|
||||
return "field:execute";
|
||||
}
|
||||
|
||||
/** Statuses in which field users may record time/material/photos/notes. */
|
||||
export const FIELD_EDITABLE: readonly WorkOrderStatus[] = [
|
||||
"assigned",
|
||||
"accepted",
|
||||
"en_route",
|
||||
"in_progress",
|
||||
"paused",
|
||||
"waiting_material",
|
||||
"daily_report_created",
|
||||
"technically_completed",
|
||||
"signature_pending",
|
||||
];
|
||||
|
||||
export const OPEN_STATUSES: readonly WorkOrderStatus[] = WORK_ORDER_STATUSES.filter(
|
||||
(s) => !["billed", "cancelled", "released_for_billing"].includes(s),
|
||||
);
|
||||
|
||||
/** UI groups per Brandbook §12.3 — label keys live in messages/<locale>/workOrders.json → statusGroup.<key>. */
|
||||
export const STATUS_GROUP: Record<WorkOrderStatus, StatusGroup> = {
|
||||
draft: "new",
|
||||
review_required: "new",
|
||||
planned: "planned",
|
||||
assigned: "planned",
|
||||
accepted: "planned",
|
||||
en_route: "en_route",
|
||||
in_progress: "in_progress",
|
||||
paused: "in_progress",
|
||||
waiting_material: "in_progress",
|
||||
daily_report_created: "in_progress",
|
||||
technically_completed: "documentation_incomplete",
|
||||
signature_pending: "documentation_incomplete",
|
||||
in_review: "in_review",
|
||||
released_for_billing: "ready_for_billing",
|
||||
billed: "billed",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
|
||||
export type StatusGroup =
|
||||
| "new"
|
||||
| "planned"
|
||||
| "en_route"
|
||||
| "in_progress"
|
||||
| "documentation_incomplete"
|
||||
| "in_review"
|
||||
| "ready_for_billing"
|
||||
| "billed"
|
||||
| "cancelled";
|
||||
|
||||
/** Semantic tone for badges (always combined with text, Brandbook §11.4). */
|
||||
export const STATUS_GROUP_TONE: Record<StatusGroup, "neutral" | "info" | "accent" | "warning" | "success" | "danger"> = {
|
||||
new: "neutral",
|
||||
planned: "info",
|
||||
en_route: "accent",
|
||||
in_progress: "accent",
|
||||
documentation_incomplete: "warning",
|
||||
in_review: "info",
|
||||
ready_for_billing: "success",
|
||||
billed: "success",
|
||||
cancelled: "danger",
|
||||
};
|
||||
|
||||
export type CompletionBlocker =
|
||||
| { kind: "checklist_item"; itemId: string; label: string }
|
||||
| { kind: "photo_requirement"; requirementId: string; label: string }
|
||||
| { kind: "running_session"; sessionId: string; userId: string }
|
||||
| { kind: "missing_field"; field: string };
|
||||
Reference in New Issue
Block a user