L14 Abrechnungsübersicht: Datenmodell, Migration, Rechte und Modul

Neue Tenant-Tabellen work_order_milestones und billing_records (RLS, TENANT_MODELS, PII-Felder), Partial-Unique-Indizes je Quelle, billing_record_id an time_entries/material_usages. Rechte billing:read/billing:write (Backoffice, Mandantenadmin), Modul billing, Events milestone.*.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:35:20 +02:00
co-authored by Claude Opus 5
parent 9b087168d1
commit 6358d5762c
11 changed files with 220 additions and 3 deletions
+2 -1
View File
@@ -14,5 +14,6 @@
"crumb": "Modul",
"status": "Modul in Umsetzung",
"hint": "Dieser Bereich wird gerade gebaut. Navigation, Modul-Freischaltung und Berechtigungen sind bereits aktiv."
}
},
"billing": "Abrechnungsübersicht"
}
+2 -1
View File
@@ -14,5 +14,6 @@
"crumb": "Module",
"status": "Module under construction",
"hint": "This area is currently being built. Navigation, module activation and permissions are already in place."
}
},
"billing": "Billing overview"
}
@@ -0,0 +1,100 @@
-- CreateEnum
CREATE TYPE "MilestoneStatus" AS ENUM ('open', 'reached', 'confirmed', 'billed');
-- CreateEnum
CREATE TYPE "BillingRecordKind" AS ENUM ('order_completion', 'milestone', 'daily_report');
-- CreateEnum
CREATE TYPE "BillingRecordStatus" AS ENUM ('open', 'billed', 'voided');
-- AlterTable
ALTER TABLE "material_usages" ADD COLUMN "billing_record_id" TEXT;
-- AlterTable
ALTER TABLE "time_entries" ADD COLUMN "billing_record_id" TEXT;
-- CreateTable
CREATE TABLE "work_order_milestones" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"work_order_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"status" "MilestoneStatus" NOT NULL DEFAULT 'open',
"reached_by_id" TEXT,
"reached_at" TIMESTAMP(3),
"reached_note" TEXT,
"confirmed_by_id" TEXT,
"confirmed_at" TIMESTAMP(3),
"rejected_by_id" TEXT,
"rejected_at" TIMESTAMP(3),
"rejection_reason" TEXT,
"billing_record_id" TEXT,
"created_by_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "work_order_milestones_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "billing_records" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"work_order_id" TEXT NOT NULL,
"kind" "BillingRecordKind" NOT NULL,
"milestone_id" TEXT,
"report_id" TEXT,
"period_from" TIMESTAMP(3) NOT NULL,
"period_to" TIMESTAMP(3) NOT NULL,
"status" "BillingRecordStatus" NOT NULL DEFAULT 'open',
"snapshot" JSONB,
"pdf_document_id" TEXT,
"pdf_checksum" TEXT,
"invoice_number" TEXT,
"billed_by_id" TEXT,
"billed_at" TIMESTAMP(3),
"voided_by_id" TEXT,
"voided_at" TIMESTAMP(3),
"void_reason" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "billing_records_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "work_order_milestones_tenant_id_work_order_id_idx" ON "work_order_milestones"("tenant_id", "work_order_id");
-- CreateIndex
CREATE INDEX "work_order_milestones_tenant_id_status_idx" ON "work_order_milestones"("tenant_id", "status");
-- CreateIndex
CREATE INDEX "billing_records_tenant_id_status_created_at_idx" ON "billing_records"("tenant_id", "status", "created_at");
-- CreateIndex
CREATE INDEX "billing_records_tenant_id_work_order_id_idx" ON "billing_records"("tenant_id", "work_order_id");
-- CreateIndex
CREATE INDEX "material_usages_tenant_id_billing_record_id_idx" ON "material_usages"("tenant_id", "billing_record_id");
-- CreateIndex
CREATE INDEX "time_entries_tenant_id_billing_record_id_idx" ON "time_entries"("tenant_id", "billing_record_id");
-- AddForeignKey
ALTER TABLE "work_order_milestones" ADD CONSTRAINT "work_order_milestones_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_records" ADD CONSTRAINT "billing_records_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- L14 Abrechnungsübersicht: Mandantentrennung (RLS) für die neuen Tabellen
SELECT enable_tenant_rls('work_order_milestones');
SELECT enable_tenant_rls('billing_records');
-- Höchstens ein nicht stornierter Abrechnungseintrag je Quelle (Stornierung legt einen neuen offenen Eintrag an)
CREATE UNIQUE INDEX "billing_records_order_completion_active_key" ON "billing_records"("tenant_id", "work_order_id") WHERE "kind" = 'order_completion' AND "status" <> 'voided';
CREATE UNIQUE INDEX "billing_records_milestone_active_key" ON "billing_records"("tenant_id", "milestone_id") WHERE "kind" = 'milestone' AND "status" <> 'voided';
CREATE UNIQUE INDEX "billing_records_daily_report_active_key" ON "billing_records"("tenant_id", "report_id") WHERE "kind" = 'daily_report' AND "status" <> 'voided';
+92
View File
@@ -737,6 +737,8 @@ model WorkOrder {
voiceNotes VoiceNote[]
reports Report[]
documents Document[]
milestones WorkOrderMilestone[] // L14
billingRecords BillingRecord[] // L14
@@unique([tenantId, number])
@@index([tenantId, status])
@@ -859,6 +861,8 @@ model MaterialUsage {
photoId String? @map("photo_id")
recordedById String? @map("recorded_by_id")
clientId String? @map("client_id") // offline local id
/// L14: billed billing record containing this position (set on „abgerechnet", cleared on void)
billingRecordId String? @map("billing_record_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -866,6 +870,7 @@ model MaterialUsage {
materialPlan MaterialPlan? @relation(fields: [materialPlanId], references: [id], onDelete: SetNull)
workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull)
@@index([tenantId, billingRecordId])
@@index([tenantId, workOrderId])
@@unique([tenantId, clientId])
@@map("material_usages")
@@ -953,11 +958,14 @@ model TimeEntry {
/// proposed correction of this entry {startedAt, endedAt, type, reason, requestedAt}; old values stay valid until approval
pendingChange Json? @map("pending_change")
note String?
/// L14: billed billing record containing this entry (set on „abgerechnet", cleared on void)
billingRecordId String? @map("billing_record_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
workSession WorkSession @relation(fields: [workSessionId], references: [id], onDelete: Cascade)
@@index([tenantId, billingRecordId])
@@index([tenantId, workSessionId])
@@index([tenantId, userId, startedAt])
@@index([tenantId, approvalStatus])
@@ -1328,3 +1336,87 @@ model AiGeneration {
@@index([tenantId, entityType, entityId])
@@map("ai_generations")
}
// ---------- L14 Abrechnungsübersicht (keine Buchhaltung: keine Preise/Beträge/Steuern) ----------
enum MilestoneStatus {
open
reached // reported by technician / team lead
confirmed // confirmed by back office → billing record
billed
}
enum BillingRecordKind {
order_completion
milestone
daily_report
}
enum BillingRecordStatus {
open
billed
voided
}
/// Optional billing section of a work order (defined by back office, reported by the field, confirmed by back office).
model WorkOrderMilestone {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
title String
description String?
sortOrder Int @default(0) @map("sort_order")
status MilestoneStatus @default(open)
reachedById String? @map("reached_by_id")
reachedAt DateTime? @map("reached_at")
reachedNote String? @map("reached_note")
confirmedById String? @map("confirmed_by_id")
confirmedAt DateTime? @map("confirmed_at")
rejectedById String? @map("rejected_by_id")
rejectedAt DateTime? @map("rejected_at")
rejectionReason String? @map("rejection_reason")
/// billed record this milestone belongs to (plain id, no FK)
billingRecordId String? @map("billing_record_id")
createdById String? @map("created_by_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
@@index([tenantId, workOrderId])
@@index([tenantId, status])
@@map("work_order_milestones")
}
/// Billing overview entry: an order completion, a confirmed milestone or an approved daily report that the
/// accounting (outside Craftvia) can invoice. `snapshot` freezes the statement when marked as billed.
/// Partial unique indexes (SQL, migration `*_abrechnung`): one non-voided record per source.
model BillingRecord {
id String @id @default(cuid())
tenantId String @map("tenant_id")
workOrderId String @map("work_order_id")
kind BillingRecordKind
milestoneId String? @map("milestone_id")
reportId String? @map("report_id")
periodFrom DateTime @map("period_from")
periodTo DateTime @map("period_to")
status BillingRecordStatus @default(open)
snapshot Json?
pdfDocumentId String? @map("pdf_document_id")
pdfChecksum String? @map("pdf_checksum")
invoiceNumber String? @map("invoice_number")
billedById String? @map("billed_by_id")
billedAt DateTime? @map("billed_at")
voidedById String? @map("voided_by_id")
voidedAt DateTime? @map("voided_at")
voidReason String? @map("void_reason")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
@@index([tenantId, status, createdAt])
@@index([tenantId, workOrderId])
@@map("billing_records")
}
+2
View File
@@ -71,6 +71,8 @@ const ENTITY_LABEL: Record<string, string> = {
import_job: "Auftragsimport",
material_usage: "Material",
time_entry: "Arbeitszeit",
billing_record: "Abrechnungseintrag",
work_order_milestone: "Meilenstein",
work_session: "Einsatzzeit",
photo: "Foto",
voice_note: "Sprachnotiz",
+5 -1
View File
@@ -23,13 +23,17 @@ export const EVENT_TYPES = [
"time.approval_requested",
"time.approved",
"time.rejected",
// L14 Abrechnungsübersicht (Meilensteine)
"milestone.reached",
"milestone.confirmed",
"milestone.rejected",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
export type DomainEvent = {
type: EventType;
entityType: "work_order" | "report" | "import_job" | "sync_operation" | "time_entry";
entityType: "work_order" | "report" | "import_job" | "sync_operation" | "time_entry" | "milestone";
entityId: string;
/** Short, human-readable facts for templates (no PII beyond what the recipient may see). */
data?: Record<string, string | number | boolean | null>;
+1
View File
@@ -28,6 +28,7 @@ export const MODULES = [
{ key: "documents", name: "Dokumente", href: "/documents", nav: "documents" },
{ key: "notifications", name: "Benachrichtigungen", href: "/notifications", nav: "notifications" },
{ key: "lotse", name: "Lotse (KI-Assistent)", href: null, nav: "lotse" },
{ key: "billing", name: "Abrechnungsübersicht", href: "/billing", nav: "billing" },
] as const satisfies readonly ModuleDef[];
export type ModuleKey = (typeof MODULES)[number]["key"];
+3
View File
@@ -72,6 +72,9 @@ export const TENANT_MODELS: readonly string[] = [
"Notification",
"SyncOperation",
"AiGeneration",
// L14 Abrechnungsübersicht
"WorkOrderMilestone",
"BillingRecord",
];
/**
+3
View File
@@ -123,6 +123,9 @@ const TENANT_MODELS = new Set<string>([
"Notification",
"SyncOperation",
"AiGeneration",
// L14 Abrechnungsübersicht
"WorkOrderMilestone",
"BillingRecord",
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
+7
View File
@@ -48,6 +48,13 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
{ model: "SyncOperation", field: "userId" },
{ model: "SyncOperation", field: "resolvedById" },
{ model: "AiGeneration", field: "createdById" },
// L14 Abrechnungsübersicht
{ model: "WorkOrderMilestone", field: "createdById" },
{ model: "WorkOrderMilestone", field: "reachedById" },
{ model: "WorkOrderMilestone", field: "confirmedById" },
{ model: "WorkOrderMilestone", field: "rejectedById" },
{ model: "BillingRecord", field: "billedById" },
{ model: "BillingRecord", field: "voidedById" },
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
// tenant business data under data processing — not part of the employee subject export.
];
+3
View File
@@ -57,6 +57,9 @@ export const PERMISSIONS = [
"settings:templates",
// Lotse (KI-Assistent)
"lotse:use",
// L14 Abrechnungsübersicht (keine Buchhaltung): Übersicht lesen / abgerechnet markieren, stornieren, Meilensteine bestätigen
"billing:read",
"billing:write",
] as const;
export type Permission = (typeof PERMISSIONS)[number];