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:
@@ -14,5 +14,6 @@
|
|||||||
"crumb": "Modul",
|
"crumb": "Modul",
|
||||||
"status": "Modul in Umsetzung",
|
"status": "Modul in Umsetzung",
|
||||||
"hint": "Dieser Bereich wird gerade gebaut. Navigation, Modul-Freischaltung und Berechtigungen sind bereits aktiv."
|
"hint": "Dieser Bereich wird gerade gebaut. Navigation, Modul-Freischaltung und Berechtigungen sind bereits aktiv."
|
||||||
}
|
},
|
||||||
|
"billing": "Abrechnungsübersicht"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,6 @@
|
|||||||
"crumb": "Module",
|
"crumb": "Module",
|
||||||
"status": "Module under construction",
|
"status": "Module under construction",
|
||||||
"hint": "This area is currently being built. Navigation, module activation and permissions are already in place."
|
"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';
|
||||||
@@ -737,6 +737,8 @@ model WorkOrder {
|
|||||||
voiceNotes VoiceNote[]
|
voiceNotes VoiceNote[]
|
||||||
reports Report[]
|
reports Report[]
|
||||||
documents Document[]
|
documents Document[]
|
||||||
|
milestones WorkOrderMilestone[] // L14
|
||||||
|
billingRecords BillingRecord[] // L14
|
||||||
|
|
||||||
@@unique([tenantId, number])
|
@@unique([tenantId, number])
|
||||||
@@index([tenantId, status])
|
@@index([tenantId, status])
|
||||||
@@ -859,6 +861,8 @@ model MaterialUsage {
|
|||||||
photoId String? @map("photo_id")
|
photoId String? @map("photo_id")
|
||||||
recordedById String? @map("recorded_by_id")
|
recordedById String? @map("recorded_by_id")
|
||||||
clientId String? @map("client_id") // offline local 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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -866,6 +870,7 @@ model MaterialUsage {
|
|||||||
materialPlan MaterialPlan? @relation(fields: [materialPlanId], references: [id], onDelete: SetNull)
|
materialPlan MaterialPlan? @relation(fields: [materialPlanId], references: [id], onDelete: SetNull)
|
||||||
workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull)
|
workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([tenantId, billingRecordId])
|
||||||
@@index([tenantId, workOrderId])
|
@@index([tenantId, workOrderId])
|
||||||
@@unique([tenantId, clientId])
|
@@unique([tenantId, clientId])
|
||||||
@@map("material_usages")
|
@@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
|
/// proposed correction of this entry {startedAt, endedAt, type, reason, requestedAt}; old values stay valid until approval
|
||||||
pendingChange Json? @map("pending_change")
|
pendingChange Json? @map("pending_change")
|
||||||
note String?
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
workSession WorkSession @relation(fields: [workSessionId], references: [id], onDelete: Cascade)
|
workSession WorkSession @relation(fields: [workSessionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([tenantId, billingRecordId])
|
||||||
@@index([tenantId, workSessionId])
|
@@index([tenantId, workSessionId])
|
||||||
@@index([tenantId, userId, startedAt])
|
@@index([tenantId, userId, startedAt])
|
||||||
@@index([tenantId, approvalStatus])
|
@@index([tenantId, approvalStatus])
|
||||||
@@ -1328,3 +1336,87 @@ model AiGeneration {
|
|||||||
@@index([tenantId, entityType, entityId])
|
@@index([tenantId, entityType, entityId])
|
||||||
@@map("ai_generations")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ const ENTITY_LABEL: Record<string, string> = {
|
|||||||
import_job: "Auftragsimport",
|
import_job: "Auftragsimport",
|
||||||
material_usage: "Material",
|
material_usage: "Material",
|
||||||
time_entry: "Arbeitszeit",
|
time_entry: "Arbeitszeit",
|
||||||
|
billing_record: "Abrechnungseintrag",
|
||||||
|
work_order_milestone: "Meilenstein",
|
||||||
work_session: "Einsatzzeit",
|
work_session: "Einsatzzeit",
|
||||||
photo: "Foto",
|
photo: "Foto",
|
||||||
voice_note: "Sprachnotiz",
|
voice_note: "Sprachnotiz",
|
||||||
|
|||||||
+5
-1
@@ -23,13 +23,17 @@ export const EVENT_TYPES = [
|
|||||||
"time.approval_requested",
|
"time.approval_requested",
|
||||||
"time.approved",
|
"time.approved",
|
||||||
"time.rejected",
|
"time.rejected",
|
||||||
|
// L14 Abrechnungsübersicht (Meilensteine)
|
||||||
|
"milestone.reached",
|
||||||
|
"milestone.confirmed",
|
||||||
|
"milestone.rejected",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type EventType = (typeof EVENT_TYPES)[number];
|
export type EventType = (typeof EVENT_TYPES)[number];
|
||||||
|
|
||||||
export type DomainEvent = {
|
export type DomainEvent = {
|
||||||
type: EventType;
|
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;
|
entityId: string;
|
||||||
/** Short, human-readable facts for templates (no PII beyond what the recipient may see). */
|
/** Short, human-readable facts for templates (no PII beyond what the recipient may see). */
|
||||||
data?: Record<string, string | number | boolean | null>;
|
data?: Record<string, string | number | boolean | null>;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const MODULES = [
|
|||||||
{ key: "documents", name: "Dokumente", href: "/documents", nav: "documents" },
|
{ key: "documents", name: "Dokumente", href: "/documents", nav: "documents" },
|
||||||
{ key: "notifications", name: "Benachrichtigungen", href: "/notifications", nav: "notifications" },
|
{ key: "notifications", name: "Benachrichtigungen", href: "/notifications", nav: "notifications" },
|
||||||
{ key: "lotse", name: "Lotse (KI-Assistent)", href: null, nav: "lotse" },
|
{ key: "lotse", name: "Lotse (KI-Assistent)", href: null, nav: "lotse" },
|
||||||
|
{ key: "billing", name: "Abrechnungsübersicht", href: "/billing", nav: "billing" },
|
||||||
] as const satisfies readonly ModuleDef[];
|
] as const satisfies readonly ModuleDef[];
|
||||||
|
|
||||||
export type ModuleKey = (typeof MODULES)[number]["key"];
|
export type ModuleKey = (typeof MODULES)[number]["key"];
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export const TENANT_MODELS: readonly string[] = [
|
|||||||
"Notification",
|
"Notification",
|
||||||
"SyncOperation",
|
"SyncOperation",
|
||||||
"AiGeneration",
|
"AiGeneration",
|
||||||
|
// L14 Abrechnungsübersicht
|
||||||
|
"WorkOrderMilestone",
|
||||||
|
"BillingRecord",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ const TENANT_MODELS = new Set<string>([
|
|||||||
"Notification",
|
"Notification",
|
||||||
"SyncOperation",
|
"SyncOperation",
|
||||||
"AiGeneration",
|
"AiGeneration",
|
||||||
|
// L14 Abrechnungsübersicht
|
||||||
|
"WorkOrderMilestone",
|
||||||
|
"BillingRecord",
|
||||||
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
|
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
|
||||||
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
|
||||||
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
|
|||||||
{ model: "SyncOperation", field: "userId" },
|
{ model: "SyncOperation", field: "userId" },
|
||||||
{ model: "SyncOperation", field: "resolvedById" },
|
{ model: "SyncOperation", field: "resolvedById" },
|
||||||
{ model: "AiGeneration", field: "createdById" },
|
{ 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
|
// 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.
|
// tenant business data under data processing — not part of the employee subject export.
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export const PERMISSIONS = [
|
|||||||
"settings:templates",
|
"settings:templates",
|
||||||
// Lotse (KI-Assistent)
|
// Lotse (KI-Assistent)
|
||||||
"lotse:use",
|
"lotse:use",
|
||||||
|
// L14 Abrechnungsübersicht (keine Buchhaltung): Übersicht lesen / abgerechnet markieren, stornieren, Meilensteine bestätigen
|
||||||
|
"billing:read",
|
||||||
|
"billing:write",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Permission = (typeof PERMISSIONS)[number];
|
export type Permission = (typeof PERMISSIONS)[number];
|
||||||
|
|||||||
Reference in New Issue
Block a user