From 6358d5762c8d97558d2112e9272b0aa6518c0cd6 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 10:35:20 +0200 Subject: [PATCH] =?UTF-8?q?L14=20Abrechnungs=C3=BCbersicht:=20Datenmodell,?= =?UTF-8?q?=20Migration,=20Rechte=20und=20Modul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- messages/de/modules.json | 3 +- messages/en/modules.json | 3 +- .../migration.sql | 100 ++++++++++++++++++ prisma/schema.prisma | 92 ++++++++++++++++ src/components/audit-trail.tsx | 2 + src/lib/events.ts | 6 +- src/lib/modules.ts | 1 + src/server/backup/topology.ts | 3 + src/server/db.ts | 3 + src/server/dsgvo/pii-fields.ts | 7 ++ src/server/rbac.ts | 3 + 11 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql diff --git a/messages/de/modules.json b/messages/de/modules.json index b1a1757..2bab86d 100644 --- a/messages/de/modules.json +++ b/messages/de/modules.json @@ -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" } diff --git a/messages/en/modules.json b/messages/en/modules.json index 02e47ae..eeab815 100644 --- a/messages/en/modules.json +++ b/messages/en/modules.json @@ -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" } diff --git a/prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql b/prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql new file mode 100644 index 0000000..5f8b3e5 --- /dev/null +++ b/prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0f5a6d..0de70c1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/components/audit-trail.tsx b/src/components/audit-trail.tsx index 81844ce..c1c0623 100644 --- a/src/components/audit-trail.tsx +++ b/src/components/audit-trail.tsx @@ -71,6 +71,8 @@ const ENTITY_LABEL: Record = { import_job: "Auftragsimport", material_usage: "Material", time_entry: "Arbeitszeit", + billing_record: "Abrechnungseintrag", + work_order_milestone: "Meilenstein", work_session: "Einsatzzeit", photo: "Foto", voice_note: "Sprachnotiz", diff --git a/src/lib/events.ts b/src/lib/events.ts index 16c2c17..d94b633 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -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; diff --git a/src/lib/modules.ts b/src/lib/modules.ts index 67139c9..80bee93 100644 --- a/src/lib/modules.ts +++ b/src/lib/modules.ts @@ -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"]; diff --git a/src/server/backup/topology.ts b/src/server/backup/topology.ts index 8c2c856..382cba3 100644 --- a/src/server/backup/topology.ts +++ b/src/server/backup/topology.ts @@ -72,6 +72,9 @@ export const TENANT_MODELS: readonly string[] = [ "Notification", "SyncOperation", "AiGeneration", + // L14 Abrechnungsübersicht + "WorkOrderMilestone", + "BillingRecord", ]; /** diff --git a/src/server/db.ts b/src/server/db.ts index b469b18..111ecaf 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -123,6 +123,9 @@ const TENANT_MODELS = new Set([ "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('')` in der Migration diff --git a/src/server/dsgvo/pii-fields.ts b/src/server/dsgvo/pii-fields.ts index ed081e2..1483356 100644 --- a/src/server/dsgvo/pii-fields.ts +++ b/src/server/dsgvo/pii-fields.ts @@ -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. ]; diff --git a/src/server/rbac.ts b/src/server/rbac.ts index f50e0f3..4cc7579 100644 --- a/src/server/rbac.ts +++ b/src/server/rbac.ts @@ -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];