diff --git a/AGENTS.md b/AGENTS.md index 77b0170..d435cf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -576,6 +576,27 @@ Future sizing will need: Users must be able to override sizing suggestions. +## Estimate Balance Module (Grobe Leistungsbilanz) + +`src/estimate-balance` plus `src/shared/estimate-balance` is a separate module +for the early-phase power balance by DIN 276 cost group (see +`docs/estimate-balance.md`). Rules: + +- It has its own tables (`estimate_*`) and never writes circuit, board or + project-device data; it only reads device rows for the comparison. +- Writes use the project command mechanism (`estimate-balance.insert/.update/ + .delete` with complete documents), so revision checks and Undo/Redo apply. +- The calculation and comparison live in the import-free + `src/shared/estimate-balance/estimate-balance.ts` and are shared by API and UI; + displayed values use `roundEstimateValue`. +- It is not part of project snapshots or project transfer yet (open question + in issue #7); do not add it there without a snapshot schema version. +- UI: `/projects/:id/grobbilanz` (Stände) and `/grobbilanz/:balanceId` (editor, + `src/frontend/estimate-balance/`). The editor autosaves the whole document + debounced (one Undo step per save), keeps invalid raw input local, and on a + 409 reloads only if the balance itself changed; otherwise it re-saves with + the new revision. `request()` throws `ApiRequestError` (status, code). + ## Persistence and Migration Rules - SQLite is the currently supported database. diff --git a/docs/README.md b/docs/README.md index cdddb67..09a3668 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we Verteilerübersicht, Verteilerpläne, Büro-Logo - [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume, Befehle, Snapshot-Schema 7, eigene Seite +- [Grobe Leistungsbilanz](estimate-balance.md) – LPH 2/3 nach Kostengruppen, + Rechnung, Vergleich mit der detaillierten Bilanz - [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte - [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) – bewusst noch nicht implementierte oder begrenzte Funktionen diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 156be62..8e6e720 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -214,6 +214,12 @@ A stale `expectedRevision` returns HTTP `409` with `PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. +## Estimate Balance (Grobe Leistungsbilanz) + +Separate module under `/projects/:projectId/estimate-balances`; complete +documents are written through `estimate-balance.insert/.update/.delete` project +commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. + ## Circuit-First Endpoints ### Distribution Board Setup diff --git a/docs/estimate-balance.md b/docs/estimate-balance.md new file mode 100644 index 0000000..4e43cec --- /dev/null +++ b/docs/estimate-balance.md @@ -0,0 +1,108 @@ +# Grobe Leistungsbilanz (LPH 2/3) + +Eigenes Modul für die Leistungsbilanz der Vor- und Entwurfsplanung, gegliedert +nach Kostengruppen (DIN 276, KG 400). Es gibt dort noch keine Verteilungen und +Stromkreise: Jedes Gewerk trägt unter seiner Kostengruppe Verbraucher als +Stückzahl mit Einzelleistung oder als Fläche mit W/m² ein. Aufbau und Rechnung +folgen der bisherigen Excel-Vorlage „Leistungsbilanz – KG400“. Abstimmung: +Issue #7. + +Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die +Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt +nichts in Stromkreislisten, Verteilungen oder Projektgeräte. + +## Aufbau + +``` +src/shared/estimate-balance/estimate-balance.ts Datenform, Rechnung, Vergleich (rein, ohne Imports) +src/estimate-balance/domain/estimate-balance-command.model.ts Snapshot-Schema (Zod), Befehle +src/estimate-balance/domain/estimate-balance-project-command.store.ts Store-Port +src/estimate-balance/api/estimate-balance-request.schemas.ts Request-Schemas +src/db/schema/estimate-balances.ts Tabellen (Migration 0010) +src/db/repositories/estimate-balance*.ts Lesen, Schreiben, Befehls-Store +src/server/controllers/estimate-balance.controller.ts API +src/app/projects/[projectId]/grobbilanz/ Übersicht der Stände und Editor +src/frontend/estimate-balance/ Editor-Komponenten und Helfer +``` + +Die gemeinsame Datei unter `src/shared/estimate-balance` hat keine Imports, weil +das Frontend sie direkt nutzt (Turbopack löst die `.js`-Suffixe der API nicht auf). + +## Datenmodell + +| Tabelle | Inhalt | +|---|---| +| `estimate_balances` | ein **Stand** je Projekt (z. B. Vorplanung, Entwurfsplanung): Name, LPH, Datum, Reserve AV/SV in %, Sortierung | +| `estimate_groups` | Kostengruppe (`"410"` … `"480"`, `""` für „Allgemein“), Titel, „bearbeitet von“, Sortierung | +| `estimate_rows` | Versorgung, Stromkreis-Nr. (Freitext), Verbraucher/Fläche, Anzahl bzw. Fläche, Einheit (`piece` = St., `area` = m²), Anschluss (fest/Steckdose), Einzelleistung in W bzw. W/m², GLF, cos φ, SV ja/nein, Anmerkung | + +Neue Stände entstehen aus der Vorlage KG 400 (410 Sanitär, 420 Wärme, 430 Luft, +434 Kälte, 440 Starkstrom, 450 Schwachstrom, 460 Förder, 470 Nutzungsspezifisch, +480 GA, Allgemein; Reserve AV 10 %), leer oder als Kopie eines bestehenden +Stands mit neuen IDs. So lässt sich ein Stand einfrieren und später vergleichen. + +## Rechnung + +- Zeile: P = Anzahl bzw. Fläche × Einzelleistung × GLF, S = P / cos φ. +- **AV** enthält alle Verbraucher (Normalversorgung). **SV** listet zusätzlich + die Zeilen mit „SV = ja“, die auch aus der Sicherheitsstromversorgung versorgt + werden müssen. Das ist die Lesart der Vorlage; die Regel liegt in + `calculateEstimateRow` und lässt sich dort zentral ändern. +- Summen je Kostengruppe, Gesamtsumme, Reserve in % (AV und SV getrennt), + Leistung inklusive Reserve und Maximalstrom I = S / (√3 · U) mit der + Drehstromspannung aus den Projekteinstellungen. +- Anzeige wie in der Tabellenkalkulation gerundet (`roundEstimateValue`, + 40,175 → 40,18). Der Test `tests/estimate-balance.test.ts` rechnet das + Beispiel der Vorlage vollständig nach (143,24 kW, 148,48 kVA, mit 10 % + Reserve 157,56 kW / 163,33 kVA, 235,75 A). + +## Vergleich geschätzt ↔ detailliert + +`GET …/comparison` summiert alle Gerätezeilen aller Stromkreislisten des +Projekts (Anzahl × Leistung je Stück × Geräte-GZF, wie der Stromkreisbaum) und +ordnet sie über das Feld **Kostengruppe** der Gerätezeile zu. Maßgeblich ist das +längste passende Präfix: `434` → Gruppe 434, `431` → Gruppe 430, `444` → 440. +Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der +Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1. +Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %. + +## Befehle, Undo/Redo + +Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit +Revision, Inverse und persistentem Undo/Redo: + +- `estimate-balance.insert` / `estimate-balance.delete` mit dem vollständigen + Stand (Gruppen und Zeilen) +- `estimate-balance.update` mit vollständigem `expected`/`target`-Dokument; der + Store prüft, dass der gespeicherte Stand genau `expected` entspricht, und + ersetzt ihn atomar. Die Inverse ist das vertauschte Paar. + +Der Editor speichert das ganze Dokument verzögert (Autosave); jeder +Speichervorgang ist ein Undo-Schritt. Unveränderte Dokumente erzeugen keine +Revision (`{ unchanged: true }`). + +## Bewusst nicht enthalten (Fragen an Julian, Issue #7) + +- Die grobe Bilanz ist **nicht** Teil von Projekt-Snapshot, Restore und + Projekttransfer; das Snapshot-Schema bleibt unverändert. Ein Restore lässt + die Stände unverändert, eine Projektkopie übernimmt sie nicht. +- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe. +- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet + mit Präfixen. + +## API + +- `GET /api/projects/:projectId/estimate-balances` +- `POST /api/projects/:projectId/estimate-balances` – + `{ "expectedRevision": 7, "name": "Entwurfsplanung", "workStage": "LPH 3", "dateIso": "2026-09-26", "template": "kg400" }` + (`template`: `kg400` | `empty` | `copy` mit `copyFromBalanceId`) +- `GET /api/projects/:projectId/estimate-balances/:balanceId` +- `PUT /api/projects/:projectId/estimate-balances/:balanceId` – + `{ "expectedRevision": 8, "balance": { …vollständiges Dokument… } }` +- `DELETE /api/projects/:projectId/estimate-balances/:balanceId` – `{ "expectedRevision": 9 }` +- `GET /api/projects/:projectId/estimate-balances/:balanceId/comparison` + +Validierung: Name 1–200 Zeichen, getrimmte Texte, Anzahl/Fläche und +Einzelleistung ≥ 0, GLF 0–1, cos φ > 0 bis 1, Reserve 0–100 %, Datum +`YYYY-MM-DD`, eindeutige Gruppen- und Zeilen-IDs, unbekannte Felder werden +abgelehnt. diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js index 947767a..fa91cc4 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -6,6 +6,9 @@ const db = new Database(dbPath, { readonly: true }); const requiredTables = [ "buildings", + "estimate_balances", + "estimate_groups", + "estimate_rows", "circuit_sections", "circuits", "circuit_device_rows", diff --git a/src/app/globals.css b/src/app/globals.css index 0c40c5c..b3f526a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1602,3 +1602,493 @@ button.structure-entry-main:focus-visible { color: var(--color-ink-soft); font-size: 0.9rem; } + +/* ── Grobe Leistungsbilanz ──────────────────────────────────────────── */ +/* Tabelle im Stil der Büro-Vorlage „Leistungsbilanz – KG400“: kompakte + Zellen-Inputs statt Bootstrap-Formularfelder, Druck als A4 quer. */ + +.estimate-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.estimate-save-status { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); + font-size: 0.8rem; +} + +.estimate-save-badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-weight: 600; + border: 1px solid var(--color-border); + color: var(--color-ink-soft); + background: var(--color-bg); +} + +.estimate-save-badge.is-saved { + color: var(--color-signal-dark); + border-color: var(--color-signal); +} + +.estimate-save-badge.is-busy { + color: var(--color-warn); + border-color: var(--color-warn); +} + +.estimate-save-badge.is-error { + color: var(--color-danger); + border-color: var(--color-danger); + background: var(--color-danger-bg); +} + +.estimate-save-hint { + color: var(--color-danger); +} + +.estimate-header-fields { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm) var(--space-lg); + margin-bottom: var(--space-md); +} + +.estimate-header-field { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 10rem; +} + +.estimate-header-field.is-wide { + flex: 1 1 18rem; +} + +.estimate-header-label { + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + font-weight: 600; + color: var(--color-ink-soft); +} + +.estimate-header-value { + font-weight: 600; +} + +.estimate-header-input { + font-size: 0.95rem; + font-weight: 600; + border-color: var(--panel-border); +} + +.estimate-sheet-wrap { + overflow-x: auto; +} + +.estimate-sheet { + width: 100%; + min-width: 1400px; + table-layout: fixed; + border-collapse: collapse; + font-size: 0.82rem; + background: var(--panel-bg); + color: var(--text-strong); +} + +.estimate-comparison { + min-width: 640px; + table-layout: auto; +} + +.estimate-sheet th, +.estimate-sheet td { + border: 1px solid var(--panel-border); + padding: 2px 4px; + vertical-align: middle; +} + +.estimate-sheet thead th { + background: var(--surface-header-row); + font-size: 0.72rem; + font-weight: 700; + text-align: center; + line-height: 1.2; +} + +.estimate-th-hint { + display: block; + font-size: 0.68rem; + font-weight: 400; + color: var(--text-faint); +} + +.estimate-col-kg { width: 3rem; } +.estimate-col-supply { width: 4rem; } +.estimate-col-circuit { width: 4.6rem; } +.estimate-col-name { width: auto; } +.estimate-col-quantity { width: 4.8rem; } +.estimate-col-unit { width: 3.8rem; } +.estimate-col-connection { width: 5rem; } +.estimate-col-power { width: 6.4rem; } +.estimate-col-gzf { width: 6.8rem; } +.estimate-col-cosphi { width: 3.8rem; } +.estimate-col-result { width: 4.6rem; } +.estimate-col-sv { width: 3.2rem; } +.estimate-col-remark { width: 9rem; } +.estimate-col-actions { width: 2.4rem; } + +.estimate-cell-input, +.estimate-cell-select { + width: 100%; + min-width: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: inherit; + font: inherit; + padding: 1px 4px; + line-height: 1.4; +} + +.estimate-cell-select { + padding-right: 0; + cursor: pointer; +} + +.estimate-cell-input:hover:not(:disabled), +.estimate-cell-select:hover:not(:disabled) { + border-color: var(--panel-border-strong); +} + +.estimate-cell-input:focus, +.estimate-cell-select:focus { + outline: none; + border-color: var(--accent-blue); + background: var(--panel-bg); + box-shadow: 0 0 0 2px var(--accent-blue-soft-bg); +} + +.estimate-cell-input.is-numeric { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.estimate-cell-input.is-invalid, +.estimate-cell-input.is-invalid:focus, +.estimate-cell-input.is-invalid:hover { + border-color: var(--color-danger); + background: var(--color-danger-bg); +} + +.estimate-cell-input::placeholder { + color: var(--text-faint); + opacity: 0.7; +} + +.estimate-unit-input, +.estimate-gzf-input { + display: flex; + align-items: center; + gap: 2px; +} + +.estimate-unit-label { + flex: 0 0 auto; + font-size: 0.7rem; + color: var(--text-faint); + min-width: 1.9rem; +} + +.estimate-amev-button { + flex: 0 0 auto; + border: 1px solid var(--panel-border-strong); + border-radius: 3px; + background: var(--panel-bg-subtle); + color: var(--text-subtle); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.03em; + padding: 1px 3px; + line-height: 1.3; +} + +.estimate-amev-button:hover:not(:disabled) { + border-color: var(--accent-blue); + color: var(--accent-blue); +} + +.estimate-number { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.estimate-number.is-muted { + color: var(--text-faint); +} + +.estimate-number.is-over { + color: var(--color-attention-dark); +} + +.estimate-number.is-under { + color: var(--color-signal-dark); +} + +.estimate-center { + text-align: center; +} + +.estimate-cost-group-cell { + font-weight: 700; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.estimate-group-title-row td { + background: var(--surface-component-header); + font-weight: 700; +} + +.estimate-group-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-lg); +} + +.estimate-group-name { + font-size: 0.9rem; +} + +.estimate-group-editor { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-weight: 400; + font-size: 0.75rem; + color: var(--text-muted); +} + +.estimate-group-editor .estimate-cell-input { + width: 12rem; +} + +.estimate-group-actions { + display: flex; + justify-content: flex-end; + gap: 2px; +} + +.estimate-group-title-row .estimate-actions-cell { + width: auto; + white-space: nowrap; +} + +.estimate-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.15rem 0.3rem; + line-height: 1; +} + +.estimate-actions-cell { + text-align: center; +} + +.estimate-empty-row td { + color: var(--text-faint); + font-style: italic; +} + +.estimate-subtotal-row td { + background: var(--panel-bg-subtle); + font-weight: 700; +} + +.estimate-total-label { + text-align: right; + font-weight: 700; +} + +.estimate-total-hint { + font-weight: 400; + color: var(--text-faint); +} + +.estimate-total-row td { + background: var(--surface-header-row); + font-weight: 600; +} + +.estimate-total-row.is-strong td { + font-weight: 800; + border-top: 2px solid var(--panel-border-strong); +} + +.estimate-add-group-row td { + background: var(--panel-bg); + padding: var(--space-sm) 4px; +} + +.estimate-unassigned-row td { + font-style: italic; + color: var(--text-muted); +} + +.estimate-collapse-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 700; + padding: 0; +} + +.estimate-list-table .estimate-number { + font-variant-numeric: tabular-nums; +} + +/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */ +.estimate-print-value { + display: none; +} + +@page estimate-balance { + size: A4 landscape; + margin: 10mm; +} + +@media print { + body:has(.estimate-print-root) .sidebar, + .estimate-print-root .estimate-no-print, + .estimate-print-root .alert, + .estimate-print-root input, + .estimate-print-root select, + .estimate-print-root button { + display: none !important; + } + + body:has(.estimate-print-root) { + background: #fff !important; + } + + body:has(.estimate-print-root) .app-shell { + display: block; + } + + .estimate-print-root { + page: estimate-balance; + padding: 0 !important; + max-width: none; + color: #000; + } + + .estimate-print-root .estimate-print-value { + display: inline; + } + + .estimate-print-root .page-header { + margin-bottom: 4mm; + } + + .estimate-print-root .page-header h1 { + font-size: 14pt; + color: #000; + } + + .estimate-print-root .card { + border: 0; + box-shadow: none; + background: transparent; + } + + .estimate-print-root .card-body, + .estimate-print-root .card-header { + padding: 0; + background: transparent; + border: 0; + } + + .estimate-print-root .estimate-comparison-card { + margin-top: 6mm; + break-inside: avoid; + } + + .estimate-sheet-wrap { + overflow: visible; + } + + .estimate-sheet { + min-width: 0; + table-layout: auto; + font-size: 7.5pt; + background: #fff; + color: #000; + } + + .estimate-sheet th, + .estimate-sheet td { + border-color: #999; + padding: 1px 3px; + } + + .estimate-sheet thead { + display: table-header-group; + } + + .estimate-sheet thead th, + .estimate-total-row td { + background: #e8e8e8 !important; + } + + .estimate-group-title-row td { + background: #d9d9d9 !important; + } + + .estimate-subtotal-row td { + background: #f2f2f2 !important; + } + + .estimate-group-title-row, + .estimate-subtotal-row, + .estimate-total-row { + break-inside: avoid; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .estimate-group-title-row { + break-after: avoid; + } + + /* Totals print once at the end, not repeated on every page. */ + .estimate-sheet tfoot { + display: table-row-group; + break-inside: avoid; + } + + .estimate-col-actions { + width: 0; + } + + .estimate-unit-input, + .estimate-gzf-input { + justify-content: flex-end; + } + + .estimate-header-fields { + margin-bottom: 3mm; + } +} diff --git a/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx new file mode 100644 index 0000000..2aa7f5e --- /dev/null +++ b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx @@ -0,0 +1,743 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { + ArrowLeft, + ChevronDown, + ChevronRight, + Printer, + Redo2, + RefreshCw, + Undo2, +} from "lucide-react"; +import { + ApiRequestError, + getEstimateBalance, + getEstimateComparison, + getProject, + getProjectHistory, + isRevisionConflictError, + redoProjectCommand, + undoProjectCommand, + updateEstimateBalance, +} from "../../../../../frontend/utils/api"; +import type { + EstimateBalanceDto, + EstimateComparisonDto, + ProjectDto, + ProjectHistoryStateDto, +} from "../../../../../frontend/types"; +import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance"; +import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal"; +import { + addEstimateGroup, + estimateBalanceFingerprint, + estimateTextLimits, + normalizeEstimateSortOrder, + toSaveableEstimateBalance, + updateEstimateGroup, + updateEstimateRow, + validateEstimateBalance, +} from "../../../../../frontend/estimate-balance/estimate-balance-document"; +import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format"; +import { + EstimateBalanceSheet, + type EstimateBalanceUpdate, +} from "../../../../../frontend/estimate-balance/estimate-balance-sheet"; +import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table"; +import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input"; +import { + EstimateGroupModal, + type EstimateGroupFormValues, +} from "../../../../../frontend/estimate-balance/estimate-group-modal"; + +const AUTOSAVE_DELAY_MS = 800; +const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen."; + +type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid"; + +type GroupDialog = { group: EstimateGroupSnapshot | null }; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +function isNotFoundError(error: unknown) { + return error instanceof ApiRequestError && error.status === 404; +} + +export default function EstimateBalanceEditorPage() { + const params = useParams<{ projectId: string; balanceId: string }>(); + const { projectId, balanceId } = params; + const router = useRouter(); + + const [project, setProject] = useState(null); + const [draft, setDraft] = useState(null); + const [history, setHistory] = useState(null); + const [comparison, setComparison] = useState(null); + const [comparisonError, setComparisonError] = useState(null); + const [isComparisonOpen, setIsComparisonOpen] = useState(false); + const [saveStatus, setSaveStatus] = useState("saved"); + const [saveError, setSaveError] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isMissing, setIsMissing] = useState(false); + const [isHistoryBusy, setIsHistoryBusy] = useState(false); + const [invalidFieldKeys, setInvalidFieldKeys] = useState>( + () => new Set() + ); + const [groupDialog, setGroupDialog] = useState(null); + const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>( + null + ); + + // Refs carry the latest values into timers, unload handlers and chained + // async saves without re-creating them. + const draftRef = useRef(null); + const revisionRef = useRef(null); + const savedFingerprintRef = useRef(null); + const saveInFlightRef = useRef | null>(null); + const saveTimerRef = useRef | null>(null); + + useEffect(() => { + draftRef.current = draft; + }, [draft]); + + const applyServerBalance = useCallback((balance: EstimateBalanceDto) => { + const normalized = normalizeEstimateSortOrder(balance); + savedFingerprintRef.current = estimateBalanceFingerprint(normalized); + draftRef.current = normalized; + setDraft(normalized); + setSaveStatus("saved"); + setSaveError(null); + }, []); + + const reloadComparison = useCallback(async () => { + try { + setComparison(await getEstimateComparison(projectId, balanceId)); + setComparisonError(null); + } catch (err) { + setComparisonError( + errorMessage(err, "Der Vergleich konnte nicht geladen werden.") + ); + } + }, [balanceId, projectId]); + + /** Reloads project, document and history; replaces the local draft. */ + const reloadAll = useCallback(async () => { + const balanceRequest = getEstimateBalance(projectId, balanceId); + // Handled below; avoids an "unhandled rejection" while project/history load. + balanceRequest.catch(() => undefined); + // Project and history are applied even when the balance is missing, so + // Redo stays available after its creation was undone. + const [currentProject, currentHistory] = await Promise.all([ + getProject(projectId), + getProjectHistory(projectId).catch(() => null), + ]); + setProject(currentProject); + revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision; + setHistory(currentHistory); + const balance = await balanceRequest; + applyServerBalance(balance); + setIsMissing(false); + void reloadComparison(); + }, [applyServerBalance, balanceId, projectId, reloadComparison]); + + const handleReloadError = useCallback((err: unknown) => { + if (isNotFoundError(err)) { + setIsMissing(true); + setDraft(null); + draftRef.current = null; + return; + } + setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden.")); + }, []); + + useEffect(() => { + if (!projectId || !balanceId) { + return; + } + setIsLoading(true); + reloadAll() + .then(() => setError(null)) + .catch(handleReloadError) + .finally(() => setIsLoading(false)); + }, [balanceId, handleReloadError, projectId, reloadAll]); + + const clearSaveTimer = useCallback(() => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + }, []); + + /** + * Saves the current draft if it differs from the last saved state. Saves + * never overlap: a save requested while another runs waits for it and then + * saves whatever changed in the meantime. Resolves to false when the draft + * could not be saved. + */ + const saveNow = useCallback(async (): Promise => { + clearSaveTimer(); + while (saveInFlightRef.current) { + await saveInFlightRef.current; + } + const current = draftRef.current; + const revision = revisionRef.current; + if (!current || revision === null) { + return true; + } + const fingerprint = estimateBalanceFingerprint(current); + if (fingerprint === savedFingerprintRef.current) { + setSaveStatus("saved"); + return true; + } + const problems = validateEstimateBalance(current); + if (problems.length) { + setSaveStatus("invalid"); + setSaveError(problems.join(" ")); + return false; + } + const body = toSaveableEstimateBalance(current); + + /** + * Another command changed the project revision. When this document is + * still unchanged on the server (e.g. an edit in a circuit list), the + * draft is kept and saved again; otherwise the server state wins. + */ + async function resolveConflict(): Promise<"retry" | "reloaded"> { + const [currentProject, serverBalance, currentHistory] = await Promise.all([ + getProject(projectId), + getEstimateBalance(projectId, balanceId), + getProjectHistory(projectId).catch(() => null), + ]); + setProject(currentProject); + setHistory(currentHistory); + revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision; + if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) { + return "retry"; + } + applyServerBalance(serverBalance); + setNotice(CONFLICT_MESSAGE); + void reloadComparison(); + return "reloaded"; + } + + const run = (async () => { + setSaveStatus("saving"); + for (let attempt = 0; ; attempt += 1) { + try { + const result = await updateEstimateBalance( + projectId, + body, + attempt === 0 ? revision : revisionRef.current ?? revision + ); + if (!("unchanged" in result)) { + revisionRef.current = result.history.currentRevision; + setHistory(result.history); + setProject((currentProject) => + currentProject + ? { ...currentProject, currentRevision: result.history.currentRevision } + : currentProject + ); + } + // The sent document is the saved state; the local draft is kept so + // typing is never interrupted by the server's copy. + savedFingerprintRef.current = fingerprint; + setSaveError(null); + const latest = draftRef.current; + if (latest && estimateBalanceFingerprint(latest) !== fingerprint) { + setSaveStatus("pending"); + } else { + setSaveStatus("saved"); + } + void reloadComparison(); + return true; + } catch (err) { + if (isRevisionConflictError(err)) { + try { + if (attempt === 0 && (await resolveConflict()) === "retry") { + continue; + } + if (attempt > 0) { + await reloadAll(); + setNotice(CONFLICT_MESSAGE); + } + } catch (reloadError) { + handleReloadError(reloadError); + } + return false; + } + if (isNotFoundError(err)) { + handleReloadError(err); + return false; + } + setSaveStatus("error"); + setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden.")); + return false; + } + } + })(); + saveInFlightRef.current = run; + try { + return await run; + } finally { + saveInFlightRef.current = null; + } + }, [ + applyServerBalance, + balanceId, + clearSaveTimer, + handleReloadError, + projectId, + reloadAll, + reloadComparison, + ]); + + // Debounced autosave after every change of the draft. + useEffect(() => { + if (!draft) { + return; + } + if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) { + return; + } + setSaveStatus((status) => (status === "saving" ? status : "pending")); + clearSaveTimer(); + saveTimerRef.current = setTimeout(() => { + saveTimerRef.current = null; + void saveNow(); + }, AUTOSAVE_DELAY_MS); + return clearSaveTimer; + }, [clearSaveTimer, draft, saveNow]); + + // Save immediately when the tab is hidden; on unload/unmount send the last + // change with keepalive so it survives the page. + useEffect(() => { + function hasUnsavedDraft() { + const current = draftRef.current; + return Boolean( + current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current + ); + } + function sendKeepalive(): boolean { + const current = draftRef.current; + const revision = revisionRef.current; + if ( + !current || + revision === null || + saveInFlightRef.current || + validateEstimateBalance(current).length + ) { + return false; + } + savedFingerprintRef.current = estimateBalanceFingerprint(current); + void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, { + keepalive: true, + }).catch(() => undefined); + return true; + } + function handleVisibilityChange() { + if (document.visibilityState === "hidden" && hasUnsavedDraft()) { + void saveNow(); + } + } + function handleBeforeUnload(event: BeforeUnloadEvent) { + if (!hasUnsavedDraft() || sendKeepalive()) { + return; + } + // A save is still running or the draft is invalid: let the browser ask. + event.preventDefault(); + event.returnValue = ""; + } + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("beforeunload", handleBeforeUnload); + // In-app navigation (e.g. sidebar) unmounts the editor. + if (hasUnsavedDraft()) { + sendKeepalive(); + } + }; + }, [projectId, saveNow]); + + const handleChange: EstimateBalanceUpdate = useCallback((update) => { + setNotice(null); + setDraft((current) => (current ? update(current) : current)); + }, []); + + const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => { + setInvalidFieldKeys((current) => { + if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) { + return current; + } + const next = new Set(current); + if (isValid) { + next.delete(fieldKey); + } else { + next.add(fieldKey); + } + return next; + }); + }, []); + + async function handleHistory(direction: "undo" | "redo") { + setError(null); + setNotice(null); + setIsHistoryBusy(true); + try { + // Pending edits are saved first so they become their own undo step. + await saveNow(); + const revision = revisionRef.current; + if (revision === null) { + return; + } + const result = + direction === "undo" + ? await undoProjectCommand(projectId, revision) + : await redoProjectCommand(projectId, revision); + revisionRef.current = result.history.currentRevision; + setHistory(result.history); + await reloadAll(); + } catch (err) { + if (isRevisionConflictError(err)) { + await reloadAll().catch(handleReloadError); + setNotice(CONFLICT_MESSAGE); + } else if (isNotFoundError(err)) { + handleReloadError(err); + } else { + setError( + errorMessage( + err, + direction === "undo" + ? "Die letzte Änderung konnte nicht rückgängig gemacht werden." + : "Die letzte Änderung konnte nicht wiederholt werden." + ) + ); + } + } finally { + setIsHistoryBusy(false); + } + } + + async function handleBackClick(event: MouseEvent) { + const current = draftRef.current; + if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) { + return; + } + event.preventDefault(); + await saveNow(); + router.push(`/projects/${projectId}/grobbilanz`); + } + + function handleSaveGroup(values: EstimateGroupFormValues) { + const editingGroup = groupDialog?.group ?? null; + handleChange((current) => + editingGroup + ? updateEstimateGroup(current, editingGroup.id, values) + : addEstimateGroup(current, values).balance + ); + setGroupDialog(null); + } + + function handleSelectGzf(value: number) { + if (gzfTarget) { + handleChange((current) => + updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, { + simultaneityFactor: value, + }) + ); + } + setGzfTarget(null); + } + + const isEditingDisabled = !draft || isHistoryBusy; + const invalidCount = invalidFieldKeys.size; + const listHref = `/projects/${projectId}/grobbilanz`; + + return ( +
+
+
+ void handleBackClick(event)} + title="Zurück zur Übersicht der Stände" + > +
+
+ void saveNow()} + status={saveStatus} + /> +
+ + +
+ +
+
+ + {notice ? ( +
+ {notice} +
+ ) : null} + {error ? ( +
+ {error} +
+ ) : null} + {isLoading ? ( +

+ Grobe Leistungsbilanz wird geladen … +

+ ) : null} + {isMissing ? ( +
+ Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen + rückgängig gemacht wurde.{" "} + Zur Übersicht der Stände +
+ ) : null} + + {draft && project ? ( + <> +
+
+
+
+ Projekt + {project.name} +
+ + + +
+ setGroupDialog({ group: null })} + onChange={handleChange} + onEditGroup={(group) => setGroupDialog({ group })} + onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })} + onValidityChange={handleValidityChange} + threePhaseVoltageV={project.threePhaseVoltageV} + /> +
+
+ +
+
+ + {isComparisonOpen ? ( + + ) : null} +
+ {isComparisonOpen ? ( +
+ {comparisonError ? ( +
+ {comparisonError} +
+ ) : null} + {comparison ? : null} +

+ Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl × + Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne + Verteiler-GZF. +

+
+ ) : null} +
+ + ) : null} + + {groupDialog ? ( + setGroupDialog(null)} + onSave={handleSaveGroup} + /> + ) : null} + {gzfTarget ? ( + setGzfTarget(null)} onSelect={handleSelectGzf} /> + ) : null} +
+ ); +} + +function SaveStatusBadge({ + error, + invalidCount, + onRetry, + status, +}: { + error: string | null; + invalidCount: number; + onRetry: () => void; + status: SaveStatus; +}) { + const label = + status === "saving" + ? "Wird gespeichert …" + : status === "pending" + ? "Ungespeicherte Änderungen" + : status === "error" + ? "Nicht gespeichert – Fehler" + : status === "invalid" + ? "Nicht gespeichert – ungültige Eingaben" + : "Gespeichert"; + const tone = + status === "error" || status === "invalid" + ? "is-error" + : status === "saved" + ? "is-saved" + : "is-busy"; + return ( +
+ + {label} + + {invalidCount ? ( + + {invalidCount === 1 + ? "1 ungültige Eingabe wird nicht gespeichert" + : `${invalidCount} ungültige Eingaben werden nicht gespeichert`} + + ) : null} + {status === "error" ? ( + + ) : null} +
+ ); +} diff --git a/src/app/projects/[projectId]/grobbilanz/page.tsx b/src/app/projects/[projectId]/grobbilanz/page.tsx new file mode 100644 index 0000000..4b2cf81 --- /dev/null +++ b/src/app/projects/[projectId]/grobbilanz/page.tsx @@ -0,0 +1,292 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react"; +import { + createEstimateBalance, + deleteEstimateBalance, + getProject, + listEstimateBalances, +} from "../../../../frontend/utils/api"; +import type { + CreateEstimateBalanceInput, + EstimateBalanceDto, + ProjectCommandResultDto, + ProjectDto, +} from "../../../../frontend/types"; +import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance"; +import { + EstimateBalanceCreateModal, + type EstimateBalanceCreateDefaults, +} from "../../../../frontend/estimate-balance/estimate-balance-create-modal"; +import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document"; +import { + formatIsoDateGerman, + formatKw, +} from "../../../../frontend/estimate-balance/estimate-balance-format"; + +function countRows(balance: EstimateBalanceDto) { + return balance.groups.reduce((sum, group) => sum + group.rows.length, 0); +} + +export default function EstimateBalanceListPage() { + const params = useParams<{ projectId: string }>(); + const projectId = params.projectId; + const router = useRouter(); + const [project, setProject] = useState(null); + const [balances, setBalances] = useState([]); + const [createDefaults, setCreateDefaults] = + useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const reload = useCallback(async () => { + const [currentProject, loadedBalances] = await Promise.all([ + getProject(projectId), + listEstimateBalances(projectId), + ]); + setProject(currentProject); + setBalances([...loadedBalances].sort((left, right) => left.sortOrder - right.sortOrder)); + }, [projectId]); + + useEffect(() => { + if (!projectId) { + return; + } + setIsLoading(true); + reload() + .then(() => setError(null)) + .catch((err: unknown) => + setError( + err instanceof Error + ? err.message + : "Grobe Leistungsbilanzen konnten nicht geladen werden." + ) + ) + .finally(() => setIsLoading(false)); + }, [projectId, reload]); + + async function runCommand( + execute: (expectedRevision: number) => Promise, + fallbackMessage: string + ): Promise { + if (!project) { + return null; + } + setIsSaving(true); + setError(null); + try { + const result = await execute(project.currentRevision); + setProject((current) => + current ? { ...current, currentRevision: result.history.currentRevision } : current + ); + await reload(); + return result; + } catch (err) { + setError(err instanceof Error ? err.message : fallbackMessage); + // Refresh revision and list so a stale state does not block the next + // attempt; the command error stays visible. + await reload().catch(() => undefined); + return null; + } finally { + setIsSaving(false); + } + } + + async function handleCreate(input: CreateEstimateBalanceInput) { + const result = await runCommand( + (expectedRevision) => createEstimateBalance(projectId, input, expectedRevision), + "Stand konnte nicht angelegt werden." + ); + if (result) { + setCreateDefaults(null); + router.push(`/projects/${projectId}/grobbilanz/${result.balance.id}`); + } + } + + async function handleDelete(balance: EstimateBalanceDto) { + if ( + !window.confirm( + `Stand „${balance.name}“ der groben Leistungsbilanz wirklich löschen?` + ) + ) { + return; + } + await runCommand( + (expectedRevision) => deleteEstimateBalance(projectId, balance.id, expectedRevision), + "Stand konnte nicht gelöscht werden." + ); + } + + const isBusy = isSaving || !project; + const voltage = project?.threePhaseVoltageV ?? 400; + + return ( +
+
+
+ +
+ +
+ + {error ? ( +
+ {error} +
+ ) : null} + {isLoading ? ( +

+ Grobe Leistungsbilanzen werden geladen … +

+ ) : null} + +
+
+

+ Stände + {balances.length} +

+
+ {balances.length ? ( +
+ + + + + + + + + + + + + + + {balances.map((balance) => { + const result = calculateEstimateBalance(balance, voltage); + const href = `/projects/${projectId}/grobbilanz/${balance.id}`; + const rowCount = countRows(balance); + return ( + + + + + + + + + + + ); + })} + +
StandLPHDatumAV kWAV kVASV kWSV kVAAktionen
+ + {balance.name} + +
+ {balance.groups.length} Kostengruppen ·{" "} + {rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`} +
+
{balance.workStage ?? "–"}{formatIsoDateGerman(balance.dateIso)} + {formatKw(result.withReserve.av.kw)} + + {formatKw(result.withReserve.av.kva)} + + {formatKw(result.withReserve.sv.kw)} + + {formatKw(result.withReserve.sv.kva)} + +
+ +
+
+

+ Leistungen jeweils inklusive Reserve. +

+
+ ) : !isLoading ? ( +
+

+ Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf + Basis der Vorlage KG 400 anlegen. +

+
+ ) : null} +
+ + {createDefaults ? ( + setCreateDefaults(null)} + onCreate={(input) => void handleCreate(input)} + /> + ) : null} +
+ ); +} diff --git a/src/db/migrations/0010_estimate_balances.sql b/src/db/migrations/0010_estimate_balances.sql new file mode 100644 index 0000000..645ccbe --- /dev/null +++ b/src/db/migrations/0010_estimate_balances.sql @@ -0,0 +1,43 @@ +CREATE TABLE `estimate_balances` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `work_stage` text, + `date_iso` text, + `reserve_av_percent` real DEFAULT 0 NOT NULL, + `reserve_sv_percent` real DEFAULT 0 NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint +CREATE TABLE `estimate_groups` ( + `id` text PRIMARY KEY NOT NULL, + `balance_id` text NOT NULL, + `cost_group` text DEFAULT '' NOT NULL, + `title` text NOT NULL, + `editor` text, + `sort_order` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint +CREATE TABLE `estimate_rows` ( + `id` text PRIMARY KEY NOT NULL, + `group_id` text NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + `supply` text, + `circuit_ref` text, + `name` text DEFAULT '' NOT NULL, + `quantity` real DEFAULT 0 NOT NULL, + `unit` text DEFAULT 'piece' NOT NULL, + `connection` text, + `power_per_unit_w` real DEFAULT 0 NOT NULL, + `simultaneity_factor` real DEFAULT 1 NOT NULL, + `cos_phi` real DEFAULT 1 NOT NULL, + `safety_supply` integer DEFAULT false NOT NULL, + `remark` text, + FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`); \ No newline at end of file diff --git a/src/db/migrations/meta/0010_snapshot.json b/src/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..869d504 --- /dev/null +++ b/src/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,2931 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6df688bd-a78a-46a1-ba7a-4068119bf8bd", + "prevId": "2cd9adef-2f08-4635-b3d9-ecf71f624c7f", + "tables": { + "buildings": { + "name": "buildings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "buildings_project_id_projects_id_fk": { + "name": "buildings_project_id_projects_id_fk", + "tableFrom": "buildings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "board_number": { + "name": "board_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fed_from": { + "name": "fed_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_assets": { + "name": "document_branding_assets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_settings": { + "name": "document_branding_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_balances": { + "name": "estimate_balances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_iso": { + "name": "date_iso", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserve_av_percent": { + "name": "reserve_av_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserve_sv_percent": { + "name": "reserve_sv_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_balances_project_id_idx": { + "name": "estimate_balances_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_balances_project_id_projects_id_fk": { + "name": "estimate_balances_project_id_projects_id_fk", + "tableFrom": "estimate_balances", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_groups": { + "name": "estimate_groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "balance_id": { + "name": "balance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editor": { + "name": "editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_groups_balance_id_idx": { + "name": "estimate_groups_balance_id_idx", + "columns": [ + "balance_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_groups_balance_id_estimate_balances_id_fk": { + "name": "estimate_groups_balance_id_estimate_balances_id_fk", + "tableFrom": "estimate_groups", + "tableTo": "estimate_balances", + "columnsFrom": [ + "balance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_rows": { + "name": "estimate_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "supply": { + "name": "supply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_ref": { + "name": "circuit_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'piece'" + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_per_unit_w": { + "name": "power_per_unit_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "safety_supply": { + "name": "safety_supply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "estimate_rows_group_id_idx": { + "name": "estimate_rows_group_id_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_rows_group_id_estimate_groups_id_fk": { + "name": "estimate_rows_group_id_estimate_groups_id_fk", + "tableFrom": "estimate_rows", + "tableTo": "estimate_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "site_address": { + "name": "site_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_lead": { + "name": "project_lead", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_editor": { + "name": "project_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 485b8f4..209a0a2 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1790419185882, "tag": "0009_buildings", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1790425784626, + "tag": "0010_estimate_balances", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/estimate-balance-project-command.repository.ts b/src/db/repositories/estimate-balance-project-command.repository.ts new file mode 100644 index 0000000..b31ca78 --- /dev/null +++ b/src/db/repositories/estimate-balance-project-command.repository.ts @@ -0,0 +1,132 @@ +import { eq } from "drizzle-orm"; +import { + assertEstimateBalanceDeleteProjectCommand, + assertEstimateBalanceInsertProjectCommand, + assertEstimateBalanceUpdateProjectCommand, + createEstimateBalanceDeleteProjectCommand, + createEstimateBalanceInsertProjectCommand, + createEstimateBalanceUpdateProjectCommand, + estimateBalanceDeleteCommandType, + estimateBalanceInsertCommandType, + estimateBalanceUpdateCommandType, + normalizeEstimateBalanceSnapshot, + sameEstimateBalance, + type EstimateBalanceProjectCommand, +} from "../../estimate-balance/domain/estimate-balance-command.model.js"; +import type { + EstimateBalanceProjectCommandStore, + ExecuteEstimateBalanceCommandInput, +} from "../../estimate-balance/domain/estimate-balance-project-command.store.js"; +import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js"; +import type { AppDatabase } from "../database-context.js"; +import { estimateBalances } from "../schema/estimate-balances.js"; +import { projects } from "../schema/projects.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { + deleteEstimateBalance, + insertEstimateBalance, + readEstimateBalances, +} from "./estimate-balance.persistence.js"; + +export class EstimateBalanceProjectCommandRepository + implements EstimateBalanceProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteEstimateBalanceCommandInput) { + return executeProjectCommandTransaction(this.database, input, (tx) => + this.applyCommand(tx, input.projectId, input.command) + ); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + command: EstimateBalanceProjectCommand + ): EstimateBalanceProjectCommand { + switch (command.type) { + case estimateBalanceInsertCommandType: + assertEstimateBalanceInsertProjectCommand(command); + return this.insert(database, projectId, command.payload.balance); + case estimateBalanceDeleteCommandType: + assertEstimateBalanceDeleteProjectCommand(command); + return this.delete(database, projectId, command.payload.balance); + case estimateBalanceUpdateCommandType: + assertEstimateBalanceUpdateProjectCommand(command); + return this.update( + database, + projectId, + command.payload.expected, + command.payload.target + ); + } + } + + private insert( + database: AppDatabase, + projectId: string, + balance: EstimateBalanceSnapshot + ) { + if (balance.projectId !== projectId) { + throw new Error("Estimate-balance snapshot does not belong to project."); + } + const project = database + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) { + throw new Error("Project does not exist."); + } + const existing = database + .select({ id: estimateBalances.id }) + .from(estimateBalances) + .where(eq(estimateBalances.id, balance.id)) + .get(); + if (existing) { + throw new Error("Estimate-balance id already exists."); + } + const normalized = normalizeEstimateBalanceSnapshot(balance); + insertEstimateBalance(database, normalized); + return createEstimateBalanceDeleteProjectCommand(normalized); + } + + private delete( + database: AppDatabase, + projectId: string, + expected: EstimateBalanceSnapshot + ) { + this.assertPersisted(database, projectId, expected, "deletion"); + if (deleteEstimateBalance(database, projectId, expected.id) !== 1) { + throw new Error("Estimate balance could not be deleted."); + } + return createEstimateBalanceInsertProjectCommand(expected); + } + + private update( + database: AppDatabase, + projectId: string, + expected: EstimateBalanceSnapshot, + target: EstimateBalanceSnapshot + ) { + this.assertPersisted(database, projectId, expected, "update"); + // Group and row ids stay stable; replacing the whole document keeps the + // command exact and its inverse trivially the swapped pair. + deleteEstimateBalance(database, projectId, expected.id); + const normalized = normalizeEstimateBalanceSnapshot(target); + insertEstimateBalance(database, normalized); + return createEstimateBalanceUpdateProjectCommand(normalized, expected); + } + + private assertPersisted( + database: AppDatabase, + projectId: string, + expected: EstimateBalanceSnapshot, + action: string + ) { + const [persisted] = readEstimateBalances(database, projectId, expected.id); + if (!persisted || !sameEstimateBalance(persisted, expected)) { + throw new Error(`Estimate balance changed before ${action}.`); + } + } +} diff --git a/src/db/repositories/estimate-balance.persistence.ts b/src/db/repositories/estimate-balance.persistence.ts new file mode 100644 index 0000000..4d59aa6 --- /dev/null +++ b/src/db/repositories/estimate-balance.persistence.ts @@ -0,0 +1,121 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js"; +import type { AppDatabase } from "../database-context.js"; +import { + estimateBalances, + estimateGroups, + estimateRows, +} from "../schema/estimate-balances.js"; + +/** Reads complete balance documents of a project, ordered like the UI shows them. */ +export function readEstimateBalances( + database: AppDatabase, + projectId: string, + balanceId?: string +): EstimateBalanceSnapshot[] { + const balances = database + .select() + .from(estimateBalances) + .where( + balanceId + ? and( + eq(estimateBalances.projectId, projectId), + eq(estimateBalances.id, balanceId) + ) + : eq(estimateBalances.projectId, projectId) + ) + .orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id)) + .all(); + if (!balances.length) { + return []; + } + const groups = database + .select() + .from(estimateGroups) + .where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id))) + .orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id)) + .all(); + const rows = groups.length + ? database + .select() + .from(estimateRows) + .where(inArray(estimateRows.groupId, groups.map((group) => group.id))) + .orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id)) + .all() + : []; + return balances.map((balance) => ({ + id: balance.id, + projectId: balance.projectId, + name: balance.name, + workStage: balance.workStage, + dateIso: balance.dateIso, + reserveAvPercent: balance.reserveAvPercent, + reserveSvPercent: balance.reserveSvPercent, + sortOrder: balance.sortOrder, + groups: groups + .filter((group) => group.balanceId === balance.id) + .map((group) => ({ + id: group.id, + costGroup: group.costGroup, + title: group.title, + editor: group.editor, + sortOrder: group.sortOrder, + rows: rows + .filter((row) => row.groupId === group.id) + .map(({ groupId: _groupId, ...row }) => row), + })), + })); +} + +/** Writes a complete balance document (the caller removed an older version first). */ +export function insertEstimateBalance( + database: AppDatabase, + balance: EstimateBalanceSnapshot +) { + database + .insert(estimateBalances) + .values({ + id: balance.id, + projectId: balance.projectId, + name: balance.name, + workStage: balance.workStage, + dateIso: balance.dateIso, + reserveAvPercent: balance.reserveAvPercent, + reserveSvPercent: balance.reserveSvPercent, + sortOrder: balance.sortOrder, + }) + .run(); + for (const group of balance.groups) { + database + .insert(estimateGroups) + .values({ + id: group.id, + balanceId: balance.id, + costGroup: group.costGroup, + title: group.title, + editor: group.editor, + sortOrder: group.sortOrder, + }) + .run(); + if (group.rows.length) { + database + .insert(estimateRows) + .values(group.rows.map((row) => ({ ...row, groupId: group.id }))) + .run(); + } + } +} + +/** Removes a balance with its groups and rows (foreign keys cascade). */ +export function deleteEstimateBalance( + database: AppDatabase, + projectId: string, + balanceId: string +) { + return database + .delete(estimateBalances) + .where( + and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId)) + ) + .run().changes; +} diff --git a/src/db/repositories/estimate-balance.repository.ts b/src/db/repositories/estimate-balance.repository.ts new file mode 100644 index 0000000..4c70d27 --- /dev/null +++ b/src/db/repositories/estimate-balance.repository.ts @@ -0,0 +1,60 @@ +import { asc, eq } from "drizzle-orm"; +import type { + DetailedDeviceRowPower, + EstimateBalanceSnapshot, +} from "../../shared/estimate-balance/estimate-balance.js"; +import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuits } from "../schema/circuits.js"; +import { estimateBalances } from "../schema/estimate-balances.js"; +import { readEstimateBalances } from "./estimate-balance.persistence.js"; + +/** Read side of the estimate-balance module; writes go through commands. */ +export class EstimateBalanceRepository { + constructor(private readonly database: AppDatabase) {} + + listByProject(projectId: string): EstimateBalanceSnapshot[] { + return readEstimateBalances(this.database, projectId); + } + + findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null { + return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null; + } + + nextSortOrder(projectId: string): number { + const rows = this.database + .select({ sortOrder: estimateBalances.sortOrder }) + .from(estimateBalances) + .where(eq(estimateBalances.projectId, projectId)) + .orderBy(asc(estimateBalances.sortOrder)) + .all(); + return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0; + } + + /** + * Device rows of all circuit lists of the project with their total power + * (quantity · power per unit · device GZF, like the circuit tree). + */ + listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] { + return this.database + .select({ + costGroup: circuitDeviceRows.costGroup, + quantity: circuitDeviceRows.quantity, + powerPerUnit: circuitDeviceRows.powerPerUnit, + simultaneityFactor: circuitDeviceRows.simultaneityFactor, + cosPhi: circuitDeviceRows.cosPhi, + }) + .from(circuitDeviceRows) + .innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)) + .innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)) + .where(eq(circuitLists.projectId, projectId)) + .all() + .map((row) => ({ + costGroup: row.costGroup, + kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor), + cosPhi: row.cosPhi, + })); + } +} diff --git a/src/db/schema/estimate-balances.ts b/src/db/schema/estimate-balances.ts new file mode 100644 index 0000000..81420f1 --- /dev/null +++ b/src/db/schema/estimate-balances.ts @@ -0,0 +1,61 @@ +import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { projects } from "./projects.js"; + +// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the +// circuit structure. See docs/estimate-balance.md. +export const estimateBalances = sqliteTable( + "estimate_balances", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + workStage: text("work_stage"), + dateIso: text("date_iso"), + reserveAvPercent: real("reserve_av_percent").notNull().default(0), + reserveSvPercent: real("reserve_sv_percent").notNull().default(0), + sortOrder: integer("sort_order").notNull().default(0), + }, + (table) => [index("estimate_balances_project_id_idx").on(table.projectId)] +); + +export const estimateGroups = sqliteTable( + "estimate_groups", + { + id: text("id").primaryKey(), + balanceId: text("balance_id") + .notNull() + .references(() => estimateBalances.id, { onDelete: "cascade" }), + costGroup: text("cost_group").notNull().default(""), + title: text("title").notNull(), + editor: text("editor"), + sortOrder: integer("sort_order").notNull().default(0), + }, + (table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)] +); + +export const estimateRows = sqliteTable( + "estimate_rows", + { + id: text("id").primaryKey(), + groupId: text("group_id") + .notNull() + .references(() => estimateGroups.id, { onDelete: "cascade" }), + sortOrder: integer("sort_order").notNull().default(0), + supply: text("supply"), + circuitRef: text("circuit_ref"), + name: text("name").notNull().default(""), + quantity: real("quantity").notNull().default(0), + unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"), + connection: text("connection", { enum: ["fixed", "socket"] }), + powerPerUnitW: real("power_per_unit_w").notNull().default(0), + simultaneityFactor: real("simultaneity_factor").notNull().default(1), + cosPhi: real("cos_phi").notNull().default(1), + safetySupply: integer("safety_supply", { mode: "boolean" }) + .notNull() + .default(false), + remark: text("remark"), + }, + (table) => [index("estimate_rows_group_id_idx").on(table.groupId)] +); diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 91ee754..cf1f740 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -194,6 +194,15 @@ import { externalObjectDeleteCreatedCircuitCommandType, } from "../models/external-object-new-circuit-project-command.model.js"; import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js"; +import { + assertEstimateBalanceDeleteProjectCommand, + assertEstimateBalanceInsertProjectCommand, + assertEstimateBalanceUpdateProjectCommand, + estimateBalanceDeleteCommandType, + estimateBalanceInsertCommandType, + estimateBalanceUpdateCommandType, +} from "../../estimate-balance/domain/estimate-balance-command.model.js"; +import type { EstimateBalanceProjectCommandStore } from "../../estimate-balance/domain/estimate-balance-project-command.store.js"; interface DispatchProjectCommandInput { projectId: string; @@ -233,7 +242,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore, private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, - private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore + private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, + private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore ) {} executeUser( @@ -654,6 +664,24 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case estimateBalanceInsertCommandType: + case estimateBalanceDeleteCommandType: + case estimateBalanceUpdateCommandType: { + if (input.command.type === estimateBalanceInsertCommandType) { + assertEstimateBalanceInsertProjectCommand(input.command); + } else if (input.command.type === estimateBalanceDeleteCommandType) { + assertEstimateBalanceDeleteProjectCommand(input.command); + } else { + assertEstimateBalanceUpdateProjectCommand(input.command); + } + if (!this.estimateBalanceStore) { + throw new Error("Estimate-balance store is not available."); + } + return this.estimateBalanceStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectStateRestoreCommandType: { assertProjectStateRestoreCommand(input.command); return this.projectStateRestoreStore.execute({ diff --git a/src/estimate-balance/api/estimate-balance-request.schemas.ts b/src/estimate-balance/api/estimate-balance-request.schemas.ts new file mode 100644 index 0000000..d1f1a5f --- /dev/null +++ b/src/estimate-balance/api/estimate-balance-request.schemas.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { expectedProjectRevisionSchema } from "../../shared/validation/project-command.schemas.js"; + +export const createEstimateBalanceRequestSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1).max(200), + workStage: z.string().trim().max(50).nullable().optional(), + dateIso: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .nullable() + .optional(), + template: z.enum(["kg400", "empty", "copy"]), + copyFromBalanceId: z.string().trim().min(1).optional(), + }) + .strict() + .refine((input) => input.template !== "copy" || Boolean(input.copyFromBalanceId), { + message: "copyFromBalanceId is required for template copy", + path: ["copyFromBalanceId"], + }); + +export const updateEstimateBalanceRequestSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + // Validated in detail by parseEstimateBalanceSnapshot. + balance: z.unknown(), + }) + .strict(); + +export const deleteEstimateBalanceRequestSchema = z + .object({ expectedRevision: expectedProjectRevisionSchema }) + .strict(); diff --git a/src/estimate-balance/domain/estimate-balance-command.model.ts b/src/estimate-balance/domain/estimate-balance-command.model.ts new file mode 100644 index 0000000..44263e4 --- /dev/null +++ b/src/estimate-balance/domain/estimate-balance-command.model.ts @@ -0,0 +1,320 @@ +import crypto from "node:crypto"; +import { z } from "zod"; +import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js"; +import { + estimateDefaultGroups, + type EstimateBalanceSnapshot, + type EstimateGroupSnapshot, +} from "../../shared/estimate-balance/estimate-balance.js"; + +export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const; +export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const; +export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const; +export const estimateBalanceCommandSchemaVersion = 1 as const; + +const idSchema = z.string().trim().min(1).max(100); +const trimmed = (max: number) => + z.string().max(max).refine((value) => value === value.trim(), { + message: "must be trimmed", + }); +const nullableText = (max: number) => + trimmed(max) + .refine((value) => value.length > 0, { message: "use null instead of empty" }) + .nullable(); +const finite = z.number().finite(); + +export const estimateRowSnapshotSchema = z + .object({ + id: idSchema, + sortOrder: z.number().int().min(0), + supply: nullableText(50), + circuitRef: nullableText(50), + name: trimmed(200), + quantity: finite.min(0), + unit: z.enum(["piece", "area"]), + connection: z.enum(["fixed", "socket"]).nullable(), + powerPerUnitW: finite.min(0), + simultaneityFactor: finite.min(0).max(1), + cosPhi: finite.gt(0).max(1), + safetySupply: z.boolean(), + remark: nullableText(500), + }) + .strict(); + +export const estimateGroupSnapshotSchema = z + .object({ + id: idSchema, + costGroup: trimmed(20), + title: trimmed(200).refine((value) => value.length > 0, { + message: "title must not be empty", + }), + editor: nullableText(100), + sortOrder: z.number().int().min(0), + rows: z.array(estimateRowSnapshotSchema).max(1000), + }) + .strict(); + +export const estimateBalanceSnapshotSchema = z + .object({ + id: idSchema, + projectId: idSchema, + name: trimmed(200).refine((value) => value.length > 0, { + message: "name must not be empty", + }), + workStage: nullableText(50), + dateIso: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), { + message: "invalid date", + }) + .nullable(), + reserveAvPercent: finite.min(0).max(100), + reserveSvPercent: finite.min(0).max(100), + sortOrder: z.number().int().min(0), + groups: z.array(estimateGroupSnapshotSchema).max(200), + }) + .strict() + .superRefine((balance, context) => { + const ids = new Set(); + for (const group of balance.groups) { + for (const id of [group.id, ...group.rows.map((row) => row.id)]) { + if (ids.has(id)) { + context.addIssue({ + code: "custom", + message: `duplicate id ${id}`, + path: ["groups"], + }); + } + ids.add(id); + } + } + }); + +export interface EstimateBalanceInsertProjectCommand + extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> { + schemaVersion: typeof estimateBalanceCommandSchemaVersion; + type: typeof estimateBalanceInsertCommandType; +} + +export interface EstimateBalanceDeleteProjectCommand + extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> { + schemaVersion: typeof estimateBalanceCommandSchemaVersion; + type: typeof estimateBalanceDeleteCommandType; +} + +export interface EstimateBalanceUpdateProjectCommand + extends SerializedProjectCommand<{ + expected: EstimateBalanceSnapshot; + target: EstimateBalanceSnapshot; + }> { + schemaVersion: typeof estimateBalanceCommandSchemaVersion; + type: typeof estimateBalanceUpdateCommandType; +} + +export type EstimateBalanceProjectCommand = + | EstimateBalanceInsertProjectCommand + | EstimateBalanceDeleteProjectCommand + | EstimateBalanceUpdateProjectCommand; + +/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */ +export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot { + const parsed = estimateBalanceSnapshotSchema.parse(value); + return normalizeEstimateBalanceSnapshot(parsed); +} + +export function normalizeEstimateBalanceSnapshot( + balance: EstimateBalanceSnapshot +): EstimateBalanceSnapshot { + return { + ...balance, + groups: balance.groups.map((group, groupIndex) => ({ + ...group, + sortOrder: groupIndex, + rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })), + })), + }; +} + +/** Stable JSON for exact equality checks of two snapshots. */ +export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string { + const normalized = normalizeEstimateBalanceSnapshot(balance); + return JSON.stringify({ + id: normalized.id, + projectId: normalized.projectId, + name: normalized.name, + workStage: normalized.workStage, + dateIso: normalized.dateIso, + reserveAvPercent: normalized.reserveAvPercent, + reserveSvPercent: normalized.reserveSvPercent, + sortOrder: normalized.sortOrder, + groups: normalized.groups.map((group) => ({ + id: group.id, + costGroup: group.costGroup, + title: group.title, + editor: group.editor, + sortOrder: group.sortOrder, + rows: group.rows.map((row) => ({ + id: row.id, + sortOrder: row.sortOrder, + supply: row.supply, + circuitRef: row.circuitRef, + name: row.name, + quantity: row.quantity, + unit: row.unit, + connection: row.connection, + powerPerUnitW: row.powerPerUnitW, + simultaneityFactor: row.simultaneityFactor, + cosPhi: row.cosPhi, + safetySupply: row.safetySupply, + remark: row.remark, + })), + })), + }); +} + +export function sameEstimateBalance( + left: EstimateBalanceSnapshot, + right: EstimateBalanceSnapshot +): boolean { + return canonicalEstimateBalance(left) === canonicalEstimateBalance(right); +} + +export function createEstimateBalanceSnapshot(input: { + projectId: string; + name: string; + workStage: string | null; + dateIso: string | null; + sortOrder: number; + template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot }; + createId?: () => string; +}): EstimateBalanceSnapshot { + const createId = input.createId ?? (() => crypto.randomUUID()); + let groups: EstimateGroupSnapshot[] = []; + let reserveAvPercent = 10; + let reserveSvPercent = 0; + if (input.template === "kg400") { + groups = estimateDefaultGroups.map((group, index) => ({ + id: createId(), + costGroup: group.costGroup, + title: group.title, + editor: null, + sortOrder: index, + rows: [], + })); + } else if (input.template !== "empty") { + const source = input.template.copyFrom; + reserveAvPercent = source.reserveAvPercent; + reserveSvPercent = source.reserveSvPercent; + groups = source.groups.map((group) => ({ + ...group, + id: createId(), + rows: group.rows.map((row) => ({ ...row, id: createId() })), + })); + } + return parseEstimateBalanceSnapshot({ + id: createId(), + projectId: input.projectId, + name: input.name.trim(), + workStage: input.workStage?.trim() || null, + dateIso: input.dateIso || null, + reserveAvPercent, + reserveSvPercent, + sortOrder: input.sortOrder, + groups, + }); +} + +export function createEstimateBalanceInsertProjectCommand( + balance: EstimateBalanceSnapshot +): EstimateBalanceInsertProjectCommand { + return { + schemaVersion: estimateBalanceCommandSchemaVersion, + type: estimateBalanceInsertCommandType, + payload: { balance: parseEstimateBalanceSnapshot(balance) }, + }; +} + +export function createEstimateBalanceDeleteProjectCommand( + balance: EstimateBalanceSnapshot +): EstimateBalanceDeleteProjectCommand { + return { + schemaVersion: estimateBalanceCommandSchemaVersion, + type: estimateBalanceDeleteCommandType, + payload: { balance: parseEstimateBalanceSnapshot(balance) }, + }; +} + +export function createEstimateBalanceUpdateProjectCommand( + expected: EstimateBalanceSnapshot, + target: EstimateBalanceSnapshot +): EstimateBalanceUpdateProjectCommand { + const payload = { + expected: parseEstimateBalanceSnapshot(expected), + target: parseEstimateBalanceSnapshot(target), + }; + assertMatchingUpdate(payload.expected, payload.target); + return { + schemaVersion: estimateBalanceCommandSchemaVersion, + type: estimateBalanceUpdateCommandType, + payload, + }; +} + +function assertMatchingUpdate( + expected: EstimateBalanceSnapshot, + target: EstimateBalanceSnapshot +) { + if (expected.id !== target.id || expected.projectId !== target.projectId) { + throw new Error("Estimate-balance update must keep id and project."); + } + if (sameEstimateBalance(expected, target)) { + throw new Error("Estimate-balance update must change the balance."); + } +} + +function assertCommandShape( + command: SerializedProjectCommand, + type: string, + keys: string[] +): Record { + const payload = command.payload; + if ( + command.schemaVersion !== estimateBalanceCommandSchemaVersion || + command.type !== type || + payload === null || + typeof payload !== "object" || + Array.isArray(payload) || + Object.keys(payload).sort().join(",") !== [...keys].sort().join(",") + ) { + throw new Error("Unsupported estimate-balance command."); + } + return payload as Record; +} + +export function assertEstimateBalanceInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is EstimateBalanceInsertProjectCommand { + const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]); + parseEstimateBalanceSnapshot(payload.balance); +} + +export function assertEstimateBalanceDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is EstimateBalanceDeleteProjectCommand { + const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]); + parseEstimateBalanceSnapshot(payload.balance); +} + +export function assertEstimateBalanceUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is EstimateBalanceUpdateProjectCommand { + const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [ + "expected", + "target", + ]); + assertMatchingUpdate( + parseEstimateBalanceSnapshot(payload.expected), + parseEstimateBalanceSnapshot(payload.target) + ); +} diff --git a/src/estimate-balance/domain/estimate-balance-project-command.store.ts b/src/estimate-balance/domain/estimate-balance-project-command.store.ts new file mode 100644 index 0000000..e5f7e04 --- /dev/null +++ b/src/estimate-balance/domain/estimate-balance-project-command.store.ts @@ -0,0 +1,24 @@ +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "../../domain/ports/project-revision.store.js"; +import type { EstimateBalanceProjectCommand } from "./estimate-balance-command.model.js"; + +export interface ExecuteEstimateBalanceCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: EstimateBalanceProjectCommand; +} + +export interface ExecutedEstimateBalanceCommand { + revision: AppendedProjectRevision; + inverse: EstimateBalanceProjectCommand; +} + +export interface EstimateBalanceProjectCommandStore { + execute(input: ExecuteEstimateBalanceCommandInput): ExecutedEstimateBalanceCommand; +} diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index a94378e..395cd7c 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -17,6 +17,7 @@ const PROJECT_SECTIONS = [ // Separate project sub-routes (/projects/[id]/). const PROJECT_ROUTES = [ { segment: "struktur", label: "Gebäudestruktur", icon: "▥" }, + { segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" }, ] as const; // Matches the project overview and all of its sub-routes (group 2 = rest). diff --git a/src/frontend/estimate-balance/estimate-balance-create-modal.tsx b/src/frontend/estimate-balance/estimate-balance-create-modal.tsx new file mode 100644 index 0000000..6d8f123 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-balance-create-modal.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import { FormModal } from "../components/form-modal"; +import type { + CreateEstimateBalanceInput, + EstimateBalanceDto, + EstimateBalanceTemplate, +} from "../types"; +import { estimateTextLimits } from "./estimate-balance-document"; +import { getTodayIsoDate } from "./estimate-balance-format"; + +export interface EstimateBalanceCreateDefaults { + name?: string; + workStage?: string | null; + template?: EstimateBalanceTemplate; + copyFromBalanceId?: string; +} + +interface EstimateBalanceCreateModalProps { + balances: EstimateBalanceDto[]; + defaults: EstimateBalanceCreateDefaults; + isSaving: boolean; + onClose: () => void; + onCreate: (input: CreateEstimateBalanceInput) => void; +} + +/** "Neuer Stand": name, LPH, date and the starting content. */ +export function EstimateBalanceCreateModal({ + balances, + defaults, + isSaving, + onClose, + onCreate, +}: EstimateBalanceCreateModalProps) { + const [name, setName] = useState(defaults.name ?? ""); + const [workStage, setWorkStage] = useState(defaults.workStage ?? ""); + const [dateIso, setDateIso] = useState(getTodayIsoDate()); + const [template, setTemplate] = useState( + defaults.template ?? "kg400" + ); + const [copyFromBalanceId, setCopyFromBalanceId] = useState( + defaults.copyFromBalanceId ?? balances[0]?.id ?? "" + ); + const isCopyInvalid = template === "copy" && !copyFromBalanceId; + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!name.trim() || isCopyInvalid) { + return; + } + onCreate({ + name: name.trim(), + workStage: workStage.trim() || null, + dateIso: dateIso || null, + template, + ...(template === "copy" ? { copyFromBalanceId } : {}), + }); + } + + return ( + +
+
+ + setName(event.target.value)} + placeholder="z. B. Entwurfsplanung" + required + value={name} + /> +
+
+ + setWorkStage(event.target.value)} + placeholder="z. B. LPH 3" + value={workStage} + /> +
+
+ + setDateIso(event.target.value)} + type="date" + value={dateIso} + /> +
+
+ + +
+ {template === "copy" ? ( +
+ + +
+ ) : null} +
+

+ {template === "kg400" + ? "Legt die Kostengruppen 410 bis 480 und „Allgemein“ ohne Einträge an." + : template === "empty" + ? "Legt einen Stand ohne Kostengruppen an." + : "Übernimmt alle Kostengruppen und Zeilen des gewählten Stands."} +

+
+
+
+ ); +} diff --git a/src/frontend/estimate-balance/estimate-balance-document.ts b/src/frontend/estimate-balance/estimate-balance-document.ts new file mode 100644 index 0000000..0049dc2 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-balance-document.ts @@ -0,0 +1,301 @@ +// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document. +// The editor keeps the complete document as local draft and saves it as a +// whole (PUT), so every edit is a function draft → new draft. +import type { + EstimateBalanceSnapshot, + EstimateGroupSnapshot, + EstimateRowSnapshot, +} from "../../shared/estimate-balance/estimate-balance"; +import { createClientId } from "../utils/client-id"; + +export type MoveDirection = -1 | 1; + +/** Maximum text lengths accepted by the server. */ +export const estimateTextLimits = { + balanceName: 200, + workStage: 50, + costGroup: 20, + groupTitle: 200, + groupEditor: 100, + supply: 50, + circuitRef: 50, + rowName: 200, + remark: 500, +} as const; + +export type EstimateRowPatch = Partial>; +export type EstimateGroupPatch = Partial>; + +export function createEstimateRow( + sortOrder: number, + createId: () => string = createClientId +): EstimateRowSnapshot { + return { + id: createId(), + sortOrder, + supply: null, + circuitRef: null, + name: "", + quantity: 1, + unit: "piece", + connection: null, + powerPerUnitW: 0, + simultaneityFactor: 1, + cosPhi: 1, + safetySupply: false, + remark: null, + }; +} + +export function createEstimateGroup( + input: { costGroup: string; title: string; editor?: string | null }, + sortOrder: number, + createId: () => string = createClientId +): EstimateGroupSnapshot { + return { + id: createId(), + costGroup: input.costGroup, + title: input.title, + editor: input.editor ?? null, + sortOrder, + rows: [], + }; +} + +function bySortOrder(left: T, right: T) { + return left.sortOrder - right.sortOrder; +} + +/** Orders groups and rows by sortOrder and renumbers both from 0. */ +export function normalizeEstimateSortOrder( + balance: EstimateBalanceSnapshot +): EstimateBalanceSnapshot { + return { + ...balance, + groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({ + ...group, + sortOrder: groupIndex, + rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({ + ...row, + sortOrder: rowIndex, + })), + })), + }; +} + +function renumber(items: T[]): T[] { + return items.map((item, index) => + item.sortOrder === index ? item : { ...item, sortOrder: index } + ); +} + +function mapGroup( + balance: EstimateBalanceSnapshot, + groupId: string, + update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot +): EstimateBalanceSnapshot { + let changed = false; + const groups = balance.groups.map((group) => { + if (group.id !== groupId) { + return group; + } + const next = update(group); + changed = next !== group; + return next; + }); + // An unknown group or a no-op keeps the identical document. + return changed ? { ...balance, groups } : balance; +} + +function moveItem(items: T[], index: number, direction: MoveDirection): T[] | null { + const target = index + direction; + if (index < 0 || target < 0 || target >= items.length) { + return null; + } + const next = [...items]; + [next[index], next[target]] = [next[target]!, next[index]!]; + return next; +} + +/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */ +export function addEstimateRow( + balance: EstimateBalanceSnapshot, + groupId: string, + options: { afterRowId?: string; createId?: () => string } = {} +): { balance: EstimateBalanceSnapshot; rowId: string | null } { + let rowId: string | null = null; + const next = mapGroup(balance, groupId, (group) => { + const row = createEstimateRow(0, options.createId); + rowId = row.id; + const afterIndex = options.afterRowId + ? group.rows.findIndex((candidate) => candidate.id === options.afterRowId) + : -1; + const rows = [...group.rows]; + rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row); + return { ...group, rows: renumber(rows) }; + }); + return { balance: next, rowId }; +} + +export function updateEstimateRow( + balance: EstimateBalanceSnapshot, + groupId: string, + rowId: string, + patch: EstimateRowPatch +): EstimateBalanceSnapshot { + return mapGroup(balance, groupId, (group) => ({ + ...group, + rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)), + })); +} + +export function removeEstimateRow( + balance: EstimateBalanceSnapshot, + groupId: string, + rowId: string +): EstimateBalanceSnapshot { + return mapGroup(balance, groupId, (group) => ({ + ...group, + rows: renumber(group.rows.filter((row) => row.id !== rowId)), + })); +} + +export function moveEstimateRow( + balance: EstimateBalanceSnapshot, + groupId: string, + rowId: string, + direction: MoveDirection +): EstimateBalanceSnapshot { + return mapGroup(balance, groupId, (group) => { + const rows = moveItem( + group.rows, + group.rows.findIndex((row) => row.id === rowId), + direction + ); + return rows ? { ...group, rows: renumber(rows) } : group; + }); +} + +/** Appends a group and returns its id. */ +export function addEstimateGroup( + balance: EstimateBalanceSnapshot, + input: { costGroup: string; title: string; editor?: string | null }, + createId: () => string = createClientId +): { balance: EstimateBalanceSnapshot; groupId: string } { + const group = createEstimateGroup(input, balance.groups.length, createId); + return { + balance: { ...balance, groups: renumber([...balance.groups, group]) }, + groupId: group.id, + }; +} + +export function updateEstimateGroup( + balance: EstimateBalanceSnapshot, + groupId: string, + patch: EstimateGroupPatch +): EstimateBalanceSnapshot { + return mapGroup(balance, groupId, (group) => ({ ...group, ...patch })); +} + +export function removeEstimateGroup( + balance: EstimateBalanceSnapshot, + groupId: string +): EstimateBalanceSnapshot { + const groups = balance.groups.filter((group) => group.id !== groupId); + return groups.length === balance.groups.length + ? balance + : { ...balance, groups: renumber(groups) }; +} + +export function moveEstimateGroup( + balance: EstimateBalanceSnapshot, + groupId: string, + direction: MoveDirection +): EstimateBalanceSnapshot { + const groups = moveItem( + balance.groups, + balance.groups.findIndex((group) => group.id === groupId), + direction + ); + return groups ? { ...balance, groups: renumber(groups) } : balance; +} + +function requiredText(value: string) { + return value.trim(); +} + +function optionalText(value: string | null) { + const trimmed = value?.trim() ?? ""; + return trimmed ? trimmed : null; +} + +/** + * The exact document the server accepts: strings trimmed, empty optional + * texts as null, sort orders continuous. Only fields of the shared snapshot + * type are copied, so no UI state can leak into the request. + */ +export function toSaveableEstimateBalance( + balance: EstimateBalanceSnapshot +): EstimateBalanceSnapshot { + const normalized = normalizeEstimateSortOrder(balance); + return { + id: normalized.id, + projectId: normalized.projectId, + name: requiredText(normalized.name), + workStage: optionalText(normalized.workStage), + dateIso: normalized.dateIso || null, + reserveAvPercent: normalized.reserveAvPercent, + reserveSvPercent: normalized.reserveSvPercent, + sortOrder: normalized.sortOrder, + groups: normalized.groups.map((group) => ({ + id: group.id, + costGroup: requiredText(group.costGroup), + title: requiredText(group.title), + editor: optionalText(group.editor), + sortOrder: group.sortOrder, + rows: group.rows.map((row) => ({ + id: row.id, + sortOrder: row.sortOrder, + supply: optionalText(row.supply), + circuitRef: optionalText(row.circuitRef), + name: requiredText(row.name), + quantity: row.quantity, + unit: row.unit, + connection: row.connection, + powerPerUnitW: row.powerPerUnitW, + simultaneityFactor: row.simultaneityFactor, + cosPhi: row.cosPhi, + safetySupply: row.safetySupply, + remark: optionalText(row.remark), + })), + })), + }; +} + +/** Stable comparison key of the saveable document (dirty detection). */ +export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string { + return JSON.stringify(toSaveableEstimateBalance(balance)); +} + +/** + * German problems that would make the server reject the document. The + * editor never saves while this list is not empty. + */ +export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] { + const problems: string[] = []; + const saveable = toSaveableEstimateBalance(balance); + if (!saveable.name) { + problems.push("Die Bezeichnung des Stands darf nicht leer sein."); + } + for (const group of saveable.groups) { + if (!group.title) { + problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`); + } + } + return problems; +} + +export function createCopyName(name: string) { + const copyName = `${name.trim()} (Kopie)`; + return copyName.slice(0, estimateTextLimits.balanceName); +} diff --git a/src/frontend/estimate-balance/estimate-balance-format.ts b/src/frontend/estimate-balance/estimate-balance-format.ts new file mode 100644 index 0000000..85cddf3 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-balance-format.ts @@ -0,0 +1,122 @@ +// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor. +// Numbers are entered and shown in German notation. As in the circuit list +// editor, only "," is accepted as decimal separator: a "." is ambiguous +// (decimal point or thousands separator) and is rejected instead of guessed. +import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance"; + +export type DecimalParseResult = + | { ok: true; value: number } + | { ok: false; error: string }; + +export interface DecimalRange { + min?: number; + /** Excludes `min` itself (e.g. cos φ > 0). */ + minExclusive?: boolean; + max?: number; +} + +const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/; + +function formatBound(value: number) { + return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false }); +} + +export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult { + const trimmed = text.trim(); + if (!trimmed) { + return { ok: false, error: "Bitte einen Wert eingeben." }; + } + if (trimmed.includes(".")) { + return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." }; + } + if (!DECIMAL_PATTERN.test(trimmed)) { + return { ok: false, error: "Ungültiger Zahlenwert." }; + } + const value = Number(trimmed.replace(",", ".")); + if (!Number.isFinite(value)) { + return { ok: false, error: "Ungültiger Zahlenwert." }; + } + if (range.min !== undefined) { + if (range.minExclusive ? value <= range.min : value < range.min) { + return { + ok: false, + error: range.minExclusive + ? `Der Wert muss größer als ${formatBound(range.min)} sein.` + : `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`, + }; + } + } + if (range.max !== undefined && value > range.max) { + return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` }; + } + return { ok: true, value }; +} + +/** Value ranges accepted by the server for the numeric document fields. */ +export const estimateNumberRanges = { + quantity: { min: 0 }, + powerPerUnitW: { min: 0 }, + simultaneityFactor: { min: 0, max: 1 }, + cosPhi: { min: 0, minExclusive: true, max: 1 }, + reservePercent: { min: 0, max: 100 }, +} satisfies Record; + +/** + * Text of a numeric input field: German comma, no thousands separator, so the + * shown text can be parsed again unchanged. + */ +export function formatDecimalInput(value: number, maximumFractionDigits = 6): string { + return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false }); +} + +/** + * Read-only display of a calculated value with thousands separator ("1.200", + * "30,24"). The value is rounded half away from zero first, like Excel does + * (40,175 → 40,18 instead of the binary-rounded 40,17). + */ +export function formatGermanNumber( + value: number, + minimumFractionDigits: number, + maximumFractionDigits = minimumFractionDigits +): string { + const rounded = roundEstimateValue(value, maximumFractionDigits); + // "+ 0" turns -0 into 0 and avoids "-0,00". + return (rounded + 0).toLocaleString("de-DE", { + minimumFractionDigits, + maximumFractionDigits, + }); +} + +export function formatKw(value: number) { + return formatGermanNumber(value, 2); +} + +export function formatAmpere(value: number) { + return formatGermanNumber(value, 2); +} + +export function formatSignedKw(value: number) { + const text = formatKw(value); + return value > 0 && text !== "0,00" ? `+${text}` : text; +} + +export function formatDeltaPercent(value: number | null) { + if (value === null) { + return "–"; + } + const text = formatGermanNumber(value, 1); + return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`; +} + +/** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */ +export function formatIsoDateGerman(dateIso: string | null): string { + const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/); + return match ? `${match[3]}.${match[2]}.${match[1]}` : "–"; +} + +/** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */ +export function getTodayIsoDate(now: Date = new Date()): string { + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${now.getFullYear()}-${month}-${day}`; +} diff --git a/src/frontend/estimate-balance/estimate-balance-sheet.tsx b/src/frontend/estimate-balance/estimate-balance-sheet.tsx new file mode 100644 index 0000000..debba7f --- /dev/null +++ b/src/frontend/estimate-balance/estimate-balance-sheet.tsx @@ -0,0 +1,652 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react"; +import { + calculateEstimateBalance, + calculateEstimateRow, + estimateRowConnectionLabels, + estimateRowUnitLabels, + type EstimateBalanceSnapshot, + type EstimateGroupSnapshot, + type EstimatePower, + type EstimateRowConnection, + type EstimateRowSnapshot, + type EstimateRowUnit, +} from "../../shared/estimate-balance/estimate-balance"; +import { + addEstimateRow, + estimateTextLimits, + moveEstimateGroup, + removeEstimateGroup, + removeEstimateRow, + updateEstimateGroup, + updateEstimateRow, + type EstimateRowPatch, +} from "./estimate-balance-document"; +import { + estimateNumberRanges, + formatAmpere, + formatDecimalInput, + formatKw, + parseGermanDecimal, + type DecimalRange, +} from "./estimate-balance-format"; +import { DraftInput } from "./estimate-draft-input"; + +export type EstimateBalanceUpdate = ( + update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot +) => void; + +interface EstimateBalanceSheetProps { + balance: EstimateBalanceSnapshot; + disabled: boolean; + onAddGroup: () => void; + onChange: EstimateBalanceUpdate; + onEditGroup: (group: EstimateGroupSnapshot) => void; + onOpenGzfPicker: (groupId: string, rowId: string) => void; + onValidityChange: (fieldKey: string, isValid: boolean) => void; + threePhaseVoltageV: number; +} + +// Leading columns up to cos φ; the power columns follow. +const LEADING_COLUMN_COUNT = 10; +const COLUMN_COUNT = 17; + +function NumberInput({ + ariaLabel, + disabled, + fieldKey, + onCommit, + onValidityChange, + range, + value, +}: { + ariaLabel: string; + disabled: boolean; + fieldKey: string; + onCommit: (value: number) => void; + onValidityChange: (fieldKey: string, isValid: boolean) => void; + range: DecimalRange; + value: number; +}) { + return ( + formatDecimalInput(current)} + inputMode="decimal" + onCommit={onCommit} + onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)} + parse={(text) => parseGermanDecimal(text, range)} + value={value} + /> + ); +} + +function TextInput({ + ariaLabel, + disabled, + maxLength, + onChange, + placeholder, + value, +}: { + ariaLabel: string; + disabled: boolean; + maxLength: number; + onChange: (value: string) => void; + placeholder?: string; + value: string | null; +}) { + return ( + <> + onChange(event.target.value)} + placeholder={placeholder} + type="text" + value={value ?? ""} + /> + {value ?? ""} + + ); +} + +function IconButton({ + children, + disabled, + label, + onClick, + variant = "secondary", +}: { + children: ReactNode; + disabled: boolean; + label: string; + onClick: () => void; + variant?: "secondary" | "danger" | "primary"; +}) { + return ( + + ); +} + +function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) { + return ( + <> + {formatKw(power.kw)} + {formatKw(power.kva)} + + ); +} + +function groupLabel(group: EstimateGroupSnapshot) { + return [group.costGroup, group.title].filter(Boolean).join(" "); +} + +function EstimateRowLine({ + disabled, + group, + onChange, + onOpenGzfPicker, + onValidityChange, + row, + rowIndex, +}: { + disabled: boolean; + group: EstimateGroupSnapshot; + onChange: EstimateBalanceUpdate; + onOpenGzfPicker: (groupId: string, rowId: string) => void; + onValidityChange: (fieldKey: string, isValid: boolean) => void; + row: EstimateRowSnapshot; + rowIndex: number; +}) { + const result = calculateEstimateRow(row); + const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`; + const fieldKey = (field: string) => `${row.id}:${field}`; + + function patch(rowPatch: EstimateRowPatch) { + onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch)); + } + + return ( + + + + patch({ supply })} + placeholder="ELT" + value={row.supply} + /> + + + patch({ circuitRef })} + value={row.circuitRef} + /> + + + patch({ name })} + placeholder="Verbraucher oder Fläche" + value={row.name} + /> + + + patch({ quantity })} + onValidityChange={onValidityChange} + range={estimateNumberRanges.quantity} + value={row.quantity} + /> + + + + {estimateRowUnitLabels[row.unit]} + + + + + {row.connection ? estimateRowConnectionLabels[row.connection] : ""} + + + +
+ patch({ powerPerUnitW })} + onValidityChange={onValidityChange} + range={estimateNumberRanges.powerPerUnitW} + value={row.powerPerUnitW} + /> + {row.unit === "area" ? "W/m²" : "W"} +
+ + +
+ patch({ simultaneityFactor })} + onValidityChange={onValidityChange} + range={estimateNumberRanges.simultaneityFactor} + value={row.simultaneityFactor} + /> + +
+ + + patch({ cosPhi })} + onValidityChange={onValidityChange} + range={estimateNumberRanges.cosPhi} + value={row.cosPhi} + /> + + + + patch({ safetySupply: event.target.checked })} + type="checkbox" + /> + {row.safetySupply ? "ja" : "nein"} + + + + patch({ remark })} + value={row.remark} + /> + + + onChange((balance) => removeEstimateRow(balance, group.id, row.id))} + variant="danger" + > + + + + ); +} + +/** + * Spreadsheet-like editor in the layout of the office template + * "Leistungsbilanz – KG400": one section per cost group with title and + * subtotal row, totals, reserve and maximum current at the end. + */ +export function EstimateBalanceSheet({ + balance, + disabled, + onAddGroup, + onChange, + onEditGroup, + onOpenGzfPicker, + onValidityChange, + threePhaseVoltageV, +}: EstimateBalanceSheetProps) { + const result = calculateEstimateBalance(balance, threePhaseVoltageV); + + function confirmRemoveGroup(group: EstimateGroupSnapshot) { + const rowText = + group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`; + if ( + window.confirm( + `Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?` + ) + ) { + onChange((current) => removeEstimateGroup(current, group.id)); + } + } + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {balance.groups.map((group, groupIndex) => { + const groupResult = result.groups[groupIndex]!; + const label = groupLabel(group) || "Kostengruppe"; + return ( + + + + + + + {group.rows.map((row, rowIndex) => ( + + ))} + {!group.rows.length ? ( + + + + ) : null} + + + + + + ); + })} + + + + + + + + + + + + + + + + + + + + + + + + + +
KG + Versorgung + ELT/ISP … + Stromkreis-Nr.Verbraucher / Flächen + Anzahl / Fläche + St. / m² + + Anschl. + Fest/Steckd. + + Einzelleist. + W bzw. W/m² + GL-Faktorcos φNötige AV-LeistungSicherheitsversorgung (SV)Anmerkungen +
WertEinh.kWkVAja/neinkWkVA
{group.costGroup} +
+ {group.title} + +
+
+
+ + onChange((current) => addEstimateRow(current, group.id).balance) + } + variant="primary" + > + + onEditGroup(group)} + > + + + onChange((current) => moveEstimateGroup(current, group.id, -1)) + } + > + + + onChange((current) => moveEstimateGroup(current, group.id, 1)) + } + > + + confirmRemoveGroup(group)} + variant="danger" + > + +
+
+ + Noch keine Einträge – mit „+“ eine Zeile hinzufügen. +
+ Summe {label} + + + + +
+ +
+ Gesamtsumme + + + +
+ Reserveleistung + +
+ + onChange((current) => ({ ...current, reserveAvPercent })) + } + onValidityChange={onValidityChange} + range={estimateNumberRanges.reservePercent} + value={balance.reserveAvPercent} + /> + % +
+
+
+ + onChange((current) => ({ ...current, reserveSvPercent })) + } + onValidityChange={onValidityChange} + range={estimateNumberRanges.reservePercent} + value={balance.reserveSvPercent} + /> + % +
+
+
+ Leistung inkl. Reserve + + + +
+ Maximalstrom + + {threePhaseVoltageV > 0 + ? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)` + : " (Drehstromspannung im Projekt fehlt)"} + + + {formatAmpere(result.maxCurrentA.av)} A + + + {formatAmpere(result.maxCurrentA.sv)} A + +
+
+ ); +} diff --git a/src/frontend/estimate-balance/estimate-comparison-table.tsx b/src/frontend/estimate-balance/estimate-comparison-table.tsx new file mode 100644 index 0000000..f7098b0 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-comparison-table.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + roundEstimateValue, + type EstimateComparison, +} from "../../shared/estimate-balance/estimate-balance"; +import { formatDeltaPercent, formatKw, formatSignedKw } from "./estimate-balance-format"; + +function deltaClass(deltaKw: number) { + if (roundEstimateValue(deltaKw, 2) === 0) { + return "estimate-number"; + } + return deltaKw > 0 ? "estimate-number is-over" : "estimate-number is-under"; +} + +/** Estimated AV power per cost group vs. the detailed circuit-list rows. */ +export function EstimateComparisonTable({ comparison }: { comparison: EstimateComparison }) { + return ( +
+ + + + + + + + + + + + + {comparison.groups.map((group) => ( + + + + + + + + + ))} + + + + + + + + + + + + + + + + + +
KGTitelgeschätzt AV kWdetailliert kWΔ kWΔ %
{group.costGroup} + {group.title} + {group.detailedRowCount ? ( + + {group.detailedRowCount === 1 + ? "1 Gerätezeile" + : `${group.detailedRowCount} Gerätezeilen`} + + ) : null} + {formatKw(group.estimatedAv.kw)}{formatKw(group.detailed.kw)}{formatSignedKw(group.deltaKw)} + {formatDeltaPercent(group.deltaPercent)} +
+ + ohne Kostengruppe / nicht zugeordnet + {comparison.unassigned.rowCount ? ( + + {comparison.unassigned.rowCount === 1 + ? "1 Gerätezeile" + : `${comparison.unassigned.rowCount} Gerätezeilen`} + + ) : null} + –{formatKw(comparison.unassigned.kw)}––
+ Summe{formatKw(comparison.total.estimatedAv.kw)}{formatKw(comparison.total.detailed.kw)} + {formatSignedKw(comparison.total.deltaKw)} + + {formatDeltaPercent(comparison.total.deltaPercent)} +
+
+ ); +} diff --git a/src/frontend/estimate-balance/estimate-draft-input.tsx b/src/frontend/estimate-balance/estimate-draft-input.tsx new file mode 100644 index 0000000..805b264 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-draft-input.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export type DraftParseResult = { ok: true; value: T } | { ok: false; error: string }; + +interface DraftInputProps { + ariaLabel: string; + className?: string; + disabled?: boolean; + format: (value: T) => string; + inputMode?: "decimal" | "text"; + maxLength?: number; + onCommit: (value: T) => void; + /** Reports whether the raw text is currently valid (false while invalid). */ + onValidityChange?: (isValid: boolean) => void; + parse: (text: string) => DraftParseResult; + placeholder?: string; + value: T; +} + +/** + * Input that keeps the raw text the user typed. Only a valid text is + * committed into the document; an invalid text stays visible with a red + * border and is never saved. A value changed from outside (reload, undo) + * replaces the text unless the text already represents that value. + */ +export function DraftInput({ + ariaLabel, + className = "estimate-cell-input", + disabled, + format, + inputMode = "text", + maxLength, + onCommit, + onValidityChange, + parse, + placeholder, + value, +}: DraftInputProps) { + const [text, setText] = useState(() => format(value)); + const [error, setError] = useState(null); + const [syncedValue, setSyncedValue] = useState(value); + + if (!Object.is(syncedValue, value)) { + // Adjusting state while rendering (instead of an effect) avoids a frame + // with stale text after an external change. + setSyncedValue(value); + const parsed = parse(text); + if (!parsed.ok || !Object.is(parsed.value, value)) { + setText(format(value)); + setError(null); + } + } + + const validityRef = useRef(onValidityChange); + useEffect(() => { + validityRef.current = onValidityChange; + }); + const isValid = error === null; + useEffect(() => { + validityRef.current?.(isValid); + }, [isValid]); + // A removed input (deleted row) must not leave an invalid marker behind. + useEffect(() => () => validityRef.current?.(true), []); + + function handleChange(nextText: string) { + setText(nextText); + const parsed = parse(nextText); + if (!parsed.ok) { + setError(parsed.error); + return; + } + setError(null); + if (!Object.is(parsed.value, value)) { + setSyncedValue(parsed.value); + onCommit(parsed.value); + } + } + + function handleBlur() { + // Show the canonical notation again ("0,80" → "0,8") once editing ends. + if (error === null) { + setText(format(value)); + } + } + + return ( + <> + handleChange(event.target.value)} + placeholder={placeholder} + title={error ?? undefined} + type="text" + value={text} + /> + {error ? format(value) : text} + + ); +} diff --git a/src/frontend/estimate-balance/estimate-group-modal.tsx b/src/frontend/estimate-balance/estimate-group-modal.tsx new file mode 100644 index 0000000..8d56308 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-group-modal.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import { FormModal } from "../components/form-modal"; +import { + estimateDefaultGroups, + type EstimateGroupSnapshot, +} from "../../shared/estimate-balance/estimate-balance"; +import { estimateTextLimits } from "./estimate-balance-document"; + +export interface EstimateGroupFormValues { + costGroup: string; + title: string; + editor: string | null; +} + +interface EstimateGroupModalProps { + group: EstimateGroupSnapshot | null; + onClose: () => void; + onSave: (values: EstimateGroupFormValues) => void; +} + +/** Creates or edits the KG number, title and "bearbeitet von" of one group. */ +export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) { + const [costGroup, setCostGroup] = useState(group?.costGroup ?? ""); + const [title, setTitle] = useState(group?.title ?? ""); + const [editor, setEditor] = useState(group?.editor ?? ""); + + function applyTemplate(index: string) { + const template = estimateDefaultGroups[Number(index)]; + if (template) { + setCostGroup(template.costGroup); + setTitle(template.title); + } + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!title.trim()) { + return; + } + onSave({ + costGroup: costGroup.trim(), + title: title.trim(), + editor: editor.trim() || null, + }); + } + + return ( + +
+ {!group ? ( +
+ + +
+ ) : null} +
+ + setCostGroup(event.target.value)} + placeholder="z. B. 440" + value={costGroup} + /> +
+
+ + setTitle(event.target.value)} + placeholder="z. B. Starkstromanlagen" + required + value={title} + /> +
+
+ + setEditor(event.target.value)} + placeholder="Gewerk oder Name (optional)" + value={editor} + /> +
+
+

+ Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der + detaillierten Bilanz keiner Kostengruppe zugeordnet. +

+
+
+
+ ); +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 5c14f5e..11bde4a 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -535,3 +535,34 @@ export interface CreateCircuitDeviceRowInputDto { } import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields"; + +// ── Grobe Leistungsbilanz (LPH 2/3) ───────────────────────────────────── +// The document shapes are shared with the API; see +// src/shared/estimate-balance/estimate-balance.ts. +import type { + EstimateBalanceSnapshot, + EstimateComparison, +} from "../shared/estimate-balance/estimate-balance"; + +export type EstimateBalanceDto = EstimateBalanceSnapshot; +export type EstimateComparisonDto = EstimateComparison; + +export type EstimateBalanceTemplate = "kg400" | "empty" | "copy"; + +export interface CreateEstimateBalanceInput { + name: string; + workStage?: string | null; + dateIso?: string | null; + template: EstimateBalanceTemplate; + /** Required for template "copy". */ + copyFromBalanceId?: string; +} + +export interface EstimateBalanceCommandResultDto extends ProjectCommandResultDto { + balance: EstimateBalanceDto; +} + +/** PUT result: either a new revision or an unchanged document. */ +export type EstimateBalanceUpdateResultDto = + | EstimateBalanceCommandResultDto + | { unchanged: true; balance: EstimateBalanceDto }; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index e1f4608..501ed01 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -38,6 +38,11 @@ import type { ExternalInitialImportPlanDto, ApplyExternalInitialImportInput, ExternalInitialImportResultDto, + CreateEstimateBalanceInput, + EstimateBalanceCommandResultDto, + EstimateBalanceDto, + EstimateBalanceUpdateResultDto, + EstimateComparisonDto, } from "../types"; import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; @@ -87,6 +92,30 @@ import type { CircuitGroupSubtreeSnapshot, } from "../../domain/models/circuit-group-subtree-snapshot.model"; +/** + * Error of a failed API request. The message stays the server's German + * error text; status and optional error code allow callers to react to + * specific failures such as a revision conflict (409). + */ +export class ApiRequestError extends Error { + readonly status: number; + readonly code: string | null; + + constructor(message: string, status: number, code: string | null = null) { + super(message); + this.name = "ApiRequestError"; + this.status = status; + this.code = code; + } +} + +export function isRevisionConflictError(error: unknown) { + return ( + error instanceof ApiRequestError && + (error.status === 409 || error.code === "PROJECT_REVISION_CONFLICT") + ); +} + async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, @@ -99,15 +128,23 @@ async function request(url: string, init?: RequestInit): Promise { if (!response.ok) { let details = await response.text(); + let code: string | null = null; try { - const parsed = JSON.parse(details) as { error?: unknown }; + const parsed = JSON.parse(details) as { error?: unknown; code?: unknown }; if (typeof parsed.error === "string") { details = parsed.error; } + if (typeof parsed.code === "string") { + code = parsed.code; + } } catch { // Keep non-JSON error responses unchanged. } - throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`); + throw new ApiRequestError( + details || `Anfrage fehlgeschlagen (Status ${response.status})`, + response.status, + code + ); } if (response.status === 204) { @@ -1135,3 +1172,66 @@ export function disconnectProjectDeviceRows( } ); } + +// ── Grobe Leistungsbilanz (LPH 2/3) ───────────────────────────────────── + +function estimateBalancesUrl(projectId: string, balanceId?: string) { + const base = `/api/projects/${projectId}/estimate-balances`; + return balanceId ? `${base}/${balanceId}` : base; +} + +export function listEstimateBalances(projectId: string) { + return request(estimateBalancesUrl(projectId)); +} + +export function getEstimateBalance(projectId: string, balanceId: string) { + return request(estimateBalancesUrl(projectId, balanceId)); +} + +export function createEstimateBalance( + projectId: string, + input: CreateEstimateBalanceInput, + expectedRevision: number +) { + return request(estimateBalancesUrl(projectId), { + method: "POST", + body: JSON.stringify({ ...input, expectedRevision }), + }); +} + +/** + * Saves the complete target document. `keepalive` lets the request outlive + * the page, e.g. for the last autosave while the tab is being closed. + */ +export function updateEstimateBalance( + projectId: string, + balance: EstimateBalanceDto, + expectedRevision: number, + options: { keepalive?: boolean } = {} +) { + return request( + estimateBalancesUrl(projectId, balance.id), + { + method: "PUT", + body: JSON.stringify({ expectedRevision, balance }), + ...(options.keepalive ? { keepalive: true } : {}), + } + ); +} + +export function deleteEstimateBalance( + projectId: string, + balanceId: string, + expectedRevision: number +) { + return request(estimateBalancesUrl(projectId, balanceId), { + method: "DELETE", + body: JSON.stringify({ expectedRevision }), + }); +} + +export function getEstimateComparison(projectId: string, balanceId: string) { + return request( + `${estimateBalancesUrl(projectId, balanceId)}/comparison` + ); +} diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index a2880ec..146cda8 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -68,6 +68,9 @@ const commandTypeLabels: Record = { "external-object.unassign-and-delete-created-row": "Externes Objekt aus erzeugter Gerätezeile gelöst", "external-object.update-row-assignment": "Externe Objektzuordnung geändert", + "estimate-balance.insert": "Grobe Leistungsbilanz angelegt", + "estimate-balance.update": "Grobe Leistungsbilanz bearbeitet", + "estimate-balance.delete": "Grobe Leistungsbilanz entfernt", }; export function getProjectRevisionSourceLabel( diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index 4f9b5ee..66fdd92 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -7,6 +7,7 @@ import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; import { BuildingRepository } from "../../db/repositories/building.repository.js"; +import { EstimateBalanceRepository } from "../../db/repositories/estimate-balance.repository.js"; import { FloorRepository } from "../../db/repositories/floor.repository.js"; import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; @@ -28,6 +29,7 @@ export const distributionBoardComponentRepository = export const distributionBoardRepository = new DistributionBoardRepository(db); export const buildingRepository = new BuildingRepository(db); +export const estimateBalanceRepository = new EstimateBalanceRepository(db); export const floorRepository = new FloorRepository(db); export const globalDeviceRepository = new GlobalDeviceRepository(db); export const projectDeviceRepository = new ProjectDeviceRepository(db); diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 79a4a9f..8768173 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -27,6 +27,7 @@ import { ExternalInitialImportProjectCommandRepository } from "../../db/reposito import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js"; import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js"; import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js"; +import { EstimateBalanceProjectCommandRepository } from "../../db/repositories/estimate-balance-project-command.repository.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitDeviceRowProjectCommandStore = @@ -80,6 +81,8 @@ export const externalObjectNewRowProjectCommandStore = new ExternalObjectNewRowProjectCommandRepository(db); export const externalObjectNewCircuitProjectCommandStore = new ExternalObjectNewCircuitProjectCommandRepository(db); +export const estimateBalanceProjectCommandStore = + new EstimateBalanceProjectCommandRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, @@ -107,5 +110,6 @@ export const projectCommandService = new ProjectCommandService( externalInitialImportProjectCommandStore, externalObjectRowAssignmentProjectCommandStore, externalObjectNewRowProjectCommandStore, - externalObjectNewCircuitProjectCommandStore + externalObjectNewCircuitProjectCommandStore, + estimateBalanceProjectCommandStore ); diff --git a/src/server/controllers/estimate-balance.controller.ts b/src/server/controllers/estimate-balance.controller.ts new file mode 100644 index 0000000..a22bcb1 --- /dev/null +++ b/src/server/controllers/estimate-balance.controller.ts @@ -0,0 +1,180 @@ +import type { Request, Response } from "express"; +import { ZodError } from "zod"; +import { + createEstimateBalanceDeleteProjectCommand, + createEstimateBalanceInsertProjectCommand, + createEstimateBalanceSnapshot, + createEstimateBalanceUpdateProjectCommand, + parseEstimateBalanceSnapshot, + sameEstimateBalance, +} from "../../estimate-balance/domain/estimate-balance-command.model.js"; +import { + createEstimateBalanceRequestSchema, + deleteEstimateBalanceRequestSchema, + updateEstimateBalanceRequestSchema, +} from "../../estimate-balance/api/estimate-balance-request.schemas.js"; +import { compareEstimateWithDetailed } from "../../shared/estimate-balance/estimate-balance.js"; +import { estimateBalanceRepository } from "../composition/application-repositories.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +function params(req: Request) { + const { projectId, balanceId } = req.params; + return { + projectId: typeof projectId === "string" ? projectId : null, + balanceId: typeof balanceId === "string" ? balanceId : null, + }; +} + +export function listEstimateBalances(req: Request, res: Response) { + const { projectId } = params(req); + if (!projectId) { + return res.status(400).json({ error: "Ungültige Projekt-ID." }); + } + return res.json(estimateBalanceRepository.listByProject(projectId)); +} + +export function getEstimateBalance(req: Request, res: Response) { + const { projectId, balanceId } = params(req); + if (!projectId || !balanceId) { + return res.status(400).json({ error: "Ungültige Parameter." }); + } + const balance = estimateBalanceRepository.findById(projectId, balanceId); + if (!balance) { + return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." }); + } + return res.json(balance); +} + +export function createEstimateBalance(req: Request, res: Response) { + const { projectId } = params(req); + if (!projectId) { + return res.status(400).json({ error: "Ungültige Projekt-ID." }); + } + const parsed = createEstimateBalanceRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + let template: Parameters[0]["template"] = + parsed.data.template === "copy" ? "empty" : parsed.data.template; + if (parsed.data.template === "copy") { + const source = estimateBalanceRepository.findById( + projectId, + parsed.data.copyFromBalanceId! + ); + if (!source) { + return res + .status(404) + .json({ error: "Der zu kopierende Stand wurde nicht gefunden." }); + } + template = { copyFrom: source }; + } + try { + const balance = createEstimateBalanceSnapshot({ + projectId, + name: parsed.data.name, + workStage: parsed.data.workStage ?? null, + dateIso: parsed.data.dateIso ?? null, + sortOrder: estimateBalanceRepository.nextSortOrder(projectId), + template, + }); + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Grobe Leistungsbilanz anlegen", + command: createEstimateBalanceInsertProjectCommand(balance), + }); + return res.status(201).json({ ...result, balance }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export function updateEstimateBalance(req: Request, res: Response) { + const { projectId, balanceId } = params(req); + if (!projectId || !balanceId) { + return res.status(400).json({ error: "Ungültige Parameter." }); + } + const parsed = updateEstimateBalanceRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + let target; + try { + target = parseEstimateBalanceSnapshot(parsed.data.balance); + } catch (error) { + return res.status(400).json({ + error: "Die grobe Leistungsbilanz enthält ungültige Werte.", + issues: error instanceof ZodError ? error.issues : undefined, + }); + } + if (target.id !== balanceId || target.projectId !== projectId) { + return res + .status(400) + .json({ error: "Die Bilanz gehört nicht zu diesem Projekt oder dieser Adresse." }); + } + const current = estimateBalanceRepository.findById(projectId, balanceId); + if (!current) { + return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." }); + } + if (sameEstimateBalance(current, target)) { + return res.json({ unchanged: true, balance: current }); + } + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Grobe Leistungsbilanz bearbeiten", + command: createEstimateBalanceUpdateProjectCommand(current, target), + }); + return res.json({ + ...result, + balance: estimateBalanceRepository.findById(projectId, balanceId), + }); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export function deleteEstimateBalance(req: Request, res: Response) { + const { projectId, balanceId } = params(req); + if (!projectId || !balanceId) { + return res.status(400).json({ error: "Ungültige Parameter." }); + } + const parsed = deleteEstimateBalanceRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + const current = estimateBalanceRepository.findById(projectId, balanceId); + if (!current) { + return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." }); + } + try { + const result = projectCommandService.executeUser({ + projectId, + expectedRevision: parsed.data.expectedRevision, + description: "Grobe Leistungsbilanz löschen", + command: createEstimateBalanceDeleteProjectCommand(current), + }); + return res.json(result); + } catch (error) { + return respondWithProjectCommandError(error, res); + } +} + +export function getEstimateComparison(req: Request, res: Response) { + const { projectId, balanceId } = params(req); + if (!projectId || !balanceId) { + return res.status(400).json({ error: "Ungültige Parameter." }); + } + const balance = estimateBalanceRepository.findById(projectId, balanceId); + if (!balance) { + return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." }); + } + return res.json( + compareEstimateWithDetailed( + balance, + estimateBalanceRepository.listDetailedDeviceRowPower(projectId) + ) + ); +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 0b61255..529def2 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -21,6 +21,14 @@ import { } from "../controllers/building.controller.js"; import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js"; import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js"; +import { + createEstimateBalance, + deleteEstimateBalance, + getEstimateBalance, + getEstimateComparison, + listEstimateBalances, + updateEstimateBalance, +} from "../controllers/estimate-balance.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js"; import { @@ -118,3 +126,12 @@ projectRouter.get("/:projectId/rooms", listRoomsByProject); projectRouter.post("/:projectId/rooms", createRoom); projectRouter.put("/:projectId/rooms/:roomId", updateRoom); projectRouter.delete("/:projectId/rooms/:roomId", deleteRoom); +projectRouter.get("/:projectId/estimate-balances", listEstimateBalances); +projectRouter.post("/:projectId/estimate-balances", createEstimateBalance); +projectRouter.get("/:projectId/estimate-balances/:balanceId", getEstimateBalance); +projectRouter.put("/:projectId/estimate-balances/:balanceId", updateEstimateBalance); +projectRouter.delete("/:projectId/estimate-balances/:balanceId", deleteEstimateBalance); +projectRouter.get( + "/:projectId/estimate-balances/:balanceId/comparison", + getEstimateComparison +); diff --git a/src/shared/estimate-balance/estimate-balance.ts b/src/shared/estimate-balance/estimate-balance.ts new file mode 100644 index 0000000..1a7b5a5 --- /dev/null +++ b/src/shared/estimate-balance/estimate-balance.ts @@ -0,0 +1,316 @@ +/** + * Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation. + * + * Used by the API (validation, comparison) and by the frontend (live totals), + * so this file has no imports: Turbopack resolves frontend imports without + * the `.js` suffix the API build needs. + */ + +export type EstimateRowUnit = "piece" | "area"; +export type EstimateRowConnection = "fixed" | "socket"; + +export interface EstimateRowSnapshot { + id: string; + sortOrder: number; + /** Versorgung, e.g. "ELT" or "ISP". */ + supply: string | null; + /** Optional circuit reference, free text. */ + circuitRef: string | null; + /** Verbraucher / Fläche. May be empty while a row is being typed. */ + name: string; + /** Anzahl (unit "piece") or area in m² (unit "area"). */ + quantity: number; + unit: EstimateRowUnit; + connection: EstimateRowConnection | null; + /** W per piece or W/m². */ + powerPerUnitW: number; + simultaneityFactor: number; + cosPhi: number; + /** Also needed on the safety power supply (SV). */ + safetySupply: boolean; + remark: string | null; +} + +export interface EstimateGroupSnapshot { + id: string; + /** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */ + costGroup: string; + title: string; + /** Trade or person responsible for the entries ("bearbeitet von"). */ + editor: string | null; + sortOrder: number; + rows: EstimateRowSnapshot[]; +} + +export interface EstimateBalanceSnapshot { + id: string; + projectId: string; + /** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */ + name: string; + workStage: string | null; + /** ISO date YYYY-MM-DD. */ + dateIso: string | null; + reserveAvPercent: number; + reserveSvPercent: number; + sortOrder: number; + groups: EstimateGroupSnapshot[]; +} + +/** Default structure of the office template "Leistungsbilanz - KG400". */ +export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [ + { costGroup: "410", title: "Sanitärtechnik" }, + { costGroup: "420", title: "Wärmeversorgung" }, + { costGroup: "430", title: "Lufttechnische Anlagen" }, + { costGroup: "434", title: "Kälteanlagen" }, + { costGroup: "440", title: "Starkstromanlagen" }, + { costGroup: "450", title: "Schwachstromanlagen" }, + { costGroup: "460", title: "Fördertechnik" }, + { costGroup: "470", title: "Nutzungsspezifische Anlagen" }, + { costGroup: "480", title: "Gebäudeautomation" }, + { costGroup: "", title: "Allgemein" }, +]; + +export const estimateRowUnitLabels: Record = { + piece: "St.", + area: "m²", +}; + +export const estimateRowConnectionLabels: Record = { + fixed: "Fest", + socket: "Steckd.", +}; + +export interface EstimatePower { + kw: number; + kva: number; +} + +export interface EstimateRowResult { + av: EstimatePower; + sv: EstimatePower; +} + +export interface EstimateGroupResult { + groupId: string; + av: EstimatePower; + sv: EstimatePower; +} + +export interface EstimateBalanceResult { + groups: EstimateGroupResult[]; + total: { av: EstimatePower; sv: EstimatePower }; + reserve: { av: EstimatePower; sv: EstimatePower }; + withReserve: { av: EstimatePower; sv: EstimatePower }; + /** Maximum current of the three-phase supply in A (S / (√3 · U)). */ + maxCurrentA: { av: number; sv: number }; +} + +/** + * Rounds a calculated value for display like the spreadsheet does: binary + * floating point stores 40.175 as 40.17499…, so the scaled value is first + * cut to 6 decimals and only then rounded half away from zero + * (40.175 -> 40.18, -1.825 -> -1.83). + */ +export function roundEstimateValue(value: number, digits = 2): number { + const factor = 10 ** digits; + const scaled = Number((Math.abs(value) * factor).toFixed(6)); + return (Math.sign(value) * Math.round(scaled)) / factor; +} + +const zeroPower = (): EstimatePower => ({ kw: 0, kva: 0 }); + +function addPower(left: EstimatePower, right: EstimatePower): EstimatePower { + return { kw: left.kw + right.kw, kva: left.kva + right.kva }; +} + +function scalePower(power: EstimatePower, factor: number): EstimatePower { + return { kw: power.kw * factor, kva: power.kva * factor }; +} + +/** + * Required power of one row: P = quantity · power per unit · GLF, S = P / cos φ. + * AV covers every consumer (normal supply); SV additionally lists the rows + * that must also be supplied by the safety power supply. + */ +export function calculateEstimateRow( + row: Pick< + EstimateRowSnapshot, + "quantity" | "powerPerUnitW" | "simultaneityFactor" | "cosPhi" | "safetySupply" + > +): EstimateRowResult { + const kw = (row.quantity * row.powerPerUnitW * row.simultaneityFactor) / 1000; + const kva = row.cosPhi > 0 ? kw / row.cosPhi : 0; + const av = { kw, kva }; + return { av, sv: row.safetySupply ? { ...av } : zeroPower() }; +} + +export function calculateEstimateBalance( + balance: Pick, + threePhaseVoltageV: number +): EstimateBalanceResult { + const groups = balance.groups.map((group) => + group.rows.reduce( + (sum, row) => { + const result = calculateEstimateRow(row); + return { + groupId: group.id, + av: addPower(sum.av, result.av), + sv: addPower(sum.sv, result.sv), + }; + }, + { groupId: group.id, av: zeroPower(), sv: zeroPower() } + ) + ); + const total = groups.reduce( + (sum, group) => ({ av: addPower(sum.av, group.av), sv: addPower(sum.sv, group.sv) }), + { av: zeroPower(), sv: zeroPower() } + ); + const reserve = { + av: scalePower(total.av, balance.reserveAvPercent / 100), + sv: scalePower(total.sv, balance.reserveSvPercent / 100), + }; + const withReserve = { + av: addPower(total.av, reserve.av), + sv: addPower(total.sv, reserve.sv), + }; + const currentFactor = + threePhaseVoltageV > 0 ? 1000 / (Math.sqrt(3) * threePhaseVoltageV) : 0; + return { + groups, + total, + reserve, + withReserve, + maxCurrentA: { + av: withReserve.av.kva * currentFactor, + sv: withReserve.sv.kva * currentFactor, + }, + }; +} + +/** + * Assigns a detailed cost group (e.g. "444" or "440.1") to the best matching + * estimate group: a group code with trailing zeros removed is a prefix + * ("430" -> "43", "434" -> "434"); the longest prefix wins. Returns null when + * no group matches. + */ +export function matchEstimateCostGroup( + detailedCostGroup: string | null, + groupCostGroups: readonly string[] +): string | null { + const digits = (detailedCostGroup ?? "").replace(/\D/g, ""); + if (!digits) { + return null; + } + let best: { code: string; length: number } | null = null; + for (const code of groupCostGroups) { + const codeDigits = code.replace(/\D/g, ""); + const prefix = codeDigits.replace(/0+$/, ""); + if (!prefix || !digits.startsWith(prefix)) { + continue; + } + if (!best || prefix.length > best.length) { + best = { code, length: prefix.length }; + } + } + return best?.code ?? null; +} + +export interface EstimateComparisonGroup { + groupId: string; + costGroup: string; + title: string; + estimatedAv: EstimatePower; + detailed: EstimatePower; + detailedRowCount: number; + deltaKw: number; + /** Relative deviation of the detailed value from the estimate, null without estimate. */ + deltaPercent: number | null; +} + +export interface EstimateComparison { + groups: EstimateComparisonGroup[]; + unassigned: EstimatePower & { rowCount: number }; + total: { + estimatedAv: EstimatePower; + detailed: EstimatePower; + deltaKw: number; + deltaPercent: number | null; + }; +} + +export interface DetailedDeviceRowPower { + costGroup: string | null; + /** Total power of the device row in kW (quantity · power · device GZF). */ + kw: number; + cosPhi: number | null; +} + +function delta(estimated: number, detailed: number) { + return { + deltaKw: detailed - estimated, + deltaPercent: estimated > 0 ? ((detailed - estimated) / estimated) * 100 : null, + }; +} + +/** + * Compares the estimate per cost group with the detailed device rows of all + * circuit lists. The distribution-board simultaneity factor is not applied + * on either side; a missing cos φ counts as 1. + */ +export function compareEstimateWithDetailed( + balance: Pick, + detailedRows: readonly DetailedDeviceRowPower[] +): EstimateComparison { + const estimate = calculateEstimateBalance(balance, 0); + const codes = balance.groups.map((group) => group.costGroup).filter(Boolean); + const detailedByCode = new Map(); + const unassigned = { kw: 0, kva: 0, rowCount: 0 }; + for (const row of detailedRows) { + const kva = row.cosPhi && row.cosPhi > 0 ? row.kw / row.cosPhi : row.kw; + const code = matchEstimateCostGroup(row.costGroup, codes); + const target = + code === null + ? unassigned + : detailedByCode.get(code) ?? + detailedByCode.set(code, { kw: 0, kva: 0, rowCount: 0 }).get(code)!; + target.kw += row.kw; + target.kva += kva; + target.rowCount += 1; + } + // A cost group code can appear in several groups; the detailed value is + // shown once, on the first group with that code. + const shownCodes = new Set(); + const groups = balance.groups.map((group, index) => { + const estimatedAv = estimate.groups[index]!.av; + const detailedEntry = + group.costGroup && !shownCodes.has(group.costGroup) + ? detailedByCode.get(group.costGroup) + : undefined; + if (group.costGroup) { + shownCodes.add(group.costGroup); + } + const detailed = { kw: detailedEntry?.kw ?? 0, kva: detailedEntry?.kva ?? 0 }; + return { + groupId: group.id, + costGroup: group.costGroup, + title: group.title, + estimatedAv, + detailed, + detailedRowCount: detailedEntry?.rowCount ?? 0, + ...delta(estimatedAv.kw, detailed.kw), + }; + }); + const detailedTotal = groups.reduce( + (sum, group) => addPower(sum, group.detailed), + { kw: unassigned.kw, kva: unassigned.kva } + ); + return { + groups, + unassigned, + total: { + estimatedAv: estimate.total.av, + detailed: detailedTotal, + ...delta(estimate.total.av.kw, detailedTotal.kw), + }, + }; +} diff --git a/tests/estimate-balance-frontend.test.ts b/tests/estimate-balance-frontend.test.ts new file mode 100644 index 0000000..fa9368d --- /dev/null +++ b/tests/estimate-balance-frontend.test.ts @@ -0,0 +1,435 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { EstimateBalanceSnapshot } from "../src/shared/estimate-balance/estimate-balance.js"; +import { + addEstimateGroup, + addEstimateRow, + createCopyName, + createEstimateRow, + estimateBalanceFingerprint, + moveEstimateGroup, + moveEstimateRow, + normalizeEstimateSortOrder, + removeEstimateGroup, + removeEstimateRow, + toSaveableEstimateBalance, + updateEstimateGroup, + updateEstimateRow, + validateEstimateBalance, +} from "../src/frontend/estimate-balance/estimate-balance-document.js"; +import { + estimateNumberRanges, + formatAmpere, + formatDecimalInput, + formatDeltaPercent, + formatIsoDateGerman, + formatKw, + formatSignedKw, + getTodayIsoDate, + parseGermanDecimal, +} from "../src/frontend/estimate-balance/estimate-balance-format.js"; +import { + ApiRequestError, + createEstimateBalance, + deleteEstimateBalance, + getEstimateBalance, + getEstimateComparison, + isRevisionConflictError, + listEstimateBalances, + updateEstimateBalance, +} from "../src/frontend/utils/api.js"; + +function sequentialIds(prefix = "id") { + let next = 0; + return () => `${prefix}-${++next}`; +} + +function sampleBalance(): EstimateBalanceSnapshot { + return { + id: "balance-1", + projectId: "project-1", + name: "Entwurfsplanung", + workStage: "LPH 3", + dateIso: "2026-09-26", + reserveAvPercent: 20, + reserveSvPercent: 10, + sortOrder: 0, + groups: [ + { + id: "group-410", + costGroup: "410", + title: "Sanitärtechnik", + editor: null, + sortOrder: 0, + rows: [ + { ...createEstimateRow(0, () => "row-a"), name: "Hebeanlage", powerPerUnitW: 1200 }, + { ...createEstimateRow(1, () => "row-b"), name: "Durchlauferhitzer" }, + ], + }, + { + id: "group-440", + costGroup: "440", + title: "Starkstromanlagen", + editor: "ELT", + sortOrder: 1, + rows: [], + }, + ], + }; +} + +describe("estimate balance number input", () => { + it("accepts a comma as decimal separator", () => { + assert.deepEqual(parseGermanDecimal("0,8"), { ok: true, value: 0.8 }); + assert.deepEqual(parseGermanDecimal(" 1200 "), { ok: true, value: 1200 }); + assert.deepEqual(parseGermanDecimal(",5"), { ok: true, value: 0.5 }); + assert.deepEqual(parseGermanDecimal("3,"), { ok: true, value: 3 }); + }); + + it("rejects dots, empty input and garbage", () => { + for (const text of ["0.8", "1.200", "", " ", "abc", "1,2,3", "1e3", "--1"]) { + assert.equal(parseGermanDecimal(text).ok, false, text); + } + const dot = parseGermanDecimal("0.8"); + assert.equal(dot.ok === false && dot.error.includes("Komma"), true); + }); + + it("checks the server ranges of the document fields", () => { + assert.equal(parseGermanDecimal("-1", estimateNumberRanges.quantity).ok, false); + assert.equal(parseGermanDecimal("0", estimateNumberRanges.quantity).ok, true); + assert.equal(parseGermanDecimal("1,1", estimateNumberRanges.simultaneityFactor).ok, false); + assert.equal(parseGermanDecimal("1", estimateNumberRanges.simultaneityFactor).ok, true); + assert.equal(parseGermanDecimal("0", estimateNumberRanges.cosPhi).ok, false); + assert.equal(parseGermanDecimal("0,01", estimateNumberRanges.cosPhi).ok, true); + assert.equal(parseGermanDecimal("101", estimateNumberRanges.reservePercent).ok, false); + }); + + it("formats input text so it parses back unchanged", () => { + assert.equal(formatDecimalInput(1200), "1200"); + assert.equal(formatDecimalInput(0.8), "0,8"); + const parsed = parseGermanDecimal(formatDecimalInput(12345.678)); + assert.deepEqual(parsed, { ok: true, value: 12345.678 }); + }); +}); + +describe("estimate balance display format", () => { + it("rounds half away from zero like Excel", () => { + assert.equal(formatKw(40.175), "40,18"); + assert.equal(formatKw(30.24), "30,24"); + assert.equal(formatKw(1234.5), "1.234,50"); + assert.equal(formatKw(-0.001), "0,00"); + assert.equal(formatAmpere(57.75), "57,75"); + assert.equal(formatAmpere(235.749), "235,75"); + }); + + it("shows signed deltas and percentages", () => { + assert.equal(formatSignedKw(1.5), "+1,50"); + assert.equal(formatSignedKw(-1.825), "-1,83"); + assert.equal(formatSignedKw(0.001), "0,00"); + assert.equal(formatDeltaPercent(12.34), "+12,3 %"); + assert.equal(formatDeltaPercent(-5), "-5,0 %"); + assert.equal(formatDeltaPercent(null), "–"); + }); + + it("formats dates", () => { + assert.equal(formatIsoDateGerman("2026-09-26"), "26.09.2026"); + assert.equal(formatIsoDateGerman(null), "–"); + assert.equal(getTodayIsoDate(new Date(2026, 0, 5, 23, 30)), "2026-01-05"); + }); +}); + +describe("estimate balance document editing", () => { + it("adds rows at the end or after a row with continuous sort orders", () => { + const createId = sequentialIds("new"); + const appended = addEstimateRow(sampleBalance(), "group-410", { createId }); + assert.equal(appended.rowId, "new-1"); + assert.deepEqual( + appended.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-a", 0], ["row-b", 1], ["new-1", 2]] + ); + const inserted = addEstimateRow(sampleBalance(), "group-410", { + afterRowId: "row-a", + createId, + }); + assert.deepEqual( + inserted.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-a", 0], ["new-2", 1], ["row-b", 2]] + ); + const unknownGroup = addEstimateRow(sampleBalance(), "missing", { createId }); + assert.equal(unknownGroup.rowId, null); + }); + + it("creates rows with neutral defaults", () => { + const row = createEstimateRow(3, () => "row-x"); + assert.deepEqual(row, { + id: "row-x", + sortOrder: 3, + supply: null, + circuitRef: null, + name: "", + quantity: 1, + unit: "piece", + connection: null, + powerPerUnitW: 0, + simultaneityFactor: 1, + cosPhi: 1, + safetySupply: false, + remark: null, + }); + }); + + it("updates, removes and moves rows without touching the input", () => { + const original = sampleBalance(); + const updated = updateEstimateRow(original, "group-410", "row-b", { + quantity: 3, + safetySupply: true, + }); + assert.equal(updated.groups[0]!.rows[1]!.quantity, 3); + assert.equal(updated.groups[0]!.rows[1]!.safetySupply, true); + assert.equal(original.groups[0]!.rows[1]!.quantity, 1); + + const removed = removeEstimateRow(original, "group-410", "row-a"); + assert.deepEqual( + removed.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-b", 0]] + ); + + const moved = moveEstimateRow(original, "group-410", "row-b", -1); + assert.deepEqual( + moved.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-b", 0], ["row-a", 1]] + ); + assert.equal(moveEstimateRow(original, "group-410", "row-a", -1), original); + }); + + it("adds, edits, moves and removes groups", () => { + const added = addEstimateGroup( + sampleBalance(), + { costGroup: "480", title: "Gebäudeautomation" }, + () => "group-480" + ); + assert.equal(added.groupId, "group-480"); + assert.deepEqual( + added.balance.groups.map((group) => [group.id, group.sortOrder]), + [["group-410", 0], ["group-440", 1], ["group-480", 2]] + ); + assert.equal(added.balance.groups[2]!.editor, null); + + const edited = updateEstimateGroup(added.balance, "group-480", { + costGroup: "481", + editor: "MSR", + }); + assert.equal(edited.groups[2]!.costGroup, "481"); + assert.equal(edited.groups[2]!.title, "Gebäudeautomation"); + + const movedUp = moveEstimateGroup(edited, "group-480", -1); + assert.deepEqual( + movedUp.groups.map((group) => [group.id, group.sortOrder]), + [["group-410", 0], ["group-480", 1], ["group-440", 2]] + ); + assert.equal(moveEstimateGroup(movedUp, "group-440", 1), movedUp); + + const removed = removeEstimateGroup(movedUp, "group-410"); + assert.deepEqual( + removed.groups.map((group) => [group.id, group.sortOrder]), + [["group-480", 0], ["group-440", 1]] + ); + assert.equal(removeEstimateGroup(removed, "missing"), removed); + }); + + it("orders loaded documents by sortOrder", () => { + const balance = sampleBalance(); + const shuffled: EstimateBalanceSnapshot = { + ...balance, + groups: [ + { ...balance.groups[1]!, sortOrder: 7 }, + { + ...balance.groups[0]!, + sortOrder: 2, + rows: [ + { ...balance.groups[0]!.rows[1]!, sortOrder: 5 }, + { ...balance.groups[0]!.rows[0]!, sortOrder: 1 }, + ], + }, + ], + }; + const normalized = normalizeEstimateSortOrder(shuffled); + assert.deepEqual( + normalized.groups.map((group) => [group.id, group.sortOrder]), + [["group-410", 0], ["group-440", 1]] + ); + assert.deepEqual( + normalized.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-a", 0], ["row-b", 1]] + ); + }); +}); + +describe("estimate balance saving", () => { + it("builds the trimmed server document with null for empty optional texts", () => { + let balance = sampleBalance(); + balance = { ...balance, name: " Entwurf ", workStage: " ", dateIso: "" }; + balance = updateEstimateGroup(balance, "group-440", { title: " Starkstrom ", editor: " " }); + balance = updateEstimateRow(balance, "group-410", "row-a", { + name: " Hebeanlage ", + supply: " ELT ", + circuitRef: "", + remark: " ", + }); + const saveable = toSaveableEstimateBalance(balance); + assert.equal(saveable.name, "Entwurf"); + assert.equal(saveable.workStage, null); + assert.equal(saveable.dateIso, null); + assert.equal(saveable.groups[1]!.title, "Starkstrom"); + assert.equal(saveable.groups[1]!.editor, null); + assert.deepEqual( + { + name: saveable.groups[0]!.rows[0]!.name, + supply: saveable.groups[0]!.rows[0]!.supply, + circuitRef: saveable.groups[0]!.rows[0]!.circuitRef, + remark: saveable.groups[0]!.rows[0]!.remark, + }, + { name: "Hebeanlage", supply: "ELT", circuitRef: null, remark: null } + ); + }); + + it("copies only snapshot fields", () => { + const balance = sampleBalance() as EstimateBalanceSnapshot & { uiState?: string }; + balance.uiState = "leak"; + assert.equal("uiState" in toSaveableEstimateBalance(balance), false); + }); + + it("treats whitespace-only changes as unchanged", () => { + const balance = sampleBalance(); + const withSpace = updateEstimateRow(balance, "group-410", "row-a", { + name: "Hebeanlage ", + }); + assert.equal(estimateBalanceFingerprint(withSpace), estimateBalanceFingerprint(balance)); + const changed = updateEstimateRow(balance, "group-410", "row-a", { quantity: 2 }); + assert.notEqual(estimateBalanceFingerprint(changed), estimateBalanceFingerprint(balance)); + }); + + it("reports documents the server would reject", () => { + assert.deepEqual(validateEstimateBalance(sampleBalance()), []); + const invalid = updateEstimateGroup( + { ...sampleBalance(), name: " " }, + "group-440", + { title: " " } + ); + assert.equal(validateEstimateBalance(invalid).length, 2); + }); + + it("names copies", () => { + assert.equal(createCopyName("Entwurfsplanung "), "Entwurfsplanung (Kopie)"); + assert.equal(createCopyName("x".repeat(200)).length, 200); + }); +}); + +describe("estimate balance frontend API", () => { + it("calls the estimate balance endpoints", async () => { + const requests: Array<{ url: string; method: string; body: unknown; keepalive?: boolean }> = + []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + ...(init?.keepalive ? { keepalive: true } : {}), + }); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + const balance = sampleBalance(); + await listEstimateBalances("project-1"); + await getEstimateBalance("project-1", "balance-1"); + await createEstimateBalance( + "project-1", + { name: "Vorplanung", workStage: null, dateIso: "2026-09-26", template: "kg400" }, + 4 + ); + await createEstimateBalance( + "project-1", + { name: "Kopie", template: "copy", copyFromBalanceId: "balance-1" }, + 5 + ); + await updateEstimateBalance("project-1", balance, 6); + await updateEstimateBalance("project-1", balance, 7, { keepalive: true }); + await deleteEstimateBalance("project-1", "balance-1", 8); + await getEstimateComparison("project-1", "balance-1"); + const base = "/api/projects/project-1/estimate-balances"; + assert.deepEqual(requests, [ + { url: base, method: "GET", body: undefined }, + { url: `${base}/balance-1`, method: "GET", body: undefined }, + { + url: base, + method: "POST", + body: { + name: "Vorplanung", + workStage: null, + dateIso: "2026-09-26", + template: "kg400", + expectedRevision: 4, + }, + }, + { + url: base, + method: "POST", + body: { + name: "Kopie", + template: "copy", + copyFromBalanceId: "balance-1", + expectedRevision: 5, + }, + }, + { + url: `${base}/balance-1`, + method: "PUT", + body: { expectedRevision: 6, balance }, + }, + { + url: `${base}/balance-1`, + method: "PUT", + body: { expectedRevision: 7, balance }, + keepalive: true, + }, + { url: `${base}/balance-1`, method: "DELETE", body: { expectedRevision: 8 } }, + { url: `${base}/balance-1/comparison`, method: "GET", body: undefined }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("exposes status and code of failed requests", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + error: "Projektstand veraltet.", + code: "PROJECT_REVISION_CONFLICT", + }), + { status: 409, headers: { "Content-Type": "application/json" } } + ); + try { + await assert.rejects( + updateEstimateBalance("project-1", sampleBalance(), 1), + (error: unknown) => { + assert.ok(error instanceof ApiRequestError); + assert.equal(error.message, "Projektstand veraltet."); + assert.equal(error.status, 409); + assert.equal(error.code, "PROJECT_REVISION_CONFLICT"); + assert.equal(isRevisionConflictError(error), true); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } + assert.equal(isRevisionConflictError(new ApiRequestError("x", 400)), false); + assert.equal(isRevisionConflictError(new Error("x")), false); + }); +}); diff --git a/tests/estimate-balance.test.ts b/tests/estimate-balance.test.ts new file mode 100644 index 0000000..7d294da --- /dev/null +++ b/tests/estimate-balance.test.ts @@ -0,0 +1,459 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { EstimateBalanceProjectCommandRepository } from "../src/db/repositories/estimate-balance-project-command.repository.js"; +import { EstimateBalanceRepository } from "../src/db/repositories/estimate-balance.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { estimateRows } from "../src/db/schema/estimate-balances.js"; +import { projects } from "../src/db/schema/projects.js"; +import { + createEstimateBalanceDeleteProjectCommand, + createEstimateBalanceInsertProjectCommand, + createEstimateBalanceSnapshot, + createEstimateBalanceUpdateProjectCommand, + parseEstimateBalanceSnapshot, + type EstimateBalanceProjectCommand, +} from "../src/estimate-balance/domain/estimate-balance-command.model.js"; +import { createEstimateBalanceRequestSchema } from "../src/estimate-balance/api/estimate-balance-request.schemas.js"; +import { + calculateEstimateBalance, + calculateEstimateRow, + compareEstimateWithDetailed, + estimateDefaultGroups, + matchEstimateCostGroup, + roundEstimateValue, + type EstimateBalanceSnapshot, + type EstimateRowSnapshot, +} from "../src/shared/estimate-balance/estimate-balance.js"; + +const round2 = (value: number) => roundEstimateValue(value, 2); +let nextId = 0; +const id = () => `id-${++nextId}`; + +function row( + name: string, + quantity: number, + powerPerUnitW: number, + simultaneityFactor: number, + cosPhi: number, + extra: Partial = {} +): EstimateRowSnapshot { + return { + id: id(), + sortOrder: 0, + supply: "ELT", + circuitRef: null, + name, + quantity, + unit: "piece", + connection: null, + powerPerUnitW, + simultaneityFactor, + cosPhi, + safetySupply: false, + remark: null, + ...extra, + }; +} + +/** All rows of the office example "Leistungsbilanz - KG400" (Entwurfsplanung). */ +function templateExample(): EstimateBalanceSnapshot { + const rowsByGroup: Record = { + "410": [ + row("Durchlauferhitzer 11kW", 9, 11200, 0.3, 1), + row("Durchlauferhitzer 4,4kW", 4, 4400, 0.3, 1), + ], + "420": [ + row("Fernwärmestation", 1, 1000, 0.8, 1), + row("Pumpen", 4, 200, 0.8, 0.9), + row("Schaltschrank Heizung", 1, 2000, 0.8, 1), + ], + "430": [ + row("Lüftungsgerät", 1, 6000, 0.8, 0.9), + row("Schaltschrank Lüftung", 1, 2000, 0.8, 0.9), + ], + "434": [ + row("Kälteaggregat Dach", 1, 1000, 0.8, 1), + row("Serverraum Kühlung Aggregat Dach", 1, 2000, 0.8, 1), + row("Serverraum Innengerät", 1, 1000, 0.8, 1), + ], + "440": [ + row("Beleuchtungsanlage Klassenzimmer", 17, 500, 0.8, 0.95), + row("Beleuchtung Allgemein", 50, 45, 0.7, 0.95), + row("Außenbeleuchtung", 10, 100, 0.4, 0.95), + row("Fassadenbeleuchtung", 20, 60, 0.5, 0.95), + row("Beleuchtung MFR", 15, 50, 0.8, 0.95), + row("Steckdosen Klassenzimmer", 170, 300, 0.2, 0.95), + row("Steckdosen 230 V allg. Verbr.", 100, 500, 0.2, 0.95), + row("Veranstaltungstechnik", 1, 4000, 0.5, 0.95), + row("Küchentechnische Anlagen", 1, 10000, 0.4, 0.8), + row("Sicherheitsbeleuchtung Zentrale", 1, 2000, 0.8, 0.9), + row("Spezielle Experimentieranlagen", 1, 6000, 0.4, 0.9), + ], + "450": [row("EDV Systeme", 5, 3000, 0.8, 0.95)], + "460": [row("Aufzug", 1, 16000, 0.7, 0.95)], + "470": [row("E-Mobility Vorbereitung", 9, 11000, 0.3, 1)], + "480": [], + "": [row("Allgemein", 1, 2000, 1, 0.95)], + }; + return { + id: "balance-1", + projectId: "project-1", + name: "Entwurfsplanung", + workStage: "LPH 3", + dateIso: "2023-06-30", + reserveAvPercent: 10, + reserveSvPercent: 0, + sortOrder: 0, + groups: estimateDefaultGroups.map((group, index) => ({ + id: `group-${group.costGroup || "allg"}`, + costGroup: group.costGroup, + title: group.title, + editor: null, + sortOrder: index, + rows: rowsByGroup[group.costGroup] ?? [], + })), + }; +} + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); + context.db + .insert(projects) + .values([ + { id: "project-1", name: "Test project" }, + { id: "project-2", name: "Foreign project" }, + ]) + .run(); + return context; +} + +describe("estimate balance calculation", () => { + it("reproduces the office template totals", () => { + const result = calculateEstimateBalance(templateExample(), 400); + const groupTotals = result.groups.map((group) => [ + round2(group.av.kw), + round2(group.av.kva), + ]); + assert.deepEqual(groupTotals, [ + [35.52, 35.52], + [3.04, 3.11], + [6.4, 7.11], + [3.2, 3.2], + [40.18, 43.31], + [12, 12.63], + [11.2, 11.79], + [29.7, 29.7], + [0, 0], + [2, 2.11], + ]); + assert.equal(round2(result.total.av.kw), 143.24); + assert.equal(round2(result.total.av.kva), 148.48); + assert.equal(round2(result.reserve.av.kw), 14.32); + assert.equal(round2(result.reserve.av.kva), 14.85); + assert.equal(round2(result.withReserve.av.kw), 157.56); + assert.equal(round2(result.withReserve.av.kva), 163.33); + assert.equal(round2(result.maxCurrentA.av), 235.75); + assert.equal(result.total.sv.kw, 0); + assert.equal(result.maxCurrentA.sv, 0); + }); + + it("counts safety-supply rows in AV and additionally in SV, also per m²", () => { + const area = calculateEstimateRow({ + quantity: 250, + powerPerUnitW: 12, + simultaneityFactor: 0.8, + cosPhi: 0.95, + safetySupply: true, + }); + assert.equal(round2(area.av.kw), 2.4); + assert.equal(round2(area.av.kva), 2.53); + assert.deepEqual(area.sv, area.av); + const balance = templateExample(); + balance.reserveSvPercent = 20; + balance.groups[4]!.rows[9]!.safetySupply = true; // Sicherheitsbeleuchtung Zentrale + const result = calculateEstimateBalance(balance, 400); + assert.equal(round2(result.total.sv.kw), 1.6); + assert.equal(round2(result.withReserve.sv.kw), 1.92); + assert.equal(round2(result.total.av.kw), 143.24); + }); + + it("rounds displayed values like the spreadsheet", () => { + assert.equal(roundEstimateValue(40.175), 40.18); + assert.equal(roundEstimateValue(143.235), 143.24); + assert.equal(roundEstimateValue(-1.825), -1.83); + assert.equal(roundEstimateValue(0.711111), 0.71); + assert.equal(roundEstimateValue(235.749, 1), 235.7); + }); + + it("matches detailed cost groups to the most specific estimate group", () => { + const codes = ["410", "430", "434", "440", "480", ""]; + assert.equal(matchEstimateCostGroup("444", codes), "440"); + assert.equal(matchEstimateCostGroup("434", codes), "434"); + assert.equal(matchEstimateCostGroup("431", codes), "430"); + assert.equal(matchEstimateCostGroup("KG 481.1", codes), "480"); + assert.equal(matchEstimateCostGroup("4", codes), null); + assert.equal(matchEstimateCostGroup("", codes), null); + assert.equal(matchEstimateCostGroup(null, codes), null); + assert.equal(matchEstimateCostGroup("450", codes), null); + }); + + it("compares the estimate with detailed device rows per cost group", () => { + const comparison = compareEstimateWithDetailed(templateExample(), [ + { costGroup: "444", kw: 30, cosPhi: 0.9 }, + { costGroup: "445", kw: 12, cosPhi: null }, + { costGroup: "412", kw: 40, cosPhi: 1 }, + { costGroup: null, kw: 5, cosPhi: 1 }, + { costGroup: "399", kw: 1, cosPhi: 1 }, + ]); + const kg440 = comparison.groups.find((group) => group.costGroup === "440")!; + assert.equal(round2(kg440.detailed.kw), 42); + assert.equal(round2(kg440.detailed.kva), 45.33); + assert.equal(kg440.detailedRowCount, 2); + assert.ok(Math.abs(kg440.deltaKw - 1.825) < 1e-9); + assert.equal(round2(kg440.deltaPercent!), 4.54); + const kg410 = comparison.groups.find((group) => group.costGroup === "410")!; + assert.equal(round2(kg410.deltaKw), 4.48); + const kg480 = comparison.groups.find((group) => group.costGroup === "480")!; + assert.equal(kg480.deltaPercent, null); + assert.deepEqual(comparison.unassigned, { kw: 6, kva: 6, rowCount: 2 }); + assert.equal(round2(comparison.total.detailed.kw), 88); + assert.equal(round2(comparison.total.estimatedAv.kw), 143.24); + }); +}); + +describe("estimate balance snapshots and commands", () => { + it("validates snapshots strictly", () => { + const valid = templateExample(); + assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3); + const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [ + (balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0), + (balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5), + (balance) => (balance.groups[0]!.rows[0]!.quantity = -1), + (balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "), + (balance) => (balance.groups[0]!.rows[0]!.supply = ""), + (balance) => (balance.groups[0]!.title = ""), + (balance) => (balance.dateIso = "30.06.2023"), + (balance) => (balance.reserveAvPercent = 101), + (balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id), + (balance) => ((balance.groups[0]!.rows[0] as unknown as Record).extra = 1), + ]; + for (const mutate of invalid) { + const balance = structuredClone(valid); + mutate(balance); + assert.throws(() => parseEstimateBalanceSnapshot(balance)); + } + assert.equal( + createEstimateBalanceRequestSchema.safeParse({ + expectedRevision: 0, + name: "Entwurf", + template: "copy", + }).success, + false + ); + }); + + it("creates the KG 400 template and copies a Stand with new ids", () => { + const created = createEstimateBalanceSnapshot({ + projectId: "project-1", + name: " Vorplanung ", + workStage: "LPH 2", + dateIso: "2026-09-26", + sortOrder: 0, + template: "kg400", + }); + assert.equal(created.name, "Vorplanung"); + assert.deepEqual( + created.groups.map((group) => group.costGroup), + estimateDefaultGroups.map((group) => group.costGroup) + ); + assert.equal(created.reserveAvPercent, 10); + const source = templateExample(); + const copy = createEstimateBalanceSnapshot({ + projectId: "project-1", + name: "Entwurfsplanung Kopie", + workStage: null, + dateIso: null, + sortOrder: 1, + template: { copyFrom: source }, + }); + assert.notEqual(copy.id, source.id); + assert.notEqual(copy.groups[0]!.id, source.groups[0]!.id); + assert.notEqual(copy.groups[0]!.rows[0]!.id, source.groups[0]!.rows[0]!.id); + assert.equal( + round2(calculateEstimateBalance(copy, 400).total.av.kw), + 143.24 + ); + }); + + it("inserts, updates and deletes balances with persisted undo and redo", () => { + const context = createTestDatabase(); + try { + const store = new EstimateBalanceProjectCommandRepository(context.db); + const reader = new EstimateBalanceRepository(context.db); + const history = new ProjectHistoryRepository(context.db); + let revision = 0; + const run = ( + command: EstimateBalanceProjectCommand, + source: "user" | "undo" | "redo" = "user" + ) => { + const step = source === "user" ? null : history.getNextCommand("project-1", source); + const result = store.execute({ + projectId: "project-1", + expectedRevision: revision, + source, + historyTargetChangeSetId: step?.changeSetId, + command, + }); + revision += 1; + return result; + }; + + const balance = templateExample(); + const insert = createEstimateBalanceInsertProjectCommand(balance); + const inserted = run(insert); + const stored = reader.findById("project-1", balance.id); + assert.deepEqual(stored, parseEstimateBalanceSnapshot(balance)); + assert.equal(reader.listByProject("project-2").length, 0); + + const target = structuredClone(stored!); + target.groups[0]!.rows.splice(1, 1); + target.groups[0]!.rows[0]!.quantity = 10; + target.groups[8]!.rows.push(row("GLT-Schrank", 2, 500, 1, 0.95)); + target.groups[8]!.editor = "MSR"; + target.reserveAvPercent = 15; + const update = createEstimateBalanceUpdateProjectCommand(stored!, target); + const updated = run(update); + assert.deepEqual( + reader.findById("project-1", balance.id), + parseEstimateBalanceSnapshot(target) + ); + + run(updated.inverse, "undo"); + assert.deepEqual(reader.findById("project-1", balance.id), stored); + run(update, "redo"); + assert.deepEqual( + reader.findById("project-1", balance.id), + parseEstimateBalanceSnapshot(target) + ); + + // A stale expected document is rejected. + assert.throws( + () => run(createEstimateBalanceUpdateProjectCommand(stored!, { ...stored!, name: "X" })), + /changed before update/ + ); + assert.throws( + () => createEstimateBalanceUpdateProjectCommand(stored!, structuredClone(stored!)), + /must change/ + ); + + const current = reader.findById("project-1", balance.id)!; + const deleted = run(createEstimateBalanceDeleteProjectCommand(current)); + assert.equal(reader.findById("project-1", balance.id), null); + assert.equal(context.db.select().from(estimateRows).all().length, 0); + run(deleted.inverse, "undo"); + assert.deepEqual(reader.findById("project-1", balance.id), current); + // The original insert's inverse no longer matches the edited balance. + assert.throws(() => run(inserted.inverse), /changed before deletion/); + run(createEstimateBalanceDeleteProjectCommand(current)); + assert.equal(reader.listByProject("project-1").length, 0); + + assert.throws( + () => run(createEstimateBalanceInsertProjectCommand({ ...balance, projectId: "project-2" })), + /does not belong to project/ + ); + } finally { + context.close(); + } + }); + + it("sums detailed device rows of all circuit lists of the project", () => { + const context = createTestDatabase(); + try { + context.db + .insert(distributionBoards) + .values({ id: "board-1", projectId: "project-1", name: "UV 1" }) + .run(); + context.db + .insert(circuitLists) + .values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "UV 1" }) + .run(); + context.db + .insert(circuitSections) + .values({ + id: "section-1", + circuitListId: "list-1", + key: "lighting-1", + displayName: "Beleuchtung 1", + prefix: "1F1", + sortOrder: 0, + }) + .run(); + context.db + .insert(circuits) + .values({ + id: "circuit-1", + circuitListId: "list-1", + sectionId: "section-1", + equipmentIdentifier: "-1F1", + displayName: "Licht", + sortOrder: 0, + }) + .run(); + context.db + .insert(circuitDeviceRows) + .values([ + { + id: "row-1", + circuitId: "circuit-1", + name: "Leuchte", + displayName: "Leuchte", + costGroup: "445", + quantity: 10, + powerPerUnit: 0.05, + simultaneityFactor: 0.8, + cosPhi: 0.95, + }, + { + id: "row-2", + circuitId: "circuit-1", + name: "Steckdose", + displayName: "Steckdose", + costGroup: null, + quantity: 2, + powerPerUnit: 0.2, + simultaneityFactor: 0.5, + cosPhi: null, + }, + ]) + .run(); + const detailed = new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower( + "project-1" + ); + assert.deepEqual( + detailed.map((entry) => ({ ...entry, kw: round2(entry.kw) })), + [ + { costGroup: "445", kw: 0.4, cosPhi: 0.95 }, + { costGroup: null, kw: 0.2, cosPhi: null }, + ] + ); + assert.equal( + new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower("project-2").length, + 0 + ); + } finally { + context.close(); + } + }); +});