forked from jappel/leistungsbilanz-ts
Add Revit return workbook export
This commit is contained in:
parent
77d775fde1
commit
1a4c8cad8c
17 changed files with 3783 additions and 6 deletions
|
|
@ -348,6 +348,14 @@ Pfade.
|
|||
|
||||
## 12. Rückgabe-Excel
|
||||
|
||||
Umsetzungsstand R8: Die Revit-Objekttabelle bietet alle drei Rückgabeumfänge
|
||||
an und lädt eine aus dem letzten vollständigen Import erzeugte XLSX herunter.
|
||||
Die Serverprojektion verwendet für verknüpfte Objekte die aktuellen Row- und
|
||||
Circuit-Werte, für freie Objekte die Planungswerte und schreibt Zahlen als
|
||||
Excel-Zahlen. Hinweise zu nicht berechenbaren Werten und fehlenden optionalen
|
||||
Spalten werden nach dem Download angezeigt. Der append-only Exportnachweis
|
||||
liegt außerhalb von Projektrevision, Undo/Redo, Snapshots und Transfer.
|
||||
|
||||
Der Export ist eine Leseoperation ohne Projektrevision. Ein append-only
|
||||
Nachweis speichert Projektrevision, zugrunde liegenden letzten Import,
|
||||
Zeitpunkt, Dateiname, SHA-256, Konfigurationskopie beziehungsweise ihren Hash
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Der alte CSV-Plan 14.0–14.6 ist abgelöst und wird nicht fortgeschrieben.
|
|||
| R5 | abgeschlossen | Zustandslose XLSX-Vorschau, sechsstufiger Erstimportwizard und atomarer Importcommand mit parserfreiem Undo/Redo. |
|
||||
| R6 | abgeschlossen | Projektweite kompakte Objektliste, clientseitige Filter/Sortierung, Virtualisierung und revisionierte Sammeländerungen. |
|
||||
| R7 | abgeschlossen | Gruppierter Editorbereich, drei Drag-and-drop-Ziele, Row-Indikator, ausdrückliches Lösen und atomare Löschübergänge mit exaktem Undo/Redo. |
|
||||
| R8 | offen | Rückgabe-Excel und append-only Exportnachweis. |
|
||||
| R8 | abgeschlossen | Rückgabe-Excel für alle, abweichende oder ausgewählte Objekte und append-only Exportnachweis sind umgesetzt. |
|
||||
| R9 | offen | Vollständiger Folgeimport nach Worksharing-ID. |
|
||||
| R10 | offen | Ende-zu-Ende-Härtung, große Datenmengen und Abschlussdokumentation. |
|
||||
|
||||
|
|
@ -173,6 +173,13 @@ Sammelaktionen gesperrt und folgen weiterhin ausschließlich ihrer Row.
|
|||
|
||||
R8 baut auf der in R2 durch Linear bestätigten Transportstrategie auf.
|
||||
|
||||
Umsetzungsstand: Die Revit-Objekttabelle erzeugt die Rückgabe revisionssicher
|
||||
aus dem letzten vollständigen Import. Die gemeinsame Projektion berechnet
|
||||
Stromkreis, Bezeichnung, Leistung, Spannung und Strom; der XLSX-Adapter erhält
|
||||
Struktur und Formate und meldet fehlende optionale Spalten. Jeder erfolgreiche
|
||||
Download wird außerhalb von Projektcommands, Snapshots und Transfers mit
|
||||
Revision, Importbatch, Konfigurationskopie und Prüfsummen protokolliert.
|
||||
|
||||
## R9 – Folgeimport
|
||||
|
||||
- exaktes, case-sensitives Matching nach Worksharing-ID;
|
||||
|
|
|
|||
16
src/db/migrations/0015_wide_night_thrasher.sql
Normal file
16
src/db/migrations/0015_wide_night_thrasher.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
CREATE TABLE `external_export_batches` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`project_revision` integer NOT NULL,
|
||||
`import_batch_id` text NOT NULL,
|
||||
`exported_at_iso` text NOT NULL,
|
||||
`file_name` text NOT NULL,
|
||||
`sha256` text NOT NULL,
|
||||
`configuration_snapshot` text NOT NULL,
|
||||
`configuration_sha256` text NOT NULL,
|
||||
`scope` text NOT NULL,
|
||||
`object_count` integer NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `external_export_batches_project_exported_idx` ON `external_export_batches` (`project_id`,`exported_at_iso`);
|
||||
3210
src/db/migrations/meta/0015_snapshot.json
Normal file
3210
src/db/migrations/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -106,6 +106,13 @@
|
|||
"when": 1790748107566,
|
||||
"tag": "0014_linear_xlsx_external_model",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1790773066696,
|
||||
"tag": "0015_wide_night_thrasher",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
18
src/db/repositories/external-export-batch.repository.ts
Normal file
18
src/db/repositories/external-export-batch.repository.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { desc, eq } from "drizzle-orm";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { externalExportBatches } from "../schema/external-export-batches.js";
|
||||
|
||||
export class ExternalExportBatchRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
append(batch: typeof externalExportBatches.$inferInsert) {
|
||||
this.database.insert(externalExportBatches).values(batch).run();
|
||||
return batch;
|
||||
}
|
||||
|
||||
listByProject(projectId: string) {
|
||||
return this.database.select().from(externalExportBatches)
|
||||
.where(eq(externalExportBatches.projectId, projectId))
|
||||
.orderBy(desc(externalExportBatches.exportedAtIso), desc(externalExportBatches.id)).all();
|
||||
}
|
||||
}
|
||||
24
src/db/schema/external-export-batches.ts
Normal file
24
src/db/schema/external-export-batches.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import type { ExternalModelConfiguration } from "../../external-model/configuration/external-model-configuration.js";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
export const externalExportScopes = ["all", "deviating", "selected"] as const;
|
||||
export type ExternalExportScope = (typeof externalExportScopes)[number];
|
||||
|
||||
export const externalExportBatches = sqliteTable(
|
||||
"external_export_batches",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
|
||||
projectRevision: integer("project_revision").notNull(),
|
||||
importBatchId: text("import_batch_id").notNull(),
|
||||
exportedAtIso: text("exported_at_iso").notNull(),
|
||||
fileName: text("file_name").notNull(),
|
||||
sha256: text("sha256").notNull(),
|
||||
configurationSnapshot: text("configuration_snapshot", { mode: "json" }).$type<ExternalModelConfiguration>().notNull(),
|
||||
configurationSha256: text("configuration_sha256").notNull(),
|
||||
scope: text("scope", { enum: externalExportScopes }).notNull(),
|
||||
objectCount: integer("object_count").notNull(),
|
||||
},
|
||||
(table) => [index("external_export_batches_project_exported_idx").on(table.projectId, table.exportedAtIso)]
|
||||
);
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import type { ExternalModelObjectSnapshot } from "../domain/external-model-contracts.js";
|
||||
import type { ExternalXlsxCircuitMode, ExternalXlsxReturnValues } from "../xlsx/external-xlsx-contracts.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
|
||||
export interface ExternalReturnRowContext {
|
||||
rowId: string;
|
||||
displayName: string;
|
||||
powerPerUnitKw: number;
|
||||
cosPhi: number | null;
|
||||
phaseType: string | null;
|
||||
category: CircuitGroupCategory | null;
|
||||
circuitIdentifier: string;
|
||||
circuitVoltageV: number | null;
|
||||
distributionBoardName: string;
|
||||
}
|
||||
|
||||
export interface ExternalReturnProjection {
|
||||
objectId: string;
|
||||
worksharingId: string;
|
||||
values: ExternalXlsxReturnValues;
|
||||
differsFromImported: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function getRevitBoardName(board: { name: string }) {
|
||||
return board.name;
|
||||
}
|
||||
|
||||
export function projectExternalXlsxReturn(input: {
|
||||
object: ExternalModelObjectSnapshot;
|
||||
row: ExternalReturnRowContext | null;
|
||||
circuitMode: ExternalXlsxCircuitMode;
|
||||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
}): ExternalReturnProjection {
|
||||
const { object, row } = input;
|
||||
const planning = object.planningValues;
|
||||
const warnings: string[] = [];
|
||||
const category = row?.category ?? planning.category;
|
||||
const phaseType = row?.phaseType ?? (category === "three_phase" ? "three_phase" : category ? "single_phase" : null);
|
||||
const voltageV = row?.circuitVoltageV ?? defaultVoltage(category, input.singlePhaseVoltageV, input.threePhaseVoltageV);
|
||||
const powerPerUnitW = row ? row.powerPerUnitKw * 1000 : planning.powerPerUnitW;
|
||||
const electricalPowerW = powerPerUnitW === null
|
||||
? null
|
||||
: roundNumber(planning.effectiveQuantity * powerPerUnitW, 6);
|
||||
const cosPhi = row?.cosPhi ?? planning.cosPhi;
|
||||
const currentA = calculateCurrent({ electricalPowerW, voltageV, cosPhi, phaseType });
|
||||
if (electricalPowerW === null) warnings.push("Elektrische Leistung ist nicht berechenbar.");
|
||||
if (voltageV === null) warnings.push("Spannung ist nicht bestimmbar.");
|
||||
if (currentA === null) warnings.push("Stromstärke ist nicht berechenbar.");
|
||||
|
||||
const values: ExternalXlsxReturnValues = {
|
||||
...(input.circuitMode === "combined"
|
||||
? { circuitCombined: row ? `${getRevitBoardName({ name: row.distributionBoardName })}${row.circuitIdentifier}` : "" }
|
||||
: {
|
||||
boardName: row ? getRevitBoardName({ name: row.distributionBoardName }) : "",
|
||||
circuitIdentifier: row?.circuitIdentifier ?? "",
|
||||
}),
|
||||
designation: row?.displayName ?? planning.displayName ?? "",
|
||||
electricalPowerW,
|
||||
voltageV,
|
||||
currentA,
|
||||
};
|
||||
const source = object.acceptedSourceValues;
|
||||
const sourceValues: ExternalXlsxReturnValues = input.circuitMode === "combined"
|
||||
? {
|
||||
circuitCombined: source.circuitCombined,
|
||||
designation: source.designation,
|
||||
electricalPowerW: source.electricalPowerW,
|
||||
voltageV: source.voltageV,
|
||||
currentA: source.currentA,
|
||||
}
|
||||
: {
|
||||
boardName: source.boardName,
|
||||
circuitIdentifier: source.circuitIdentifier,
|
||||
designation: source.designation,
|
||||
electricalPowerW: source.electricalPowerW,
|
||||
voltageV: source.voltageV,
|
||||
currentA: source.currentA,
|
||||
};
|
||||
|
||||
return {
|
||||
objectId: object.id,
|
||||
worksharingId: object.worksharingId,
|
||||
values,
|
||||
differsFromImported: Object.keys(values).some((key) =>
|
||||
!sameScalar(values[key as keyof ExternalXlsxReturnValues], sourceValues[key as keyof ExternalXlsxReturnValues])
|
||||
),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultVoltage(category: CircuitGroupCategory | null, singlePhase: number, threePhase: number) {
|
||||
if (category === "three_phase") return threePhase;
|
||||
if (category === "lighting" || category === "single_phase") return singlePhase;
|
||||
return null;
|
||||
}
|
||||
|
||||
function calculateCurrent(input: { electricalPowerW: number | null; voltageV: number | null; cosPhi: number | null; phaseType: string | null }) {
|
||||
if (
|
||||
input.electricalPowerW === null ||
|
||||
input.voltageV === null || input.voltageV <= 0 ||
|
||||
input.cosPhi === null || input.cosPhi <= 0 ||
|
||||
(input.phaseType !== "single_phase" && input.phaseType !== "three_phase")
|
||||
) return null;
|
||||
const divisor = input.phaseType === "three_phase"
|
||||
? Math.sqrt(3) * input.voltageV * input.cosPhi
|
||||
: input.voltageV * input.cosPhi;
|
||||
return roundNumber(input.electricalPowerW / divisor, 2);
|
||||
}
|
||||
|
||||
function sameScalar(left: string | number | boolean | null | undefined, right: string | number | boolean | null | undefined) {
|
||||
const normalizedLeft = normalizeScalar(left);
|
||||
const normalizedRight = normalizeScalar(right);
|
||||
if (typeof normalizedLeft === "number" && typeof normalizedRight === "number") {
|
||||
return Math.abs(normalizedLeft - normalizedRight) < 1e-9;
|
||||
}
|
||||
return normalizedLeft === normalizedRight;
|
||||
}
|
||||
|
||||
function normalizeScalar(value: string | number | boolean | null | undefined): string | number | boolean {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
const numeric = Number(trimmed.replace(",", "."));
|
||||
return Number.isFinite(numeric) ? numeric : trimmed;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function roundNumber(value: number, decimals: number) {
|
||||
const factor = 10 ** decimals;
|
||||
return Math.round((value + Number.EPSILON) * factor) / factor;
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ExternalProjectObjectsDto } from "../types";
|
||||
import { getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
|
||||
import type { ExternalProjectObjectsDto, ExternalReturnScopeDto } from "../types";
|
||||
import { createExternalReturnWorkbook, getExternalProjectObjects, updateExternalProjectObjects } from "../utils/api";
|
||||
|
||||
const rowHeight = 44;
|
||||
const viewportHeight = 520;
|
||||
|
|
@ -23,6 +23,10 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
|
|||
const [planningValue, setPlanningValue] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [returnScope, setReturnScope] = useState<ExternalReturnScopeDto>("all");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportWarnings, setExportWarnings] = useState<string[]>([]);
|
||||
const [exportMessage, setExportMessage] = useState<string | null>(null);
|
||||
|
||||
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
|
||||
|
|
@ -44,6 +48,7 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
|
|||
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);
|
||||
const selectedHasLinkedObject = data?.objects.some((object) => selected.has(object.id) && object.circuitDeviceRowId !== null) ?? false;
|
||||
|
||||
async function applyBulk() {
|
||||
if (!data || selected.size === 0) return;
|
||||
|
|
@ -64,10 +69,38 @@ export function RevitObjectTable({ projectId, onRevision }: { projectId: string;
|
|||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function exportReturnWorkbook() {
|
||||
if (!data || (returnScope === "selected" && selected.size === 0)) return;
|
||||
setExporting(true); setError(null); setExportWarnings([]); setExportMessage(null);
|
||||
try {
|
||||
const result = await createExternalReturnWorkbook(projectId, data.currentRevision, returnScope, [...selected]);
|
||||
downloadBase64(result.contentBase64, result.fileName);
|
||||
setExportWarnings(result.warnings);
|
||||
setExportMessage(`${result.objectCount} Revit-Objekte wurden in ${result.fileName} geschrieben.`);
|
||||
} catch (value) {
|
||||
setError(value instanceof Error ? value.message : "Rückgabe-Excel konnte nicht erstellt werden.");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return <section className="card mt-3"><div className="card-body">Revit-Objekte werden geladen …</div></section>;
|
||||
return <section className="card mt-3"><div className="card-body"><div className="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"><div><h2 className="h5 mb-1">Revit-Objekte</h2><div className="text-secondary small">{rows.length} von {data.objects.length} · {selected.size} ausgewählt</div></div><div className="d-flex gap-2 flex-wrap"><input aria-label="Revit-Objekte suchen" className="form-control" placeholder="Suchen …" value={search} onChange={(event) => setSearch(event.target.value)} /><select aria-label="Verteiler filtern" className="form-select" value={boardFilter} onChange={(event) => setBoardFilter(event.target.value)}><option value="all">Alle Verteiler</option><option value="none">Ohne Verteiler</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select><select aria-label="Zuordnung filtern" className="form-select" value={assignmentFilter} onChange={(event) => setAssignmentFilter(event.target.value)}><option value="all">Alle Zuordnungen</option><option value="unassigned">Unzugeordnet</option><option value="linked">Mit Gerätezeile</option></select><select aria-label="Sortierung" className="form-select" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="family">Familie</option><option value="room">Raum</option><option value="worksharing">Worksharing-ID</option></select></div></div>
|
||||
{error ? <div className="alert alert-danger">{error}</div> : null}
|
||||
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} disabled={linked} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
|
||||
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
|
||||
<div className="border rounded p-3 mb-3"><div className="d-flex gap-2 align-items-end flex-wrap"><label className="small">Rückgabeumfang<select aria-label="Rückgabeumfang" className="form-select form-select-sm" value={returnScope} onChange={(event) => setReturnScope(event.target.value as ExternalReturnScopeDto)}><option value="all">Alle Objekte des letzten Imports</option><option value="deviating">Nur geänderte Objekte</option><option value="selected">Nur ausgewählte Objekte</option></select></label><button className="btn btn-sm btn-primary" disabled={exporting || (returnScope === "selected" && selected.size === 0)} type="button" onClick={() => void exportReturnWorkbook()}>{exporting ? "Rückgabe wird erstellt …" : "Rückgabe-Excel herunterladen"}</button></div>{exportMessage ? <div className="alert alert-success mt-3 mb-0">{exportMessage}</div> : null}{exportWarnings.length ? <div className="alert alert-warning mt-3 mb-0"><strong>Hinweise zur Rückgabe:</strong><ul className="mb-0">{exportWarnings.map((warning) => <li key={warning}>{warning}</li>)}</ul></div> : null}</div>
|
||||
<div className="border rounded mb-3" style={{ height: viewportHeight, overflowY: "auto" }} onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}><div style={{ height: rows.length * rowHeight, position: "relative" }}>{visible.map((object, index) => { const top = (first + index) * rowHeight; const linked = object.circuitDeviceRowId !== null; return <div className="d-grid align-items-center border-bottom px-2" key={object.id} style={{ gridTemplateColumns: "32px minmax(180px,2fr) minmax(120px,1fr) minmax(120px,1fr) minmax(130px,1fr) 100px", height: rowHeight, left: 0, position: "absolute", right: 0, top }}><input aria-label={`${object.familyAndType} auswählen`} checked={selected.has(object.id)} type="checkbox" onChange={() => setSelected((current) => { const next = new Set(current); if (next.has(object.id)) next.delete(object.id); else next.add(object.id); return next; })} /><div className="text-truncate"><strong>{object.planningValues.displayName ?? object.familyAndType}</strong><div className="small text-secondary text-truncate">{object.familyAndType}</div></div><div className="text-truncate">{object.sourceRoomNumber} {object.sourceRoomName}</div><div className="text-truncate">{data.distributionBoards.find((board) => board.id === object.distributionBoardId)?.name ?? "Ohne Verteiler"}</div><div className="text-truncate">{object.selectionMarker || object.worksharingId}</div><span className={`badge ${linked ? "text-bg-success" : "text-bg-secondary"}`}>{linked ? "verknüpft" : "frei"}</span></div>; })}</div></div>
|
||||
<div className="d-flex gap-2 flex-wrap align-items-end"><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(rows.map((object) => object.id)))}>Sichtbare für Rückgabe auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set(selectable.map((object) => object.id)))}>Sichtbare freie auswählen</button><button className="btn btn-sm btn-outline-secondary" type="button" onClick={() => setSelected(new Set())}>Auswahl leeren</button><label className="small">Verteiler<select className="form-select form-select-sm" value={bulkBoard} onChange={(event) => setBulkBoard(event.target.value)}><option value="">Unverändert</option><option value="none">Zuordnung lösen</option>{data.distributionBoards.map((board) => <option value={board.id} key={board.id}>{board.name}</option>)}</select></label><label className="small">Projektgerät<select className="form-select form-select-sm" value={bulkDevice} onChange={(event) => setBulkDevice(event.target.value)}><option value="">Unverändert</option><option value="none">Verknüpfung lösen</option>{data.projectDevices.map((device) => <option value={device.id} key={device.id}>{device.name}</option>)}</select></label><label className="small">Leistung [W]<input className="form-control form-control-sm" inputMode="decimal" value={power} onChange={(event) => setPower(event.target.value)} /></label><label className="small">GZF<input className="form-control form-control-sm" inputMode="decimal" value={gzf} onChange={(event) => setGzf(event.target.value)} /></label><label className="small">Weiterer Planungswert<select className="form-select form-select-sm" value={planningField} onChange={(event) => setPlanningField(event.target.value)}><option value="">Unverändert</option><option value="displayName">Anzeigename</option><option value="category">Kategorie (lighting/single_phase/three_phase)</option><option value="connectionKind">Anschlussart</option><option value="effectiveQuantity">Wirksame Menge</option><option value="cosPhi">cosPhi</option><option value="costGroup">Kostengruppe</option><option value="remark">Bemerkung</option></select></label>{planningField ? <label className="small">Neuer Wert<input className="form-control form-control-sm" value={planningValue} onChange={(event) => setPlanningValue(event.target.value)} /></label> : null}<button className="btn btn-sm btn-primary" disabled={saving || selected.size === 0 || selectedHasLinkedObject} title={selectedHasLinkedObject ? "Sammeländerungen sind nur für freie Revit-Objekte möglich." : undefined} type="button" onClick={() => void applyBulk()}>{saving ? "Wird gespeichert …" : "Sammeländerung anwenden"}</button></div>
|
||||
</div></section>;
|
||||
}
|
||||
|
||||
function downloadBase64(contentBase64: string, fileName: string) {
|
||||
const binary = atob(contentBase64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,29 @@ export interface ExternalProjectObjectsDto {
|
|||
objects: ExternalProjectObjectDto[];
|
||||
}
|
||||
|
||||
export type ExternalReturnScopeDto = "all" | "deviating" | "selected";
|
||||
|
||||
export interface ExternalReturnWorkbookDto {
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
sha256: string;
|
||||
objectCount: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ExternalExportBatchDto {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectRevision: number;
|
||||
importBatchId: string;
|
||||
exportedAtIso: string;
|
||||
fileName: string;
|
||||
sha256: string;
|
||||
configurationSha256: string;
|
||||
scope: ExternalReturnScopeDto;
|
||||
objectCount: number;
|
||||
}
|
||||
|
||||
export interface ProjectDto {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ import type {
|
|||
ExternalInitialImportStatusDto,
|
||||
ApplyExternalInitialImportInput,
|
||||
ExternalProjectObjectsDto,
|
||||
ExternalReturnScopeDto,
|
||||
ExternalReturnWorkbookDto,
|
||||
CreateEstimateBalanceInput,
|
||||
EstimateBalanceCommandResultDto,
|
||||
EstimateBalanceDto,
|
||||
|
|
@ -245,6 +247,18 @@ export function updateExternalProjectObjects(projectId: string, expectedRevision
|
|||
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects`, { method: "PUT", body: JSON.stringify({ expectedRevision, objectIds, patch }) });
|
||||
}
|
||||
|
||||
export function createExternalReturnWorkbook(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
scope: ExternalReturnScopeDto,
|
||||
selectedObjectIds: string[]
|
||||
) {
|
||||
return request<ExternalReturnWorkbookDto>(`/api/projects/${projectId}/external-model/return-workbook`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expectedRevision, scope, selectedObjectIds }),
|
||||
});
|
||||
}
|
||||
|
||||
export function assignExternalProjectObjectsToRow(projectId: string, expectedRevision: number, objectIds: string[], rowId: string, confirmedConflictObjectIds: string[] = []) {
|
||||
return request<ProjectCommandResultDto>(`/api/projects/${projectId}/external-model/objects/assign-row`, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
|
|||
import { RoomRepository } from "../../db/repositories/room.repository.js";
|
||||
import { ExternalModelConfigurationRepository } from "../../db/repositories/external-model-configuration.repository.js";
|
||||
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
|
||||
import { ExternalExportBatchRepository } from "../../db/repositories/external-export-batch.repository.js";
|
||||
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
|
||||
import { UserRepository } from "../../db/repositories/user.repository.js";
|
||||
|
||||
|
|
@ -39,5 +40,6 @@ export const roomRepository = new RoomRepository(db);
|
|||
export const externalModelConfigurationRepository =
|
||||
new ExternalModelConfigurationRepository(db);
|
||||
export const externalModelStateRepository = new ExternalModelStateRepository(db);
|
||||
export const externalExportBatchRepository = new ExternalExportBatchRepository(db);
|
||||
export const documentBrandingRepository = new DocumentBrandingRepository(db);
|
||||
export const userRepository = new UserRepository(db);
|
||||
|
|
|
|||
180
src/server/controllers/external-return-workbook.controller.ts
Normal file
180
src/server/controllers/external-return-workbook.controller.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { Request, Response } from "express";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group.js";
|
||||
import type { ExternalExportScope } from "../../db/schema/external-export-batches.js";
|
||||
import { projectExternalXlsxReturn, type ExternalReturnRowContext } from "../../external-model/application/external-xlsx-return-projection.js";
|
||||
import { createExternalXlsxReturn } from "../../external-model/xlsx/external-xlsx-transport.js";
|
||||
import {
|
||||
externalExportBatchRepository,
|
||||
externalModelStateRepository,
|
||||
projectRepository,
|
||||
} from "../composition/application-repositories.js";
|
||||
import { buildProjectCircuitTrees } from "./circuit-tree.controller.js";
|
||||
|
||||
const exportScopes = new Set<ExternalExportScope>(["all", "deviating", "selected"]);
|
||||
|
||||
export async function createExternalReturnWorkbook(req: Request, res: Response) {
|
||||
const projectId = String(req.params.projectId ?? "");
|
||||
try {
|
||||
const expectedRevision = parseExpectedRevision(req.body?.expectedRevision);
|
||||
const scope = parseScope(req.body?.scope);
|
||||
const selectedObjectIds = scope === "selected" ? parseSelectedIds(req.body?.selectedObjectIds) : [];
|
||||
if (scope === "selected" && selectedObjectIds.length === 0) {
|
||||
return res.status(400).json({ error: "Für die ausgewählte Rückgabe wurden keine Revit-Objekte markiert." });
|
||||
}
|
||||
|
||||
const project = await projectRepository.findById(projectId);
|
||||
if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||
if (project.currentRevision !== expectedRevision) {
|
||||
return res.status(409).json({
|
||||
error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
|
||||
code: "PROJECT_REVISION_CONFLICT",
|
||||
expectedRevision,
|
||||
currentRevision: project.currentRevision,
|
||||
});
|
||||
}
|
||||
|
||||
const { state } = externalModelStateRepository.getByProject(projectId);
|
||||
const importBatch = state.importBatches.at(-1);
|
||||
if (!importBatch?.originalContentBase64 || !importBatch.configurationSnapshot) {
|
||||
return res.status(409).json({
|
||||
error: "Für die Rückgabe fehlt der letzte vollständige Revit-Import.",
|
||||
code: "EXTERNAL_XLSX_IMPORT_REQUIRED",
|
||||
});
|
||||
}
|
||||
const treeResult = await buildProjectCircuitTrees(projectId);
|
||||
if (!treeResult || treeResult.currentRevision !== expectedRevision) {
|
||||
return res.status(409).json({
|
||||
error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
|
||||
code: "PROJECT_REVISION_CONFLICT",
|
||||
});
|
||||
}
|
||||
|
||||
const rowById = new Map<string, ExternalReturnRowContext>();
|
||||
for (const tree of treeResult.trees) {
|
||||
for (const section of tree.sections) {
|
||||
for (const circuit of section.circuits) {
|
||||
for (const row of circuit.deviceRows) {
|
||||
rowById.set(row.id, {
|
||||
rowId: row.id,
|
||||
displayName: row.displayName,
|
||||
powerPerUnitKw: row.powerPerUnit,
|
||||
cosPhi: row.cosPhi ?? null,
|
||||
phaseType: row.phaseType ?? null,
|
||||
category: (section.category ?? null) as CircuitGroupCategory | null,
|
||||
circuitIdentifier: circuit.equipmentIdentifier,
|
||||
circuitVoltageV: circuit.voltage ?? null,
|
||||
distributionBoardName: tree.distributionBoardName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const projections = state.objects
|
||||
.filter((object) => object.presenceStatus === "present" && object.lastSeenImportBatchId === importBatch.id)
|
||||
.map((object) => projectExternalXlsxReturn({
|
||||
object,
|
||||
row: object.circuitDeviceRowId ? rowById.get(object.circuitDeviceRowId) ?? null : null,
|
||||
circuitMode: importBatch.configurationSnapshot!.circuitMode,
|
||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||
}));
|
||||
const projectionById = new Map(projections.map((projection) => [projection.objectId, projection]));
|
||||
for (const objectId of selectedObjectIds) {
|
||||
if (!projectionById.has(objectId)) {
|
||||
return res.status(400).json({ error: "Ein ausgewähltes Revit-Objekt gehört nicht zum letzten Import." });
|
||||
}
|
||||
}
|
||||
const selected = scope === "all"
|
||||
? projections
|
||||
: scope === "deviating"
|
||||
? projections.filter((projection) => projection.differsFromImported)
|
||||
: selectedObjectIds.map((id) => projectionById.get(id)!);
|
||||
if (selected.length === 0) {
|
||||
return res.status(400).json({ error: "Der gewählte Rückgabeumfang enthält keine Revit-Objekte." });
|
||||
}
|
||||
|
||||
const result = await createExternalXlsxReturn({
|
||||
fileName: importBatch.fileName,
|
||||
bytes: Buffer.from(importBatch.originalContentBase64, "base64"),
|
||||
configuration: importBatch.configurationSnapshot,
|
||||
selectedWorksharingIds: selected.map((projection) => projection.worksharingId),
|
||||
updates: selected.map((projection) => ({
|
||||
worksharingId: projection.worksharingId,
|
||||
values: projection.values,
|
||||
})),
|
||||
});
|
||||
const fileName = `${safeFileName(project.name)}_Revit-Rueckgabe_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||||
const sha256 = hash(result.bytes);
|
||||
const configurationSha256 = hash(Buffer.from(JSON.stringify(importBatch.configurationSnapshot), "utf8"));
|
||||
const currentProject = await projectRepository.findById(projectId);
|
||||
if (!currentProject || currentProject.currentRevision !== expectedRevision) {
|
||||
return res.status(409).json({
|
||||
error: "Die Rückgabe ist wegen einer neueren Projektrevision ungültig.",
|
||||
code: "PROJECT_REVISION_CONFLICT",
|
||||
expectedRevision,
|
||||
currentRevision: currentProject?.currentRevision ?? null,
|
||||
});
|
||||
}
|
||||
externalExportBatchRepository.append({
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
projectRevision: expectedRevision,
|
||||
importBatchId: importBatch.id,
|
||||
exportedAtIso: new Date().toISOString(),
|
||||
fileName,
|
||||
sha256,
|
||||
configurationSnapshot: importBatch.configurationSnapshot,
|
||||
configurationSha256,
|
||||
scope,
|
||||
objectCount: result.exportedObjectCount,
|
||||
});
|
||||
|
||||
const projectionWarnings = selected.flatMap((projection) =>
|
||||
projection.warnings.map((warning) => `${projection.worksharingId}: ${warning}`)
|
||||
);
|
||||
return res.json({
|
||||
fileName,
|
||||
contentBase64: Buffer.from(result.bytes).toString("base64"),
|
||||
sha256,
|
||||
objectCount: result.exportedObjectCount,
|
||||
warnings: [...new Set([...result.issues.map((issue) => issue.message), ...projectionWarnings])],
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Revit-Rückgabe fehlgeschlagen." });
|
||||
}
|
||||
}
|
||||
|
||||
export async function listExternalExportBatches(req: Request, res: Response) {
|
||||
const projectId = String(req.params.projectId ?? "");
|
||||
const project = await projectRepository.findById(projectId);
|
||||
if (!project) return res.status(404).json({ error: "Projekt nicht gefunden." });
|
||||
return res.json(externalExportBatchRepository.listByProject(projectId).map(({ configurationSnapshot: _configuration, ...batch }) => batch));
|
||||
}
|
||||
|
||||
function parseExpectedRevision(value: unknown) {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error("Ungültige Projektrevision.");
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function parseScope(value: unknown): ExternalExportScope {
|
||||
if (typeof value !== "string" || !exportScopes.has(value as ExternalExportScope)) throw new Error("Ungültiger Rückgabeumfang.");
|
||||
return value as ExternalExportScope;
|
||||
}
|
||||
|
||||
function parseSelectedIds(value: unknown) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string") || new Set(value).size !== value.length) {
|
||||
throw new Error("Ungültige Objektauswahl.");
|
||||
}
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function safeFileName(value: string) {
|
||||
return value.trim().replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") || "Projekt";
|
||||
}
|
||||
|
||||
function hash(bytes: Uint8Array) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ import {
|
|||
planExternalInitialImport,
|
||||
} from "../controllers/external-initial-import.controller.js";
|
||||
import { assignExternalObjectsToNewCircuit, assignExternalObjectsToNewRow, assignExternalObjectsToRow, detachExternalObjectsFromRow, listExternalObjects, updateExternalObjects } from "../controllers/external-objects.controller.js";
|
||||
import { createExternalReturnWorkbook, listExternalExportBatches } from "../controllers/external-return-workbook.controller.js";
|
||||
|
||||
export const projectRouter = Router();
|
||||
|
||||
|
|
@ -97,6 +98,8 @@ projectRouter.post("/:projectId/external-model/objects/assign-row", assignExtern
|
|||
projectRouter.post("/:projectId/external-model/objects/assign-new-row", assignExternalObjectsToNewRow);
|
||||
projectRouter.post("/:projectId/external-model/objects/assign-new-circuit", assignExternalObjectsToNewCircuit);
|
||||
projectRouter.post("/:projectId/external-model/objects/detach-row", detachExternalObjectsFromRow);
|
||||
projectRouter.post("/:projectId/external-model/return-workbook", createExternalReturnWorkbook);
|
||||
projectRouter.get("/:projectId/external-model/export-batches", listExternalExportBatches);
|
||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||
projectRouter.post(
|
||||
|
|
|
|||
40
tests/external-export-batch.repository.test.ts
Normal file
40
tests/external-export-batch.repository.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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 } from "../src/db/database-context.js";
|
||||
import { ExternalExportBatchRepository } from "../src/db/repositories/external-export-batch.repository.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { createDefaultExternalModelConfiguration } from "../src/external-model/configuration/external-model-configuration.js";
|
||||
|
||||
describe("external export batch repository", () => {
|
||||
it("appends immutable audit metadata without changing the project revision", () => {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
try {
|
||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||
context.db.insert(projects).values({ id: "project-1", name: "Projekt", currentRevision: 7 }).run();
|
||||
const repository = new ExternalExportBatchRepository(context.db);
|
||||
const configuration = createDefaultExternalModelConfiguration();
|
||||
repository.append({
|
||||
id: "export-1", projectId: "project-1", projectRevision: 7,
|
||||
importBatchId: "historical-import-without-foreign-key", exportedAtIso: "2026-09-30T10:00:00.000Z",
|
||||
fileName: "Projekt_Revit-Rueckgabe_2026-09-30.xlsx", sha256: "a".repeat(64),
|
||||
configurationSnapshot: configuration, configurationSha256: "b".repeat(64), scope: "all", objectCount: 4,
|
||||
});
|
||||
configuration.columns.designation = "Nachträglich geändert";
|
||||
repository.append({
|
||||
id: "export-2", projectId: "project-1", projectRevision: 7,
|
||||
importBatchId: "historical-import-without-foreign-key", exportedAtIso: "2026-09-30T11:00:00.000Z",
|
||||
fileName: "Projekt_Revit-Rueckgabe_2026-09-30.xlsx", sha256: "c".repeat(64),
|
||||
configurationSnapshot: configuration, configurationSha256: "d".repeat(64), scope: "selected", objectCount: 1,
|
||||
});
|
||||
|
||||
const batches = repository.listByProject("project-1");
|
||||
assert.deepEqual(batches.map((batch) => batch.id), ["export-2", "export-1"]);
|
||||
assert.notEqual(batches[0]!.configurationSnapshot.columns.designation, batches[1]!.configurationSnapshot.columns.designation);
|
||||
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()!.revision, 7);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
43
tests/external-xlsx-return-projection.test.ts
Normal file
43
tests/external-xlsx-return-projection.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { projectExternalXlsxReturn } from "../src/external-model/application/external-xlsx-return-projection.js";
|
||||
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
||||
|
||||
function object(): ExternalModelObjectSnapshot {
|
||||
return {
|
||||
id: "object-1", projectId: "project-1", sourceId: "source-1",
|
||||
worksharingId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee-ffffffff", ifcGuid: null,
|
||||
deviceTypeId: "type-1", lastSeenImportBatchId: "batch-1", lastAcceptedImportBatchId: "batch-1",
|
||||
acceptedSourceValues: { rowNumber: 2, roomNumber: "101", roomName: "Büro", familyAndType: "Steckdose", selectionMarker: "SD", socketCount: 2, sourceRemark: null, circuitCombined: "UV 1-2F1.1", boardName: "UV 1", circuitIdentifier: "-2F1.1", designation: "Steckdose", electricalPowerW: "240", voltageV: 230, currentA: "1,16" },
|
||||
planningValues: { displayName: "Steckdose", category: "single_phase", connectionKind: "socket", effectiveQuantity: 2, powerPerUnitW: 120, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null, remark: null },
|
||||
overriddenFields: [], externalRoomMappingId: null, distributionBoardId: "board-1", linkedProjectDeviceId: null, circuitDeviceRowId: "row-1", presenceStatus: "present",
|
||||
};
|
||||
}
|
||||
|
||||
describe("external XLSX return projection", () => {
|
||||
it("projects typed row values and compares normalized imported scalars", () => {
|
||||
const result = projectExternalXlsxReturn({ object: object(), circuitMode: "combined", singlePhaseVoltageV: 230, threePhaseVoltageV: 400, row: { rowId: "row-1", displayName: "Steckdose", powerPerUnitKw: 0.12, cosPhi: 0.9, phaseType: "single_phase", category: "single_phase", circuitIdentifier: "-2F1.1", circuitVoltageV: 230, distributionBoardName: "UV 1" } });
|
||||
assert.deepEqual(result.values, { circuitCombined: "UV 1-2F1.1", designation: "Steckdose", electricalPowerW: 240, voltageV: 230, currentA: 1.16 });
|
||||
assert.equal(result.differsFromImported, false);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
});
|
||||
|
||||
it("uses object planning without a row and leaves an uncomputable current empty", () => {
|
||||
const input = object(); input.circuitDeviceRowId = null; input.planningValues.cosPhi = null;
|
||||
const result = projectExternalXlsxReturn({ object: input, row: null, circuitMode: "separate", singlePhaseVoltageV: 230, threePhaseVoltageV: 400 });
|
||||
assert.equal(result.values.boardName, "");
|
||||
assert.equal(result.values.circuitIdentifier, "");
|
||||
assert.equal(result.values.electricalPowerW, 240);
|
||||
assert.equal(result.values.currentA, null);
|
||||
assert.equal(result.differsFromImported, true);
|
||||
assert.match(result.warnings.join(" "), /Stromstärke/);
|
||||
});
|
||||
|
||||
it("uses three-phase current and object-specific effective quantity", () => {
|
||||
const input = object(); input.planningValues.category = "three_phase"; input.planningValues.effectiveQuantity = 3;
|
||||
const result = projectExternalXlsxReturn({ object: input, row: null, circuitMode: "combined", singlePhaseVoltageV: 230, threePhaseVoltageV: 400 });
|
||||
assert.equal(result.values.electricalPowerW, 360);
|
||||
assert.equal(result.values.voltageV, 400);
|
||||
assert.equal(result.values.currentA, 0.58);
|
||||
});
|
||||
});
|
||||
|
|
@ -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, getExternalProjectObjects, planExternalInitialImport, updateExternalModelConfiguration, updateExternalProjectObjects } from "../src/frontend/utils/api.js";
|
||||
import { applyExternalInitialImport, createExternalReturnWorkbook, 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 () => {
|
||||
|
|
@ -66,6 +66,8 @@ describe("Revit XLSX settings", () => {
|
|||
assert.match(wizard, /GZF 1/);
|
||||
assert.match(objectTable, /viewportHeight/);
|
||||
assert.match(objectTable, /Sammeländerung anwenden/);
|
||||
assert.match(objectTable, /Rückgabe-Excel herunterladen/);
|
||||
assert.match(objectTable, /Nur geänderte Objekte/);
|
||||
});
|
||||
|
||||
it("loads one project object list and sends revision-safe bulk updates", async () => {
|
||||
|
|
@ -76,4 +78,16 @@ describe("Revit XLSX settings", () => {
|
|||
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 } } }]);
|
||||
});
|
||||
|
||||
it("creates a revision-safe return workbook for the requested scope", 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({ fileName: "return.xlsx", contentBase64: "", sha256: "a".repeat(64), objectCount: 1, warnings: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
};
|
||||
try { await createExternalReturnWorkbook("project-1", 7, "selected", ["object-1"]); }
|
||||
finally { globalThis.fetch = originalFetch; }
|
||||
assert.deepEqual(requests, [{ url: "/api/projects/project-1/external-model/return-workbook", method: "POST", body: { expectedRevision: 7, scope: "selected", selectedObjectIds: ["object-1"] } }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue