import assert from "node:assert/strict"; import { describe, it } from "node:test"; import ExcelJS from "exceljs"; 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 type { ExternalXlsxReturnValues } from "../src/external-model/xlsx/external-xlsx-contracts.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 newWorksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000003"; const catalogs = { floors: [], rooms: [], distributionBoards: [], projectDevices: [] }; 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 }] }; async function initialState() { const bytes = await createRevitXlsxFixture(); const { plan, document } = await createExternalXlsxInitialImportPlan({ fileName: "initial.xlsx", bytes, configuration, catalogs }); let sequence = 0; const state = buildExternalXlsxInitialImportTarget({ projectId: "project-1", expectedRevision: 0, sourceName: "Revit", importedAtIso: "2026-09-30T08:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, roomDecisions: plan.sourceRooms.map((room) => ({ sourceRoomKey: room.sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.familyGroups.map((family) => ({ familyAndType: family.familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, createId: () => `30000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); const missing = structuredClone(state.objects[1]!); missing.id = "30000000-0000-4000-8000-999999999999"; missing.worksharingId = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE-00000099"; state.objects.push(missing); return state; } async function changedWorkbook(firstId = firstWorksharingId) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(await createRevitXlsxFixture()); const sheet = workbook.worksheets[0]!; sheet.getCell(3, 1).value = firstId; sheet.getCell(3, 6).value = 3; sheet.getCell(5, 1).value = newWorksharingId; sheet.getCell(5, 2).value = "Leuchte neu"; sheet.getCell(5, 5).value = "Leuchte: Decke"; sheet.getCell(5, 6).value = 1; return new Uint8Array(await workbook.xlsx.writeBuffer()); } function currentReturns(state: Awaited>) { const map = new Map(); for (const object of state.objects) map.set(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 }); map.set(state.objects[0]!.id, { ...map.get(state.objects[0]!.id)!, designation: "Noch nicht in Revit" }); return map; } describe("Linear XLSX follow-up import", () => { it("classifies new, changed, unchanged, missing and return-deviating objects with exact IDs", async () => { const state = await initialState(); const bytes = await changedWorkbook(); const { plan } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) }); assert.deepEqual({ new: plan.newObjectCount, changed: plan.changedObjectCount, unchanged: plan.unchangedObjectCount, missing: plan.missingObjectCount, returnDeviation: plan.returnDeviationCount }, { new: 1, changed: 1, unchanged: 1, missing: 1, returnDeviation: 1 }); assert.deepEqual(plan.changes[0]!.changedFields, ["socketCount"]); assert.equal(plan.missingObjects[0]!.worksharingId.endsWith("99"), true); const caseChanged = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes: await changedWorkbook(firstWorksharingId.toLowerCase()), configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) }); assert.equal(caseChanged.plan.newObjectCount, 2); assert.equal(caseChanged.plan.missingObjectCount, 2); }); it("updates accepted quantities and row totals while preserving missing links and compacting old batches", async () => { const state = await initialState(); const first = state.objects.find((object) => object.worksharingId === firstWorksharingId)!; first.circuitDeviceRowId = "row-1"; const bytes = await changedWorkbook(); const { plan, document } = await createExternalXlsxFollowUpImportPlan({ fileName: "follow-up.xlsx", bytes, configuration, catalogs, currentState: state, currentReturnValuesByObjectId: currentReturns(state) }); let sequence = 0; const row = { id: "row-1", circuitId: "circuit-1", linkedProjectDeviceId: null, sortOrder: 10, name: "Steckdose", displayName: "Steckdose", phaseType: "single_phase", connectionKind: null, costGroup: null, category: "single_phase", level: null, roomId: null, roomNumberSnapshot: null, roomNameSnapshot: null, quantity: 3, manualQuantity: 1, powerPerUnit: 0.125, simultaneityFactor: 1, cosPhi: 0.9, remark: null, overriddenFields: null }; const built = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: true })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `40000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); assert.equal(built.rowTransitions[0]!.target.quantity, 4); assert.equal(built.targetState.objects.find((object) => object.id === first.id)!.planningValues.effectiveQuantity, 3); const missing = built.targetState.objects.find((object) => object.worksharingId.endsWith("99"))!; assert.equal(missing.presenceStatus, "missing"); assert.equal(missing.circuitDeviceRowId, null); assert.equal(built.targetState.importBatches[0]!.originalContentBase64, null); assert.ok(built.targetState.importBatches[1]!.originalContentBase64); const rejected = buildExternalXlsxFollowUpTarget({ projectId: "project-1", expectedRevision: 1, importedAtIso: "2026-09-30T09:00:00.000Z", configuration, originalContentBase64: Buffer.from(bytes).toString("base64"), document, plan, currentState: state, sourceChangeDecisions: plan.changes.map((change) => ({ objectId: change.objectId, accept: false })), roomDecisions: plan.newSourceRoomKeys.map((sourceRoomKey) => ({ sourceRoomKey, roomId: null, defaultDistributionBoardId: null })), familyProjectDeviceDecisions: plan.newFamilyAndTypes.map((familyAndType) => ({ familyAndType, projectDeviceId: null })), objectsWithoutRoomDefaultDistributionBoardId: null, rowsById: new Map([[row.id, row]]), createId: () => `50000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}` }); const rejectedObject = rejected.targetState.objects.find((object) => object.id === first.id)!; assert.equal(rejectedObject.planningValues.effectiveQuantity, first.planningValues.effectiveQuantity); assert.equal(rejectedObject.lastAcceptedImportBatchId, first.lastAcceptedImportBatchId); assert.notEqual(rejectedObject.lastSeenImportBatchId, first.lastSeenImportBatchId); assert.equal(rejected.rowTransitions.length, 0); }); });