diff --git a/AGENTS.md b/AGENTS.md index bdac707..4298762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -615,30 +615,6 @@ 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 deliberately not part of named/automatic project snapshots or snapshot - restore; those operations leave estimate balances unchanged. Portable project - transfer format version 2 includes every estimate balance and remaps balance, - group and row ids when duplicating a project. Version 1 transfers remain - importable and contain no estimate balances. -- 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 5b4bc13..4f84717 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,8 +16,6 @@ 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 ecbbdd4..156be62 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -59,14 +59,6 @@ this value for optimistic concurrency checks. - clients cannot submit arbitrary `project.restore-state` payloads through the generic command endpoint -Grobbilanzen sind bewusst kein Teil dieser logischen Snapshots. Der portable -Projektexport unter `GET /projects/:projectId/export` verwendet dagegen -Transferformat 2 und enthält alle Grobbilanzen. Die beiden Importwege -`POST /projects/:projectId/import` und `POST /projects/import` prüfen ihre -Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo, -Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen -importierbar. - The public dispatcher supports the Circuit and CircuitDeviceRow commands `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, `circuit-device-row.insert`, @@ -222,12 +214,6 @@ 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/current-architecture.md b/docs/current-architecture.md index 41fd725..6eeb6e1 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -290,20 +290,15 @@ Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den bestehenden persistenten Projektbefehl. -`GET /api/projects/:projectId/export` verpackt den vollständigen portablen -Projektzustand in ein format- und schema-versioniertes JSON-Dokument mit -SHA-256-Prüfsumme. Transferformat 2 enthält zusätzlich alle Grobbilanzen, -ohne sie in das logische Snapshot-Schema aufzunehmen; Format 1 bleibt ohne -Grobbilanzen importierbar. `POST /api/projects/:projectId/import` prüft Format, -Snapshot-Relationen, Grobbilanzen und Prüfsumme vor jedem Schreibzugriff. Der -Modus `replace` läuft über `project.restore-state` Version 2 und ersetzt den -Projektzustand und die Grobbilanzen atomar und dauerhaft rückgängig machbar. -Normale Snapshot-Restores verwenden weiterhin Version 1 und lassen -Grobbilanzen unverändert. Der Modus `duplicate` ordnet Projekt-, +`GET /api/projects/:projectId/export` verpackt denselben vollständigen +Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument +mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format, +Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace` +läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft +rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-, Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und -Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen und die IDs aller Grobbilanzen -vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an. -Fachliche Verknüpfungen +Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt +die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die Projektübersicht verwendet dafür den separaten Collection-Endpunkt diff --git a/docs/estimate-balance.md b/docs/estimate-balance.md deleted file mode 100644 index b8b675b..0000000 --- a/docs/estimate-balance.md +++ /dev/null @@ -1,119 +0,0 @@ -# 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 (Auswahl ELT/MSR oder 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. Stände bleiben bearbeitbar; ein zusätzlicher Status oder -eine Bearbeitungssperre ist derzeit nicht vorgesehen. - -## 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 }`). - -## Projekttransfer und bewusste Abgrenzung - -- Das reguläre SQLite-Datenbank-Backup sichert die Grobbilanz-Tabellen zusammen - mit allen übrigen Anwendungsdaten. -- Benannte und automatische Projektsnapshots enthalten keine Grobbilanzen; das - Snapshot-Schema bleibt Version 7. Snapshot-Restore und dessen Undo/Redo lassen - vorhandene Grobbilanzen unverändert. -- Der portable Projektexport enthält alle Grobbilanzen ab Transferformat 2. - Ersetzen übernimmt sie atomar und rückgängig machbar; Duplizieren vergibt neue - IDs für Bilanz, Gruppen und Zeilen. Alte Transferdateien in Version 1 bleiben - importierbar und enthalten definitionsgemäß keine Grobbilanzen. -- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe. -- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet - mit Präfixen. -- Der erste Vergleich zeigt die geschätzte und detaillierte AV-Wirkleistung in - kW. SV- und kVA-Vergleiche können später ergänzt werden. -- Eine Stromkreisnummer gehört nicht zur Grobbilanz, weil sie eine frühe - Abschätzung ohne Stromkreisplanung ist. - -## 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 fa91cc4..947767a 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -6,9 +6,6 @@ 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 e5474b8..d5f9f4a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2002,710 +2002,3 @@ 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-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; -} - -/* Ausrichtung: alle Zellen zentriert, nur „Verbraucher / Flächen“, die - Gruppentitel und die Summenbeschriftungen stehen links bzw. rechts. */ -.estimate-sheet td, -.estimate-sheet td.estimate-number, -.estimate-sheet .estimate-cell-input, -.estimate-sheet .estimate-cell-input.is-numeric { - text-align: center; -} - -.estimate-sheet .estimate-cell-select { - text-align: center; - text-align-last: center; -} - -.estimate-sheet .estimate-unit-input, -.estimate-sheet .estimate-gzf-input { - justify-content: center; -} - -.estimate-sheet td.estimate-name-cell, -.estimate-sheet td.estimate-name-cell .estimate-cell-input, -.estimate-sheet .estimate-group-title-row td:not(.estimate-cost-group-cell), -.estimate-comparison td:nth-child(2) { - text-align: left; -} - -.estimate-sheet td.estimate-total-label { - text-align: right; -} - -/* Druck im kbp-CD der Seitenansicht (src/frontend/print/print-document.css): - gleiche Farben, Schrift, Titelblock und Fußzeile. Die Fußzeile mit - Seitenzahlen setzt EstimatePrintHeader als @page-Regel mit Projektdaten. */ -.estimate-print-titleblock { - display: none; -} - -@page estimate-balance { - size: A4 landscape; - margin: 10mm 10mm 13mm; -} - -@media print { - body:has(.estimate-print-root) .sidebar, - .estimate-print-root .estimate-no-print, - .estimate-print-root .estimate-page-header, - .estimate-print-root .estimate-header-fields, - .estimate-print-root .alert, - .estimate-print-root input, - .estimate-print-root select, - .estimate-print-root button { - display: none !important; - } - - html:has(.estimate-print-root), - body:has(.estimate-print-root), - body:has(.estimate-print-root) .app-shell, - body:has(.estimate-print-root) .app-shell-content { - background: #fff !important; - } - - /* Der Dunkelmodus setzt color-scheme: dark; Chrome färbt damit auch den - Seitenrand dunkel. Im Druck gilt immer das helle Schema. */ - html:has(.estimate-print-root) { - color-scheme: light !important; - } - - .estimate-print-root .estimate-group-editor.is-empty { - display: none; - } - - /* Verbraucher bekommt den Platz; Anmerkungen bleiben schmal. */ - .estimate-sheet .estimate-col-name { - width: 52mm; - } - - .estimate-sheet .estimate-col-remark { - width: 24mm; - } - - .estimate-sheet .estimate-col-supply { - width: 13mm; - } - - body:has(.estimate-print-root) .app-shell { - display: block; - } - - .estimate-print-root { - --ep-blue: #476088; - --ep-lilac: #9198c4; - --ep-line: #d6dae5; - --ep-tint: #f5f6fa; - --ep-text: #2b2b2b; - --ep-muted: #6b7280; - --ep-accent: #c9782a; - page: estimate-balance; - padding: 0 !important; - max-width: none; - color: var(--ep-text); - font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; - color-scheme: light; - } - - .estimate-print-root * { - -webkit-print-color-adjust: exact; - print-color-adjust: exact; - } - - .estimate-print-root .estimate-print-value { - display: inline; - } - - /* Titelblock wie der Plankopf der Seitenansicht */ - .estimate-print-titleblock { - display: grid; - grid-template-columns: 30mm 1fr auto auto; - margin-bottom: 4mm; - border: 1.5px solid var(--ep-blue); - background: #fff; - font-size: 8.5pt; - break-inside: avoid; - } - - .estimate-print-titleblock > * { - padding: 2.5mm 3.5mm; - border-left: 1px solid var(--ep-line); - } - - .estimate-print-titleblock > :first-child { - border-left: none; - } - - .estimate-print-logo { - display: flex; - align-items: center; - justify-content: center; - min-height: 16mm; - color: var(--ep-blue); - font-weight: bold; - text-align: center; - } - - .estimate-print-logo img { - max-width: 100%; - max-height: 10mm; - } - - .estimate-print-kicker { - color: var(--ep-muted); - font-size: 7pt; - letter-spacing: 0.05em; - text-transform: uppercase; - } - - .estimate-print-heading { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 1mm 4mm; - margin-top: 1mm; - } - - .estimate-print-stand { - color: var(--ep-blue); - font-size: 16pt; - font-weight: bold; - } - - .estimate-print-project { - font-size: 12pt; - font-weight: bold; - } - - .estimate-print-meta { - display: grid; - grid-template-columns: auto auto; - gap: 0.8mm 4mm; - align-content: center; - margin: 0; - font-size: 7.5pt; - } - - .estimate-print-meta div { - display: contents; - } - - .estimate-print-meta dt { - color: var(--ep-muted); - font-weight: normal; - } - - .estimate-print-meta dd { - margin: 0; - font-weight: bold; - } - - .estimate-print-meta.is-tinted { - background: var(--ep-tint); - } - - /* Karten und Ränder der Bildschirmansicht entfallen */ - .estimate-print-root .card { - border: 0; - box-shadow: none !important; - 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-print-root .estimate-comparison-card .card-header { - margin-bottom: 2mm; - color: var(--ep-blue); - font-size: 10pt; - font-weight: bold; - } - - .estimate-sheet-wrap { - overflow: visible; - } - - /* Tabelle */ - .estimate-sheet { - min-width: 0; - table-layout: auto; - font-size: 7.4pt; - line-height: 1.2; - background: #fff; - color: var(--ep-text); - } - - .estimate-sheet th, - .estimate-sheet td { - border: 1px solid var(--ep-line) !important; - padding: 0.7mm 1.5mm; - background: #fff; - color: var(--ep-text); - } - - .estimate-sheet thead { - display: table-header-group; - } - - .estimate-sheet thead th { - background: var(--ep-blue) !important; - border-color: var(--ep-blue) !important; - color: #fff !important; - font-weight: bold; - vertical-align: bottom; - } - - .estimate-sheet thead .estimate-th-hint { - color: #dfe3f0 !important; - } - - .estimate-group-title-row td { - background: var(--ep-tint) !important; - border-top: 1.5px solid var(--ep-lilac) !important; - font-weight: bold; - } - - .estimate-group-title-row .estimate-cost-group-cell { - color: var(--ep-blue) !important; - } - - .estimate-group-title-row .estimate-group-editor { - color: var(--ep-muted); - font-weight: normal; - } - - .estimate-subtotal-row td { - border-bottom: 1.5px solid var(--ep-line) !important; - font-weight: bold; - } - - .estimate-subtotal-row .estimate-total-label { - color: var(--ep-muted); - } - - .estimate-total-row td { - background: var(--ep-tint) !important; - font-weight: bold; - } - - .estimate-total-row.is-strong td { - background: var(--ep-blue) !important; - border-color: var(--ep-blue) !important; - color: #fff !important; - } - - .estimate-total-row .estimate-total-hint { - color: var(--ep-muted); - font-weight: normal; - } - - .estimate-total-row.is-strong .estimate-total-hint { - color: #dfe3f0; - } - - .estimate-comparison .is-over { - color: var(--ep-accent) !important; - font-weight: bold; - } - - .estimate-comparison .is-under { - color: var(--ep-blue) !important; - font-weight: bold; - } - - .estimate-group-title-row, - .estimate-subtotal-row, - .estimate-total-row, - .estimate-row { - break-inside: avoid; - } - - .estimate-group-title-row { - break-after: avoid; - } - - /* Summen einmal am Ende, nicht auf jeder Seite */ - .estimate-sheet tfoot { - display: table-row-group; - break-inside: avoid; - } - - .estimate-col-actions { - width: 0; - } - - .estimate-unit-input, - .estimate-gzf-input { - justify-content: center; - } -} diff --git a/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx deleted file mode 100644 index 584f5cf..0000000 --- a/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx +++ /dev/null @@ -1,745 +0,0 @@ -"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 { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header"; -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 ( -
- {project && draft ? : null} -
-
- 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 deleted file mode 100644 index 4b2cf81..0000000 --- a/src/app/projects/[projectId]/grobbilanz/page.tsx +++ /dev/null @@ -1,292 +0,0 @@ -"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 deleted file mode 100644 index bfa9dd3..0000000 --- a/src/db/migrations/0010_estimate_balances.sql +++ /dev/null @@ -1,42 +0,0 @@ -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, - `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 deleted file mode 100644 index d58518a..0000000 --- a/src/db/migrations/meta/0010_snapshot.json +++ /dev/null @@ -1,2924 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "5376ae9f-67c7-4603-b3d2-8527136436f9", - "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 - }, - "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 f2c5dba..485b8f4 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -71,13 +71,6 @@ "when": 1790419185882, "tag": "0009_buildings", "breakpoints": true - }, - { - "idx": 10, - "version": "6", - "when": 1790438092269, - "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 deleted file mode 100644 index b31ca78..0000000 --- a/src/db/repositories/estimate-balance-project-command.repository.ts +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index 4d59aa6..0000000 --- a/src/db/repositories/estimate-balance.persistence.ts +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index 4c70d27..0000000 --- a/src/db/repositories/estimate-balance.repository.ts +++ /dev/null @@ -1,60 +0,0 @@ -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/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts index 97cfe17..496f2cb 100644 --- a/src/db/repositories/project-state-restore-command.repository.ts +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -3,8 +3,6 @@ import { ProjectStateConflictError } from "../../domain/errors/project-state-con import { assertProjectStateRestoreCommand, createProjectStateRestoreCommand, - createProjectTransferRestoreCommand, - projectStateRestoreCommandSchemaVersion, } from "../../domain/models/project-state-restore-command.model.js"; import { parseProjectStateSnapshot, @@ -15,8 +13,6 @@ import type { ProjectStateRestoreCommandStore, } from "../../domain/ports/project-state-restore-command.store.js"; import type { AppDatabase } from "../database-context.js"; -import { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js"; -import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js"; @@ -36,11 +32,6 @@ import { externalImportBatches } from "../schema/external-import-batches.js"; import { externalModelObjects } from "../schema/external-model-objects.js"; import { externalModelSources } from "../schema/external-model-sources.js"; import { externalRoomMappings } from "../schema/external-room-mappings.js"; -import { estimateBalances } from "../schema/estimate-balances.js"; -import { - insertEstimateBalance, - readEstimateBalances, -} from "./estimate-balance.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { readProjectStateSnapshot, @@ -75,39 +66,10 @@ export class ProjectStateRestoreCommandRepository if (!current) { throw new Error("Project not found."); } - const currentEstimateBalances = readEstimateBalances(tx, input.projectId); - const transferCommand = - input.command.schemaVersion === projectStateRestoreCommandSchemaVersion - ? input.command - : null; if ( current.payloadSha256 !== input.command.payload.expectedStateSha256 && - !matchesHistoryRestoreTarget( - tx, - input, - current.state, - currentEstimateBalances - ) - ) { - throw new ProjectStateConflictError( - input.projectId, - input.command.payload.expectedStateSha256, - current.payloadSha256 - ); - } - if ( - transferCommand && - canonicalEstimateBalances(currentEstimateBalances) !== - canonicalEstimateBalances( - transferCommand.payload.expectedEstimateBalances - ) && - !matchesHistoryRestoreTarget( - tx, - input, - current.state, - currentEstimateBalances - ) + !matchesHistoryRestoreTarget(tx, input, current.state) ) { throw new ProjectStateConflictError( input.projectId, @@ -120,28 +82,14 @@ export class ProjectStateRestoreCommandRepository input.command.payload.targetState ); replaceProjectState(tx, targetState); - if (transferCommand) { - replaceEstimateBalances( - tx, - input.projectId, - transferCommand.payload.targetEstimateBalances - ); - } const persistedTarget = readProjectStateSnapshot(tx, input.projectId); if (!persistedTarget) { throw new Error("Restored project could not be loaded."); } - const inverse = transferCommand - ? createProjectTransferRestoreCommand( - persistedTarget.payloadSha256, - current.state, - transferCommand.payload.targetEstimateBalances, - currentEstimateBalances - ) - : createProjectStateRestoreCommand( - persistedTarget.payloadSha256, - current.state - ); + const inverse = createProjectStateRestoreCommand( + persistedTarget.payloadSha256, + current.state + ); return inverse; } @@ -150,8 +98,7 @@ export class ProjectStateRestoreCommandRepository function matchesHistoryRestoreTarget( database: AppDatabase, input: ExecuteProjectStateRestoreCommandInput, - currentState: ProjectStateSnapshot, - currentEstimateBalances: EstimateBalanceSnapshot[] + currentState: ProjectStateSnapshot ) { if ( (input.source !== "undo" && input.source !== "redo") || @@ -175,45 +122,25 @@ function matchesHistoryRestoreTarget( input.source === "undo" ? changeSet.forwardPayloadJson : changeSet.inversePayloadJson; - const command = JSON.parse(serialized) as unknown; - assertProjectStateRestoreCommand(command); - const expectedState = parseProjectStateSnapshot(command.payload.targetState); - const stateMatches = - canonicalProjectState(expectedState) === - canonicalProjectState(currentState); - if (!stateMatches) { + const command = JSON.parse(serialized) as { + type?: unknown; + payload?: { targetState?: unknown }; + }; + if (command.type !== "project.restore-state") { return false; } - return command.schemaVersion !== projectStateRestoreCommandSchemaVersion || - canonicalEstimateBalances(command.payload.targetEstimateBalances) === - canonicalEstimateBalances(currentEstimateBalances); + const expectedState = parseProjectStateSnapshot( + command.payload?.targetState + ); + return ( + canonicalProjectState(expectedState) === + canonicalProjectState(currentState) + ); } catch { return false; } } -function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) { - return JSON.stringify( - [...balances] - .sort((left, right) => left.id.localeCompare(right.id)) - .map((balance) => canonicalEstimateBalance(balance)) - ); -} - -function replaceEstimateBalances( - database: AppDatabase, - projectId: string, - balances: EstimateBalanceSnapshot[] -) { - database - .delete(estimateBalances) - .where(eq(estimateBalances.projectId, projectId)) - .run(); - for (const balance of balances) { - insertEstimateBalance(database, balance); - } -} - function canonicalProjectState(state: ProjectStateSnapshot) { return JSON.stringify({ ...state, diff --git a/src/db/repositories/project-transfer.repository.ts b/src/db/repositories/project-transfer.repository.ts index 21647ff..11f23f7 100644 --- a/src/db/repositories/project-transfer.repository.ts +++ b/src/db/repositories/project-transfer.repository.ts @@ -3,11 +3,9 @@ import { eq } from "drizzle-orm"; import { createProjectTransferEnvelope, parseProjectTransferEnvelope, - remapEstimateBalances, remapProjectState, - serializeProjectTransferPayload, } from "../../domain/models/project-transfer.model.js"; -import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js"; +import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js"; import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js"; import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; @@ -28,10 +26,6 @@ import { externalImportBatches } from "../schema/external-import-batches.js"; import { externalModelObjects } from "../schema/external-model-objects.js"; import { externalModelSources } from "../schema/external-model-sources.js"; import { externalRoomMappings } from "../schema/external-room-mappings.js"; -import { - insertEstimateBalance, - readEstimateBalances, -} from "./estimate-balance.persistence.js"; import { hashProjectStatePayload, readProjectStateSnapshot, @@ -46,15 +40,10 @@ export class ProjectTransferRepository implements ProjectTransferStore { if (!snapshot) { return null; } - const estimateBalances = readEstimateBalances(this.database, projectId); - const payloadSha256 = hashProjectStatePayload( - serializeProjectTransferPayload(snapshot.state, estimateBalances) - ); return createProjectTransferEnvelope( new Date().toISOString(), - payloadSha256, - snapshot.state, - estimateBalances + snapshot.payloadSha256, + snapshot.state ); } @@ -72,21 +61,10 @@ export class ProjectTransferRepository implements ProjectTransferStore { projectId, () => crypto.randomUUID() ); - const currentEstimateBalances = readEstimateBalances(this.database, projectId); - const targetEstimateBalances = - transfer.projectState.project.id === projectId - ? transfer.estimateBalances - : remapEstimateBalances( - transfer.estimateBalances, - projectId, - () => crypto.randomUUID() - ); return { - command: createProjectTransferRestoreCommand( + command: createProjectStateRestoreCommand( current.payloadSha256, - targetState, - currentEstimateBalances, - targetEstimateBalances + targetState ), }; } @@ -100,39 +78,20 @@ export class ProjectTransferRepository implements ProjectTransferStore { () => crypto.randomUUID(), `${transfer.projectState.project.name} (Kopie)` ); - const estimateBalances = remapEstimateBalances( - transfer.estimateBalances, - projectId, - () => crypto.randomUUID() - ); - this.database.transaction((tx) => { - insertProjectState(tx, state); - for (const balance of estimateBalances) { - insertEstimateBalance(tx, balance); - } - }); + this.database.transaction((tx) => insertProjectState(tx, state)); return { projectId, name: state.project.name }; } private parseVerified(value: unknown) { - const isLegacy = isPlainObject(value) && value.formatVersion === 1; - const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState) - ? hashProjectStatePayload( - isLegacy - ? JSON.stringify(value.projectState) - : JSON.stringify({ - projectState: value.projectState, - estimateBalances: value.estimateBalances, - }) - ) - : null; + const rawPayloadSha256 = + isPlainObject(value) && + isPlainObject(value.projectState) + ? hashProjectStatePayload(JSON.stringify(value.projectState)) + : null; const transfer = parseProjectTransferEnvelope(value); - const payloadJson = isLegacy - ? serializeProjectStateSnapshot(transfer.projectState) - : serializeProjectTransferPayload( - transfer.projectState, - transfer.estimateBalances - ); + const payloadJson = serializeProjectStateSnapshot( + transfer.projectState + ); if ( hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 && rawPayloadSha256 !== transfer.payloadSha256 diff --git a/src/db/schema/estimate-balances.ts b/src/db/schema/estimate-balances.ts deleted file mode 100644 index 4425a03..0000000 --- a/src/db/schema/estimate-balances.ts +++ /dev/null @@ -1,60 +0,0 @@ -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"), - 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/models/project-state-restore-command.model.ts b/src/domain/models/project-state-restore-command.model.ts index 83b8405..14b876c 100644 --- a/src/domain/models/project-state-restore-command.model.ts +++ b/src/domain/models/project-state-restore-command.model.ts @@ -3,80 +3,32 @@ import { type ProjectStateSnapshot, } from "./project-state-snapshot.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js"; -import { - parseEstimateBalanceSnapshot, -} from "../../estimate-balance/domain/estimate-balance-command.model.js"; -import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js"; export const projectStateRestoreCommandType = "project.restore-state"; -export const projectStateRestoreCommandSchemaVersion = 2 as const; -const snapshotRestoreCommandSchemaVersion = 1 as const; +export const projectStateRestoreCommandSchemaVersion = 1 as const; export interface ProjectStateRestoreCommandPayload { expectedStateSha256: string; targetState: ProjectStateSnapshot; } -export type SnapshotProjectStateRestoreCommand = SerializedProjectCommand< +export type ProjectStateRestoreCommand = SerializedProjectCommand< ProjectStateRestoreCommandPayload -> & { - schemaVersion: typeof snapshotRestoreCommandSchemaVersion; - type: typeof projectStateRestoreCommandType; -}; - -export interface ProjectTransferRestoreCommandPayload - extends ProjectStateRestoreCommandPayload { - expectedEstimateBalances: EstimateBalanceSnapshot[]; - targetEstimateBalances: EstimateBalanceSnapshot[]; -} - -export type ProjectTransferRestoreCommand = SerializedProjectCommand< - ProjectTransferRestoreCommandPayload > & { schemaVersion: typeof projectStateRestoreCommandSchemaVersion; type: typeof projectStateRestoreCommandType; }; -export type ProjectStateRestoreCommand = - | SnapshotProjectStateRestoreCommand - | ProjectTransferRestoreCommand; - export function createProjectStateRestoreCommand( expectedStateSha256: string, targetState: ProjectStateSnapshot ): ProjectStateRestoreCommand { - const command: SnapshotProjectStateRestoreCommand = { - schemaVersion: snapshotRestoreCommandSchemaVersion, - type: projectStateRestoreCommandType, - payload: { - expectedStateSha256, - targetState: parseProjectStateSnapshot(targetState), - }, - }; - assertProjectStateRestoreCommand(command); - return command; -} - -export function createProjectTransferRestoreCommand( - expectedStateSha256: string, - targetState: ProjectStateSnapshot, - expectedEstimateBalances: EstimateBalanceSnapshot[], - targetEstimateBalances: EstimateBalanceSnapshot[] -): ProjectTransferRestoreCommand { - const command: ProjectTransferRestoreCommand = { + const command: ProjectStateRestoreCommand = { schemaVersion: projectStateRestoreCommandSchemaVersion, type: projectStateRestoreCommandType, payload: { expectedStateSha256, targetState: parseProjectStateSnapshot(targetState), - expectedEstimateBalances: parseEstimateBalances( - expectedEstimateBalances, - targetState.project.id - ), - targetEstimateBalances: parseEstimateBalances( - targetEstimateBalances, - targetState.project.id - ), }, }; assertProjectStateRestoreCommand(command); @@ -90,7 +42,10 @@ export function assertProjectStateRestoreCommand( throw new Error("Project state restore command must be an object."); } const command = value as Partial; - if (command.type !== projectStateRestoreCommandType) { + if ( + command.type !== projectStateRestoreCommandType || + command.schemaVersion !== projectStateRestoreCommandSchemaVersion + ) { throw new Error("Unsupported project state restore command."); } if (!command.payload || typeof command.payload !== "object") { @@ -103,43 +58,5 @@ export function assertProjectStateRestoreCommand( ) { throw new Error("Expected project state checksum is invalid."); } - const targetState = parseProjectStateSnapshot(payload.targetState); - if (command.schemaVersion === snapshotRestoreCommandSchemaVersion) { - if (Object.keys(payload).length !== 2) { - throw new Error("Snapshot restore payload contains unknown fields."); - } - return; - } - if (command.schemaVersion !== projectStateRestoreCommandSchemaVersion) { - throw new Error("Unsupported project state restore command."); - } - const transferPayload = payload as Partial; - parseEstimateBalances( - transferPayload.expectedEstimateBalances, - targetState.project.id - ); - parseEstimateBalances( - transferPayload.targetEstimateBalances, - targetState.project.id - ); - if (Object.keys(payload).length !== 4) { - throw new Error("Project transfer restore payload contains unknown fields."); - } -} - -function parseEstimateBalances( - value: unknown, - projectId: string -): EstimateBalanceSnapshot[] { - if (!Array.isArray(value)) { - throw new Error("Project transfer estimate balances must be an array."); - } - const balances = value.map(parseEstimateBalanceSnapshot); - if (balances.some((balance) => balance.projectId !== projectId)) { - throw new Error("Project transfer estimate balance belongs to another project."); - } - if (new Set(balances.map((balance) => balance.id)).size !== balances.length) { - throw new Error("Project transfer contains duplicate estimate balances."); - } - return balances; + parseProjectStateSnapshot(payload.targetState); } diff --git a/src/domain/models/project-transfer.model.ts b/src/domain/models/project-transfer.model.ts index 80b9d59..24c0a9c 100644 --- a/src/domain/models/project-transfer.model.ts +++ b/src/domain/models/project-transfer.model.ts @@ -3,12 +3,9 @@ import { projectStateSnapshotSchemaVersion, type ProjectStateSnapshot, } from "./project-state-snapshot.model.js"; -import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js"; -import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js"; export const projectTransferFormat = "leistungsbilanz.project" as const; -export const projectTransferFormatVersion = 2 as const; -const legacyProjectTransferFormatVersion = 1 as const; +export const projectTransferFormatVersion = 1 as const; export interface ProjectTransferEnvelope { format: typeof projectTransferFormat; @@ -16,14 +13,12 @@ export interface ProjectTransferEnvelope { exportedAtIso: string; payloadSha256: string; projectState: ProjectStateSnapshot; - estimateBalances: EstimateBalanceSnapshot[]; } export function createProjectTransferEnvelope( exportedAtIso: string, payloadSha256: string, - projectState: ProjectStateSnapshot, - estimateBalances: EstimateBalanceSnapshot[] + projectState: ProjectStateSnapshot ): ProjectTransferEnvelope { return parseProjectTransferEnvelope({ format: projectTransferFormat, @@ -31,7 +26,6 @@ export function createProjectTransferEnvelope( exportedAtIso, payloadSha256, projectState, - estimateBalances, }); } @@ -43,8 +37,7 @@ export function parseProjectTransferEnvelope( } if ( value.format !== projectTransferFormat || - (value.formatVersion !== legacyProjectTransferFormatVersion && - value.formatVersion !== projectTransferFormatVersion) + value.formatVersion !== projectTransferFormatVersion ) { throw new Error("Project transfer format is not supported."); } @@ -64,13 +57,7 @@ export function parseProjectTransferEnvelope( if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) { throw new Error("Project transfer snapshot version is not supported."); } - const estimateBalances = - value.formatVersion === legacyProjectTransferFormatVersion - ? [] - : parseTransferEstimateBalances(value.estimateBalances, projectState.project.id); - const expectedFieldCount = - value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6; - if (Object.keys(value).length !== expectedFieldCount) { + if (Object.keys(value).length !== 5) { throw new Error("Project transfer file contains unknown fields."); } return { @@ -79,43 +66,9 @@ export function parseProjectTransferEnvelope( exportedAtIso: value.exportedAtIso, payloadSha256: value.payloadSha256, projectState, - estimateBalances, }; } -export function serializeProjectTransferPayload( - projectState: ProjectStateSnapshot, - estimateBalances: EstimateBalanceSnapshot[] -) { - const parsedState = parseProjectStateSnapshot(projectState); - return JSON.stringify({ - projectState: parsedState, - estimateBalances: parseTransferEstimateBalances( - estimateBalances, - parsedState.project.id - ), - }); -} - -export function remapEstimateBalances( - source: EstimateBalanceSnapshot[], - targetProjectId: string, - createId: () => string -): EstimateBalanceSnapshot[] { - return source.map((balance) => - parseEstimateBalanceSnapshot({ - ...balance, - id: createId(), - projectId: targetProjectId, - groups: balance.groups.map((group) => ({ - ...group, - id: createId(), - rows: group.rows.map((row) => ({ ...row, id: createId() })), - })), - }) - ); -} - export function remapProjectState( source: ProjectStateSnapshot, targetProjectId: string, @@ -329,37 +282,6 @@ function requiredId(ids: ReadonlyMap, sourceId: string) { return targetId; } -function parseTransferEstimateBalances( - value: unknown, - projectId: string -): EstimateBalanceSnapshot[] { - if (!Array.isArray(value)) { - throw new Error("Project transfer estimate balances must be an array."); - } - const balances = value.map(parseEstimateBalanceSnapshot); - if (balances.some((balance) => balance.projectId !== projectId)) { - throw new Error("Project transfer estimate balance belongs to another project."); - } - assertUniqueIds(balances.map((balance) => balance.id), "estimate balance"); - assertUniqueIds( - balances.flatMap((balance) => balance.groups.map((group) => group.id)), - "estimate group" - ); - assertUniqueIds( - balances.flatMap((balance) => - balance.groups.flatMap((group) => group.rows.map((row) => row.id)) - ), - "estimate row" - ); - return balances; -} - -function assertUniqueIds(ids: string[], label: string) { - if (new Set(ids).size !== ids.length) { - throw new Error(`Project transfer contains a duplicate ${label} id.`); - } -} - function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index cf1f740..91ee754 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -194,15 +194,6 @@ 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; @@ -242,8 +233,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore, private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, - private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, - private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore + private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore ) {} executeUser( @@ -664,24 +654,6 @@ 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 deleted file mode 100644 index d1f1a5f..0000000 --- a/src/estimate-balance/api/estimate-balance-request.schemas.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index f1b0da8..0000000 --- a/src/estimate-balance/domain/estimate-balance-command.model.ts +++ /dev/null @@ -1,318 +0,0 @@ -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), - 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, - 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 deleted file mode 100644 index e5f7e04..0000000 --- a/src/estimate-balance/domain/estimate-balance-project-command.store.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 395cd7c..a94378e 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -17,7 +17,6 @@ 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 deleted file mode 100644 index 6d8f123..0000000 --- a/src/frontend/estimate-balance/estimate-balance-create-modal.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"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 deleted file mode 100644 index e2726d1..0000000 --- a/src/frontend/estimate-balance/estimate-balance-document.ts +++ /dev/null @@ -1,298 +0,0 @@ -// 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, - 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, - 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), - 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 deleted file mode 100644 index 85cddf3..0000000 --- a/src/frontend/estimate-balance/estimate-balance-format.ts +++ /dev/null @@ -1,122 +0,0 @@ -// 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 deleted file mode 100644 index 07dc19c..0000000 --- a/src/frontend/estimate-balance/estimate-balance-sheet.tsx +++ /dev/null @@ -1,669 +0,0 @@ -"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, - formatGermanNumber, -} 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 = 9; -const COLUMN_COUNT = 16; - -/** Suggested supply values; free text stays possible. */ -const SUPPLY_SUGGESTIONS = ["ELT", "MSR"]; -const SUPPLY_LIST_ID = "estimate-supply-options"; - -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" - printFormat={(current: number) => formatGermanNumber(current, 0, 3)} - onCommit={onCommit} - onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)} - parse={(text) => parseGermanDecimal(text, range)} - value={value} - /> - ); -} - -function supplyOptions(balance: EstimateBalanceSnapshot): string[] { - const used = balance.groups.flatMap((group) => - group.rows.map((row) => row.supply?.trim() ?? "") - ); - return [...new Set([...SUPPLY_SUGGESTIONS, ...used.filter(Boolean)])]; -} - -function TextInput({ - ariaLabel, - disabled, - list, - maxLength, - onChange, - placeholder, - value, -}: { - ariaLabel: string; - disabled: boolean; - list?: string; - 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({ 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 ( -
- - {supplyOptions(balance).map((supply) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {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/MSR … - 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 deleted file mode 100644 index f7098b0..0000000 --- a/src/frontend/estimate-balance/estimate-comparison-table.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"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 deleted file mode 100644 index 904fa2e..0000000 --- a/src/frontend/estimate-balance/estimate-draft-input.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"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; - /** Printed text; defaults to the input text. */ - printFormat?: (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, - printFormat, - 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} - /> - - {printFormat ? printFormat(value) : error ? format(value) : text} - - - ); -} diff --git a/src/frontend/estimate-balance/estimate-group-modal.tsx b/src/frontend/estimate-balance/estimate-group-modal.tsx deleted file mode 100644 index 8d56308..0000000 --- a/src/frontend/estimate-balance/estimate-group-modal.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"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/estimate-balance/estimate-print-header.tsx b/src/frontend/estimate-balance/estimate-print-header.tsx deleted file mode 100644 index 84ce3e9..0000000 --- a/src/frontend/estimate-balance/estimate-print-header.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { documentLogoUrl, getDocumentBranding } from "../utils/api"; -import type { EstimateBalanceDto, OfficeProfileDto, ProjectDto } from "../types"; -import { formatIsoDateGerman } from "./estimate-balance-format"; - -interface EstimatePrintHeaderProps { - project: ProjectDto; - balance: EstimateBalanceDto; -} - -function cssString(value: string) { - return JSON.stringify(value.replace(/[<>\r\n]/g, " ")); -} - -/** - * Print-only title block and page footer of the rough power balance in the - * kbp corporate design of the print view (Seitenansicht): logo, blue title, - * meta data, "Seite x von y". Hidden on screen. - */ -export function EstimatePrintHeader({ project, balance }: EstimatePrintHeaderProps) { - const [office, setOffice] = useState(null); - const [hasLogo, setHasLogo] = useState(false); - const [logoVersion, setLogoVersion] = useState(""); - - useEffect(() => { - getDocumentBranding() - .then((branding) => { - setOffice(branding.office); - setHasLogo(Boolean(branding.logo)); - setLogoVersion(branding.logo ? branding.logo.updatedAtIso : ""); - }) - .catch(() => { - setOffice(null); - setHasLogo(false); - }); - }, []); - - const stand = [balance.name, balance.workStage].filter(Boolean).join(" · "); - const date = balance.dateIso ? formatIsoDateGerman(balance.dateIso) : null; - const footer = ` - @page estimate-balance { - size: A4 landscape; - margin: 10mm 10mm 13mm; - @bottom-left { content: ${cssString(`${project.name} · Grobe Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; } - @bottom-center { content: ${cssString(`Stand ${stand}${date ? ` · ${date}` : ""}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; } - @bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; } - }`; - - return ( - <> - - - - ); -} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 11bde4a..5c14f5e 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -535,34 +535,3 @@ 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 501ed01..e1f4608 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -38,11 +38,6 @@ 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"; @@ -92,30 +87,6 @@ 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, @@ -128,23 +99,15 @@ 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; code?: unknown }; + const parsed = JSON.parse(details) as { error?: 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 ApiRequestError( - details || `Anfrage fehlgeschlagen (Status ${response.status})`, - response.status, - code - ); + throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`); } if (response.status === 204) { @@ -1172,66 +1135,3 @@ 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 146cda8..a2880ec 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -68,9 +68,6 @@ 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 66fdd92..4f9b5ee 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -7,7 +7,6 @@ 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"; @@ -29,7 +28,6 @@ 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 8768173..79a4a9f 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -27,7 +27,6 @@ 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 = @@ -81,8 +80,6 @@ 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, @@ -110,6 +107,5 @@ export const projectCommandService = new ProjectCommandService( externalInitialImportProjectCommandStore, externalObjectRowAssignmentProjectCommandStore, externalObjectNewRowProjectCommandStore, - externalObjectNewCircuitProjectCommandStore, - estimateBalanceProjectCommandStore + externalObjectNewCircuitProjectCommandStore ); diff --git a/src/server/controllers/estimate-balance.controller.ts b/src/server/controllers/estimate-balance.controller.ts deleted file mode 100644 index a22bcb1..0000000 --- a/src/server/controllers/estimate-balance.controller.ts +++ /dev/null @@ -1,180 +0,0 @@ -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 529def2..0b61255 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -21,14 +21,6 @@ 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 { @@ -126,12 +118,3 @@ 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 deleted file mode 100644 index 63d3753..0000000 --- a/src/shared/estimate-balance/estimate-balance.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * 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 "MSR" (free text). */ - supply: 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 deleted file mode 100644 index ed5f4c7..0000000 --- a/tests/estimate-balance-frontend.test.ts +++ /dev/null @@ -1,432 +0,0 @@ -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, - 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 ", - 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, - remark: saveable.groups[0]!.rows[0]!.remark, - }, - { name: "Hebeanlage", supply: "ELT", 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 deleted file mode 100644 index 1463fe4..0000000 --- a/tests/estimate-balance.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -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", - 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(); - } - }); -}); diff --git a/tests/print-view-migration.test.ts b/tests/print-view-migration.test.ts index f94baf8..659b6da 100644 --- a/tests/print-view-migration.test.ts +++ b/tests/print-view-migration.test.ts @@ -49,9 +49,6 @@ it("upgrades a populated pre-print database through the current schema without c assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0); assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0); assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0); - assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_balances").get()!.count, 0); - assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_groups").get()!.count, 0); - assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_rows").get()!.count, 0); assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []); assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok"); migrate(context.db, { migrationsFolder: source }); diff --git a/tests/project-snapshot.repository.test.ts b/tests/project-snapshot.repository.test.ts index 9131205..64141c2 100644 --- a/tests/project-snapshot.repository.test.ts +++ b/tests/project-snapshot.repository.test.ts @@ -11,13 +11,7 @@ import { import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js"; import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js"; -import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js"; -import { - deleteEstimateBalance, - insertEstimateBalance, - readEstimateBalances, -} from "../src/db/repositories/estimate-balance.persistence.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js"; @@ -34,48 +28,8 @@ import { projects } from "../src/db/schema/projects.js"; import { rooms } from "../src/db/schema/rooms.js"; import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js"; import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js"; -import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.js"; -import { parseEstimateBalanceSnapshot } from "../src/estimate-balance/domain/estimate-balance-command.model.js"; - -function estimateBalance(projectId: string, suffix = "") { - return parseEstimateBalanceSnapshot({ - id: `estimate${suffix}`, - projectId, - name: `Vorplanung${suffix}`, - workStage: "LPH 2", - dateIso: "2026-09-27", - reserveAvPercent: 10, - reserveSvPercent: 5, - sortOrder: 0, - groups: [ - { - id: `estimate-group${suffix}`, - costGroup: "440", - title: "Starkstromanlagen", - editor: "Planung", - sortOrder: 0, - rows: [ - { - id: `estimate-row${suffix}`, - sortOrder: 0, - supply: "ELT", - name: "Beleuchtung", - quantity: 100, - unit: "area", - connection: "fixed", - powerPerUnitW: 12, - simultaneityFactor: 0.8, - cosPhi: 0.95, - safetySupply: false, - remark: null, - }, - ], - }, - ], - }); -} function createTestDatabase(): DatabaseContext { const context = createDatabaseContext(":memory:"); @@ -509,41 +463,14 @@ describe("project snapshot repository", () => { }); describe("portable project transfer", () => { - it("keeps estimate balances outside normal snapshot restore", () => { - const context = createTestDatabase(); - try { - insertEstimateBalance(context.db, estimateBalance("project-1")); - const current = readProjectStateSnapshot(context.db, "project-1"); - assert.ok(current); - new ProjectStateRestoreCommandRepository(context.db).execute({ - projectId: "project-1", - expectedRevision: 0, - source: "restore", - command: createProjectStateRestoreCommand(current.payloadSha256, { - ...current.state, - project: { ...current.state.project, name: "Restored project" }, - }), - }); - assert.deepEqual( - readEstimateBalances(context.db, "project-1").map((entry) => entry.id), - ["estimate"] - ); - } finally { - context.close(); - } - }); - it("exports and atomically duplicates a complete project with new ids", () => { const context = createTestDatabase(); try { context.db.update(projects).set({ siteAddress: "Musterstraße 1", workStage: "LPH 5", projectLead: "Planung", projectEditor: "Test" }).where(eq(projects.id, "project-1")).run(); context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "Technik", fedFrom: "HAK", remark: "Prüfstand" }).where(eq(distributionBoards.projectId, "project-1")).run(); - insertEstimateBalance(context.db, estimateBalance("project-1")); const repository = new ProjectTransferRepository(context.db); const transfer = repository.exportProject("project-1"); assert.ok(transfer); - assert.equal(transfer.formatVersion, 2); - assert.equal(transfer.estimateBalances.length, 1); const duplicate = repository.importDuplicate(transfer); assert.notEqual(duplicate.projectId, "project-1"); assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)"); @@ -614,18 +541,6 @@ describe("portable project transfer", () => { copied.state.distributionBoards[0].simultaneityFactor, 0.65 ); - const copiedBalances = readEstimateBalances(context.db, duplicate.projectId); - assert.equal(copiedBalances.length, 1); - assert.notEqual(copiedBalances[0].id, transfer.estimateBalances[0].id); - assert.notEqual( - copiedBalances[0].groups[0].id, - transfer.estimateBalances[0].groups[0].id - ); - assert.notEqual( - copiedBalances[0].groups[0].rows[0].id, - transfer.estimateBalances[0].groups[0].rows[0].id - ); - assert.equal(copiedBalances[0].groups[0].rows[0].name, "Beleuchtung"); } finally { context.close(); } @@ -651,14 +566,6 @@ describe("portable project transfer", () => { }), /checksum verification failed/ ); - assert.throws( - () => - repository.importDuplicate({ - ...transfer, - estimateBalances: [estimateBalance("project-1", "-tampered")], - }), - /checksum verification failed/ - ); assert.equal( context.db.select().from(projects).all().length, 1 @@ -668,79 +575,15 @@ describe("portable project transfer", () => { } }); - it("imports legacy transfer version 1 with no estimate balances", () => { - const context = createTestDatabase(); - try { - const snapshot = readProjectStateSnapshot(context.db, "project-1"); - assert.ok(snapshot); - const legacyTransfer = { - format: "leistungsbilanz.project", - formatVersion: 1, - exportedAtIso: "2026-09-27T12:00:00.000Z", - payloadSha256: snapshot.payloadSha256, - projectState: snapshot.state, - }; - const duplicate = new ProjectTransferRepository(context.db).importDuplicate( - legacyTransfer - ); - assert.deepEqual( - readEstimateBalances(context.db, duplicate.projectId), - [] - ); - } finally { - context.close(); - } - }); - - it("replaces exported estimate balances atomically and restores them on undo", () => { - const context = createTestDatabase(); - try { - insertEstimateBalance(context.db, estimateBalance("project-1")); - const transferRepository = new ProjectTransferRepository(context.db); - const transfer = transferRepository.exportProject("project-1"); - assert.ok(transfer); - - deleteEstimateBalance(context.db, "project-1", "estimate"); - insertEstimateBalance(context.db, estimateBalance("project-1", "-changed")); - const prepared = transferRepository.prepareReplace("project-1", transfer); - assert.ok(prepared); - const restored = new ProjectStateRestoreCommandRepository(context.db).execute({ - projectId: "project-1", - expectedRevision: 0, - source: "restore", - command: prepared.command, - }); - assert.deepEqual( - readEstimateBalances(context.db, "project-1").map((entry) => entry.id), - ["estimate"] - ); - - new ProjectStateRestoreCommandRepository(context.db).execute({ - projectId: "project-1", - expectedRevision: 1, - source: "undo", - historyTargetChangeSetId: restored.revision.changeSetId, - command: restored.inverse, - }); - assert.deepEqual( - readEstimateBalances(context.db, "project-1").map((entry) => entry.id), - ["estimate-changed"] - ); - } finally { - context.close(); - } - }); - it("rolls back a duplicate when a late project-state insert fails", () => { const context = createTestDatabase(); try { - insertEstimateBalance(context.db, estimateBalance("project-1")); const repository = new ProjectTransferRepository(context.db); const transfer = repository.exportProject("project-1"); assert.ok(transfer); context.sqlite.exec(` - CREATE TRIGGER fail_imported_estimate_row - BEFORE INSERT ON estimate_rows + CREATE TRIGGER fail_imported_device_row + BEFORE INSERT ON circuit_device_rows BEGIN SELECT RAISE(ABORT, 'forced project transfer failure'); END;