diff --git a/AGENTS.md b/AGENTS.md index d771e59..d438bae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -339,6 +339,11 @@ external model state atomically; its inverse powers persistent Undo/Redo without parsing workbook bytes. Apply retransmits and reparses the XLSX, verifies its preview SHA-256 and creates no circuit, equipment identifier or CircuitDeviceRow. +R6 adds one compact project-wide external-object GET without pagination or +batch payloads. The Revit page filters and sorts it in the client and renders a +virtualized window. `external-object.update-planning` applies multi-object +board, ProjectDevice and planning-value changes as one persistent command; +linked objects are excluded because they follow their CircuitDeviceRow board. The target configuration has no independent version counter; `expectedRevision` invalidates stale plans, and each import batch stores a configuration copy. @@ -592,7 +597,7 @@ Critical target rules: selected combined/separate mode block export. - Missing GZF defaults visibly to 1 without separate confirmation. - The corrected R2 return workbook was successfully imported into Linear on - 2026-09-30; R3 through R5 are complete and R6 is the next package. + 2026-09-30; R3 through R6 are complete and R7 is the next package. - All confirmed writes use typed project commands with `expectedRevision`, complete inverse data and the shared project-command transaction boundary. - Do not add automatic circuit planning, Revit circuit adoption, multi-source diff --git a/README.md b/README.md index b965df8..d4aeae4 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ für mehrere gleichzeitig arbeitende Prozesse und ein vollständig unterstützte Produktionsdeployment fehlen weiterhin. Anmeldung und zwei feste Rollen sind vorhanden. Die Backend- und Persistenzgrundlage der bisherigen Revit-CSV-/IfcGUID-Anbindung ist durch den XLSX-Austausch des Linear-Plugins mit -Worksharing-ID als externem Schlüssel ersetzt. R0 bis R5 sind abgeschlossen; +Worksharing-ID als externem Schlüssel ersetzt. R0 bis R6 sind abgeschlossen; die eigene Revit-Seite besitzt XLSX-Einstellungen und einen persistenten -Erstimport. Objekttabelle, Editorzuordnung, Rückgabe und Folgeimport folgen in -R6 bis R10. +Erstimport sowie eine virtualisierte Objekttabelle mit Sammeländerungen. +Editorzuordnung, Rückgabe und Folgeimport folgen in R7 bis R10. ## Unterstützter Arbeitsablauf diff --git a/docs/spec/revit-excel-phase-14-plan.md b/docs/spec/revit-excel-phase-14-plan.md index f4d1c7c..9b43c4f 100644 --- a/docs/spec/revit-excel-phase-14-plan.md +++ b/docs/spec/revit-excel-phase-14-plan.md @@ -14,7 +14,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben. | R3 | abgeschlossen | Konfiguration ohne eigene Revision, Worksharing-ID-Datenmodell, Migration 0014, Snapshot 10, Transfer und Backend-CSV-Entfernung sind umgesetzt und geprüft. | | R4 | abgeschlossen | Revit-Seite, Navigation und revisionierte XLSX-Einstellungen mit Musterdateiprüfung sind umgesetzt; das alte Projektseitenmodal ist entfernt. | | R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. | -| R6 | offen | Virtualisierte Objekttabelle und Sammelzuordnungen. | +| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. | | R7 | offen | Editorbereich, Gruppierung, Drag-and-drop, Lösen und integrierte Löschübergänge. | | R8 | offen | Rückgabe-Excel und append-only Exportnachweis. | | R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. | @@ -142,6 +142,14 @@ Zustände vollständig, sodass Undo/Redo weder Datei noch Parser benötigen. - Standardverteileränderung wirkt nur auf unzugeordnete Objekte; verknüpfte Objekte folgen unverändert ihrer Row. +Implementierungsstand vom 30.09.2026: Der projektweite GET-Endpunkt liefert +eine kompakte Liste ohne Batchbytes, Matrix oder Pagination. Die Revit-Seite +sucht, filtert und sortiert vollständig im Client und rendert nur den +sichtbaren Ausschnitt. Eine stabile Mehrfachauswahl ändert Verteiler, +ProjectDevice, Leistung und GZF in einem persistenten +`external-object.update-planning`-Command. Verknüpfte Objekte sind für diese +Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row. + ## R7 – Stromkreislisteneditor - gruppierter rechter Revit-Bereich mit Anzahl-Badge; diff --git a/src/app/projects/[projectId]/revit/page.tsx b/src/app/projects/[projectId]/revit/page.tsx index bf9b39f..fc8b85e 100644 --- a/src/app/projects/[projectId]/revit/page.tsx +++ b/src/app/projects/[projectId]/revit/page.tsx @@ -5,6 +5,7 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; import { IconLink } from "../../../../frontend/components/icon-link"; import { RevitInitialImportWizard } from "../../../../frontend/components/revit-initial-import-wizard"; +import { RevitObjectTable } from "../../../../frontend/components/revit-object-table"; import { RevitXlsxSettingsModal } from "../../../../frontend/components/revit-xlsx-settings-modal"; import type { ExternalModelConfiguration } from "../../../../external-model/configuration/external-model-configuration"; import { detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getProject, updateExternalModelConfiguration } from "../../../../frontend/utils/api"; @@ -43,6 +44,7 @@ export default function RevitPage() {
Projekt · Revit

{project?.name ?? "Revit-Anbindung"}

Linear-XLSX austauschen und Revit-Objekte verwalten

{error ?
{error}
: null}

XLSX-Konfiguration

{snapshot ? "Die Linear-Spalten und das Tabellenlayout sind konfiguriert." : "Noch keine XLSX-Konfiguration gespeichert."}

{snapshot ? "Konfiguriert" : "Offen"}

Importstatus

{importStatus?.imported ? <>

{importStatus.objectCount} Revit-Objekte{importStatus.fileName ? ` aus ${importStatus.fileName}` : ""}

Quelle: {importStatus.sourceName}

:

Noch kein Linear-XLSX-Import vorhanden.

}
+ {importStatus?.imported ? setProject((current) => current ? { ...current, currentRevision: revision } : current)} /> : null} {settingsOpen ? setSettingsOpen(false)} onInspectSample={async (file, configuration) => { const result = await detectExternalModelWorkbook(projectId, file, configuration); return `Muster erkannt: ${result.sheetName}, ${result.columnCount} Spalten, ${result.objectCount} Objekte.`; }} onSave={save} /> : null} {importOpen && project && snapshot ? setImportOpen(false)} onImported={({ history }) => { setProject({ ...project, currentRevision: history.currentRevision }); setImportOpen(false); void Promise.all([getExternalInitialImportStatus(projectId), getExternalModelConfiguration(projectId)]).then(([status, configuration]) => { setImportStatus(status); setSnapshot(configuration.configuration); }); }} /> : null} ; diff --git a/src/db/repositories/external-object-planning-project-command.repository.ts b/src/db/repositories/external-object-planning-project-command.repository.ts new file mode 100644 index 0000000..e6b3b1f --- /dev/null +++ b/src/db/repositories/external-object-planning-project-command.repository.ts @@ -0,0 +1,29 @@ +import { and, eq } from "drizzle-orm"; +import { assertExternalObjectPlanningUpdateProjectCommand, invertExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js"; +import type { ExternalObjectPlanningProjectCommandStore } from "../../domain/ports/external-object-planning-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { distributionBoards } from "../schema/distribution-boards.js"; +import { externalModelObjects } from "../schema/external-model-objects.js"; +import { projectDevices } from "../schema/project-devices.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { snapshotsEqual, toExternalObjectSnapshot } from "./external-object-assignment.persistence.js"; + +export class ExternalObjectPlanningProjectCommandRepository implements ExternalObjectPlanningProjectCommandStore { + constructor(private readonly database: AppDatabase) {} + execute(input: Parameters[0]) { + assertExternalObjectPlanningUpdateProjectCommand(input.command); + return executeProjectCommandTransaction(this.database, input, (tx) => { + const boardIds = new Set(tx.select({ id: distributionBoards.id }).from(distributionBoards).where(eq(distributionBoards.projectId, input.projectId)).all().map((row) => row.id)); + const deviceIds = new Set(tx.select({ id: projectDevices.id }).from(projectDevices).where(eq(projectDevices.projectId, input.projectId)).all().map((row) => row.id)); + for (const { expected, target } of input.command.payload.objects) { + const current = tx.select().from(externalModelObjects).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).get(); + if (!current || !snapshotsEqual(toExternalObjectSnapshot(current), expected)) throw new Error("Ein Revit-Objekt wurde zwischenzeitlich geändert."); + if (target.distributionBoardId !== null && !boardIds.has(target.distributionBoardId)) throw new Error("Der Verteiler gehört zu einem anderen Projekt."); + if (target.linkedProjectDeviceId !== null && !deviceIds.has(target.linkedProjectDeviceId)) throw new Error("Das Projektgerät gehört zu einem anderen Projekt."); + const updated = tx.update(externalModelObjects).set({ planningValues: target.planningValues, overriddenFields: target.overriddenFields, distributionBoardId: target.distributionBoardId, linkedProjectDeviceId: target.linkedProjectDeviceId }).where(and(eq(externalModelObjects.id, expected.id), eq(externalModelObjects.projectId, input.projectId))).run(); + if (updated.changes !== 1) throw new Error("Revit-Objekt konnte nicht geändert werden."); + } + return invertExternalObjectPlanningUpdateProjectCommand(input.command); + }); + } +} diff --git a/src/domain/models/external-object-planning-project-command.model.ts b/src/domain/models/external-object-planning-project-command.model.ts new file mode 100644 index 0000000..f31d126 --- /dev/null +++ b/src/domain/models/external-object-planning-project-command.model.ts @@ -0,0 +1,30 @@ +import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js"; +import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js"; +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const externalObjectPlanningUpdateCommandType = "external-object.update-planning" as const; +export interface ExternalObjectPlanningUpdateProjectCommand extends SerializedProjectCommand<{ objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }> }> { + schemaVersion: 1; + type: typeof externalObjectPlanningUpdateCommandType; +} +export function createExternalObjectPlanningUpdateProjectCommand(objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }>): ExternalObjectPlanningUpdateProjectCommand { + const command: ExternalObjectPlanningUpdateProjectCommand = { schemaVersion: 1, type: externalObjectPlanningUpdateCommandType, payload: { objects } }; + assertExternalObjectPlanningUpdateProjectCommand(command); return command; +} +export function invertExternalObjectPlanningUpdateProjectCommand(command: ExternalObjectPlanningUpdateProjectCommand) { return createExternalObjectPlanningUpdateProjectCommand(command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected }))); } +export function assertExternalObjectPlanningUpdateProjectCommand(command: SerializedProjectCommand): asserts command is ExternalObjectPlanningUpdateProjectCommand { + if (command.schemaVersion !== 1 || command.type !== externalObjectPlanningUpdateCommandType || !isRecord(command.payload) || !Array.isArray(command.payload.objects) || command.payload.objects.length === 0) throw new Error("Nicht unterstützter Revit-Objektplanungs-Command."); + const ids = new Set(); + for (const transition of command.payload.objects) { + if (!isRecord(transition)) throw new Error("Ungültiger Revit-Objektübergang."); + const expected = parseObject(transition.expected); const target = parseObject(transition.target); + if (expected.id !== target.id || ids.has(expected.id)) throw new Error("Doppelte oder abweichende Revit-Objekt-ID."); + ids.add(expected.id); + if (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== null) throw new Error("Verknüpfte Revit-Objekte werden über ihre Gerätezeile geplant."); + const left = { ...expected, planningValues: undefined, overriddenFields: undefined, distributionBoardId: undefined, linkedProjectDeviceId: undefined }; + const right = { ...target, planningValues: undefined, overriddenFields: undefined, distributionBoardId: undefined, linkedProjectDeviceId: undefined }; + if (JSON.stringify(left) !== JSON.stringify(right) || JSON.stringify(expected) === JSON.stringify(target)) throw new Error("Der Objektplanungs-Command ändert unzulässige oder keine Felder."); + } +} +function parseObject(value: unknown) { return parseExternalModelStateSnapshot({ source: null, importBatches: [], roomMappings: [], objects: [value] }).objects[0]!; } +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/src/domain/ports/external-object-planning-project-command.store.ts b/src/domain/ports/external-object-planning-project-command.store.ts new file mode 100644 index 0000000..19c610b --- /dev/null +++ b/src/domain/ports/external-object-planning-project-command.store.ts @@ -0,0 +1,3 @@ +import type { ExternalObjectPlanningUpdateProjectCommand } from "../models/external-object-planning-project-command.model.js"; +import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js"; +export interface ExternalObjectPlanningProjectCommandStore { execute(input: { projectId: string; expectedRevision: number; source: ProjectRevisionSource; description?: string; actorId?: string; historyTargetChangeSetId?: string; command: ExternalObjectPlanningUpdateProjectCommand }): { revision: AppendedProjectRevision; inverse: ExternalObjectPlanningUpdateProjectCommand }; } diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 8e4bf36..ca99c5f 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -182,6 +182,8 @@ import { externalInitialImportCommandType, } from "../models/external-initial-import-project-command.model.js"; import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js"; +import { assertExternalObjectPlanningUpdateProjectCommand, externalObjectPlanningUpdateCommandType } from "../models/external-object-planning-project-command.model.js"; +import type { ExternalObjectPlanningProjectCommandStore } from "../ports/external-object-planning-project-command.store.js"; import { assertExternalObjectRowAssignmentProjectCommand, externalObjectRowAssignmentCommandType, @@ -248,7 +250,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore, - private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore + private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, + private readonly externalObjectPlanningStore?: ExternalObjectPlanningProjectCommandStore ) {} executeUser( @@ -647,6 +650,11 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case externalObjectPlanningUpdateCommandType: { + assertExternalObjectPlanningUpdateProjectCommand(input.command); + if (!this.externalObjectPlanningStore) throw new Error("External object planning store is not available."); + return this.externalObjectPlanningStore.execute({ ...input, command: input.command }).revision; + } case externalObjectRowAssignmentCommandType: { assertExternalObjectRowAssignmentProjectCommand(input.command); if (!this.externalObjectRowAssignmentStore) { diff --git a/src/frontend/components/revit-object-table.tsx b/src/frontend/components/revit-object-table.tsx new file mode 100644 index 0000000..c82d9c7 --- /dev/null +++ b/src/frontend/components/revit-object-table.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import type { ExternalProjectObjectsDto } from "../types"; +import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api"; + +const rowHeight = 44; +const viewportHeight = 520; + +export function RevitObjectTable({ projectId, onRevision }: { projectId: string; onRevision: (revision: number) => void }) { + const [data, setData] = useState(null); + const [search, setSearch] = useState(""); + const [boardFilter, setBoardFilter] = useState("all"); + const [assignmentFilter, setAssignmentFilter] = useState("all"); + const [sort, setSort] = useState<"family" | "room" | "worksharing">("family"); + const [selected, setSelected] = useState>(new Set()); + const [scrollTop, setScrollTop] = useState(0); + const [bulkBoard, setBulkBoard] = useState(""); + const [bulkDevice, setBulkDevice] = useState(""); + const [power, setPower] = useState(""); + const [gzf, setGzf] = useState(""); + const [planningField, setPlanningField] = useState(""); + const [planningValue, setPlanningValue] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + async function load() { try { const loaded = await getExternalProjectObjects(projectId); setData(loaded); onRevision(loaded.currentRevision); } catch (value) { setError(value instanceof Error ? value.message : "Revit-Objekte konnten nicht geladen werden."); } } + useEffect(() => { void load(); }, [projectId]); // eslint-disable-line react-hooks/exhaustive-deps + + const rows = useMemo(() => { + if (!data) return []; + const needle = search.trim().toLocaleLowerCase("de-DE"); + return data.objects.filter((object) => { + if (boardFilter !== "all" && (boardFilter === "none" ? object.distributionBoardId !== null : object.distributionBoardId !== boardFilter)) return false; + if (assignmentFilter === "unassigned" && object.circuitDeviceRowId !== null) return false; + if (assignmentFilter === "linked" && object.circuitDeviceRowId === null) return false; + return !needle || [object.worksharingId, object.familyAndType, object.selectionMarker, object.sourceRoomNumber, object.sourceRoomName, object.planningValues.displayName ?? ""].some((value) => value.toLocaleLowerCase("de-DE").includes(needle)); + }).sort((a, b) => { + const left = sort === "family" ? a.familyAndType : sort === "room" ? `${a.sourceRoomNumber}\u0000${a.sourceRoomName}` : a.worksharingId; + const right = sort === "family" ? b.familyAndType : sort === "room" ? `${b.sourceRoomNumber}\u0000${b.sourceRoomName}` : b.worksharingId; + return left.localeCompare(right, "de", { numeric: true }) || a.worksharingId.localeCompare(b.worksharingId); + }); + }, [data, search, boardFilter, assignmentFilter, sort]); + const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 5); + const visible = rows.slice(first, first + Math.ceil(viewportHeight / rowHeight) + 10); + const selectable = rows.filter((object) => object.circuitDeviceRowId === null); + + async function applyBulk() { + if (!data || selected.size === 0) return; + const patch: Record = {}; + if (bulkBoard !== "") patch.distributionBoardId = bulkBoard === "none" ? null : bulkBoard; + if (bulkDevice !== "") patch.linkedProjectDeviceId = bulkDevice === "none" ? null : bulkDevice; + if (power !== "") patch.powerPerUnitW = Number(power.replace(",", ".")); + if (gzf !== "") patch.simultaneityFactor = Number(gzf.replace(",", ".")); + if (planningField) { + patch[planningField] = ["effectiveQuantity", "cosPhi"].includes(planningField) + ? Number(planningValue.replace(",", ".")) + : planningValue === "" ? null : planningValue; + } + if (Object.keys(patch).length === 0) { setError("Mindestens eine Sammeländerung auswählen."); return; } + setSaving(true); setError(null); + try { const result = await updateExternalProjectObjects(projectId, data.currentRevision, [...selected], patch); onRevision(result.history.currentRevision); setSelected(new Set()); await load(); } + catch (value) { setError(value instanceof Error ? value.message : "Sammeländerung fehlgeschlagen."); } + finally { setSaving(false); } + } + + if (!data) return
Revit-Objekte werden geladen …
; + return

Revit-Objekte

{rows.length} von {data.objects.length} · {selected.size} ausgewählt
setSearch(event.target.value)} />
+ {error ?
{error}
: null} +
setScrollTop(event.currentTarget.scrollTop)}>
{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return
setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} />
{object.planningValues.displayName ?? object.familyAndType}
{object.familyAndType}
{object.sourceRoomNumber} {object.sourceRoomName}
{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}
{object.selectionMarker || object.worksharingId}
{linked ? "verknüpft" : "frei"}
; })}
+
{planningField ? : null}
+
; +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 9a18a94..49466f5 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -111,6 +111,21 @@ export interface ExternalInitialImportResultDto extends ProjectCommandResultDto }; } +export interface ExternalProjectObjectDto { + id: string; worksharingId: string; ifcGuid: string | null; deviceTypeId: string; + familyAndType: string; selectionMarker: string; sourceCircuitIdentifier: string; + sourceRoomNumber: string; sourceRoomName: string; roomId: string | null; + distributionBoardId: string | null; linkedProjectDeviceId: string | null; + circuitDeviceRowId: string | null; presenceStatus: "present" | "missing"; + planningValues: { displayName: string | null; category: CircuitGroupCategory | null; connectionKind: string | null; effectiveQuantity: number; powerPerUnitW: number | null; simultaneityFactor: number | null; cosPhi: number | null; costGroup: string | null; remark: string | null }; +} +export interface ExternalProjectObjectsDto { + currentRevision: number; sourceName: string | null; + distributionBoards: Array<{ id: string; name: string }>; + projectDevices: Array<{ id: string; name: string; displayName: string }>; + objects: ExternalProjectObjectDto[]; +} + export interface ProjectDto { id: string; name: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 692640d..c46f80b 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -43,6 +43,7 @@ import type { ExternalInitialImportResultDto, ExternalInitialImportStatusDto, ApplyExternalInitialImportInput, + ExternalProjectObjectsDto, CreateEstimateBalanceInput, EstimateBalanceCommandResultDto, EstimateBalanceDto, @@ -236,6 +237,14 @@ async function fileToBase64(file: File) { return btoa(binary); } +export function getExternalProjectObjects(projectId: string) { + return request(`/api/projects/${projectId}/external-model/objects`); +} + +export function updateExternalProjectObjects(projectId: string, expectedRevision: number, objectIds: string[], patch: Record) { + return request(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) }); +} + export function getProjectHistory(projectId: string) { return request( `/api/projects/${projectId}/history` diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index 4d6003a..2f1f35b 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -65,6 +65,7 @@ const commandTypeLabels: Record = { "external-model-configuration.update": "Revit-XLSX-Einstellungen bearbeitet", "external-import.apply-initial": "Revit-Erstimport übernommen", "external-model.apply-initial-import": "Linear-XLSX-Erstimport übernommen", + "external-object.update-planning": "Revit-Objekte bearbeitet", "external-object.assign-to-new-circuit": "Externes Objekt in neuen Stromkreis übernommen", "external-object.unassign-and-delete-created-circuit": diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 4017144..669e25d 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -24,6 +24,7 @@ import { ProjectStateRestoreCommandRepository } from "../../db/repositories/proj import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ExternalModelConfigurationProjectCommandRepository } from "../../db/repositories/external-model-configuration-project-command.repository.js"; import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js"; +import { ExternalObjectPlanningProjectCommandRepository } from "../../db/repositories/external-object-planning-project-command.repository.js"; 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"; @@ -75,6 +76,8 @@ export const externalModelConfigurationProjectCommandStore = new ExternalModelConfigurationProjectCommandRepository(db); export const externalInitialImportProjectCommandStore = new ExternalInitialImportProjectCommandRepository(db); +export const externalObjectPlanningProjectCommandStore = + new ExternalObjectPlanningProjectCommandRepository(db); export const externalObjectRowAssignmentProjectCommandStore = new ExternalObjectRowAssignmentProjectCommandRepository(db); export const externalObjectNewRowProjectCommandStore = @@ -111,5 +114,6 @@ export const projectCommandService = new ProjectCommandService( externalObjectNewRowProjectCommandStore, externalObjectNewCircuitProjectCommandStore, estimateBalanceProjectCommandStore, - externalInitialImportProjectCommandStore + externalInitialImportProjectCommandStore, + externalObjectPlanningProjectCommandStore ); diff --git a/src/server/controllers/external-objects.controller.ts b/src/server/controllers/external-objects.controller.ts new file mode 100644 index 0000000..f56f7be --- /dev/null +++ b/src/server/controllers/external-objects.controller.ts @@ -0,0 +1,56 @@ +import type { Request, Response } from "express"; +import { createExternalObjectPlanningUpdateProjectCommand } from "../../domain/models/external-object-planning-project-command.model.js"; +import type { ExternalModelObjectPlanningValues } from "../../external-model/domain/external-model-contracts.js"; +import { distributionBoardRepository, externalModelStateRepository, projectDeviceRepository, projectRepository } from "../composition/application-repositories.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +export async function listExternalObjects(req: Request, res: Response) { + const projectId = String(req.params.projectId ?? ""); + const [project, boards, devices] = await Promise.all([projectRepository.findById(projectId), distributionBoardRepository.listByProject(projectId), projectDeviceRepository.listByProject(projectId)]); + if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." }); + const state = externalModelStateRepository.getByProject(projectId).state; + const mappings = new Map(state.roomMappings.map((mapping) => [mapping.id, mapping])); + return res.json({ + currentRevision: project.currentRevision, + sourceName: state.source?.name ?? null, + distributionBoards: boards.map(({ id, name }) => ({ id, name })), + projectDevices: devices.map(({ id, name, displayName }) => ({ id, name, displayName })), + objects: state.objects.map((object) => { + const mapping = object.externalRoomMappingId ? mappings.get(object.externalRoomMappingId) : null; + return { id: object.id, worksharingId: object.worksharingId, ifcGuid: object.ifcGuid, deviceTypeId: object.deviceTypeId, familyAndType: object.acceptedSourceValues.familyAndType, selectionMarker: scalarText(object.acceptedSourceValues.selectionMarker), sourceCircuitIdentifier: scalarText(object.acceptedSourceValues.circuitCombined) || scalarText(object.acceptedSourceValues.circuitIdentifier), sourceRoomNumber: scalarText(object.acceptedSourceValues.roomNumber), sourceRoomName: scalarText(object.acceptedSourceValues.roomName), roomId: mapping?.roomId ?? null, distributionBoardId: object.distributionBoardId, linkedProjectDeviceId: object.linkedProjectDeviceId, circuitDeviceRowId: object.circuitDeviceRowId, presenceStatus: object.presenceStatus, planningValues: object.planningValues }; + }), + }); +} + +export function updateExternalObjects(req: Request, res: Response) { + const projectId = String(req.params.projectId ?? ""); + try { + const expectedRevision = req.body?.expectedRevision; + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new Error("Ungültige Projektrevision."); + const objectIds = req.body?.objectIds; + const patch = req.body?.patch; + if (!Array.isArray(objectIds) || objectIds.length === 0 || new Set(objectIds).size !== objectIds.length || !isRecord(patch)) throw new Error("Objektauswahl oder Änderungen sind ungültig."); + const state = externalModelStateRepository.getByProject(projectId); + if (!state.projectExists) return res.status(404).json({ error: "Projekt nicht gefunden." }); + const byId = new Map(state.state.objects.map((object) => [object.id, object])); + const transitions = objectIds.map((id: unknown) => { + if (typeof id !== "string") throw new Error("Ungültige Revit-Objekt-ID."); + const expected = byId.get(id); + if (!expected) throw new Error("Ein ausgewähltes Revit-Objekt wurde nicht gefunden."); + if (expected.circuitDeviceRowId !== null) throw new Error("Verknüpfte Revit-Objekte folgen ihrer Gerätezeile und können hier nicht geändert werden."); + const planningValues = { ...expected.planningValues }; + const overriddenFields = new Set(expected.overriddenFields); + for (const field of planningFields) if (hasOwn(patch, field)) { planningValues[field] = patch[field] as never; overriddenFields.add(field); } + return { expected, target: { ...expected, planningValues, overriddenFields: [...overriddenFields], distributionBoardId: hasOwn(patch, "distributionBoardId") ? nullableId(patch.distributionBoardId) : expected.distributionBoardId, linkedProjectDeviceId: hasOwn(patch, "linkedProjectDeviceId") ? nullableId(patch.linkedProjectDeviceId) : expected.linkedProjectDeviceId } }; + }); + const result = projectCommandService.executeUser({ projectId, expectedRevision, description: `${transitions.length} Revit-Objekte bearbeitet`, command: createExternalObjectPlanningUpdateProjectCommand(transitions) }); + return res.json(result); + } catch (error) { return respondWithProjectCommandError(error, res); } +} + +const planningFields = ["displayName", "category", "connectionKind", "effectiveQuantity", "powerPerUnitW", "simultaneityFactor", "cosPhi", "costGroup", "remark"] as const satisfies readonly (keyof ExternalModelObjectPlanningValues)[]; +function scalarText(value: string | number | boolean | null) { return value === null ? "" : String(value); } +function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function hasOwn(value: object, key: PropertyKey) { return Object.prototype.hasOwnProperty.call(value, key); } +function nullableId(value: unknown) { if (value === null || value === "") return null; if (typeof value !== "string") throw new Error("Ungültige Zuordnungs-ID."); return value; } diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 84f2ea3..53c2225 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -63,6 +63,7 @@ import { getExternalInitialImportStatus, planExternalInitialImport, } from "../controllers/external-initial-import.controller.js"; +import { listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js"; export const projectRouter = Router(); @@ -90,6 +91,8 @@ projectRouter.post("/:projectId/external-model/configuration/detect", detectExte projectRouter.get("/:projectId/external-model/import-status", getExternalInitialImportStatus); projectRouter.post("/:projectId/external-model/initial-import/plan", planExternalInitialImport); projectRouter.post("/:projectId/external-model/initial-import/apply", applyExternalInitialImport); +projectRouter.get("/:projectId/external-model/objects", listExternalObjects); +projectRouter.put("/:projectId/external-model/objects", updateExternalObjects); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); projectRouter.post( diff --git a/tests/external-xlsx-initial-import.test.ts b/tests/external-xlsx-initial-import.test.ts index 33400d6..75af423 100644 --- a/tests/external-xlsx-initial-import.test.ts +++ b/tests/external-xlsx-initial-import.test.ts @@ -5,9 +5,11 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext } from "../src/db/database-context.js"; import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js"; import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; +import { ExternalObjectPlanningProjectCommandRepository } from "../src/db/repositories/external-object-planning-project-command.repository.js"; import { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js"; import { projects } from "../src/db/schema/projects.js"; import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js"; +import { createExternalObjectPlanningUpdateProjectCommand } from "../src/domain/models/external-object-planning-project-command.model.js"; import { createExternalXlsxInitialImportPlan } from "../src/external-model/application/external-xlsx-initial-import.js"; import { buildExternalXlsxInitialImportTarget } from "../src/external-model/application/external-xlsx-initial-import-target.js"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; @@ -62,7 +64,15 @@ describe("Linear XLSX initial import", () => { const undone = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "undo", historyTargetChangeSetId: inserted.revision.changeSetId, command: inserted.inverse }); assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.source, null); repository.execute({ projectId: "project-1", expectedRevision: 2, source: "redo", historyTargetChangeSetId: inserted.revision.changeSetId, command: undone.inverse }); - assert.deepEqual(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.map((object) => object.worksharingId), plan.objects.map((object) => object.worksharingId).sort()); + const redone = new ExternalModelStateRepository(context.db).getByProject("project-1").state; + assert.deepEqual(redone.objects.map((object) => object.worksharingId), plan.objects.map((object) => object.worksharingId).sort()); + const expectedObject = redone.objects[0]; + const targetObject = { ...expectedObject, planningValues: { ...expectedObject.planningValues, simultaneityFactor: 0.5 }, overriddenFields: [...expectedObject.overriddenFields, "simultaneityFactor" as const] }; + const planningRepository = new ExternalObjectPlanningProjectCommandRepository(context.db); + const planned = planningRepository.execute({ projectId: "project-1", expectedRevision: 3, source: "user", command: createExternalObjectPlanningUpdateProjectCommand([{ expected: expectedObject, target: targetObject }]) }); + assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects[0].planningValues.simultaneityFactor, 0.5); + planningRepository.execute({ projectId: "project-1", expectedRevision: 4, source: "undo", historyTargetChangeSetId: planned.revision.changeSetId, command: planned.inverse }); + assert.equal(new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects[0].planningValues.simultaneityFactor, 1); } finally { context.close(); } }); }); diff --git a/tests/revit-xlsx-settings-api.test.ts b/tests/revit-xlsx-settings-api.test.ts index 22d633c..ed91b57 100644 --- a/tests/revit-xlsx-settings-api.test.ts +++ b/tests/revit-xlsx-settings-api.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js"; -import { applyExternalInitialImport, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, planExternalInitialImport, updateExternalModelConfiguration } from "../src/frontend/utils/api.js"; +import { applyExternalInitialImport, detectExternalModelWorkbook, getExternalInitialImportStatus, getExternalModelConfiguration, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js"; describe("Revit XLSX settings", () => { it("uses the project-scoped configuration endpoint with expectedRevision", async () => { @@ -53,6 +53,7 @@ describe("Revit XLSX settings", () => { const revitPage = readFileSync("src/app/projects/[projectId]/revit/page.tsx", "utf8"); const settings = readFileSync("src/frontend/components/revit-xlsx-settings-modal.tsx", "utf8"); const wizard = readFileSync("src/frontend/components/revit-initial-import-wizard.tsx", "utf8"); + const objectTable = readFileSync("src/frontend/components/revit-object-table.tsx", "utf8"); assert.match(projectPage, /href={`\/projects\/\$\{projectId\}\/revit`}/); assert.doesNotMatch(projectPage, /RevitCsvModal|Revit-CSV/); assert.match(revitPage, /Importstatus/); @@ -61,5 +62,16 @@ describe("Revit XLSX settings", () => { assert.match(wizard, /Schritt.*von/); assert.match(wizard, /keine Gerätezeilen, Stromkreise oder BMKs/); assert.match(wizard, /GZF 1/); + assert.match(objectTable, /viewportHeight/); + assert.match(objectTable, /Sammeländerung anwenden/); + }); + + it("loads one project object list and sends revision-safe bulk updates", async () => { + const requests: Array<{ url: string; method: string; body: unknown }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { requests.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); return new Response(JSON.stringify({ currentRevision: 4, sourceName: null, distributionBoards: [], projectDevices: [], objects: [], history: { currentRevision: 5 } }), { status: 200, headers: { "Content-Type": "application/json" } }); }; + try { await getExternalProjectObjects("project-1"); await updateExternalProjectObjects("project-1", 4, ["object-1"], { simultaneityFactor: 0.7 }); } + finally { globalThis.fetch = originalFetch; } + assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/objects", method: "GET", body: undefined }, { url: "/api/projects/project-1/external-model/objects", method: "PUT", body: { expectedRevision: 4, objectIds: ["object-1"], patch: { simultaneityFactor: 0.7 } } }]); }); });