Add Linear XLSX follow-up import
This commit is contained in:
parent
1a4c8cad8c
commit
2581beee3c
28 changed files with 1114 additions and 31 deletions
|
|
@ -0,0 +1,98 @@
|
|||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import ExcelJS from "exceljs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||
import { ExternalFollowUpImportProjectCommandRepository } from "../src/db/repositories/external-follow-up-import-project-command.repository.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 { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.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 { externalModelConfigurations } from "../src/db/schema/external-model-configurations.js";
|
||||
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { createExternalFollowUpImportProjectCommand } from "../src/domain/models/external-follow-up-import-project-command.model.js";
|
||||
import { createExternalObjectDeleteMissingProjectCommand } from "../src/domain/models/external-object-delete-missing-project-command.model.js";
|
||||
import { ExternalObjectDeleteMissingProjectCommandRepository } from "../src/db/repositories/external-object-delete-missing-project-command.repository.js";
|
||||
import { createEmptyExternalModelState, createExternalInitialImportProjectCommand } from "../src/domain/models/external-initial-import-project-command.model.js";
|
||||
import { createExternalXlsxFollowUpImportPlan } from "../src/external-model/application/external-xlsx-follow-up-import.js";
|
||||
import { buildExternalXlsxFollowUpTarget } from "../src/external-model/application/external-xlsx-follow-up-target.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";
|
||||
import { createRevitXlsxFixture, externalXlsxTestConfiguration, firstWorksharingId } from "./fixtures/revit-xlsx-fixtures.js";
|
||||
|
||||
const deviceTypeA = "10000000-0000-4000-8000-000000000001";
|
||||
const deviceTypeB = "10000000-0000-4000-8000-000000000002";
|
||||
const configuration = { ...createDefaultExternalModelConfiguration(), ...externalXlsxTestConfiguration, deviceTypes: [{ id: deviceTypeA, name: "Steckdose", category: "single_phase" as const, defaults: { powerPerUnitW: null, simultaneityFactor: 1, cosPhi: 0.9, costGroup: null } }, { id: deviceTypeB, name: "Leuchte", category: "lighting" as const, defaults: { powerPerUnitW: 40, simultaneityFactor: 1, cosPhi: null, costGroup: null } }], familyTypeRules: [{ id: "20000000-0000-4000-8000-000000000001", exactFamilyAndType: "Steckdose: Standard", deviceTypeId: deviceTypeA, quantityRule: "socketCount" as const }, { id: "20000000-0000-4000-8000-000000000002", exactFamilyAndType: "Leuchte: Decke", deviceTypeId: deviceTypeB, quantityRule: "one" as const }] };
|
||||
const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] };
|
||||
|
||||
describe("external follow-up import project command", () => {
|
||||
it("commits object and row quantities atomically and restores exact batches on undo and redo", async () => {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
try {
|
||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
||||
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: "Liste" }).run();
|
||||
context.db.insert(circuitSections).values({ id: "section-1", circuitListId: "list-1", key: "single-1", displayName: "1-phasig", prefix: "-2F1.", sortOrder: 10, category: "single_phase", groupNumber: 1 }).run();
|
||||
context.db.insert(circuits).values({ id: "circuit-1", circuitListId: "list-1", sectionId: "section-1", equipmentIdentifier: "-2F1.1", sortOrder: 10, voltage: 230 }).run();
|
||||
context.db.insert(circuitDeviceRows).values({ id: "row-1", circuitId: "circuit-1", sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", category: "single_phase", quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9 }).run();
|
||||
const configurationSnapshot = { id: "config-1", projectId: "project-1", configuration };
|
||||
context.db.insert(externalModelConfigurations).values(configurationSnapshot).run();
|
||||
const initialBytes = await createRevitXlsxFixture();
|
||||
const initialParsed = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes: initialBytes, configuration, catalogs });
|
||||
let sequence = 0;
|
||||
const initialState = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(initialBytes).toString("base64"), document: initialParsed.document, plan: initialParsed.plan, roomDecisions: initialParsed.plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: initialParsed.plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
|
||||
new ExternalInitialImportProjectCommandRepository(context.db).execute({ projectId: "project-1", expectedRevision: 0, source: "user", command: createExternalInitialImportProjectCommand(configurationSnapshot, configurationSnapshot, createEmptyExternalModelState(), initialState) });
|
||||
const importedFirst = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.worksharingId === firstWorksharingId)!;
|
||||
context.db.update(externalModelObjects).set({ circuitDeviceRowId: "row-1" }).where(eq(externalModelObjects.id, importedFirst.id)).run();
|
||||
|
||||
const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(initialBytes); workbook.worksheets[0]!.getCell(3, 6).value = 3;
|
||||
const followBytes = new Uint8Array(await workbook.xlsx.writeBuffer());
|
||||
const current = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
|
||||
const returnValues = new Map(current.objects.map((object) => [object.id, { circuitCombined: object.acceptedSourceValues.circuitCombined, boardName: object.acceptedSourceValues.boardName, circuitIdentifier: object.acceptedSourceValues.circuitIdentifier, designation: object.acceptedSourceValues.designation, electricalPowerW: object.acceptedSourceValues.electricalPowerW, voltageV: object.acceptedSourceValues.voltageV, currentA: object.acceptedSourceValues.currentA }]));
|
||||
const followParsed = await createExternalXlsxFollowUpImportPlan({ fileName: "follow.xlsx", bytes: followBytes, configuration, catalogs, currentState: current, currentReturnValuesByObjectId: returnValues });
|
||||
const expectedRow = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!);
|
||||
const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(followBytes).toString("base64"), document: followParsed.document, plan: followParsed.plan, currentState: current, sourceChangeDecisions: followParsed.plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: [], familyProjectDeviceDecisions: [], objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([["row-1", expectedRow]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` });
|
||||
const repository = new ExternalFollowUpImportProjectCommandRepository(context.db);
|
||||
const commandWithoutRequiredRowTransition = createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: [] });
|
||||
assert.throws(
|
||||
() => repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: commandWithoutRequiredRowTransition }),
|
||||
/Materialized quantity 3 does not match manual and external quantity 4/,
|
||||
);
|
||||
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3);
|
||||
const applied = repository.execute({ projectId: "project-1", expectedRevision: 1, source: "user", command: createExternalFollowUpImportProjectCommand({ expectedConfiguration: configurationSnapshot, targetConfiguration: configurationSnapshot, expectedState: current, targetState: built.targetState, rows: built.rowTransitions }) });
|
||||
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4);
|
||||
let state = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
|
||||
assert.equal(state.importBatches.length, 2); assert.equal(state.importBatches[0]!.document, null); assert.equal(state.objects.find((object) => object.worksharingId === firstWorksharingId)!.planningValues.effectiveQuantity, 3);
|
||||
|
||||
const undone = repository.execute({ projectId: "project-1", expectedRevision: 2, source: "undo", historyTargetChangeSetId: applied.revision.changeSetId, command: applied.inverse });
|
||||
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 3);
|
||||
state = new ExternalModelStateRepository(context.db).getByProject("project-1").state;
|
||||
assert.equal(state.importBatches.length, 1); assert.ok(state.importBatches[0]!.document);
|
||||
repository.execute({ projectId: "project-1", expectedRevision: 3, source: "redo", historyTargetChangeSetId: applied.revision.changeSetId, command: undone.inverse });
|
||||
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4);
|
||||
|
||||
context.db.update(externalModelObjects).set({ presenceStatus: "missing" }).where(eq(externalModelObjects.id, importedFirst.id)).run();
|
||||
const missingObject = new ExternalModelStateRepository(context.db).getByProject("project-1").state.objects.find((object) => object.id === importedFirst.id)!;
|
||||
const rowBeforeDelete = toCircuitDeviceRowSnapshot(context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-1")).get()!);
|
||||
const deleteRepository = new ExternalObjectDeleteMissingProjectCommandRepository(context.db);
|
||||
const deleted = deleteRepository.execute({ projectId: "project-1", expectedRevision: 4, source: "user", command: createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: missingObject, target: null }], rows: [{ expected: rowBeforeDelete, target: { ...rowBeforeDelete, quantity: 1 } }] }) });
|
||||
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 1);
|
||||
assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get(), undefined);
|
||||
const foreignRestore = createExternalObjectDeleteMissingProjectCommand({ objects: [{ expected: null, target: { ...missingObject, projectId: "project-2" } }], rows: deleted.inverse.payload.rows });
|
||||
assert.throws(() => deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "user", command: foreignRestore }), /anderes Projekt/);
|
||||
deleteRepository.execute({ projectId: "project-1", expectedRevision: 5, source: "undo", historyTargetChangeSetId: deleted.revision.changeSetId, command: deleted.inverse });
|
||||
assert.equal(context.db.select().from(circuitDeviceRows).get()!.quantity, 4);
|
||||
assert.equal(context.db.select().from(externalModelObjects).where(eq(externalModelObjects.id, importedFirst.id)).get()!.circuitDeviceRowId, "row-1");
|
||||
} finally { context.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue