From ad6d873e29432a7a90f1e9a0b76b3154aae0dc63 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 1 Oct 2026 13:28:35 +0200 Subject: [PATCH 1/4] =?UTF-8?q?docs(openspec):=20cmdb-export-import=20?= =?UTF-8?q?=E2=80=94=20import=20a=20TOPdesk=20CMDB=20export=20(xlsx)=20int?= =?UTF-8?q?o=20the=20catalogue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposal, specs (REQ-CMDB-001..014), design, contract, migration, test-plan and tasks for importing a TOPdesk application export into OpenRegister through Stackiq: per row a module, the manufacturer and the importing municipality as organisations, a usage linking them and owner contacts; upsert on Middel-ID; publicationDate set so OpenCatalogi lists the applications and Portaliq shows the usages. Jira: https://conduction.atlassian.net/browse/WOO-586 Co-Authored-By: Claude Fable 5.1 --- .../changes/cmdb-export-import/.openspec.yaml | 2 + .../changes/cmdb-export-import/contract.md | 117 +++++ openspec/changes/cmdb-export-import/design.md | 419 ++++++++++++++++++ .../changes/cmdb-export-import/migration.md | 52 +++ .../changes/cmdb-export-import/proposal.md | 105 +++++ .../specs/cmdb-export-import/spec.md | 367 +++++++++++++++ openspec/changes/cmdb-export-import/tasks.md | 127 ++++++ .../changes/cmdb-export-import/test-plan.md | 147 ++++++ openspec/specs/cmdb-export-import/spec.md | 51 +++ 9 files changed, 1387 insertions(+) create mode 100644 openspec/changes/cmdb-export-import/.openspec.yaml create mode 100644 openspec/changes/cmdb-export-import/contract.md create mode 100644 openspec/changes/cmdb-export-import/design.md create mode 100644 openspec/changes/cmdb-export-import/migration.md create mode 100644 openspec/changes/cmdb-export-import/proposal.md create mode 100644 openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md create mode 100644 openspec/changes/cmdb-export-import/tasks.md create mode 100644 openspec/changes/cmdb-export-import/test-plan.md create mode 100644 openspec/specs/cmdb-export-import/spec.md diff --git a/openspec/changes/cmdb-export-import/.openspec.yaml b/openspec/changes/cmdb-export-import/.openspec.yaml new file mode 100644 index 00000000..67206a85 --- /dev/null +++ b/openspec/changes/cmdb-export-import/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-10-01 diff --git a/openspec/changes/cmdb-export-import/contract.md b/openspec/changes/cmdb-export-import/contract.md new file mode 100644 index 00000000..2b8a969a --- /dev/null +++ b/openspec/changes/cmdb-export-import/contract.md @@ -0,0 +1,117 @@ +# Contract: cmdb-export-import + +## Consumers + +- `stackiq` frontend: the "CMDB import" admin-settings section (`src/views/settings/sections/CmdbImport.vue`) is the only caller of the two new endpoints. +- `opencatalogi` and `portaliq` call no new endpoint. They read the objects the import writes through their existing OpenRegister paths. Their interface is the data shape below: `module.publicationDate` for OpenCatalogi, and `usage.consumer` / `usage.module` for Portaliq. + +Paths are relative to `/index.php/apps/stackiq`. + +## Endpoints + +### `POST /api/cmdb-import` +**Auth**: Nextcloud session of a Nextcloud admin, plus CSRF `requesttoken` (header or form field). No `NoAdminRequired`, no `NoCSRFRequired`. + +**Request:** `multipart/form-data` + +| Field | Type | Required | Default | Meaning | +|---|---|---|---|---| +| `cmdbFile` | file | yes | | the TOPdesk export, `.xlsx`, at most 10 MB | +| `municipalityUuid` | string (uuid) | one of the two | | an existing `organization` of type Municipality | +| `municipalityName` | string | one of the two | | name of a Municipality to reuse (same normalised name) or create | +| `updateExisting` | `true`/`false` | no | `true` | `false` reports matched rows as skipped (`exists`) | +| `missingRecords` | string | no | `keep` | only `keep` is accepted; `mark` and `remove` are reserved | +| `operationId` | string | no | generated | progress operation id, readable through `GET /api/progress/{operationId}` | + +**Response (200):** +```json +{ + "success": true, + "operationId": "cmdb-00000000-0000-0000-0000-000000000000", + "cancelled": false, + "municipality": { "uuid": "00000000-0000-0000-0000-000000000001", "name": "Gemeente Voorbeeldstad", "created": false }, + "summary": { "rowsRead": 2, "created": 2, "updated": 0, "unchanged": 0, "skipped": 0, "failed": 0, "warnings": 0 }, + "importWarnings": [ + { "sheet": "Invoer AIA data", "message": "Optional column \"ICT TIME Classificatie\" not found" } + ], + "rows": [ + { + "sheet": "Invoer AIA data", + "row": 2, + "middelId": "AIA-AangetekendMailen", + "name": "Aangetekend Mailen", + "outcome": "created", + "reasons": [], + "warnings": [], + "moduleUuid": "00000000-0000-0000-0000-000000000004", + "usageUuid": "00000000-0000-0000-0000-000000000005" + } + ] +} +``` + +`outcome` is one of `created`, `updated`, `unchanged`, `skipped`, `failed`. `reasons` and `warnings` are strings that name columns and values. They never contain owner names, e-mail addresses or other person data. + +**Errors:** +| Code | Condition | +|------|-----------| +| 400 | `NO_FILE_UPLOADED`, `NOT_XLSX` | +| 401 | not signed in (Nextcloud) | +| 403 | not a Nextcloud admin (Nextcloud) | +| 412 | missing or invalid CSRF token (Nextcloud) | +| 413 | `FILE_TOO_LARGE` | +| 422 | `MISSING_RECORDS_UNSUPPORTED`, `MUNICIPALITY_REQUIRED`, `MUNICIPALITY_INVALID`, `NO_SOURCE_SHEET`, `MISSING_COLUMN`, `TOO_MANY_ROWS` | +| 500 | `IMPORT_FAILED` (unexpected; generic message, details only in the log) | +| 503 | `MAPPING_UNAVAILABLE`, `READER_UNAVAILABLE` | + +Error body: `{"success": false, "error": "", "message": "", "details": {...}}`. For `MISSING_COLUMN`, `details` is `{"sheet": "...", "column": "..."}`. For `NO_SOURCE_SHEET`, it is `{"expected": ["Invoer AIA data", "Invoer APP data"]}`. + +### `POST /api/cmdb-import/{operationId}/cancel` +**Auth**: Nextcloud admin session plus CSRF token. + +**Request:** no body. + +**Response (200):** +```json +{ "success": true, "cancelRequested": true } +``` + +**Errors:** +| Code | Condition | +|------|-----------| +| 403 | not a Nextcloud admin | +| 404 | `OPERATION_NOT_FOUND`: no `cmdb_import` operation with this id | +| 412 | missing or invalid CSRF token | + +### `GET /api/progress/{operationId}` (existing, unchanged) +Returns the `ProgressTracker` snapshot for the `cmdb_import` operation. After completion, `progress.statistics.report` holds the report from the 200 response above, for as long as the tracker keeps the entry (one hour). + +## Error Codes + +| Code | Meaning | Condition | +|------|---------|-----------| +| `NO_FILE_UPLOADED` | no file | `cmdbFile` missing | +| `NOT_XLSX` | not an xlsx workbook | extension is not `.xlsx`, no ZIP signature, or no `xl/workbook.xml` | +| `FILE_TOO_LARGE` | too large | larger than the profile's `maxFileBytes` (10 MB) | +| `MISSING_RECORDS_UNSUPPORTED` | option not supported | `missingRecords` is not `keep` | +| `MUNICIPALITY_REQUIRED` | no consumer | neither `municipalityUuid` nor `municipalityName` given | +| `MUNICIPALITY_INVALID` | wrong consumer | uuid unknown, or the organisation is not of type Municipality | +| `NO_SOURCE_SHEET` | nothing to read | neither "Invoer AIA data" nor "Invoer APP data" present | +| `MISSING_COLUMN` | required column absent | a present source sheet lacks "Middel-ID" or "Naam" | +| `TOO_MANY_ROWS` | file too large to process | a source sheet has more non-empty rows than `maxRowsPerSheet` (10,000) | +| `MAPPING_UNAVAILABLE` | mapping cannot run | OpenRegister's `MappingEngine`/`PackDefinitionValidator` missing, or a shipped pack is invalid | +| `READER_UNAVAILABLE` | xlsx reader missing | PhpSpreadsheet's Xlsx reader cannot be loaded | +| `OPERATION_NOT_FOUND` | unknown operation | cancel for an id without a `cmdb_import` operation | +| `IMPORT_FAILED` | unexpected error | anything not listed above | + +## Versioning + +Internal app API, unversioned like the other stackiq settings endpoints. The report fields above are additive-only: new fields MAY be added, and existing fields keep their meaning. The `module` properties `externalId`, `externalNumber`, `externalKey`, `externalCreatedAt` and `externalModifiedAt` are part of the register schema and follow the register's versioning (`module` 0.3.5). + +## Breaking Change Policy + +A breaking change to the endpoints only affects stackiq's own settings section and ships in the same release. A change to the meaning of `externalKey` (the matching rule) is breaking for repeat imports. It requires a new OpenSpec change with a migration that rewrites the stored keys. + +## SLA + +Synchronous request. An unchanged 1,100-row export SHALL finish within PHP's default execution limits on the local rig. Progress is readable while the request runs. No availability promise beyond the Nextcloud instance itself. diff --git a/openspec/changes/cmdb-export-import/design.md b/openspec/changes/cmdb-export-import/design.md new file mode 100644 index 00000000..a7418f3a --- /dev/null +++ b/openspec/changes/cmdb-export-import/design.md @@ -0,0 +1,419 @@ +# Design: cmdb-export-import + +## Context + +A municipality delivers its application landscape as a TOPdesk export (xlsx). The anonymised test export has ten sheets. Two of them are the raw TOPdesk exports this change reads: "Invoer AIA data" (52 columns, Soort = Application Inventory) and "Invoer APP data" (82 columns, Soort = Applicatie). The "*CMDB" sheets are derived from them with formulas, and "Invoer gearchiveerde appl" is the archive; neither is read. Both source sheets carry hundreds of formatted but empty rows below the data. Dates are Excel serial numbers. The file also contains document metadata, a SharePoint sensitivity label, an embedded Power Query package and an external data connection (`xl/connections.xml`). + +The chain baseline on the local rig (OpenRegister 2.1.34-unstable, OpenCatalogi 2.1.17-unstable, Portaliq 0.2.8-unstable, stackiq 0.2.4-unstable) fixed what the import has to produce: + +- OpenRegister's `POST /api/registers/{id}/import` cannot take this file. It maps sheet names to schema slugs and stops at the first unknown sheet. Migration packs work on CSV and JSON only, and one pack targets one schema. +- OpenCatalogi lists a stackiq `module` only through a catalogue that includes the stackiq register and `module` schema, and only when the module's `publicationDate` is set and not in the future. +- Portaliq shows an application to a municipality only through a `usage` whose `consumer` is the organisation in the account's `stackiq.organisationId` claim. + +Stackiq already has two upload imports: `SbomController` with `SbomImportService`, and `SettingsController::importArchiMate` with `ArchiMateImportService`. Both write through `ObjectServiceInterface` and report progress through `ProgressTracker`. This change follows the same pattern. + +## Goals / Non-Goals + +**Goals** + +- One admin action turns a TOPdesk export into modules, manufacturers, usages and owners for one municipality. +- Re-importing a newer export updates the same records and creates no duplicates. +- The mapping is data (JSON), not code, and runs through OpenRegister's mapping engine. +- An untrusted spreadsheet is read safely, and one bad row never breaks the import. + +**Non-Goals** + +- Connections from "Ouders" / "Kind-middelen", suites, hosting parties, the archive sheet. +- Marking or removing records that disappeared from the export. +- A background job, a dry run, an `occ` command, a live TOPdesk connection. +- Writing OpenCatalogi catalogues or Portaliq accounts. + +## Architecture Overview + +``` +Admin settings, "CMDB import" section (CmdbImport.vue) + │ multipart: cmdbFile, municipalityUuid | municipalityName, + │ updateExisting, missingRecords, operationId (+ requesttoken) + ▼ +CmdbImportController::import() admin-only, CSRF, size/type checks + ▼ +CmdbExportImportService::import() + ├─ CmdbImportProfile lib/Settings/cmdb-import/topdesk-profile.json + 6 packs + │ (packs checked with OR PackDefinitionValidator) + ├─ CmdbWorkbookReader PhpSpreadsheet Xlsx, read-data-only, profile sheets only, + │ header-name columns, allowlisted columns, empty rows dropped + ├─ CmdbRowNormaliser trim, Excel serial → Y-m-d, numeric ids → string + ├─ OR MappingEngine::mapRow() once per pack per row + ├─ resolve per row, in order: + │ municipality (once) → manufacturer → module → owners → usage + │ via ObjectServiceInterface::searchObjects()/saveObject() + │ and StackiqContactSyncService (OCP\Contacts\IManager) + ├─ ProgressTracker operation `cmdb_import`, per-row progress, cancel + └─ report summary + one entry per counted row + ▼ +OpenRegister, register `stackiq`: module, organization, usage, contactPerson + ├─▶ OpenCatalogi search (module with publicationDate, via its catalogue) + └─▶ Portaliq (usage.consumer = the account's organisation) +``` + +## Decisions + +### D1. A stackiq service, not OpenRegister's import endpoint + +The import is a stackiq service plus controller (route A in the WOO-586 plan). + +- **Alternative: OpenRegister `/api/registers/{id}/import` with a migration pack.** Rejected. It does not accept a pack on xlsx, maps one sheet to one schema, and cannot link the objects it creates (usage.module, usage.consumer, module.provider). +- **Alternative: stackiq splits the file into one CSV per schema and runs four OpenRegister imports.** Rejected. Stackiq still has to split and link the rows, so the four pack runs add moving parts without taking work away. + +### D2. The mapping is a set of migration packs executed by OpenRegister's MappingEngine + +Each target has one pack in OpenRegister's migration-pack format (`id`, `name`, `sourceFormat: excel`, `version`, `fieldMappings`, `idStrategy: {type: generate}`, optional `defaults`). The service validates each pack with `OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator` when an import starts, and maps rows with `MappingEngine::mapRow($pack, $row, $rowNumber)`. The engine supplies `trim`, `date`, `lookup`, `concat` and `const`, the "required" rule, the guard that an unmapped lookup value never passes through, and errors that name the row, the column and the transform. + +Files, all in `lib/Settings/cmdb-import/`: + +| File | Target | Notes | +|---|---|---| +| `topdesk-profile.json` | none | Sheets with their accepted Soort values, key column, required columns, date and id columns, the pack per target, create-only fields, size and row limits | +| `topdesk-module.json` | `module` | Mapping errors on `required` mappings skip the row | +| `topdesk-manufacturer.json` | `organization` (Supplier) | Empty "Fabrikant" means no provider | +| `topdesk-municipality.json` | `organization` (Municipality) | Maps the options row `{municipalityName}`, not a sheet row | +| `topdesk-usage.json` | `usage` | Lookups for status and TIME class | +| `topdesk-business-owner.json` | owner identity | `name`, `email`, `role`; the service turns it into a contact and a `contactPerson` | +| `topdesk-technical-owner.json` | owner identity | `name` | + +Stackiq-specific settings live in the profile, not in the packs, so every pack stays a valid OpenRegister pack. + +`MappingEngine` and `PackDefinitionValidator` are not part of OpenRegister's `Contract` namespace. The service resolves them from the container inside a guard. If either is missing, the import answers 503 `MAPPING_UNAVAILABLE` before it reads the file. + +- **Alternative: OpenRegister's Twig-based `MappingService::executeMapping()` with `Mapping` entities shipped in the register's `components.mappings`.** Those mappings would be editable in OpenRegister's UI. Rejected for now: lookups and "required" would have to be written as Twig templates, and errors would come without row and column. Kept as an option if admins need to edit the mapping in a UI. +- **Follow-up (not built here):** before using the shipped file, look up a pack with the same `id` in OpenRegister's migration-pack store (`MigrationPackService::findByPackSlug()`). An admin could then override the mapping through `POST /api/migration-packs/import`, without a release. + +### D3. Reading the workbook + +`CmdbWorkbookReader` checks the upload, then reads it: + +1. Before PhpSpreadsheet: the name ends in `.xlsx`, the first bytes are the ZIP signature `PK\x03\x04`, and `ZipArchive` lists `xl/workbook.xml`. Otherwise 400 `NOT_XLSX`. +2. `new \PhpOffice\PhpSpreadsheet\Reader\Xlsx()`, then `setReadDataOnly(true)` and `setLoadSheetsOnly([...profile sheet names that exist])`. The sheet names come from `listWorksheetNames()`. The class comes from OpenRegister's vendor directory, which is loaded whenever OpenRegister is enabled. It is checked with `class_exists`; if absent, 503 `READER_UNAVAILABLE`. +3. Row 1 holds the headers. Each header is normalised (trim, collapse whitespace, drop a trailing `:` or `⚡`, lower case) and matched to the column names the profile and the packs reference. Only those columns are kept. Every other cell, such as Personeelsnummer, phone numbers and group mailboxes, is never copied out of the reader. +4. For each cell the reader takes `getValue()`. For a formula cell (data type `f`) it takes `getOldCalculatedValue()`, the value Excel cached. It never calls `getCalculatedValue()` or `toArray()` with formula calculation. The source sheets have no formulas today; the rule covers exports that do. +5. A row whose kept cells are all empty is dropped and not counted. A source sheet with more than `maxRowsPerSheet` (10,000) non-empty rows stops the import with 422 `TOO_MANY_ROWS`. + +External connections, the Power Query package and hyperlinks are never resolved: PhpSpreadsheet does not follow them, and the reader gets no HTTP client. + +### D4. Normalising a row before mapping + +`CmdbRowNormaliser` turns reader output into the flat `column => string` row the engine expects: + +- Columns listed in the profile's `dateColumns` ("Aanmaakdatum", "Wijzigingsdatum", "End of Life Business", "Einddatum"): a numeric value is converted with `PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject()` in UTC and written as `Y-m-d`. For example, `45111.38…` becomes `2023-07-04` and `53359` becomes `2046-02-01`. A non-numeric value stays as it is, so the pack's `date` transform (`sourceFormat: Y-m-d`) either accepts it or reports a warning. +- Columns listed in `idColumns` ("Middel-ID", "ICT Applicatienummer"): a whole number becomes a string without a decimal part (`1234.0` becomes `"1234"`). +- Every value is trimmed. An empty string counts as empty. + +The engine's `date` transform only parses formatted strings. Doing the serial conversion in the normaliser keeps the packs plain OpenRegister packs. + +### D5. Matching key and upsert + +The key is the TOPdesk Middel-ID, scoped to the municipality: `externalKey = "topdesk:" + municipalityUuid + ":" + Middel-ID`. Middel-IDs are unique within one TOPdesk instance, not across municipalities. With the scope, two municipalities can each import an "APP-00001" without colliding. + +Per row: + +1. A row without a Middel-ID is skipped (`missing Middel-ID`). A Middel-ID already seen in this upload is skipped (`duplicate Middel-ID in file`). A Soort outside the sheet's accepted values is skipped (`unsupported Soort ""`). +2. Look up the module with `searchObjects` on the configured register and module schema, filtered on `externalKey`, with `_rbac: false` and `_multitenancy: false` (as `SbomImportService` does; the caller is an admin). The result is cached for the run. +3. No match: create the module from the mapped data, plus `externalKey`, the create-only defaults (`type: Application`), and `publicationDate` (D6). +4. Match and `updateExisting=false`: skip with reason `exists`. +5. Match: merge the mapped fields onto the stored object. Every field the pack does not map stays as it is. Create-only fields stay as they are, unless the stored value is empty. If the merged object equals the stored one, do not save, and report `unchanged`. Otherwise save, and report `updated`. + +`ICT Applicatienummer` is stored as `externalNumber` and shown, but is not a match key. If the Middel-ID is missing and a second key is needed, that is a follow-up (open question). + +- **Alternative: OpenRegister's `idStrategy: sourceField` (Middel-ID as the object id).** Rejected. Object ids are global uuids, and Middel-ID is neither a uuid nor unique across municipalities. +- **Alternative: put the key on `usage` (per municipality by nature).** Rejected for this change: the key on `module` was decided in the plan (Q3), and the usage is found from the module anyway (D7). + +### D6. publicationDate + +- New module: `publicationDate` = the import's start time (ISO 8601 with offset). This makes it visible to OpenCatalogi, given a catalogue that covers the stackiq `module` schema. +- Existing module: `publicationDate` and `depublicationDate` are never written, also when they are empty. An admin who depublished an imported module keeps it depublished. + +### D7. Related objects and their order + +Per row, in this order: + +1. **Municipality** (once per import): `municipalityUuid` must resolve to an `organization` of type `Municipality`. Otherwise 422 `MUNICIPALITY_INVALID`. With `municipalityName`, the service reuses an existing Municipality with the same normalised name, or creates one through the municipality pack. +2. **Manufacturer**: map "Fabrikant" through the manufacturer pack. The normalised name (trim, collapse whitespace, lower case) is looked up in the run cache, then among `organization` objects of type `Supplier`. A new one is created only when neither matches. +3. **Module** (D5), with `provider` = the manufacturer when there is one. +4. **Owners** (D8). +5. **Usage**: `searchObjects` on `consumer` = municipality and `module` = module uuid. Create or merge the usage pack's fields, plus `consumer`, `module`, `provider` = the manufacturer, and `businessOwner` / `technicalOwner`. `interneAnnotation` ("Eigenaar afdeling / Eigenaar cluster") is create-only, because it is a free-text note an admin may edit. + +When step 3 succeeds and step 5 fails, the row is `failed` with the step named. The next import completes it, because every step is find-or-create. + +### D8. Owners as contact persons + +Stackiq keeps a person's identity in Nextcloud Contacts. A `contactPerson` object holds only `contactsUid`, `role`, `organization` and `roles`. For each owner identity mapped from the row: + +1. `StackiqContactSyncService::syncToContacts('contactPerson', ['name' => …, 'email' => …])` resolves the contact: by e-mail when one is given, otherwise it creates one. Without an e-mail, the service first runs `searchContacts(name)` and accepts only an exact, case-insensitive display-name match. This avoids creating a new contact for "FB contactpersoon 1" on every import. +2. Find the `contactPerson` with that `contactsUid` and `organization` = the municipality (run cache, then `searchObjects`). If none exists, create it with `role` = "Eigenaar functie" when given. +3. Set `usage.businessOwner` / `usage.technicalOwner` to its uuid. + +The import never calls the user-provisioning paths (`ContactpersoonService::processContactpersoon`, `convertToUser`). The scheduled `OrganizationSyncService::performUserSync` provisions users for contact persons. A unit test asserts that a `contactPerson` written by the import does not meet its selection criteria, and the implementation task verifies that before shipping (see Risks). When Contacts is disabled, owners are skipped with a warning and the row is still imported. + +### D9. Progress, cancel and the report + +The import runs inside the upload request, as the SBOM and ArchiMate imports do. The client sends a fresh `operationId`. The service calls `startOperation('cmdb_import', ['total_items' => rowCount])`, then `updateProgress` after each row and `completeOperation($report)` at the end. The UI polls the existing `GET /api/progress/{operationId}`. `POST /api/cmdb-import/{operationId}/cancel` calls `setCancelRequested()`. The service checks `isCancelRequested()` between rows and returns the partial report with `cancelled: true`. + +Report shape (contract.md is authoritative): `summary {rowsRead, created, updated, unchanged, skipped, failed, warnings}`, `importWarnings[]` (for example, a missing optional column), and `rows[] {sheet, row, middelId, name, outcome, reasons[], warnings[], moduleUuid, usageUuid}`. Reasons name columns and values. They never name owners, e-mail addresses or other person data, and neither do log lines. + +### D10. Controller and validation order + +`CmdbImportController::import()` has neither `#[NoAdminRequired]` nor `#[NoCSRFRequired]`, so Nextcloud's middleware enforces admin and CSRF before the method runs. The method then checks, in this order: + +1. A file is present: otherwise 400 `NO_FILE_UPLOADED`. +2. Size: otherwise 413 `FILE_TOO_LARGE`. +3. xlsx: otherwise 400 `NOT_XLSX`. +4. `missingRecords` is `keep`: otherwise 422 `MISSING_RECORDS_UNSUPPORTED`. +5. A municipality is given: otherwise 422 `MUNICIPALITY_REQUIRED`. +6. The service runs. It answers 503 `MAPPING_UNAVAILABLE` or `READER_UNAVAILABLE`, 422 `NO_SOURCE_SHEET`, `MISSING_COLUMN`, `TOO_MANY_ROWS` or `MUNICIPALITY_INVALID`, or 200 with the report. + +Every expected service exception is translated to its status code in the controller (hydra gate controller-exception-translation). Only unexpected errors become 500, with a generic message and the detail logged. + +### D11. The settings section + +`src/views/settings/sections/CmdbImport.vue` sits next to `ArchiMateImportExport.vue` in `StackiqSettings.vue`, inside `AlwaysVisibleSection`, and is not added to the vue-router (hydra gate admin-router). + +- Municipality: an `NcSelect` with a label. It lists organisations of type Municipality, read through the OpenRegister objects API, and has an option to type a new name. +- File: an `` with a label. +- Options: an "Update existing records" checkbox. +- Import and Cancel buttons. +- During the import: `NcProgressBar` with a polite live region. +- Afterwards: summary counts and a `CnDataTable` report with an outcome filter and links to the modules. + +Cell values are shown with text interpolation only, never `v-html`. Requests use `@nextcloud/axios`, which sends the CSRF token. + +## Column mapping + +Source columns of "Invoer AIA data" and "Invoer APP data" and where they go. Columns that are not listed are not read (D3). + +| Column | Target | Rule | +|---|---|---| +| Naam | module.name | trim, required | +| Middel-ID | module.externalId; part of module.externalKey | trim, required, match key (D5) | +| ICT Applicatienummer | module.externalNumber | numeric to string | +| Functionele omschrijving | module.longDescription | trim | +| ICT BBN Classificatie | module.bbnLevel | lookup "BBN1"/"BBN 1" etc. to `BBN1`/`BBN2`/`BBN3`; unknown value: warning | +| Aanmaakdatum | module.externalCreatedAt | Excel serial to date | +| Wijzigingsdatum | module.externalModifiedAt | Excel serial to date | +| Fabrikant | organization (Supplier) via module.provider and usage.provider | dedup on normalised name (D7) | +| Status | usage.status | lookup (provisional): In productie → In production, In voorraad → Planned, In ontwikkeling → Acquisition, Uit te faseren → To be phased out, Uitgefaseerd → Phased out; unknown value: warning | +| ICT TIME Classificatie | usage.timeClassification | lookup of the English and Dutch forms (Tolerate/Tolereren, Invest/Investeren, Migrate/Migreren, Eliminate/Elimineren) | +| End of Life Business | usage.startDateOutPhased | Excel serial to date | +| Eigenaar afdeling, Eigenaar cluster | usage.interneAnnotation | concat with " / ", create-only | +| Eigenaar, Eigenaar e-mail, Eigenaar functie | usage.businessOwner (contactPerson + Nextcloud contact; role = functie) | D8 | +| FB contactpersoon 1 | usage.technicalOwner (contactPerson + Nextcloud contact) | D8, match on name | +| Soort | none (row filter) | accepted values per sheet in the profile | +| Roepnaam | not mapped (no field) | a pack mapping to shortDescription is the documented example of adjusting the mapping | +| ICT Applicatiesoort | not mapped (no field: cloudDienstverleningsmodel uses another vocabulary) | | +| ICT Hostingspartij | not mapped (no field for a hosting party on module) | follow-up | +| Software suite | not mapped in this change (suite schema; needs a second pass) | follow-up | +| Leverancier | not mapped in this change | candidate second supplier source | +| Afdeling, Behandelaarsgroep | not mapped (no field) | | +| ICT Beschikbaarheid / Integriteit / Vertrouwelijkheid, IB-*, ICT Back-up-*, ICT Cryptografie-*, ICT Audit logging and the other ICT assessment columns | not mapped (no field on module or usage) | schema extension is out of scope | +| Ouders, Kind-middelen, APM / afhankelijkheid / gebruik / gedistribueerd / proces koppeling | not mapped in this change | connections follow-up | +| Einddatum, Publiceren op SARA, Toewijzingen-* | not mapped (meaning to be confirmed with the municipality) | | +| Personeelsnummer, Eigenaar mobiel nummer, Groepseigenaar-*, Groepsmail, Groepsnummer, Configuratie coördinator, FB contactpersoon 2, Opmerkingen | never read (privacy, D3) | | + +## API Design + +The authoritative interface is in `contract.md`. In short: + +- `POST /api/cmdb-import`: multipart `cmdbFile`, plus `municipalityUuid` or `municipalityName`, `updateExisting` (default `true`), `missingRecords` (default `keep`) and `operationId`. Admin, CSRF. Answers 200 with the report, or one of the errors in D10. +- `POST /api/cmdb-import/{operationId}/cancel`: admin, CSRF. Answers 200 `{cancelRequested: true}`. +- `GET /api/progress/{operationId}`: the existing route, unchanged. + +## Database Changes + +No tables or Nextcloud migrations (ADR-001). The `module` schema gains five optional properties through a register fragment (see Mixed-spec rationale and `migration.md`). + +## Mixed-spec rationale (ADR-032) + +The change is `kind: code`. Its weight is the import service, controller, reader and settings section. It also contains a thin schema delta: `lib/Settings/register.d/topdesk-cmdb-import.json` adds `externalId`, `externalNumber`, `externalKey`, `externalCreatedAt` and `externalModifiedAt` to `module`, all optional strings or dates, and seeds three example modules. This is not the ADR-032 `mixed` anti-pattern: + +1. The delta is additive glue that exists only for this code. Nothing else reads the properties, and the import cannot be idempotent without a stored key (no existing `module` property can hold a TOPdesk id). +2. It follows the app's fragment convention (ADR-037), so it touches no other change's file. +3. It is deployed by the existing register import in the repair step, without a migration class. + +The fragment bumps `module` to `0.3.5`. Fragments are merged in filename order and a scalar `version` is overwritten by the last fragment that sets it. `maintenance-and-roadmap.json` sets `module` to `0.3.4`, so a fragment that sorts before it would have its bump overwritten, and the new properties would never deploy. The file is therefore named `topdesk-cmdb-import.json`, which sorts after it, and a unit test asserts that the merged register declares `module` version `0.3.5` with the five properties. + +## Declarative-vs-imperative decision (ADR-031) + +- **Imperative, because it is an external integration:** reading an uploaded third-party file, splitting a row into four linked objects, resolving contacts in Nextcloud Contacts, progress and cancel. These are not object lifecycle, aggregation, notification or relation rules that an `x-openregister-*` block can express. This is the external-integration exception: the service is imperative glue around the file. +- **Declarative:** what each column becomes (target property, transform, lookup, required) is JSON in OpenRegister's migration-pack format, executed by OpenRegister's `MappingEngine`. Changing the mapping changes no PHP. +- **Matching rule (stated once, enforced in code):** a module matches when its `externalKey` equals `topdesk::`. A usage matches on (`consumer`, `module`). A supplier matches on its normalised name and type `Supplier`. A contact person matches on (`contactsUid`, `organization`). +- **publicationDate rule (stated once, enforced in code):** set to the import's start time on create; never written on update. +- No `x-openregister-*` block is added or changed. The usage name keeps coming from the schema's existing name template. + +## Nextcloud Integration + +- Controllers: `CmdbImportController` (`import`, `cancel`), admin-only with CSRF, no `NoAdminRequired` / `NoCSRFRequired`. +- Services: `CmdbExportImportService` (orchestration), `Cmdb\CmdbWorkbookReader`, `Cmdb\CmdbRowNormaliser`, `Cmdb\CmdbImportProfile` (loads and validates the profile and packs). They reuse `ProgressTracker`, `SettingsService` (register and schema ids) and `StackiqContactSyncService`. +- OCP: `IRequest::getUploadedFile()`, `IUserSession`, `IL10N`, `OCP\Contacts\IManager` (through `StackiqContactSyncService`), `ICacheFactory` (through `ProgressTracker`). +- OpenRegister: `ObjectServiceInterface::searchObjects()` / `saveObject()` (contract), `MigrationPack\MappingEngine` and `PackDefinitionValidator` (container, guarded), PhpSpreadsheet (guarded). +- Mappers/Entities: none (ADR-001, ADR-008: Controller → Service → OpenRegister). +- Events/Hooks: none. Saves go through `saveObject()`, so the existing `ModuleRegistrationSubscriber` and `ModuleComplianceSubscriber` run as they do for any module save. + +## Security Considerations + +- **Auth and CSRF:** both routes are admin-only through Nextcloud's middleware, with CSRF required. This is stricter than `SbomController` and `importArchiMate`, which carry `NoCSRFRequired`. The admin check happens before the body is read. +- **File checks before parsing:** size limit (10 MB, profile), `.xlsx` extension, ZIP signature and `xl/workbook.xml`. `.xlsm` and `.xls` are rejected. The upload is read from PHP's temporary upload file and never written into Nextcloud Files. +- **No evaluation, no fetching:** read-data-only, profile sheets only, cached values for formula cells, no `getCalculatedValue()`, no HTTP client in the reader. External connections, Power Query packages and hyperlinks are inert. +- **Resource bounds:** row cap per sheet, and only allowlisted columns are kept. Memory is bounded by loading only the two source sheets. +- **Injection:** every value is a string that goes through OpenRegister's schema validation on save, and is never used in SQL, file paths or templates. The UI renders values as text only. +- **Isolation:** every row runs in its own try/catch. Errors are reported per row, and the import continues. +- **Privacy:** the column allowlist keeps personnel numbers, phones and group mailboxes out of memory. Owner identity goes only to Nextcloud Contacts. Reports and logs carry no person data. +- **Fixture hygiene:** the test fixture is the anonymised export with document metadata, the custom properties (sensitivity label), `customXml/` (including the Power Query package) and `xl/connections.xml` removed. One small synthetic connection part is added back for the external-connection test. + +## NL Design System + +Nextcloud and `@conduction/nextcloud-vue` components only (ADR-012): `NcSelect`, `NcButton`, `NcCheckboxRadioSwitch`, `NcProgressBar`, `NcNoteCard` for errors, and `CnDataTable` for the report. The file input follows the label pattern of `ArchiMateImportExport.vue`. Colours and spacing come from Nextcloud CSS variables (ADR-003). Outcome badges reuse the existing status-tag styling. + +## File Structure + +``` +appinfo/ + routes.php (+ cmdbImport#import, cmdbImport#cancel) +lib/ + Controller/ + CmdbImportController.php + Service/ + CmdbExportImportService.php + Cmdb/ + CmdbImportProfile.php + CmdbWorkbookReader.php + CmdbRowNormaliser.php + CmdbImportReport.php + Exception/ + CmdbImportException.php (carries error code + HTTP status) + Settings/ + cmdb-import/ + topdesk-profile.json + topdesk-module.json + topdesk-manufacturer.json + topdesk-municipality.json + topdesk-usage.json + topdesk-business-owner.json + topdesk-technical-owner.json + register.d/ + topdesk-cmdb-import.json (module 0.3.5: five properties + seed modules) +src/views/settings/ + StackiqSettings.vue (registers the section) + sections/CmdbImport.vue +tests/ + fixtures/cmdb/ + topdesk-export-anonymised.xlsx (sanitised copy of the test export) + topdesk-missing-middel-id.xlsx + topdesk-shuffled-columns.xlsx + topdesk-formula-and-connection.xlsx + Unit/Service/CmdbExportImportServiceTest.php + Unit/Service/Cmdb/CmdbWorkbookReaderTest.php + Unit/Service/Cmdb/CmdbRowNormaliserTest.php + Unit/Service/Cmdb/CmdbImportProfileTest.php + Unit/Controller/CmdbImportControllerTest.php + Unit/Settings/TopdeskCmdbFragmentTest.php + e2e/spec-coverage/cmdb-import.spec.ts +docs/features/ + cmdb-import.md +l10n/ + en.json, en.js, nl.json, nl.js +``` + +## Seed Data + +Placeholders: uuids are nil-style (`00000000-0000-0000-0000-00000000000N`). All names are fictional ("Gemeente Voorbeeldstad", "Voorbeeld Software B.V."). No real people, addresses or numbers appear. + +### Schema: `module` (modified; seeded through the fragment's `components.objects`) + +The seeds show the new properties in a fresh install. They carry no `publicationDate`, so they are not published as open data, and no `externalKey`, because the key holds a municipality uuid that only exists at run time. + +| Field | Object 1 | Object 2 | Object 3 | +|---|---|---|---| +| @self | register `stackiq`, schema `module`, slug `voorbeeld-zaaksysteem` | slug `voorbeeld-afsprakenplanner` | slug `voorbeeld-belastingapplicatie` | +| name | Voorbeeld Zaaksysteem | Voorbeeld Afsprakenplanner | Voorbeeld Belastingapplicatie | +| type | Application | Application | Application | +| longDescription | Registreert en volgt zaken van intake tot archivering. | Laat inwoners online een afspraak maken bij de balie. | Berekent en verstuurt gemeentelijke belastingaanslagen. | +| externalId | APP-00001 | APP-00002 | AIA-00003 | +| externalNumber | 101 | 102 | 103 | +| externalCreatedAt | 2023-07-04 | 2022-03-16 | 2024-01-15 | +| externalModifiedAt | 2026-07-29 | 2026-09-01 | 2026-05-20 | +| bbnLevel | BBN2 | BBN1 | BBN2 | + +**Related items per object:** none seeded. Files, notes, tasks and contacts are not used by these modules. Provider and usages come from a real import, not from seeds. + +### Objects an import writes (not seeded; reference shapes for tests and docs) + +`organization` (municipality, created from `municipalityName`): + +| Field | Value | +|---|---| +| uuid | 00000000-0000-0000-0000-000000000001 | +| name | Gemeente Voorbeeldstad | +| type | Municipality | +| status | Active | + +`organization` (manufacturer): + +| Field | Object A | Object B | +|---|---|---| +| uuid | 00000000-0000-0000-0000-000000000002 | 00000000-0000-0000-0000-000000000003 | +| name | Voorbeeld Software B.V. | Fabfrikant | +| type | Supplier | Supplier | +| status | Active | Active | + +`module` (as written by the import): + +| Field | Value | +|---|---| +| uuid | 00000000-0000-0000-0000-000000000004 | +| name | naamtest123 | +| externalId | APP-test123 | +| externalNumber | 2 | +| externalKey | topdesk:00000000-0000-0000-0000-000000000001:APP-test123 | +| longDescription | Accomodatieplanning. | +| provider | 00000000-0000-0000-0000-000000000003 | +| publicationDate | 2026-10-01T10:00:00+00:00 | + +`usage`: + +| Field | Value | +|---|---| +| uuid | 00000000-0000-0000-0000-000000000005 | +| consumer | 00000000-0000-0000-0000-000000000001 | +| module | 00000000-0000-0000-0000-000000000004 | +| provider | 00000000-0000-0000-0000-000000000003 | +| status | In production | +| startDateOutPhased | 2046-02-01 | +| businessOwner | 00000000-0000-0000-0000-000000000006 | + +`contactPerson`: + +| Field | Value | +|---|---| +| uuid | 00000000-0000-0000-0000-000000000006 | +| contactsUid | `` | +| organization | 00000000-0000-0000-0000-000000000001 | +| role | Afdelingshoofd | + +## Risks / Trade-offs + +- [The user sync might provision accounts for imported contact persons] → The import writes contact persons without e-mail or user fields on the OpenRegister object. A unit test runs `performUserSync`'s selection against an imported `contactPerson`. If the selection would pick it up, the implementation adds an explicit marker that excludes it before shipping, and does not ship otherwise. +- [Owner contacts land in the importing admin's address book] → `StackiqContactSyncService` writes to the first writable address book of the acting user, the same as every other stackiq contact path. The docs say so. A dedicated system address book is a follow-up. +- [Long synchronous request] → Per-row progress, cancel, and "unchanged" rows skip the save. About 1,100 rows is expected to fit. A background job is a follow-up if it does not. +- [OpenRegister internals (`MappingEngine`, `PackDefinitionValidator`, PhpSpreadsheet) change shape] → Guarded resolution with 503, and a contract test that maps the fixture through the real engine in the dev environment. +- [Provisional status lookup] → An unknown value is a warning, never a wrong value. Once the municipality confirms its status values, the map in the JSON is extended, with no code change. +- [An unknown status on create falls back to the usage schema's default "In production"] → Accepted. The warning in the report makes it visible. +- [The fragment version is overwritten by merge order] → Filename ordering plus a unit test on the merged version (Mixed-spec rationale). + +## Migration Plan + +No data migration. The register fragment deploys with the existing repair-step register import (see `migration.md`). Rollback is a revert of the PR. The optional `module` properties may stay deployed without harm. + +## Open Questions + +- Which "Status" and "ICT TIME Classificatie" values occur in the municipality's real export (lookup maps)? +- Should "ICT Applicatienummer" serve as a fallback key when "Middel-ID" is empty? +- Should OpenRegister promote `MigrationPack\MappingEngine` to its `Contract` namespace? diff --git a/openspec/changes/cmdb-export-import/migration.md b/openspec/changes/cmdb-export-import/migration.md new file mode 100644 index 00000000..a1ab7d80 --- /dev/null +++ b/openspec/changes/cmdb-export-import/migration.md @@ -0,0 +1,52 @@ +# Migration: cmdb-export-import + +## Current State + +The `module` schema in register `stackiq` is at version `0.3.4` after merging `softwarecatalogus_register.json` (0.3.3) with `register.d/maintenance-and-roadmap.json` (0.3.4, `roadmapStatement`). It has no property that holds an identifier from an external source system. No Nextcloud database table is involved (ADR-001). OpenRegister stores modules in its magic table for the `stackiq` register and `module` schema. + +## Target State + +The `module` schema is at version `0.3.5` with five extra optional properties, all `visible`. None is `required`, so every existing module stays valid unchanged. + +| Property | Type | Notes | +|---|---|---| +| `externalId` | string, maxLength 100 | TOPdesk Middel-ID, shown as "Source id" | +| `externalNumber` | string, maxLength 50 | TOPdesk "ICT Applicatienummer" | +| `externalKey` | string, maxLength 200, `table.default: false` | `topdesk::`, the import's match key | +| `externalCreatedAt` | string, format date | creation date in the source system | +| `externalModifiedAt` | string, format date | last change in the source system | + +Three seed modules (design.md, Seed Data) are added through the fragment's `components.objects`. + +## Migration Class + +No Nextcloud migration class. The schema change deploys through stackiq's existing register import in the repair step (`SettingsService` loads `softwarecatalogus_register.json`, deep-merges `register.d/*.json` in filename order, and imports the result through OpenRegister's `ConfigurationService`). OpenRegister adds the new columns to the magic table when the schema version increases. + +``` +Version: n/a (register version bump, no lib/Migration class) +File: lib/Settings/register.d/topdesk-cmdb-import.json +Key operations: +- components.schemas.module.version = "0.3.5" +- components.schemas.module.properties += externalId, externalNumber, externalKey, externalCreatedAt, externalModifiedAt +- components.objects += 3 seed modules (no publicationDate, no externalKey) +``` + +## Migration Steps + +1. Add `lib/Settings/register.d/topdesk-cmdb-import.json`. The filename must sort after `maintenance-and-roadmap.json`, so its `module.version` wins the scalar overwrite in the merge. +2. Run the repair step (app upgrade or `occ maintenance:repair`). OpenRegister sees `module` 0.3.5 > deployed 0.3.4, and updates the schema and its magic table. +3. Seed modules are created when absent (matched on slug), as with the other seeds. + +## Data Impact + +Existing modules get five new empty columns. There is no data loss and no transformation. Safe on live data: the change is additive, and the columns are nullable. + +## Rollback Procedure + +Remove the fragment and revert the PR. OpenRegister does not drop columns on a lower version, so the five columns stay, empty, and nothing reads them. To remove imported data, delete the modules with a non-empty `externalKey` and their usages. To remove the seed modules, delete the slugs `voorbeeld-zaaksysteem`, `voorbeeld-afsprakenplanner` and `voorbeeld-belastingapplicatie`. + +## Validation + +- Unit test `tests/Unit/Settings/TopdeskCmdbFragmentTest.php` merges the register exactly as `SettingsService` does, and asserts `module.version === "0.3.5"` with the five properties present and none required. +- On the rig after the repair step: `GET /index.php/apps/openregister/api/schemas/` shows version `0.3.5` and the five properties. +- `GET /index.php/apps/openregister/api/objects/stackiq/module?externalId=APP-00001` returns the seed module `voorbeeld-zaaksysteem`. diff --git a/openspec/changes/cmdb-export-import/proposal.md b/openspec/changes/cmdb-export-import/proposal.md new file mode 100644 index 00000000..3bb2cc08 --- /dev/null +++ b/openspec/changes/cmdb-export-import/proposal.md @@ -0,0 +1,105 @@ +--- +kind: code +depends_on: [] +--- + +# Proposal: cmdb-export-import + +## Summary + +A Nextcloud admin uploads a TOPdesk CMDB export (xlsx) in stackiq's admin settings, picks the municipality the export belongs to, and stackiq turns every application row into OpenRegister objects in the `stackiq` register: a `module` (the application), an `organization` for its manufacturer, a `usage` that links the application to the municipality, and `contactPerson` objects for the business and technical owner. Rows are matched on TOPdesk's `Middel-ID`, so a second import of a newer export updates the same records instead of duplicating them. The column-to-field mapping is declarative JSON executed by OpenRegister's migration-pack mapping engine. The admin follows the import live and gets a per-row report: created, updated, unchanged, skipped or failed, with the reason. + +## Motivation + +A municipality wants to search all its applications in one place in OpenCatalogi and see the ones it uses in Portaliq. Its CMDB is the source of that list, but a live API connection is not possible yet (calls must come from the municipality's own IP range), so the municipality delivers a periodic TOPdesk export instead (Jira WOO-586, epic WOO-281). + +The existing paths cannot read this file. A chain baseline on a local rig (2026-10-01) showed: + +- `POST /api/registers/{id}/import` in OpenRegister treats every xlsx sheet as a schema named after the sheet, and stops at the first sheet: `Schema not found (id='Invoer AIA data')`. +- OpenRegister migration packs apply to CSV and JSON imports only, and one pack maps one sheet to one schema. One TOPdesk row has to become a module, a manufacturer organisation, a usage and up to two contact persons, linked to each other. +- OpenCatalogi only lists a stackiq module that has a `publicationDate`. Portaliq only shows an application to a municipality through a `usage` whose `consumer` is that municipality. An import that writes modules alone leaves both apps empty. + +Stackiq already has two upload-and-import flows (SBOM, ArchiMate). This change adds a third one for CMDB exports, built the same way. + +## Capabilities + +### New Capabilities + +- `cmdb-export-import`: an admin uploads a TOPdesk CMDB export (xlsx) and stackiq creates or updates modules, manufacturer organisations, usages and owner contact persons for one municipality, matched on the TOPdesk Middel-ID, with live progress and a per-row report. + +### Modified Capabilities + +None. The `module` schema gains five optional properties through a register fragment (see design.md, Mixed-spec rationale). No existing requirement changes. + +## Affected Projects + +- [ ] Project: `stackiq`: import service, controller and routes, a "CMDB import" section in admin settings, declarative mapping and import-profile JSON under `lib/Settings/cmdb-import/`, a register fragment that adds external-id properties to `module`, tests, administrator docs and translations. + +## Scope + +### In Scope + +- Upload endpoint for `.xlsx` files only, with a size limit, admin-only and CSRF-protected. +- Reading the two raw TOPdesk sheets, "Invoer AIA data" (Soort = Application Inventory) and "Invoer APP data" (Soort = Applicatie). Columns are found by header name, not position. Values are read as stored; formulas are never evaluated. +- One municipality per import, chosen by the admin from existing stackiq organisations of type Municipality, or created from a name the admin types. +- Per row: upsert the `module` on Middel-ID, find or create the manufacturer `organization` from the "Fabrikant" column (one organisation per distinct manufacturer), upsert the `usage` (consumer = municipality, module = the application), and find or create `contactPerson` objects for "Eigenaar" (business owner) and "FB contactpersoon 1" (technical owner) through Nextcloud Contacts. +- Declarative mapping: one migration-pack JSON per target (module, manufacturer, municipality, usage, business owner, technical owner) plus one import profile (sheets, required columns, key column, date columns), executed through OpenRegister's `MappingEngine::mapRow()`. +- Excel serial dates converted to ISO dates before mapping. +- `publicationDate` set to the import time on newly created modules, never changed on update. +- Repeatable import: matched records are updated, records missing from a newer export are left alone (`missingRecords: keep`, the only accepted value for now). +- Per-row error isolation, live progress and cancel through the existing `ProgressTracker`, and a per-row report. +- PHPUnit tests using the anonymised test export as a fixture, a Playwright e2e for the admin flow, an administrator docs page, and Dutch and English strings. + +### Out of Scope + +- The sheet "Invoer gearchiveerde appl" and the derived "*CMDB" sheets (deferred until the municipality confirms what they hold). +- Connections between applications from the "Ouders" / "Kind-middelen" columns (a second pass after all modules exist, as a follow-up change). +- Suites from "Software suite", hosting parties from "ICT Hostingspartij", and "Leverancier" as a second supplier source. The mapping can take them later without code once a target is agreed. +- Marking or removing records that disappeared from a newer export (`missingRecords: mark|remove`, reserved values; belongs with operations-record-reconciliation, stackiq#1127). +- A live TOPdesk or ServiceNow connection (stackiq#373, stackiq#1134), a dry-run mode, an `occ` command, and running the import as a background job. +- Configuring OpenCatalogi catalogues or Portaliq account claims. The docs describe both prerequisites; the import does not write to those apps. + +## Approach + +A `CmdbImportController` accepts the upload and options, validates the file before parsing, and hands it to `CmdbExportImportService`. The service reads the two sheets with PhpSpreadsheet's Xlsx reader in read-data-only mode, resolves columns by header name from the import profile, normalises each row (trim, Excel serial to ISO date, numeric ids to strings), and maps it with OpenRegister's migration-pack `MappingEngine` once per target pack. It then resolves the related objects in a fixed order (manufacturer, module, contact persons, usage) and saves each through OpenRegister's `ObjectServiceInterface`. Each row runs in its own try/catch and its outcome goes into the report. Progress and cancel use the existing `ProgressTracker` and `/api/progress/{operationId}` route, as the ArchiMate import does. A new admin-settings section uploads the file, polls progress and shows the report. Details are in design.md. + +## New Dependencies + +None for stackiq's `composer.json` or `package.json`. The xlsx reader (`phpoffice/phpspreadsheet`) and the mapping engine come from OpenRegister, which stackiq already requires. Design.md describes the guard for when either class is not available. + +## Impact + +- **New backend**: `lib/Service/CmdbExportImportService.php` (plus small helpers for sheet reading and row normalisation), `lib/Controller/CmdbImportController.php`, two routes in `appinfo/routes.php`. +- **New configuration**: `lib/Settings/cmdb-import/topdesk-profile.json` and six pack files `lib/Settings/cmdb-import/topdesk-*.json`. +- **Schema**: `lib/Settings/register.d/topdesk-cmdb-import.json` adds `externalId`, `externalNumber`, `externalKey`, `externalCreatedAt` and `externalModifiedAt` to `module` (all optional), with a version bump so the register import deploys them. +- **New frontend**: `src/views/settings/sections/CmdbImport.vue`, registered in `src/views/settings/StackiqSettings.vue`. +- **Data**: imports write `module`, `organization`, `usage` and `contactPerson` objects, and Nextcloud Contacts cards for owners. No existing object is deleted. + +## Cross-Project Dependencies + +- **openregister** (consumed, not changed): `ObjectServiceInterface` (public contract), `MigrationPack\MappingEngine` and `PackDefinitionValidator` (not yet a public contract), and the PhpSpreadsheet library it ships. +- **opencatalogi** and **portaliq** (consumers, not changed): they show the imported data once their own configuration is in place, namely a catalogue that includes the stackiq register's `module` schema, and a portal account with claim `stackiq.organisationId` for the municipality. Portaliq's contribution is stackiq's existing `usage` contribution (hydra ADR-046); this change adds no new portal contribution. + +## Risks + +### Risk 1: Personal data from a third party's export +**Severity:** High — **Mitigation:** only the owner columns the import maps ("Eigenaar", "Eigenaar e-mail", "Eigenaar functie", "FB contactpersoon 1") are read into stackiq, and they go to Nextcloud Contacts as the existing contact model requires. Personnel numbers, phone numbers and group mailboxes are never read. The report and the logs name rows by sheet, row number and Middel-ID only. Tests use the anonymised export. The import never creates Nextcloud user accounts, and a test asserts that the contact-person objects it writes do not qualify for the user sync. + +### Risk 2: Hidden coupling to OpenRegister internals +**Severity:** Medium — **Mitigation:** `MappingEngine`, `PackDefinitionValidator` and PhpSpreadsheet are resolved through the container or a `class_exists` check. If any of them is missing, the import endpoint answers 503 with a clear message instead of failing halfway. Promoting `MappingEngine` to an OpenRegister contract is noted as an open question. + +### Risk 3: Long imports over HTTP +**Severity:** Medium — **Mitigation:** an export with about 1,100 rows needs several saves per row. The service reports progress per row, honours cancel between rows, and skips the save when nothing changed. A background-job variant is a follow-up if real exports time out. + +### Risk 4: TOPdesk values that do not match stackiq vocabularies +**Severity:** Low — **Mitigation:** "Status", "ICT TIME Classificatie" and "ICT BBN Classificatie" go through `lookup` maps. An unknown value drops only that field, and the row gets a warning that names the column and the value. Admins can extend the maps in the JSON. + +## Rollback Strategy + +The change is additive. Revert the PR to remove the routes, the settings section, the service and the mapping files. The register fragment only adds optional properties; after a revert they stay in the deployed schema without harm, and imported objects stay as ordinary stackiq objects. To remove imported data, filter modules on a non-empty `externalKey` and delete them together with their usages. + +## Open Questions + +- Does the municipality confirm that the "Invoer" sheets are the unedited export, what the difference between AIA and APP is, and whether archived applications should be included? Until then the change reads both "Invoer" sheets and skips the archive sheet. +- Which TOPdesk "Status" values occur, and which usage status should each one get? The lookup map in design.md is provisional. +- Should OpenRegister expose `MappingEngine` as a public contract (as it does for `ObjectServiceInterface`)? diff --git a/openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md new file mode 100644 index 00000000..2583f4ee --- /dev/null +++ b/openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md @@ -0,0 +1,367 @@ +# cmdb-export-import Specification + +**Status**: in-progress +**Scope**: stackiq +**OpenSpec changes**: +- [cmdb-export-import](../../changes/cmdb-export-import/) + +## Purpose + +A Nextcloud admin imports a TOPdesk CMDB export (xlsx) into stackiq for one municipality. Every application row becomes, or updates, a `module` (schema:SoftwareApplication) with its manufacturer `organization` (schema:Organization), a `usage` that links the application to the municipality, and `contactPerson` objects (schema:Person) for its owners. All data is stored as OpenRegister objects (ADR-001). The column-to-field mapping is declarative JSON executed by OpenRegister's mapping engine (ADR-011, ADR-031), so the import can be repeated with a newer export without creating duplicates. OpenCatalogi lists the imported applications, and Portaliq shows them to the municipality. + +Nextcloud OCP interfaces used: `OCP\IRequest` (multipart upload), `OCP\IUserSession` and `OCP\IGroupManager` (admin check), `OCP\Contacts\IManager` (owner identity, through `StackiqContactSyncService`), `OCP\ICacheFactory` (progress, through `ProgressTracker`), `OCP\IL10N` (messages). OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` for every read and write. + +## ADDED Requirements + +### Requirement: REQ-CMDB-001 The import endpoint SHALL accept only a bounded xlsx upload from a Nextcloud admin + +`POST /api/cmdb-import` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token. The endpoint SHALL NOT carry `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL reject the upload before any parsing when the file is larger than the configured maximum (default 10 MB), when its name does not end in `.xlsx`, or when its content is not a ZIP package containing `xl/workbook.xml`. Macro-enabled (`.xlsm`), legacy (`.xls`) and CSV files SHALL be rejected. No object SHALL be written in any of these cases. + +#### Scenario: A file that is not xlsx is rejected +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** a Nextcloud admin on the CMDB import section +- **WHEN** they upload `applications.csv`, or a file named `export.xlsx` whose content is plain text +- **THEN** the endpoint SHALL answer 400 with error `NOT_XLSX` +- **AND** no `module`, `organization`, `usage` or `contactPerson` object SHALL be created or changed + +#### Scenario: An oversized file is rejected before it is read +@e2e exclude Building a file over 10 MB in the browser adds nothing over the unit test; tests/Unit/Controller/CmdbImportControllerTest.php asserts 413 FILE_TOO_LARGE and that the reader is never called. + +- **GIVEN** an xlsx upload of 10 MB plus one byte +- **WHEN** a Nextcloud admin posts it to `POST /api/cmdb-import` +- **THEN** the endpoint SHALL answer 413 with error `FILE_TOO_LARGE` +- **AND** the workbook reader SHALL NOT be invoked + +#### Scenario: A user who is not a Nextcloud admin cannot import +@e2e exclude Authorisation rule; tests/Unit/Controller/CmdbImportControllerTest.php asserts the method has no NoAdminRequired attribute, and the Newman collection asserts 403 for a non-admin user. + +- **GIVEN** a signed-in user who is not a Nextcloud admin, including a member of `software-catalog-admins` +- **WHEN** they post an export to `POST /api/cmdb-import` +- **THEN** Nextcloud SHALL answer 403 +- **AND** no object SHALL be written + +#### Scenario: A request without a CSRF token is refused +@e2e exclude CSRF is enforced by Nextcloud's middleware; the Newman collection posts without a requesttoken and asserts 412. + +- **GIVEN** a Nextcloud admin session +- **WHEN** a request to `POST /api/cmdb-import` arrives without a valid `requesttoken` header or parameter +- **THEN** Nextcloud SHALL refuse it with 412 +- **AND** no object SHALL be written + +### Requirement: REQ-CMDB-002 The workbook SHALL be read as stored data, without evaluating formulas or following links + +The reader SHALL open the workbook with PhpSpreadsheet's Xlsx reader in read-data-only mode, SHALL load only the sheets named in the import profile, and SHALL read each cell's stored value. For a formula cell it SHALL use the value cached in the file and SHALL NOT evaluate the formula. It SHALL NOT contact external data connections, linked workbooks or URLs found in the file. It SHALL stop with 422 `TOO_MANY_ROWS` when a source sheet holds more data rows than the profile's limit (default 10,000). + +#### Scenario: A formula cell yields its cached value and is not evaluated +@e2e exclude Reader behaviour; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php reads a fixture whose source sheet has a formula cell and asserts the cached value is returned and the calculation engine is never invoked. + +- **GIVEN** a source sheet where column "Naam" in row 2 holds a formula with a cached value `Rekenmodel` +- **WHEN** the workbook is read +- **THEN** the row SHALL carry `Naam = Rekenmodel` +- **AND** the formula SHALL NOT be evaluated + +#### Scenario: An external data connection in the workbook is never contacted +@e2e exclude Network isolation; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php reads a fixture that declares an external connection, with a reader that has no HTTP client, and asserts the read succeeds. + +- **GIVEN** an export that contains `xl/connections.xml` with an external data connection +- **WHEN** the workbook is read +- **THEN** no network request SHALL be made +- **AND** the source sheets SHALL be read normally + +### Requirement: REQ-CMDB-003 Columns SHALL be resolved by header name, and a missing required column SHALL stop the import with 422 + +The reader SHALL take the first row of each source sheet as headers and SHALL match them to the profile's column names case-insensitively, after trimming whitespace and dropping a trailing `:` or `⚡`. Column order SHALL NOT matter. When a present source sheet lacks a column the profile marks as required (`Middel-ID`, `Naam`), the endpoint SHALL answer 422 with error `MISSING_COLUMN`, naming the column and the sheet, before any object is written. When neither source sheet ("Invoer AIA data", "Invoer APP data") exists, the endpoint SHALL answer 422 with error `NO_SOURCE_SHEET`, naming both expected sheets. A missing optional column SHALL produce one import-level warning and no row error. + +#### Scenario: A missing required column is named in the 422 response +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** an export whose sheet "Invoer APP data" has no column "Middel-ID" +- **WHEN** a Nextcloud admin uploads it +- **THEN** the endpoint SHALL answer 422 with error `MISSING_COLUMN`, column `Middel-ID` and sheet `Invoer APP data` +- **AND** the section SHALL show that column and sheet name to the admin +- **AND** no object SHALL be written + +#### Scenario: Columns in a different order map the same +@e2e exclude Reader behaviour; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php reads a fixture with shuffled columns and asserts identical rows. + +- **GIVEN** an export where "Naam" comes before "Middel-ID" and the header reads `Groepseigenaar mail⚡` +- **WHEN** the workbook is read +- **THEN** every row SHALL carry the same values under the profile's column names as in the original order + +#### Scenario: A workbook without either source sheet is refused +@e2e exclude Same error path as the missing column; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php asserts NO_SOURCE_SHEET naming both sheets. + +- **GIVEN** an xlsx that contains only a sheet "Blad1" +- **WHEN** a Nextcloud admin uploads it +- **THEN** the endpoint SHALL answer 422 with error `NO_SOURCE_SHEET` naming "Invoer AIA data" and "Invoer APP data" + +### Requirement: REQ-CMDB-004 Every import SHALL have exactly one consuming municipality, chosen by the admin + +The request SHALL carry either `municipalityUuid`, the uuid of an existing stackiq `organization` of type `Municipality`, or `municipalityName`, a name for a new one. With a name, the service SHALL reuse an existing organisation of type `Municipality` with the same normalised name, or create one through the municipality pack (type `Municipality`, status `Active`). It SHALL answer 422 `MUNICIPALITY_REQUIRED` when neither is given, and 422 `MUNICIPALITY_INVALID` when the uuid does not resolve to an organisation of type `Municipality`. Every `usage` and `contactPerson` the import writes SHALL reference that organisation. + +#### Scenario: The admin picks an existing municipality +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** an organisation "Gemeente Voorbeeldstad" of type `Municipality` +- **WHEN** a Nextcloud admin selects it and imports the anonymised export +- **THEN** both imported usages SHALL have `consumer` = the uuid of "Gemeente Voorbeeldstad" +- **AND** no new organisation of type `Municipality` SHALL be created + +#### Scenario: A new municipality is created once from a typed name +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php imports twice with municipalityName "Gemeente Voorbeeldstad" and asserts one organisation of type Municipality. + +- **GIVEN** no organisation named "Gemeente Voorbeeldstad" +- **WHEN** a Nextcloud admin imports with `municipalityName` "Gemeente Voorbeeldstad", and later imports again with the same name +- **THEN** exactly one organisation "Gemeente Voorbeeldstad" of type `Municipality` and status `Active` SHALL exist + +#### Scenario: An import without a municipality is refused +@e2e exclude Validation; tests/Unit/Controller/CmdbImportControllerTest.php asserts 422 MUNICIPALITY_REQUIRED, and 422 MUNICIPALITY_INVALID for the uuid of a Supplier organisation. + +- **GIVEN** a valid export +- **WHEN** a Nextcloud admin posts it with neither `municipalityUuid` nor `municipalityName` +- **THEN** the endpoint SHALL answer 422 with error `MUNICIPALITY_REQUIRED` +- **AND** no object SHALL be written + +### Requirement: REQ-CMDB-005 Field mapping SHALL be declarative and executed by OpenRegister's mapping engine + +The service SHALL map each normalised row with OpenRegister's `MigrationPack\MappingEngine::mapRow()`, once per target pack: module, manufacturer, municipality, usage, business owner, technical owner. The packs and the import profile SHALL ship as JSON under `lib/Settings/cmdb-import/`. Each pack SHALL pass OpenRegister's `PackDefinitionValidator` when the import starts; an invalid pack, or a missing `MappingEngine`, SHALL stop the import with 503 `MAPPING_UNAVAILABLE` before any row is read. Before mapping, the service SHALL convert the cells of the profile's date columns from Excel serial numbers to `Y-m-d`, and SHALL turn numeric id cells into strings without a decimal part. A mapping error on a mapping marked `required` in the module pack SHALL skip the row. In the manufacturer and owner packs it SHALL mean the row has no manufacturer or no such owner, without a warning. A mapping error on any other mapping SHALL drop only that field and add a row warning naming the column and the value. The reader SHALL keep only the columns that the profile or a pack references, and SHALL discard every other cell when it reads the row. + +#### Scenario: Excel serial dates are converted before mapping +@e2e exclude Pure transformation; tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php asserts the conversions below. + +- **GIVEN** the AIA row of the anonymised export with "Aanmaakdatum" = `45111.380322627316` and "Wijzigingsdatum" = `46232.552113113423`, and the APP row with "End of Life Business" = `53359` +- **WHEN** the rows are normalised +- **THEN** "Aanmaakdatum" SHALL be `2023-07-04`, "Wijzigingsdatum" SHALL be `2026-07-29`, and "End of Life Business" SHALL be `2046-02-01` +- **AND** "ICT Applicatienummer" `1234` SHALL be the string `"1234"` + +#### Scenario: Changing a pack changes the mapping without code +@e2e exclude Configuration behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php loads an alternate module pack that maps "Roepnaam" to shortDescription and asserts the mapped module. + +- **GIVEN** the module pack is edited to add a mapping from "Roepnaam" to `shortDescription` +- **WHEN** an export is imported whose row has "Roepnaam" = `Mailen` +- **THEN** the created module SHALL have `shortDescription` = `Mailen` +- **AND** no PHP code SHALL have changed + +#### Scenario: An unknown status value drops only that field +@e2e exclude Mapping behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the row outcome and warning. + +- **GIVEN** a row whose "Status" is `Onbekende status`, which the usage pack's lookup does not contain +- **WHEN** the row is imported +- **THEN** the module and the usage SHALL be saved without a status from the export +- **AND** the row's report entry SHALL carry a warning naming column "Status" and value `Onbekende status` + +### Requirement: REQ-CMDB-006 A module SHALL be matched on its TOPdesk Middel-ID, so a re-import updates instead of duplicating + +For each row the service SHALL compute `externalKey` = `topdesk::` and look up a `module` with that `externalKey`. When none exists it SHALL create one. When one exists it SHALL update only the fields the module pack maps and SHALL leave every other field as it is. When the mapped fields equal the stored values it SHALL NOT save the module and SHALL report the row as `unchanged`. With `updateExisting=false` a matched row SHALL be reported as `skipped` with reason `exists`, without changes. A row without a Middel-ID SHALL be skipped with reason `missing Middel-ID`. When a Middel-ID occurs more than once in one upload, across both sheets, the first occurrence SHALL be imported and every later one SHALL be skipped with reason `duplicate Middel-ID in file`. + +#### Scenario: Re-importing the same export creates no duplicates +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** the anonymised export was imported once for "Gemeente Voorbeeldstad", which created modules `APP-test123` and `AIA-AangetekendMailen` +- **WHEN** the same export is imported again for the same municipality +- **THEN** the report SHALL show 0 created and 2 unchanged rows +- **AND** the number of modules, organisations, usages and contact persons in the register SHALL be the same as after the first import + +#### Scenario: A changed field is updated on re-import +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php imports the fixture, changes "Naam" of APP-test123 to `naamtest124` in the row data, imports again, and asserts one module with the new name. + +- **GIVEN** module `APP-test123` was imported with name `naamtest123`, and an admin has since set its `website` +- **WHEN** a newer export where "Naam" for `APP-test123` is `naamtest124` is imported +- **THEN** the same module SHALL now have name `naamtest124` +- **AND** its `website` SHALL be unchanged +- **AND** the report SHALL show the row as `updated` + +#### Scenario: A Middel-ID that occurs twice in one file is imported once +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php feeds two rows with the same Middel-ID. + +- **GIVEN** an upload where `APP-test123` appears in row 2 and row 7 of "Invoer APP data" +- **WHEN** it is imported +- **THEN** row 2 SHALL be imported +- **AND** row 7 SHALL be reported as `skipped` with reason `duplicate Middel-ID in file` + +### Requirement: REQ-CMDB-007 A newly created module SHALL get a publicationDate, and an existing one SHALL keep its own + +When the service creates a `module` it SHALL set `publicationDate` to the time the import started, as an ISO 8601 date-time, so OpenCatalogi lists the module. When it updates an existing `module` it SHALL NOT change `publicationDate` or `depublicationDate`, also when they are empty. + +#### Scenario: OpenCatalogi can list an imported application +@e2e exclude Crosses into OpenCatalogi, whose catalogue configuration is outside this change; tests/Unit/Service/CmdbExportImportServiceTest.php asserts publicationDate on created modules, and the manual test plan checks the search in OpenCatalogi. + +- **GIVEN** an OpenCatalogi catalogue that includes the stackiq register's `module` schema +- **WHEN** the anonymised export is imported +- **THEN** each created module SHALL have a `publicationDate` that is not later than the moment the import finished +- **AND** a search in OpenCatalogi for `Aangetekend Mailen` SHALL find the module + +#### Scenario: Re-import preserves publicationDate +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php asserts both cases. + +- **GIVEN** module `AIA-AangetekendMailen` was imported with `publicationDate` 2026-10-01T09:00:00+00:00, and module `APP-test123` was later depublished by an admin +- **WHEN** a newer export is imported that changes both modules' names +- **THEN** `AIA-AangetekendMailen` SHALL keep `publicationDate` 2026-10-01T09:00:00+00:00 +- **AND** `APP-test123` SHALL keep its `depublicationDate` and SHALL NOT get a new `publicationDate` + +### Requirement: REQ-CMDB-008 A manufacturer SHALL become one supplier organisation, however many rows name it + +The service SHALL map "Fabrikant" through the manufacturer pack to an `organization` of type `Supplier`. It SHALL match names after trimming, collapsing whitespace and ignoring case, first against the organisations it has already resolved during this import, then against existing organisations of type `Supplier`, and SHALL create one only when neither matches. The imported module's `provider` and the usage's `provider` SHALL reference that organisation. A row with an empty "Fabrikant" SHALL be imported without a provider. + +#### Scenario: Rows with the same manufacturer share one organisation +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php feeds three rows with "Fabfrikant", "Fabfrikant " and "FABFRIKANT". + +- **GIVEN** three rows whose "Fabrikant" is `Fabfrikant`, `Fabfrikant ` and `FABFRIKANT` +- **WHEN** they are imported +- **THEN** exactly one organisation `Fabfrikant` of type `Supplier` SHALL exist +- **AND** all three modules SHALL have `provider` = its uuid + +#### Scenario: An existing supplier is reused +@e2e exclude Covered by the service test. + +- **GIVEN** an existing organisation `Aangetekend B.V.` of type `Supplier` +- **WHEN** the AIA row with "Fabrikant" `Aangetekend B.V.` is imported +- **THEN** no new organisation SHALL be created +- **AND** module `AIA-AangetekendMailen` SHALL have `provider` = the existing organisation's uuid + +### Requirement: REQ-CMDB-009 Each imported application SHALL have one usage that links it to the municipality + +For each imported module the service SHALL keep exactly one `usage` with `consumer` = the municipality and `module` = the module, found by those two references and created when missing. The usage pack SHALL map "Status" to `status` and "ICT TIME Classificatie" to `timeClassification` through lookups, "End of Life Business" to `startDateOutPhased`, and "Eigenaar afdeling" and "Eigenaar cluster" to `interneAnnotation`. `interneAnnotation` SHALL be written only when the usage is created or the field is empty, so a note an admin wrote is never overwritten. + +#### Scenario: Portaliq can show the application to the municipality +@e2e exclude Crosses into Portaliq, whose account claim is outside this change; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the usage references, and the manual test plan checks Portaliq's "Software we use". + +- **GIVEN** a Portaliq account with claim `stackiq.organisationId` = the uuid of "Gemeente Voorbeeldstad" +- **WHEN** the anonymised export is imported for "Gemeente Voorbeeldstad" +- **THEN** a usage SHALL exist for each imported module with `consumer` = that uuid and `module` = the module's uuid +- **AND** that account SHALL see `Aangetekend Mailen` and `naamtest123` under "Software we use" + +#### Scenario: A re-import does not add a second usage +@e2e exclude Covered by the re-import scenario of REQ-CMDB-006 and the service test. + +- **GIVEN** module `APP-test123` already has a usage for "Gemeente Voorbeeldstad" +- **WHEN** a newer export is imported for the same municipality +- **THEN** module `APP-test123` SHALL still have exactly one usage for "Gemeente Voorbeeldstad" + +### Requirement: REQ-CMDB-010 Owners SHALL become contact persons of the municipality through Nextcloud Contacts, never user accounts + +The business owner pack SHALL map "Eigenaar", "Eigenaar e-mail" and "Eigenaar functie", and the technical owner pack SHALL map "FB contactpersoon 1". For each owner the service SHALL resolve a Nextcloud contact through `StackiqContactSyncService`: by e-mail when one is given, otherwise by an exact match on the display name, and otherwise by creating one. It SHALL then reuse or create one `contactPerson` with that `contactsUid`, `organization` = the municipality and `role` = "Eigenaar functie" when given, and SHALL set `usage.businessOwner` or `usage.technicalOwner` to it. The import SHALL NOT create Nextcloud user accounts, and SHALL NOT read the personnel number, phone, group mailbox or group owner columns. When Nextcloud Contacts is unavailable, the row SHALL be imported without owners and SHALL carry a warning. + +#### Scenario: An owner with an e-mail address becomes the business owner +@e2e exclude Needs a Contacts address book; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the calls to a StackiqContactSyncService test double and the saved contactPerson. + +- **GIVEN** the AIA row with "Eigenaar" `Achternaam, Voornaam`, "Eigenaar e-mail" `letter.achternaam@gemeente.nl` and "Eigenaar functie" `Afdelingshoofd` +- **WHEN** it is imported for "Gemeente Voorbeeldstad" +- **THEN** one `contactPerson` SHALL exist with the resolved `contactsUid`, `organization` = "Gemeente Voorbeeldstad" and `role` = `Afdelingshoofd` +- **AND** the usage of `AIA-AangetekendMailen` SHALL have `businessOwner` = that contact person +- **AND** no Nextcloud user account SHALL be created + +#### Scenario: The same owner on two rows is one contact person +@e2e exclude Covered by the service test. + +- **GIVEN** two rows with the same "Eigenaar e-mail" +- **WHEN** they are imported +- **THEN** exactly one `contactPerson` for that contact SHALL exist for the municipality, referenced by both usages + +#### Scenario: Contacts disabled does not block the import +@e2e exclude Environment condition; tests/Unit/Service/CmdbExportImportServiceTest.php sets isAvailable() to false. + +- **GIVEN** the Nextcloud Contacts app is disabled +- **WHEN** the anonymised export is imported +- **THEN** both modules and usages SHALL be saved without owners +- **AND** the AIA row's report entry SHALL carry the warning that owners were skipped because Contacts is unavailable + +### Requirement: REQ-CMDB-011 Each row SHALL be processed in isolation and reported with its outcome + +The service SHALL process every non-empty row in its own error boundary. An exception in one row SHALL mark that row `failed` with the reason and SHALL NOT stop the import or change the outcome of other rows. Rows whose cells are all empty SHALL be ignored and not counted. The response SHALL contain a summary (rows read, created, updated, unchanged, skipped, failed, warnings) and one entry per counted row with sheet, row number, Middel-ID, application name, outcome, reasons, warnings and the uuids of the module and usage. Report entries and log lines SHALL NOT contain owner names, e-mail addresses or other person data. The section SHALL render report values as text, never as HTML. + +#### Scenario: Upload with a per-row report +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** a Nextcloud admin, "Gemeente Voorbeeldstad" selected, and the anonymised export +- **WHEN** they start the import and it finishes +- **THEN** the section SHALL show 2 rows read and 2 created +- **AND** the report SHALL list `Invoer AIA data` row 2 `AIA-AangetekendMailen` and `Invoer APP data` row 2 `APP-test123`, each with outcome `created` and a link to its module +- **AND** the hundreds of formatted but empty rows in both sheets SHALL NOT appear in the report + +#### Scenario: One bad row does not stop the others +@e2e exclude Fault injection; tests/Unit/Service/CmdbExportImportServiceTest.php makes saveObject() throw for one row of three. + +- **GIVEN** an export with three rows, where saving the module of the second row fails in OpenRegister +- **WHEN** it is imported +- **THEN** rows 1 and 3 SHALL be `created` +- **AND** row 2 SHALL be `failed` with a reason naming the step that failed +- **AND** the response SHALL be 200 with that summary + +#### Scenario: A row of another kind is skipped with its reason +@e2e exclude Covered by the service test. + +- **GIVEN** a row on "Invoer APP data" whose "Soort" is `Hardware` +- **WHEN** it is imported +- **THEN** it SHALL be `skipped` with reason `unsupported Soort "Hardware"` + +### Requirement: REQ-CMDB-012 Records missing from a newer export SHALL be left untouched + +The import SHALL accept `missingRecords` with the value `keep`, which is also the default. It SHALL NOT change, depublish or delete a module, usage, organisation or contact person because its Middel-ID is absent from the upload. Any other value, including the reserved `mark` and `remove`, SHALL be refused with 422 `MISSING_RECORDS_UNSUPPORTED`. + +#### Scenario: An application dropped from the export stays +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php imports two rows, then one, and asserts the other module and usage are unchanged. + +- **GIVEN** modules `APP-test123` and `AIA-AangetekendMailen` were imported for "Gemeente Voorbeeldstad" +- **WHEN** a newer export that only contains `APP-test123` is imported +- **THEN** module `AIA-AangetekendMailen` and its usage SHALL be unchanged + +#### Scenario: A reserved value is refused +@e2e exclude Validation; tests/Unit/Controller/CmdbImportControllerTest.php. + +- **GIVEN** a valid export +- **WHEN** a Nextcloud admin posts it with `missingRecords=remove` +- **THEN** the endpoint SHALL answer 422 with error `MISSING_RECORDS_UNSUPPORTED` +- **AND** no object SHALL be written + +### Requirement: REQ-CMDB-013 A running import SHALL report its progress and SHALL stop when cancelled + +The import SHALL run as a `ProgressTracker` operation of type `cmdb_import` under the `operationId` the client sends, and SHALL update the processed row count after every row, readable through the existing `GET /api/progress/{operationId}`. `POST /api/cmdb-import/{operationId}/cancel`, admin-only and CSRF-protected, SHALL request cancellation. The service SHALL check for cancellation between rows, SHALL keep the rows already processed, and SHALL return the report with `cancelled: true`. The final report SHALL also be stored with the operation, so it can be read again within the tracker's lifetime. + +#### Scenario: The admin follows and cancels a running import +@e2e exclude Timing-dependent with a two-row fixture; tests/Unit/Service/CmdbExportImportServiceTest.php requests cancellation after row 1 of three and asserts one processed row and cancelled true. + +- **GIVEN** an import of three rows that is running +- **WHEN** the admin presses Cancel after the first row is done +- **THEN** the service SHALL stop before the second row +- **AND** the report SHALL show 1 processed row and `cancelled: true` +- **AND** the module created for the first row SHALL stay + +### Requirement: REQ-CMDB-014 The admin settings SHALL offer a CMDB import section + +Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. Every control SHALL have a visible label, and every string SHALL be translatable. + +#### Scenario: The admin runs an import from the settings page +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** a Nextcloud admin on stackiq's admin settings page +- **WHEN** they choose "Gemeente Voorbeeldstad", choose the anonymised export and press "Import" +- **THEN** a progress bar SHALL appear while the import runs +- **AND** afterwards the summary and the report table SHALL be shown +- **AND** filtering the table on `created` SHALL show the two imported rows + +## Non-Functional Requirements + +- **Performance:** an export of 1,100 rows SHALL import on the local rig without exceeding PHP's default memory limit, by loading only the source sheets in read-data-only mode. A re-import of an unchanged export SHALL make no `saveObject()` call for unchanged modules and usages. Lookups of organisations, modules and contact persons SHALL be cached per import run, so each distinct manufacturer, Middel-ID and contact is looked up at most once. +- **Security:** an uploaded third-party file is input: xlsx only, bounded size and row count, no formula evaluation, no external links, header-name resolution, per-row isolation, admin-only routes with CSRF (REQ-CMDB-001 to 003, 011). No cell value is ever rendered as HTML. +- **Privacy:** only the owner columns named in REQ-CMDB-010 are read into stackiq. The report and the logs contain no person data. Test fixtures are anonymised and carry no document metadata naming real people. +- **Accessibility:** Target WCAG 2.2 AA. The section uses Nextcloud and `@conduction/nextcloud-vue` components: labelled file input and municipality select (SC 1.3.1, 3.3.2; gates `form-label-association`, `nc-input-labels`), a labelled Cancel button (SC 4.1.2; gate `button-name`), a progress bar and summary announced through a polite live region (SC 4.1.3; `axe`), and a report table with header cells (SC 1.3.1; gate `table-headers`). New in 2.2: 2.4.11 Focus Not Obscured applies (the report must not hide focus behind sticky headers); 2.5.7 Dragging Movements does not apply (the file input works without drag and drop); 2.5.8 Target Size applies to the buttons (Nextcloud defaults); 3.2.6 Consistent Help does not apply (no help mechanism added); 3.3.7 Redundant Entry applies (the chosen municipality stays selected after an import); 3.3.8 Accessible Authentication does not apply (no authentication step). +- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the section, the error messages and the report reasons. + +## Acceptance Criteria + +- [ ] A Nextcloud admin imports the anonymised TOPdesk export for a chosen municipality, and the report lists both data rows as created. +- [ ] Importing the same export again creates no object, and reports both rows as unchanged. +- [ ] A changed "Naam" in a newer export updates the same module; `publicationDate` and fields the export does not map stay as they were. +- [ ] Rows with the same "Fabrikant" share one supplier organisation. +- [ ] Every imported module has one usage whose consumer is the municipality. +- [ ] A missing "Middel-ID" or "Naam" column stops the import with 422 naming the column and sheet; a non-xlsx or oversized file is rejected before reading. +- [ ] One failing row is reported as failed while the other rows are imported. +- [ ] Imported modules are found by OpenCatalogi's search, and appear in Portaliq's "Software we use" for the municipality's account, once both apps are configured as the docs describe. + +## Notes + +- Mapping decisions per column, including the columns that are not mapped because the target schema has no field, are listed in design.md. +- Connections from "Ouders" / "Kind-middelen", suites, hosting parties, the archive sheet and `missingRecords: mark|remove` are follow-ups (proposal, Out of Scope). +- Related: stackiq#373 (live TOPdesk connector), stackiq#1127 (record reconciliation), stackiq#1134 (ITSM exchange, the opposite direction), sbom-import and archimate-import (the upload patterns this follows). diff --git a/openspec/changes/cmdb-export-import/tasks.md b/openspec/changes/cmdb-export-import/tasks.md new file mode 100644 index 00000000..2ebf44ff --- /dev/null +++ b/openspec/changes/cmdb-export-import/tasks.md @@ -0,0 +1,127 @@ +# Tasks: cmdb-export-import + +Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`SPEC` below). Contract: `contract.md` (authoritative for routes, request fields, report shape and error codes). + +## Implementation Tasks + +### Task 1: Sanitised test fixtures +- **spec_ref**: `SPEC#requirement-req-cmdb-002-the-workbook-shall-be-read-as-stored-data-without-evaluating-formulas-or-following-links` (cmdb-export-import#REQ-CMDB-002, also used by every other task) +- **files**: `tests/fixtures/cmdb/topdesk-export-anonymised.xlsx`, `tests/fixtures/cmdb/topdesk-missing-middel-id.xlsx`, `tests/fixtures/cmdb/topdesk-shuffled-columns.xlsx`, `tests/fixtures/cmdb/topdesk-formula-and-connection.xlsx`, `tests/fixtures/cmdb/README.md`, `tests/fixtures/cmdb/build-fixtures.py` +- **acceptance_criteria**: + - GIVEN the anonymised test export from the WOO-586 plan folder WHEN it is copied to `topdesk-export-anonymised.xlsx` THEN `docProps/core.xml` has no creator or lastModifiedBy, and `docProps/custom.xml`, `customXml/` and `xl/connections.xml` are removed, with their entries in `[Content_Types].xml` and the rels files + - GIVEN the sanitised fixture WHEN every shared string and cell value is scanned THEN no real person name, municipality domain, personnel number or phone number remains, only the placeholder values (`Achternaam, Voornaam`, `letter.achternaam@gemeente.nl`, `123456`) + - GIVEN `build-fixtures.py` WHEN it runs (Python stdlib zipfile only) THEN it derives the three variant fixtures from the sanitised one: no "Middel-ID" header on "Invoer APP data"; shuffled columns with header `Groepseigenaar mail⚡`; a formula cell in "Naam" with cached value `Rekenmodel` plus a synthetic `xl/connections.xml` + - The original export of the municipality is never used or committed +- [ ] Implement +- [ ] Test (the scan is a PHPUnit test `tests/Unit/Fixtures/CmdbFixtureHygieneTest.php` that fails on metadata or non-placeholder person data) + +### Task 2: Register fragment with external-id properties and seed modules +- **spec_ref**: `SPEC#requirement-req-cmdb-006-a-module-shall-be-matched-on-its-topdesk-middel-id-so-a-re-import-updates-instead-of-duplicating` (cmdb-export-import#REQ-CMDB-006) +- **files**: `lib/Settings/register.d/topdesk-cmdb-import.json`, `tests/Unit/Settings/TopdeskCmdbFragmentTest.php` +- **acceptance_criteria**: + - GIVEN all `register.d` fragments WHEN they are merged in filename order the way `SettingsService` does THEN `module.version` is `0.3.5` and `externalId`, `externalNumber`, `externalKey`, `externalCreatedAt`, `externalModifiedAt` exist, none required, with titles (hydra gate schema-property-titles) + - GIVEN the fragment WHEN the register is imported on the rig THEN existing modules load and save unchanged, and the seed modules `voorbeeld-zaaksysteem`, `voorbeeld-afsprakenplanner` and `voorbeeld-belastingapplicatie` exist without `publicationDate` or `externalKey` (design.md, Seed Data) +- [ ] Implement +- [ ] Test + +### Task 3: Import profile, mapping packs and their loader +- **spec_ref**: `SPEC#requirement-req-cmdb-005-field-mapping-shall-be-declarative-and-executed-by-openregisters-mapping-engine` (cmdb-export-import#REQ-CMDB-005) +- **files**: `lib/Settings/cmdb-import/topdesk-profile.json`, `lib/Settings/cmdb-import/topdesk-module.json`, `lib/Settings/cmdb-import/topdesk-manufacturer.json`, `lib/Settings/cmdb-import/topdesk-municipality.json`, `lib/Settings/cmdb-import/topdesk-usage.json`, `lib/Settings/cmdb-import/topdesk-business-owner.json`, `lib/Settings/cmdb-import/topdesk-technical-owner.json`, `lib/Service/Cmdb/CmdbImportProfile.php`, `lib/Exception/CmdbImportException.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php` +- **acceptance_criteria**: + - GIVEN the six packs WHEN each is passed to OpenRegister's `PackDefinitionValidator` THEN all are valid with `sourceFormat: excel` and `idStrategy: generate`, and they implement the column table in design.md + - GIVEN a pack with an unknown transform, or no `MappingEngine` in the container WHEN the profile loads THEN it throws `CmdbImportException` with code `MAPPING_UNAVAILABLE` and status 503 + - GIVEN the profile WHEN its referenced columns are listed THEN Personeelsnummer, phone, group-owner and group-mailbox columns are not among them +- [ ] Implement +- [ ] Test + +### Task 4: Workbook reader and row normaliser +- **spec_ref**: `SPEC#requirement-req-cmdb-002-…` and `SPEC#requirement-req-cmdb-003-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422` (cmdb-export-import#REQ-CMDB-002, #REQ-CMDB-003, #REQ-CMDB-005) +- **files**: `lib/Service/Cmdb/CmdbWorkbookReader.php`, `lib/Service/Cmdb/CmdbRowNormaliser.php`, `tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php`, `tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php` +- **acceptance_criteria**: + - GIVEN the sanitised fixture WHEN it is read THEN exactly one row per source sheet is returned (empty formatted rows dropped), keyed by profile column names, with only allowlisted columns + - GIVEN the formula/connection fixture WHEN it is read THEN "Naam" is `Rekenmodel`, `getCalculatedValue()` is never called, and no HTTP client is involved + - GIVEN the shuffled fixture WHEN it is read THEN rows equal those of the original; GIVEN the missing-column fixture THEN `MISSING_COLUMN` names `Middel-ID` and `Invoer APP data`; GIVEN only "Blad1" THEN `NO_SOURCE_SHEET`; GIVEN more than `maxRowsPerSheet` rows THEN `TOO_MANY_ROWS` + - GIVEN a text file named `.xlsx`, or a `.xlsm` WHEN checked THEN `NOT_XLSX` before PhpSpreadsheet is touched; GIVEN PhpSpreadsheet absent THEN `READER_UNAVAILABLE` + - GIVEN serials `45111.380322627316`, `46232.552113113423`, `53359` and id `1234.0` WHEN normalised THEN `2023-07-04`, `2026-07-29`, `2046-02-01` and `"1234"` +- [ ] Implement +- [ ] Test + +### Task 5: Import service: municipality, manufacturer, module upsert, usage +- **spec_ref**: `SPEC#requirement-req-cmdb-004-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin`, `SPEC#requirement-req-cmdb-006-…`, `SPEC#requirement-req-cmdb-007-a-newly-created-module-shall-get-a-publicationdate-and-an-existing-one-shall-keep-its-own`, `SPEC#requirement-req-cmdb-008-a-manufacturer-shall-become-one-supplier-organisation-however-many-rows-name-it`, `SPEC#requirement-req-cmdb-009-each-imported-application-shall-have-one-usage-that-links-it-to-the-municipality`, `SPEC#requirement-req-cmdb-012-records-missing-from-a-newer-export-shall-be-left-untouched` +- **files**: `lib/Service/CmdbExportImportService.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php` +- **acceptance_criteria**: + - GIVEN the sanitised fixture and "Gemeente Voorbeeldstad" WHEN imported THEN two modules with `externalKey` `topdesk::`, `publicationDate` = import start, `provider` set, and two usages with `consumer` = the municipality and `module` = the module + - GIVEN the same import twice WHEN run THEN 0 created / 2 unchanged and no `saveObject()` call for unchanged objects; GIVEN a changed "Naam" THEN one module updated, `website`, `publicationDate` and `depublicationDate` untouched + - GIVEN `municipalityName` twice THEN one Municipality; GIVEN the uuid of a Supplier THEN `MUNICIPALITY_INVALID` + - GIVEN "Fabfrikant", "Fabfrikant " and "FABFRIKANT" THEN one Supplier; GIVEN an existing Supplier with the same name THEN it is reused + - GIVEN `updateExisting=false` THEN matched rows are `skipped` (`exists`); GIVEN a second export without one Middel-ID THEN that module and usage are unchanged; GIVEN an unknown "Status" THEN `status` is dropped with a warning naming column and value + - GIVEN the module pack mapping "Roepnaam" to shortDescription (test-only pack) THEN the module carries it, with no code change + - Every new method carries `@spec openspec/changes/cmdb-export-import/tasks.md#task-5` (hydra gate spec-coverage) +- [ ] Implement +- [ ] Test + +### Task 6: Owners as contact persons through Nextcloud Contacts +- **spec_ref**: `SPEC#requirement-req-cmdb-010-owners-shall-become-contact-persons-of-the-municipality-through-nextcloud-contacts-never-user-accounts` (cmdb-export-import#REQ-CMDB-010) +- **files**: `lib/Service/CmdbExportImportService.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php` +- **acceptance_criteria**: + - GIVEN the AIA row WHEN imported THEN `StackiqContactSyncService` resolves the contact by e-mail, one `contactPerson` exists with that `contactsUid`, `organization` = municipality and `role` `Afdelingshoofd`, and it is the usage's `businessOwner` + - GIVEN "FB contactpersoon 1" without e-mail WHEN imported twice THEN one contact (exact display-name match) and one `contactPerson` + - GIVEN Contacts disabled WHEN imported THEN modules and usages are saved, owners skipped with a warning + - GIVEN an imported `contactPerson` WHEN `OrganizationSyncService::performUserSync`'s selection is applied THEN it is not selected, and no Nextcloud user is created (if it would be, add an exclusion marker before shipping) + - GIVEN any import WHEN the report and log lines are inspected THEN no owner name or e-mail appears +- [ ] Implement +- [ ] Test + +### Task 7: Row isolation, report, progress and cancel +- **spec_ref**: `SPEC#requirement-req-cmdb-011-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome`, `SPEC#requirement-req-cmdb-013-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled` +- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/Cmdb/CmdbImportReport.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php` +- **acceptance_criteria**: + - GIVEN three rows where saving the second module throws WHEN imported THEN rows 1 and 3 are `created`, row 2 is `failed` naming the step, and the summary matches contract.md + - GIVEN duplicate Middel-ID rows, a missing Middel-ID and a Soort `Hardware` THEN they are `skipped` with the reasons in the spec + - GIVEN an `operationId` WHEN the import runs THEN a `cmdb_import` operation reports per-row progress, and after completion its statistics hold the report + - GIVEN cancel requested after row 1 of three THEN one processed row, `cancelled: true`, row 1's objects kept +- [ ] Implement +- [ ] Test + +### Task 8: Controller, routes and API tests +- **spec_ref**: `SPEC#requirement-req-cmdb-001-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin` (cmdb-export-import#REQ-CMDB-001, #REQ-CMDB-012, #REQ-CMDB-013) +- **files**: `lib/Controller/CmdbImportController.php`, `appinfo/routes.php`, `tests/Unit/Controller/CmdbImportControllerTest.php`, `postman/stackiq-tests.json`, `openapi.json` +- **acceptance_criteria**: + - GIVEN `cmdbImport#import` and `cmdbImport#cancel` WHEN their attributes are inspected THEN neither has `NoAdminRequired` or `NoCSRFRequired` (hydra gates route-auth, csrf-cochange, no-admin-idor) + - GIVEN the validation order in design.md D10 THEN each error code from contract.md is returned with its status, and every service exception is translated (hydra gate controller-exception-translation) + - GIVEN Newman WHEN run against the rig THEN 403 for a non-admin and for a `software-catalog-admins` member, 412 without requesttoken, 413 for an oversized file, 422 `MISSING_RECORDS_UNSUPPORTED`, and 200 with the report for the fixture +- [ ] Implement +- [ ] Test + +### Task 9: CMDB import section in admin settings, l10n and Playwright e2e +- **spec_ref**: `SPEC#requirement-req-cmdb-014-the-admin-settings-shall-offer-a-cmdb-import-section` (cmdb-export-import#REQ-CMDB-014, #REQ-CMDB-003, #REQ-CMDB-011) +- **files**: `src/views/settings/sections/CmdbImport.vue`, `src/views/settings/StackiqSettings.vue`, `l10n/en.json`, `l10n/en.js`, `l10n/nl.json`, `l10n/nl.js`, `tests/e2e/spec-coverage/cmdb-import.spec.ts` +- **acceptance_criteria**: + - GIVEN a Nextcloud admin on stackiq's admin settings WHEN they choose "Gemeente Voorbeeldstad" and the sanitised fixture and press Import THEN a progress bar shows, then the summary (2 read, 2 created) and a `CnDataTable` report filterable by outcome with links to the modules + - GIVEN a second import of the same file THEN the report shows 2 unchanged; GIVEN the missing-column fixture THEN the section shows column `Middel-ID` and sheet `Invoer APP data`; GIVEN a CSV THEN it shows the `NOT_XLSX` message + - GIVEN the section WHEN the hydra gates run THEN admin-router, form-label-association, nc-input-labels, button-name, table-headers and modal-isolation pass, and no `v-html` renders report values + - GIVEN a Dutch and an English locale THEN every new string, error message and report reason is translated + - The e2e file references every `@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts` scenario in the spec (hydra gate e2e-coverage) +- [ ] Implement +- [ ] Test + +### Task 10: Administrator documentation with screenshots +- **spec_ref**: `SPEC#purpose` +- **files**: `docs/features/cmdb-import.md`, `docs/images/cmdb-import-*.png`, `docs/features/README.md` +- **acceptance_criteria**: + - GIVEN the docs page WHEN an administrator reads it THEN it covers the steps, the expected file structure (sheets, required and mapped columns, the column table), the error codes and what to do, repeat-import behaviour (match on Middel-ID per municipality, unchanged rows, records missing from the export stay, publicationDate rule), where owner contacts end up, and how to adjust the mapping JSON + - GIVEN the prerequisites section THEN it explains the OpenCatalogi catalogue (registers `stackiq`, schema `module`) and the Portaliq account claim `stackiq.organisationId`, needed to see the data there + - GIVEN Playwright MCP on the rig WHEN screenshots are taken of the empty section, a running import and a finished report (sanitised fixture only) THEN they are committed under `docs/images/` +- [ ] Implement +- [ ] Test (screenshots reviewed: no data other than the sanitised fixture visible) + +## Quality checklist + +- PHPUnit for all new business logic (`tests/Unit/`), at least 75% coverage of new code (ADR-009), using the sanitised xlsx fixtures (not mocked rows) for reader and service tests +- Newman/Postman for both new endpoints (Task 8); Playwright for the settings flow (Task 9) +- `composer test`, `newman run` and the Playwright spec pass on the local rig +- Test against OpenRegister on the rig: the saved objects pass schema validation (module 0.3.5, organization, usage, contactPerson) +- Hydra gates run locally (`scripts/run-hydra-gates.sh`); read the COVERAGE line and name any SKIPPED gate +- Dutch (`nl_NL`) and English (`en_US`) strings for every new user-facing string (ADR-005) +- Docs in `docs/features/cmdb-import.md` with screenshots (ADR-010) +- `openspec validate cmdb-export-import` passes diff --git a/openspec/changes/cmdb-export-import/test-plan.md b/openspec/changes/cmdb-export-import/test-plan.md new file mode 100644 index 00000000..5e01ba85 --- /dev/null +++ b/openspec/changes/cmdb-export-import/test-plan.md @@ -0,0 +1,147 @@ +# Test Plan: cmdb-export-import + +Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (abbreviated `spec.md` below). Fixture: `tests/fixtures/cmdb/topdesk-export-anonymised.xlsx` (one fake data row per source sheet, metadata removed). Municipality in every case: "Gemeente Voorbeeldstad". Environment: local rig (Deploy-target n.v.t.). + +## Test Cases + +### TC-1: Admin imports the export and sees a per-row report +- **spec_ref**: `spec.md#requirement-req-cmdb-011-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome`, `#requirement-req-cmdb-014-the-admin-settings-shall-offer-a-cmdb-import-section`, `#requirement-req-cmdb-004-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin` +- **type**: functional +- **persona**: Noor Yilmaz (Municipal CISO / Functional Admin) +- **preconditions**: Nextcloud admin; "Gemeente Voorbeeldstad" exists as type Municipality; no imported modules +- **steps**: open stackiq admin settings, section "CMDB import", choose the municipality, choose the fixture, press Import +- **expected result**: progress bar during the run; summary 2 read / 2 created; report rows `Invoer AIA data` row 2 `AIA-AangetekendMailen` and `Invoer APP data` row 2 `APP-test123`, each `created` and linking to its module; no empty rows listed +- **test command**: Playwright `tests/e2e/spec-coverage/cmdb-import.spec.ts`, `/test-functional`, `/test-persona-noor` + +### TC-2: Re-import creates no duplicates +- **spec_ref**: `spec.md#requirement-req-cmdb-006-a-module-shall-be-matched-on-its-topdesk-middel-id-so-a-re-import-updates-instead-of-duplicating`, `#requirement-req-cmdb-009-each-imported-application-shall-have-one-usage-that-links-it-to-the-municipality` +- **type**: functional +- **persona**: Noor Yilmaz +- **preconditions**: TC-1 done; object counts of module, organization, usage, contactPerson recorded +- **steps**: import the same fixture again for the same municipality +- **expected result**: 0 created, 2 unchanged; all four counts equal to before +- **test command**: Playwright `cmdb-import.spec.ts`; PHPUnit `CmdbExportImportServiceTest` + +### TC-3: Changed fields update, publicationDate and unmapped fields are kept +- **spec_ref**: `spec.md#requirement-req-cmdb-006-…`, `#requirement-req-cmdb-007-a-newly-created-module-shall-get-a-publicationdate-and-an-existing-one-shall-keep-its-own` +- **type**: api +- **preconditions**: modules imported; an admin set `website` on `APP-test123` and depublished it +- **steps**: import rows where "Naam" of `APP-test123` is `naamtest124` +- **expected result**: same uuid, name `naamtest124`, `website` unchanged, `depublicationDate` unchanged, no new `publicationDate`; `AIA-AangetekendMailen` keeps its original `publicationDate` +- **test command**: PHPUnit `tests/Unit/Service/CmdbExportImportServiceTest.php` + +### TC-4: Manufacturer dedup +- **spec_ref**: `spec.md#requirement-req-cmdb-008-a-manufacturer-shall-become-one-supplier-organisation-however-many-rows-name-it` +- **type**: api +- **preconditions**: an existing Supplier `Aangetekend B.V.` +- **steps**: import rows with "Fabrikant" `Fabfrikant`, `Fabfrikant `, `FABFRIKANT`, and the AIA row +- **expected result**: one new Supplier `Fabfrikant`; `Aangetekend B.V.` reused; module and usage `provider` set accordingly +- **test command**: PHPUnit `CmdbExportImportServiceTest` + +### TC-5: Upload validation (type, size, columns, sheets, options) +- **spec_ref**: `spec.md#requirement-req-cmdb-001-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin`, `#requirement-req-cmdb-003-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422`, `#requirement-req-cmdb-012-records-missing-from-a-newer-export-shall-be-left-untouched` +- **type**: api +- **preconditions**: admin session +- **steps**: post `applications.csv`; a text file named `.xlsx`; a 10 MB + 1 byte file; `topdesk-missing-middel-id.xlsx`; a workbook with only "Blad1"; the fixture with `missingRecords=remove`; the fixture without a municipality +- **expected result**: 400 `NOT_XLSX` (twice), 413 `FILE_TOO_LARGE`, 422 `MISSING_COLUMN` naming `Middel-ID` and `Invoer APP data`, 422 `NO_SOURCE_SHEET`, 422 `MISSING_RECORDS_UNSUPPORTED`, 422 `MUNICIPALITY_REQUIRED`; no object written in any case +- **test command**: PHPUnit `CmdbImportControllerTest`, Newman (Postman collection), `/test-api`; the missing-column UI message also in Playwright + +### TC-6: Authorisation and CSRF +- **spec_ref**: `spec.md#requirement-req-cmdb-001-…`, `#requirement-req-cmdb-013-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled` +- **type**: security +- **preconditions**: a non-admin user, also one in `software-catalog-admins` +- **steps**: post the fixture and the cancel route as that user; post as admin without `requesttoken` +- **expected result**: 403 for non-admins; 412 without CSRF token; no object written +- **test command**: Newman, `/test-security` + +### TC-7: Safe reading (formulas, external connection, column order) +- **spec_ref**: `spec.md#requirement-req-cmdb-002-the-workbook-shall-be-read-as-stored-data-without-evaluating-formulas-or-following-links`, `#requirement-req-cmdb-003-…` +- **type**: security +- **preconditions**: fixtures `topdesk-formula-and-connection.xlsx`, `topdesk-shuffled-columns.xlsx` +- **steps**: read both through `CmdbWorkbookReader` +- **expected result**: formula cell yields the cached `Rekenmodel`, calculation engine never invoked; no network access; shuffled columns give identical rows; disallowed columns (Personeelsnummer, phones, group mailbox) are absent from the reader output +- **test command**: PHPUnit `tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php` + +### TC-8: Normalisation and declarative mapping +- **spec_ref**: `spec.md#requirement-req-cmdb-005-field-mapping-shall-be-declarative-and-executed-by-openregisters-mapping-engine` +- **type**: api +- **preconditions**: fixture rows; an alternate module pack mapping "Roepnaam" to `shortDescription` +- **steps**: normalise and map the rows through the real `MappingEngine` +- **expected result**: `2023-07-04`, `2026-07-29`, `2046-02-01`, `"1234"`; alternate pack yields `shortDescription`; an unknown "Status" drops only `status` with a warning; an invalid pack or a missing engine gives 503 `MAPPING_UNAVAILABLE` +- **test command**: PHPUnit `CmdbRowNormaliserTest`, `CmdbImportProfileTest`, `CmdbExportImportServiceTest` + +### TC-9: Per-row isolation and cancel +- **spec_ref**: `spec.md#requirement-req-cmdb-011-…`, `#requirement-req-cmdb-013-…` +- **type**: regression +- **preconditions**: three rows; `saveObject()` throws for the second module; separately, cancel requested after row 1 +- **steps**: run the import twice +- **expected result**: run 1: rows 1 and 3 created, row 2 failed naming the step, HTTP 200; run 2: 1 processed row, `cancelled: true`, row 1's module kept +- **test command**: PHPUnit `CmdbExportImportServiceTest` + +### TC-10: Owners as contact persons, no user accounts +- **spec_ref**: `spec.md#requirement-req-cmdb-010-owners-shall-become-contact-persons-of-the-municipality-through-nextcloud-contacts-never-user-accounts` +- **type**: security +- **preconditions**: Contacts enabled (test double); separately disabled +- **steps**: import the AIA row twice and a second row with the same owner e-mail; run `performUserSync` selection on the result +- **expected result**: one contactPerson with `role` `Afdelingshoofd` and `organization` = municipality, set as `businessOwner` on both usages; no Nextcloud user created and the contactPerson not selected by the user sync; with Contacts disabled: no owners, a warning, modules and usages saved; report and log contain no owner name or e-mail +- **test command**: PHPUnit `CmdbExportImportServiceTest`, `/test-security` + +### TC-11: OpenCatalogi finds an imported application +- **spec_ref**: `spec.md#requirement-req-cmdb-007-…` +- **type**: functional +- **persona**: Sem de Jong (Young Digital Native; anonymous search) +- **preconditions**: OpenCatalogi catalogue with registers `[stackiq]`, schemas `[module]`, listed and published (docs, prerequisites); TC-1 done +- **steps**: anonymous `GET /apps/opencatalogi/api/search?_search=Aangetekend` +- **expected result**: one hit `Aangetekend Mailen` +- **test command**: manual on the rig (USER MANUAL TEST, WOO-586 Stap 6b), `/test-functional` + +### TC-12: Portaliq shows the applications to the municipality +- **spec_ref**: `spec.md#requirement-req-cmdb-009-…` +- **type**: persona +- **persona**: Noor Yilmaz (Municipal CISO / Functional Admin) +- **preconditions**: Portaliq account with claim `stackiq.organisationId` = uuid of "Gemeente Voorbeeldstad", audience participant-org; TC-1 done +- **steps**: sign in to the portal, open "Software we use" +- **expected result**: `Aangetekend Mailen` and `naamtest123` listed; an account for another organisation sees neither +- **test command**: manual on the rig, `/test-persona-noor` + +### TC-13: Accessibility of the section +- **spec_ref**: `spec.md#requirement-req-cmdb-014-…` +- **type**: accessibility +- **preconditions**: section rendered with a finished report +- **steps**: keyboard-only run of TC-1; axe scan; screen-reader check of progress and summary +- **expected result**: every control labelled and reachable; progress and summary announced through a polite live region; report table has header cells; no serious/critical axe violations +- **test command**: `/test-accessibility`, hydra gates `form-label-association`, `nc-input-labels`, `button-name`, `table-headers`, `axe` + +### TC-14: Register fragment deploys the module properties +- **spec_ref**: `spec.md#requirement-req-cmdb-006-…` (stored key), design.md Mixed-spec rationale +- **type**: regression +- **preconditions**: all `register.d` fragments present +- **steps**: merge the register as `SettingsService` does; run the repair step on the rig +- **expected result**: merged `module.version` is `0.3.5` with the five optional properties; existing modules still load and save; seed module `voorbeeld-zaaksysteem` present without `publicationDate` +- **test command**: PHPUnit `tests/Unit/Settings/TopdeskCmdbFragmentTest.php`, `/test-regression` + +## Coverage Summary + +| Requirement | Covered by | +|---|---| +| REQ-CMDB-001 upload bounds, admin, CSRF | TC-5, TC-6 | +| REQ-CMDB-002 safe reading | TC-7 | +| REQ-CMDB-003 header-name columns, 422 | TC-5, TC-7 | +| REQ-CMDB-004 one municipality | TC-1, TC-5, PHPUnit (created once) | +| REQ-CMDB-005 declarative mapping, dates | TC-8 | +| REQ-CMDB-006 upsert on Middel-ID | TC-2, TC-3, TC-14 | +| REQ-CMDB-007 publicationDate rule | TC-3, TC-11 | +| REQ-CMDB-008 manufacturer dedup | TC-4 | +| REQ-CMDB-009 usage per municipality | TC-2, TC-12 | +| REQ-CMDB-010 owners via Contacts | TC-10 | +| REQ-CMDB-011 per-row isolation and report | TC-1, TC-9 | +| REQ-CMDB-012 missing records kept | TC-5, PHPUnit (dropped row stays) | +| REQ-CMDB-013 progress and cancel | TC-6, TC-9 | +| REQ-CMDB-014 settings section | TC-1, TC-13 | + +All requirements are covered. After implementation, TC-1, TC-2 and TC-11/12 are candidates for `/test-scenario-create` (key user flow and cross-app chain). + +## Out of Scope + +- Performance on the real 1,100-row export: the real file never enters a repo or a test run. It is measured once, manually, on the rig, and only the timing is recorded. +- The archive sheet, connections, suites and hosting parties are not built, so they are not tested. diff --git a/openspec/specs/cmdb-export-import/spec.md b/openspec/specs/cmdb-export-import/spec.md new file mode 100644 index 00000000..5d5078dc --- /dev/null +++ b/openspec/specs/cmdb-export-import/spec.md @@ -0,0 +1,51 @@ +--- +capability: cmdb-export-import +status: in-progress +built_by: openspec/changes/cmdb-export-import +--- + +# cmdb-export-import Specification + +**Status**: in-progress +**Scope**: stackiq +**OpenSpec changes**: +- [cmdb-export-import](../../changes/cmdb-export-import/) _(active)_ — admin uploads a TOPdesk CMDB export (xlsx); stackiq upserts modules, manufacturer organisations, usages and owner contact persons for one municipality, matched on Middel-ID, mapped by OpenRegister migration packs (kind: code) + +## Purpose + +A Nextcloud admin imports a TOPdesk CMDB export (xlsx) into stackiq for one +municipality. Every application row becomes, or updates, a `module` with its +manufacturer `organization`, a `usage` that links it to the municipality, and +`contactPerson` objects for its owners, all stored as OpenRegister objects +(ADR-001). The mapping is declarative JSON executed by OpenRegister's mapping +engine (ADR-031), so a newer export can be imported again without duplicates, +and OpenCatalogi and Portaliq can show the result (Jira WOO-586). + +## Requirements + +Detailed requirements (REQ-CMDB-001 … REQ-CMDB-014) are defined in the active +change's delta spec — +[`openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md`](../../changes/cmdb-export-import/specs/cmdb-export-import/spec.md) +— and are merged here by `openspec sync` when the change is archived. The +umbrella requirement below anchors the capability until then. + +### Requirement: Stackiq imports a TOPdesk CMDB export into OpenRegister objects (REQ-CMDB-000) + +Stackiq MUST offer Nextcloud admins one import path for a TOPdesk CMDB export +(xlsx) that writes only OpenRegister objects in the `stackiq` register +(`module`, `organization`, `usage`, `contactPerson`), with no app-local table, +and that matches rows on the TOPdesk Middel-ID so that a repeated import +creates no duplicates. + +#### Scenario: A repeated import adds no objects + +- GIVEN a TOPdesk export imported once for a municipality +- WHEN the same export is imported again for that municipality +- THEN the number of `module`, `organization`, `usage` and `contactPerson` objects SHALL be unchanged +- @e2e exclude umbrella anchor; the behaviour is covered by REQ-CMDB-006 in the change's delta spec (tests/e2e/spec-coverage/cmdb-import.spec.ts and tests/Unit/Service/CmdbExportImportServiceTest.php) + +## Notes + +- Follows the upload patterns of `sbom-import` and `archimate-import`. +- Related: stackiq#373 (live TOPdesk connector), stackiq#1127 (record + reconciliation), stackiq#1134 (ITSM exchange). From 6ddc2943a7a69e859b53b8bccb34a3b58290d344 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 1 Oct 2026 14:27:42 +0200 Subject: [PATCH 2/4] feat(cmdb-import): import a TOPdesk CMDB export (xlsx) into the catalogue An admin uploads the TOPdesk application export in Stackiq's admin settings. Per row Stackiq creates or updates a module, the manufacturer and the chosen municipality as organisations, a usage linking the municipality to the module and owner contact persons, all through OpenRegister's object service. Column mapping is declarative (lib/Settings/cmdb-import/) and runs through OpenRegister's mapping engine. Modules are matched on the TOPdesk Middel-ID per municipality, so a re-import updates instead of duplicating; new modules get a publicationDate so OpenCatalogi lists them. Admin-only routes with CSRF, xlsx only, 10 MB and 10,000 rows per sheet, values read without evaluating formulas, one failing row never aborts the import. Includes sanitised fixtures, PHPUnit, Newman and Playwright tests and administrator docs. Jira: https://conduction.atlassian.net/browse/WOO-586 Co-Authored-By: Claude Opus 5.5 --- appinfo/routes.php | 6 + docs/features/README.md | 11 + docs/features/cmdb-import.md | 247 +++ l10n/en.js | 105 +- l10n/en.json | 105 +- l10n/nl.js | 105 +- l10n/nl.json | 105 +- lib/Controller/CmdbImportController.php | 288 ++++ lib/Exception/CmdbImportException.php | 118 ++ lib/Service/Cmdb/CmdbImportProfile.php | 537 +++++++ lib/Service/Cmdb/CmdbImportReport.php | 220 +++ lib/Service/Cmdb/CmdbRowNormaliser.php | 177 +++ lib/Service/Cmdb/CmdbWorkbookReader.php | 427 +++++ lib/Service/CmdbExportImportService.php | 1411 +++++++++++++++++ .../cmdb-import/topdesk-business-owner.json | 13 + .../cmdb-import/topdesk-manufacturer.json | 12 + lib/Settings/cmdb-import/topdesk-module.json | 28 + .../cmdb-import/topdesk-municipality.json | 12 + lib/Settings/cmdb-import/topdesk-profile.json | 35 + .../cmdb-import/topdesk-technical-owner.json | 11 + lib/Settings/cmdb-import/topdesk-usage.json | 39 + .../register.d/topdesk-cmdb-import.json | 109 ++ openapi.json | 387 ++++- .../changes/cmdb-export-import/contract.md | 13 +- openspec/changes/cmdb-export-import/tasks.md | 32 +- postman/stackiq-tests.json | 491 ++++++ src/utils/cmdbImport.js | 494 ++++++ src/views/settings/StackiqSettings.vue | 5 + src/views/settings/sections/CmdbImport.vue | 983 ++++++++++++ .../Controller/CmdbImportControllerTest.php | 341 ++++ .../Unit/Fixtures/CmdbFixtureHygieneTest.php | 303 ++++ .../Service/Cmdb/CmdbImportProfileTest.php | 270 ++++ .../Service/Cmdb/CmdbRowNormaliserTest.php | 117 ++ .../Service/Cmdb/CmdbWorkbookReaderTest.php | 308 ++++ .../Service/CmdbExportImportServiceTest.php | 1085 +++++++++++++ .../Unit/Settings/TopdeskCmdbFragmentTest.php | 132 ++ tests/Unit/Support/CmdbTestSupport.php | 159 ++ .../Support/OpenRegister/MappingEngine.php | 353 +++++ .../OpenRegister/PackDefinitionValidator.php | 383 +++++ tests/e2e/spec-coverage/cmdb-import.spec.ts | 480 ++++++ tests/fixtures/cmdb/README.md | 36 + tests/fixtures/cmdb/build-fixtures.py | 270 ++++ .../cmdb/topdesk-export-anonymised.xlsx | Bin 0 -> 160471 bytes .../cmdb/topdesk-formula-and-connection.xlsx | Bin 0 -> 160907 bytes .../cmdb/topdesk-missing-middel-id.xlsx | Bin 0 -> 160513 bytes .../cmdb/topdesk-no-source-sheet.xlsx | Bin 0 -> 1589 bytes .../cmdb/topdesk-shuffled-columns.xlsx | Bin 0 -> 157262 bytes 47 files changed, 10736 insertions(+), 27 deletions(-) create mode 100644 docs/features/cmdb-import.md create mode 100644 lib/Controller/CmdbImportController.php create mode 100644 lib/Exception/CmdbImportException.php create mode 100644 lib/Service/Cmdb/CmdbImportProfile.php create mode 100644 lib/Service/Cmdb/CmdbImportReport.php create mode 100644 lib/Service/Cmdb/CmdbRowNormaliser.php create mode 100644 lib/Service/Cmdb/CmdbWorkbookReader.php create mode 100644 lib/Service/CmdbExportImportService.php create mode 100644 lib/Settings/cmdb-import/topdesk-business-owner.json create mode 100644 lib/Settings/cmdb-import/topdesk-manufacturer.json create mode 100644 lib/Settings/cmdb-import/topdesk-module.json create mode 100644 lib/Settings/cmdb-import/topdesk-municipality.json create mode 100644 lib/Settings/cmdb-import/topdesk-profile.json create mode 100644 lib/Settings/cmdb-import/topdesk-technical-owner.json create mode 100644 lib/Settings/cmdb-import/topdesk-usage.json create mode 100644 lib/Settings/register.d/topdesk-cmdb-import.json create mode 100644 src/utils/cmdbImport.js create mode 100644 src/views/settings/sections/CmdbImport.vue create mode 100644 tests/Unit/Controller/CmdbImportControllerTest.php create mode 100644 tests/Unit/Fixtures/CmdbFixtureHygieneTest.php create mode 100644 tests/Unit/Service/Cmdb/CmdbImportProfileTest.php create mode 100644 tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php create mode 100644 tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php create mode 100644 tests/Unit/Service/CmdbExportImportServiceTest.php create mode 100644 tests/Unit/Settings/TopdeskCmdbFragmentTest.php create mode 100644 tests/Unit/Support/CmdbTestSupport.php create mode 100644 tests/Unit/Support/OpenRegister/MappingEngine.php create mode 100644 tests/Unit/Support/OpenRegister/PackDefinitionValidator.php create mode 100644 tests/e2e/spec-coverage/cmdb-import.spec.ts create mode 100644 tests/fixtures/cmdb/README.md create mode 100644 tests/fixtures/cmdb/build-fixtures.py create mode 100644 tests/fixtures/cmdb/topdesk-export-anonymised.xlsx create mode 100644 tests/fixtures/cmdb/topdesk-formula-and-connection.xlsx create mode 100644 tests/fixtures/cmdb/topdesk-missing-middel-id.xlsx create mode 100644 tests/fixtures/cmdb/topdesk-no-source-sheet.xlsx create mode 100644 tests/fixtures/cmdb/topdesk-shuffled-columns.xlsx diff --git a/appinfo/routes.php b/appinfo/routes.php index 57ec9fbc..95bd982e 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -104,6 +104,12 @@ ['name' => 'settings#killArchiMateImport', 'url' => '/api/archimate/import/kill', 'verb' => 'POST'], // deprecated ['name' => 'settings#clearArchiMateExportStatus', 'url' => '/api/archimate/status/export/clear', 'verb' => 'POST'], + // CMDB export import (TOPdesk xlsx) — admin-only, CSRF-protected. + // Progress is read through the existing /api/progress/{operationId}. + // @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + ['name' => 'cmdbImport#import', 'url' => '/api/cmdb-import', 'verb' => 'POST'], + ['name' => 'cmdbImport#cancel', 'url' => '/api/cmdb-import/{operationId}/cancel', 'verb' => 'POST'], + // User Groups management routes ['name' => 'settings#getGenericUserGroups', 'url' => '/api/settings/user-groups/generic', 'verb' => 'GET'], ['name' => 'settings#setGenericUserGroups', 'url' => '/api/settings/user-groups/generic', 'verb' => 'POST'], diff --git a/docs/features/README.md b/docs/features/README.md index 82747cae..b14e666e 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -17,6 +17,7 @@ All data is stored as OpenRegister objects (no own database tables). OpenRegiste | [Federated Synchronisation](#federated-synchronisation) | Sync catalogue data across organisations and sources | | [Automatic User Provisioning](#automatic-user-provisioning) | Create Nextcloud users from catalogue contacts | | [ArchiMate Import/Export](#archimate-importexport) | Exchange software landscape data in ArchiMate format | +| [CMDB Import](#cmdb-import) | Import a municipality's TOPdesk CMDB export (xlsx) as applications, suppliers, usages and owners | | [Open Data Publishing](#open-data-publishing) | Expose the catalogue as a public open-data API | | [GEMMA Compliance](#gemma-compliance) | Built around VNG GEMMA Softwarecatalogus reference | @@ -155,6 +156,16 @@ The ArchiMate integration maps GEMMA Softwarecatalogus objects to ArchiMate appl **Key services:** `lib/Service/ArchiMateService.php`, `lib/Service/ArchiMateImportService.php`, `lib/Service/ArchiMateExportService.php` +## CMDB Import + +Import a TOPdesk CMDB export (`.xlsx`) for one municipality from the **CMDB import** section of the admin settings. Every application row becomes or updates a module, its manufacturer as a Supplier organisation, a usage that links it to the municipality, and contact persons for its owners (identity in Nextcloud Contacts). A repeat import matches on Middel-ID per municipality, so it updates instead of duplicating, and leaves applications missing from the newer export as they are. The column mapping is declarative JSON executed by OpenRegister's mapping engine. + +See [CMDB import](cmdb-import.md) for the steps, the expected file structure, the error codes and how to adjust the mapping. + +**Key services:** `lib/Service/CmdbExportImportService.php`, `lib/Service/Cmdb/` +**Controller:** `lib/Controller/CmdbImportController.php` +**Endpoint:** `POST /apps/stackiq/api/cmdb-import` + ## Open Data Publishing The catalogue is published as an open-data API. All registered applications, modules, and connections are accessible via public endpoints: diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md new file mode 100644 index 00000000..39d48574 --- /dev/null +++ b/docs/features/cmdb-import.md @@ -0,0 +1,247 @@ + + +# CMDB import + +Imports a TOPdesk CMDB export (an Excel workbook, `.xlsx`) for one +municipality. Every application row of the export becomes, or updates: + +- a **module** (the application, `schema:SoftwareApplication`); +- its manufacturer as an **organisation** of type Supplier; +- a **usage** that links the application to the municipality; +- **contact persons** of the municipality for its owners, with their identity in + Nextcloud Contacts. + +All of it is stored as OpenRegister objects in the stackiq register. Import a +newer export later and the same applications are updated, not duplicated. + +Specification: [`openspec/changes/cmdb-export-import/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/cmdb-export-import). + +## Who can import + +Only Nextcloud administrators. Members of the `software-catalog-admins` group +who are not Nextcloud administrators cannot import. The section is part of +stackiq's admin settings, under **Administration settings → Stackiq → +CMDB import**. + +## Before you start + +The import itself only needs stackiq and OpenRegister. To see the imported +applications in the other apps, two things must be set up there. The import +does not change either of them. + +**OpenCatalogi (search).** OpenCatalogi lists an application only through a +catalogue that includes the register `stackiq` and the schema `module`. In +OpenCatalogi, open the catalogue that should show the municipality's +applications and add that register and schema. A newly imported module gets +a publication date (the moment the import started), so it is listed from then +on. + +**Portaliq ("Software we use").** Portaliq shows an application to a +municipality through a usage whose consumer is that municipality. The portal +account of the municipality needs the claim `stackiq.organisationId` set to +the uuid of the municipality organisation the import used. The uuid is in +the import result (the municipality line) and on the organisation's detail +page in stackiq. + +## Steps + + + +1. Open **Administration settings → Stackiq** and scroll to **CMDB import**. +2. **Municipality.** Pick an existing organisation of type Municipality from + the list, or type the name of a new one and press Enter. A typed name that + matches an existing municipality (ignoring case and extra spaces) uses that + municipality; otherwise a new organisation of type Municipality with + status Active is created during the import. +3. **File.** Choose the TOPdesk export (`.xlsx`, at most 10 MB). +4. **Update existing records.** On by default. Turn it off to import only + applications that are new for this municipality; rows that match an + existing application are then reported as *skipped* with reason `exists` + and nothing about them changes. +5. Press **Import**. A progress bar shows how many rows have been processed. + **Cancel import** stops the import before the next row; rows that were + already processed stay imported. + + + +When the import finishes, the section shows: + +- the **summary**: rows read, created, updated, unchanged, skipped, failed + and warnings; +- **warnings for the whole file**, for example an optional column that is + missing; +- the **rows** table: sheet, row number, Middel-ID, application, outcome, + and the reasons and warnings for that row. Filter it with **Show rows with + outcome**. The application name links to the module in stackiq. + + + +The municipality stays selected after an import, so a second import goes to +the same organisation. + +## The file + +The import reads two sheets and ignores all others: + +| Sheet | Rows it accepts (column "Soort") | +|---|---| +| `Invoer AIA data` | `Application Inventory` | +| `Invoer APP data` | `Applicatie` | + +At least one of the two must be present. Row 1 of each sheet holds the +column names. Columns are found by name, not by position: case, surrounding +spaces and a trailing `:` or `⚡` do not matter, and the order of the columns +does not matter. Empty rows, including formatted rows below the data, are +ignored and not counted. A sheet may hold at most 10,000 rows with data. + +Two columns are **required** on every source sheet that is present: +`Middel-ID` and `Naam`. Every other column is optional; when one is missing, +the import names it once in the warnings for the whole file. + +Formula cells are read as the value Excel stored with them; formulas are +never recalculated. External data connections, Power Query queries and links +in the workbook are never opened. Macro-enabled workbooks (`.xlsm`), old +Excel files (`.xls`) and CSV files are not accepted. + +### Columns and where they go + +| Column | Goes to | Rule | +|---|---|---| +| Naam | module name | required | +| Middel-ID | module external id, and the match key | required, see [Repeat imports](#repeat-imports) | +| ICT Applicatienummer | module external number | number stored as text | +| Functionele omschrijving | module long description | | +| ICT BBN Classificatie | module BBN level | `BBN1`/`BBN 1` etc. become `BBN1`, `BBN2`, `BBN3`; another value is dropped with a warning | +| Aanmaakdatum | module external creation date | Excel date | +| Wijzigingsdatum | module external modification date | Excel date | +| Fabrikant | Supplier organisation, set as provider on the module and the usage | one organisation per name, see below | +| Status | usage status | In productie → In production, In voorraad → Planned, In ontwikkeling → Acquisition, Uit te faseren → To be phased out, Uitgefaseerd → Phased out; another value is dropped with a warning | +| ICT TIME Classificatie | usage TIME classification | Tolerate/Tolereren, Invest/Investeren, Migrate/Migreren, Eliminate/Elimineren | +| End of Life Business | usage phase-out date | Excel date | +| Eigenaar afdeling, Eigenaar cluster | usage internal annotation | joined with ` / `, written only when the usage is new or the note is empty | +| Eigenaar, Eigenaar e-mail, Eigenaar functie | usage business owner (contact person) | see [Owners](#owners) | +| FB contactpersoon 1 | usage technical owner (contact person) | see [Owners](#owners) | +| Soort | not stored | decides whether the row is imported | + +Columns not in this table are not read at all. That includes Personeelsnummer, +the phone number columns, the group owner and group mailbox columns, +Configuratie coördinator, FB contactpersoon 2 and Opmerkingen. + +**Manufacturers.** Names are compared after trimming, collapsing spaces and +ignoring case, so `Fabfrikant`, `Fabfrikant ` and `FABFRIKANT` are one +Supplier. An existing organisation of type Supplier with the same name is +reused. A row without a manufacturer is imported without a provider. + +## Repeat imports + +An application is recognised by its **Middel-ID within the municipality**. +Two municipalities can each have an `APP-00001` without colliding. + +- **New Middel-ID**: a module and a usage are created. The module gets a + publication date (the moment the import started), so OpenCatalogi lists it. +- **Known Middel-ID, values changed**: only the fields in the column table + are updated. Everything else on the module stays as it is, for example a + website an administrator added. The publication date and the depublication + date are never changed: a module an administrator depublished stays + depublished. The row is reported as *updated*. +- **Known Middel-ID, nothing changed**: nothing is saved; the row is reported + as *unchanged*. Importing the same export twice creates nothing the second + time. +- **Middel-ID missing from a newer export**: the application, its usage and + its contact persons are left as they are. They are not changed, depublished + or deleted. +- Each application keeps exactly one usage for the municipality. + +Rows are **skipped** when the Middel-ID is empty (`missing Middel-ID`), when +a Middel-ID appears a second time in the same upload, also across the two +sheets (`duplicate Middel-ID in file`; the first occurrence is imported), when +"Soort" is not accepted for the sheet (`unsupported Soort "…"`), or, with +**Update existing records** off, when the application already exists +(`exists`). + +Every row is processed on its own. When one row fails, for example because +OpenRegister refuses to save it, that row is reported as *failed* with the +step that failed, and the other rows are imported. Importing again completes +the failed row. + +## Owners + +Owners become **contact persons of the municipality**, never Nextcloud user +accounts. + +- The business owner comes from "Eigenaar", "Eigenaar e-mail" and "Eigenaar + functie" (the function is stored as the contact person's role). +- The technical owner comes from "FB contactpersoon 1". + +The person's identity (name, e-mail address) is kept in **Nextcloud +Contacts**, in the first writable address book of the administrator who runs +the import, the same as every other stackiq contact. A contact is found by +e-mail address when the export has one, otherwise by an exact match on the +name, and created when neither finds one. The stackiq contact person object +only holds the link to that contact, the role and the municipality. The same +owner on several rows is one contact person. + +When the Contacts app is disabled, applications and usages are still +imported; the owners are skipped and each affected row carries a warning. + +The import report and the Nextcloud log never contain owner names or e-mail +addresses. + +## Errors and what to do + +When the file or the request cannot be imported at all, nothing is written +and the section shows the reason and the error code. + +| Error code | What it means | What to do | +|---|---|---| +| `NOT_XLSX` | The file is not an Excel workbook: wrong extension, or the content is not an `.xlsx` package. | Save the export as Excel workbook (`.xlsx`). | +| `FILE_TOO_LARGE` | The file is larger than 10 MB. | Remove sheets the import does not read, or split the export. | +| `NO_FILE_UPLOADED` | No file arrived. | Choose the file again. | +| `MUNICIPALITY_REQUIRED` | No municipality was chosen. | Pick or type a municipality. | +| `MUNICIPALITY_INVALID` | The chosen organisation does not exist or is not of type Municipality. | Pick an organisation of type Municipality, or type a new name. | +| `NO_SOURCE_SHEET` | Neither `Invoer AIA data` nor `Invoer APP data` is in the workbook. | Check the sheet names; they must match exactly. | +| `MISSING_COLUMN` | A present source sheet has no `Middel-ID` or `Naam` column. The message names the sheet and the column. | Add the column to that sheet. | +| `TOO_MANY_ROWS` | A source sheet has more than 10,000 rows with data. | Split the export and import the parts one after the other. | +| `MISSING_RECORDS_UNSUPPORTED` | The request asked to mark or remove records missing from the export. Only keeping them is supported. | Not reachable from the section; reported for API callers. | +| `MAPPING_UNAVAILABLE` | OpenRegister's mapping engine is missing, or one of the mapping files is invalid. | Update OpenRegister. If you changed a mapping file, check it against the Nextcloud log. | +| `READER_UNAVAILABLE` | The Excel reader that ships with OpenRegister cannot be loaded. | Make sure OpenRegister is installed and enabled. | +| `NOT_CONFIGURED` | The stackiq register or its schemas cannot be found. | Run **Auto Configure** at the top of the stackiq admin settings. | +| `IMPORT_FAILED` | Something unexpected went wrong. | The Nextcloud log has the details. | + +A message that you are not signed in, not an administrator, or that your +session expired comes from Nextcloud itself: sign in again, use an +administrator account, or reload the page. + +## Adjusting the mapping + +The mapping from columns to fields is not in code. It is a set of JSON files +in `lib/Settings/cmdb-import/`, executed by OpenRegister's mapping engine: + +| File | What it maps | +|---|---| +| `topdesk-profile.json` | the sheets and accepted "Soort" values, the match column, the required, date and id columns, the limits, and which pack is used for which target | +| `topdesk-module.json` | a row to the module | +| `topdesk-manufacturer.json` | "Fabrikant" to the Supplier organisation | +| `topdesk-municipality.json` | a typed municipality name to a new organisation | +| `topdesk-usage.json` | a row to the usage (status and TIME lookups, dates, annotation) | +| `topdesk-business-owner.json` | the business owner columns | +| `topdesk-technical-owner.json` | the technical owner column | + +Each pack has a list of `fieldMappings`, one per column: `source` (the column +name in the export), `target` (the field), optionally `required`, and a +`transform` such as `trim`, `date` or a `lookup` with a `map` of export values +to stored values. For example, to also store "Roepnaam" as the module's short +description, add to `topdesk-module.json`: + +```json +{ "source": "Roepnaam", "target": "shortDescription", "transform": { "type": "trim" } } +``` + +To accept a new "Status" value, add it to the `map` of the status lookup in +`topdesk-usage.json`. The packs are checked by OpenRegister when an import +starts; an invalid pack stops the import with `MAPPING_UNAVAILABLE` before +any row is read. A mapping file changed on the server is overwritten by the +next app update, so propose lasting changes to the app itself. diff --git a/l10n/en.js b/l10n/en.js index f81da82a..9109bcc2 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -901,7 +901,110 @@ OC.L10N.register( "Scored on": "Scored on", "The date the scores were set.": "The date the scores were set.", "Suggested TIME classification": "Suggested TIME classification", - "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision." + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.", + "{processed} of {total} rows processed": "{processed} of {total} rows processed", + "{size} KB": "{size} KB", + "{size} MB": "{size} MB", + "A new municipality \"{name}\" is created, unless one with this name already exists.": "A new municipality \"{name}\" is created, unless one with this name already exists.", + "A sheet has more rows than the import can process.": "A sheet has more rows than the import can process.", + "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.", + "All outcomes": "All outcomes", + "Check the connection and try again.": "Check the connection and try again.", + "Choose a municipality first.": "Choose a municipality first.", + "Choose or type a municipality": "Choose or type a municipality", + "Choose the TOPdesk export": "Choose the TOPdesk export", + "Choose the TOPdesk export and try again.": "Choose the TOPdesk export and try again.", + "CMDB import": "CMDB import", + "Created": "Created", + "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.": "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.", + "Error code: {code}": "Error code: {code}", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".", + "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.", + "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.", + "Failed": "Failed", + "Import": "Import", + "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses", + "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", + "Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.", + "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", + "Import progress": "Import progress", + "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.", + "Importing the export…": "Importing the export…", + "Importing…": "Importing…", + "Middel-ID": "Middel-ID", + "Municipality": "Municipality", + "No file was uploaded.": "No file was uploaded.", + "No rows with this outcome": "No rows with this outcome", + "Nothing more is known on this page; the Nextcloud log has the details.": "Nothing more is known on this page; the Nextcloud log has the details.", + "Only Nextcloud administrators can import a CMDB export.": "Only Nextcloud administrators can import a CMDB export.", + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.", + "Outcome": "Outcome", + "Pick an existing municipality or type the name of a new one.": "Pick an existing municipality or type the name of a new one.", + "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Pick an existing organisation of type Municipality, or type a new name and press Enter.", + "Pick an organisation of type Municipality, or type the name of a new one.": "Pick an organisation of type Municipality, or type the name of a new one.", + "Reasons and warnings": "Reasons and warnings", + "Records missing from the export can only be kept.": "Records missing from the export can only be kept.", + "Reload the page and try again.": "Reload the page and try again.", + "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.", + "Row": "Row", + "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.", + "Rows": "Rows", + "Rows read": "Rows read", + "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.", + "Sheet": "Sheet", + "Show rows with outcome": "Show rows with outcome", + "Sign in again and retry the import.": "Sign in again and retry the import.", + "Skipped": "Skipped", + "The chosen organisation is not a municipality.": "The chosen organisation is not a municipality.", + "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.", + "The Excel reader is not available.": "The Excel reader is not available.", + "The file": "The file", + "The file is larger than 10 MB.": "The file is larger than 10 MB.", + "The import failed unexpectedly.": "The import failed unexpectedly.", + "The import mapping cannot run.": "The import mapping cannot run.", + "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.", + "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.", + "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.", + "The server could not be reached.": "The server could not be reached.", + "The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".", + "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.": "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.", + "The workbook has none of the sheets the import reads.": "The workbook has none of the sheets the import reads.", + "This file is not an Excel workbook (.xlsx).": "This file is not an Excel workbook (.xlsx).", + "This import is no longer running.": "This import is no longer running.", + "Unchanged": "Unchanged", + "Update existing records": "Update existing records", + "Updated": "Updated", + "Warnings": "Warnings", + "Warnings for the whole file": "Warnings for the whole file", + "When off, applications imported before are left as they are and reported as skipped.": "When off, applications imported before are left as they are and reported as skipped.", + "You are not signed in.": "You are not signed in.", + "Your session has expired.": "Your session has expired.", + "Stackiq is not configured for the import.": "Stackiq is not configured for the import.", + "The sheet \"{sheet}\" has more rows than the import can process.": "The sheet \"{sheet}\" has more rows than the import can process.", + "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.", + "Choose a municipality or enter the name of a new one.": "Choose a municipality or enter the name of a new one.", + "No running CMDB import has this id.": "No running CMDB import has this id.", + "Only keeping records that are missing from the export is supported.": "Only keeping records that are missing from the export is supported.", + "Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.", + "Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".", + "Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.", + "The Excel reader is not available: OpenRegister is missing or incomplete.": "The Excel reader is not available: OpenRegister is missing or incomplete.", + "The file is larger than the maximum of %s MB.": "The file is larger than the maximum of %s MB.", + "The file is not an Excel workbook (.xlsx).": "The file is not an Excel workbook (.xlsx).", + "The import failed. The details are in the Nextcloud log.": "The import failed. The details are in the Nextcloud log.", + "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.", + "The workbook has neither of the sheets %s.": "The workbook has neither of the sheets %s.", + "Column \"%1$s\": %2$s": "Column \"%1$s\": %2$s", + "Optional column \"%s\" not found": "Optional column \"%s\" not found", + "Owner from column \"%s\" could not be resolved": "Owner from column \"%s\" could not be resolved", + "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Owner from column \"%s\" could not be resolved in Nextcloud Contacts", + "Owners skipped: Nextcloud Contacts is unavailable": "Owners skipped: Nextcloud Contacts is unavailable", + "duplicate %s in file": "duplicate %s in file", + "exists": "exists", + "missing %s": "missing %s", + "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s", + "step \"%s\" failed": "step \"%s\" failed", + "unsupported %1$s \"%2$s\"": "unsupported %1$s \"%2$s\"" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 21f9325c..3975c5ab 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -900,6 +900,109 @@ "Scored on": "Scored on", "The date the scores were set.": "The date the scores were set.", "Suggested TIME classification": "Suggested TIME classification", - "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision." + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.", + "{processed} of {total} rows processed": "{processed} of {total} rows processed", + "{size} KB": "{size} KB", + "{size} MB": "{size} MB", + "A new municipality \"{name}\" is created, unless one with this name already exists.": "A new municipality \"{name}\" is created, unless one with this name already exists.", + "A sheet has more rows than the import can process.": "A sheet has more rows than the import can process.", + "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.", + "All outcomes": "All outcomes", + "Check the connection and try again.": "Check the connection and try again.", + "Choose a municipality first.": "Choose a municipality first.", + "Choose or type a municipality": "Choose or type a municipality", + "Choose the TOPdesk export": "Choose the TOPdesk export", + "Choose the TOPdesk export and try again.": "Choose the TOPdesk export and try again.", + "CMDB import": "CMDB import", + "Created": "Created", + "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.": "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.", + "Error code: {code}": "Error code: {code}", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".", + "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.", + "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.", + "Failed": "Failed", + "Import": "Import", + "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses", + "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", + "Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.", + "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", + "Import progress": "Import progress", + "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.", + "Importing the export…": "Importing the export…", + "Importing…": "Importing…", + "Middel-ID": "Middel-ID", + "Municipality": "Municipality", + "No file was uploaded.": "No file was uploaded.", + "No rows with this outcome": "No rows with this outcome", + "Nothing more is known on this page; the Nextcloud log has the details.": "Nothing more is known on this page; the Nextcloud log has the details.", + "Only Nextcloud administrators can import a CMDB export.": "Only Nextcloud administrators can import a CMDB export.", + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.", + "Outcome": "Outcome", + "Pick an existing municipality or type the name of a new one.": "Pick an existing municipality or type the name of a new one.", + "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Pick an existing organisation of type Municipality, or type a new name and press Enter.", + "Pick an organisation of type Municipality, or type the name of a new one.": "Pick an organisation of type Municipality, or type the name of a new one.", + "Reasons and warnings": "Reasons and warnings", + "Records missing from the export can only be kept.": "Records missing from the export can only be kept.", + "Reload the page and try again.": "Reload the page and try again.", + "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.", + "Row": "Row", + "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.", + "Rows": "Rows", + "Rows read": "Rows read", + "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.", + "Sheet": "Sheet", + "Show rows with outcome": "Show rows with outcome", + "Sign in again and retry the import.": "Sign in again and retry the import.", + "Skipped": "Skipped", + "The chosen organisation is not a municipality.": "The chosen organisation is not a municipality.", + "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.", + "The Excel reader is not available.": "The Excel reader is not available.", + "The file": "The file", + "The file is larger than 10 MB.": "The file is larger than 10 MB.", + "The import failed unexpectedly.": "The import failed unexpectedly.", + "The import mapping cannot run.": "The import mapping cannot run.", + "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.", + "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.", + "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.", + "The server could not be reached.": "The server could not be reached.", + "The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".", + "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.": "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.", + "The workbook has none of the sheets the import reads.": "The workbook has none of the sheets the import reads.", + "This file is not an Excel workbook (.xlsx).": "This file is not an Excel workbook (.xlsx).", + "This import is no longer running.": "This import is no longer running.", + "Unchanged": "Unchanged", + "Update existing records": "Update existing records", + "Updated": "Updated", + "Warnings": "Warnings", + "Warnings for the whole file": "Warnings for the whole file", + "When off, applications imported before are left as they are and reported as skipped.": "When off, applications imported before are left as they are and reported as skipped.", + "You are not signed in.": "You are not signed in.", + "Your session has expired.": "Your session has expired.", + "Stackiq is not configured for the import.": "Stackiq is not configured for the import.", + "The sheet \"{sheet}\" has more rows than the import can process.": "The sheet \"{sheet}\" has more rows than the import can process.", + "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.", + "Choose a municipality or enter the name of a new one.": "Choose a municipality or enter the name of a new one.", + "No running CMDB import has this id.": "No running CMDB import has this id.", + "Only keeping records that are missing from the export is supported.": "Only keeping records that are missing from the export is supported.", + "Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.", + "Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".", + "Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.", + "The Excel reader is not available: OpenRegister is missing or incomplete.": "The Excel reader is not available: OpenRegister is missing or incomplete.", + "The file is larger than the maximum of %s MB.": "The file is larger than the maximum of %s MB.", + "The file is not an Excel workbook (.xlsx).": "The file is not an Excel workbook (.xlsx).", + "The import failed. The details are in the Nextcloud log.": "The import failed. The details are in the Nextcloud log.", + "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.", + "The workbook has neither of the sheets %s.": "The workbook has neither of the sheets %s.", + "Column \"%1$s\": %2$s": "Column \"%1$s\": %2$s", + "Optional column \"%s\" not found": "Optional column \"%s\" not found", + "Owner from column \"%s\" could not be resolved": "Owner from column \"%s\" could not be resolved", + "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Owner from column \"%s\" could not be resolved in Nextcloud Contacts", + "Owners skipped: Nextcloud Contacts is unavailable": "Owners skipped: Nextcloud Contacts is unavailable", + "duplicate %s in file": "duplicate %s in file", + "exists": "exists", + "missing %s": "missing %s", + "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s", + "step \"%s\" failed": "step \"%s\" failed", + "unsupported %1$s \"%2$s\"": "unsupported %1$s \"%2$s\"" } } diff --git a/l10n/nl.js b/l10n/nl.js index 5d5db0a5..04278215 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -971,7 +971,110 @@ OC.L10N.register( "Scored on": "Gescoord op", "The date the scores were set.": "De datum waarop de scores zijn vastgesteld.", "Suggested TIME classification": "Voorgestelde TIME-classificatie", - "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit." + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit.", + "{processed} of {total} rows processed": "{processed} van {total} rijen verwerkt", + "{size} KB": "{size} kB", + "{size} MB": "{size} MB", + "A new municipality \"{name}\" is created, unless one with this name already exists.": "Er wordt een nieuwe gemeente \"{name}\" aangemaakt, tenzij er al een gemeente met deze naam bestaat.", + "A sheet has more rows than the import can process.": "Een tabblad heeft meer rijen dan de import kan verwerken.", + "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens 10.000 rijen bevatten. Splits de export en importeer de delen na elkaar.", + "All outcomes": "Alle resultaten", + "Check the connection and try again.": "Controleer de verbinding en probeer het opnieuw.", + "Choose a municipality first.": "Kies eerst een gemeente.", + "Choose or type a municipality": "Kies of typ een gemeente", + "Choose the TOPdesk export": "Kies de TOPdesk-export", + "Choose the TOPdesk export and try again.": "Kies de TOPdesk-export en probeer het opnieuw.", + "CMDB import": "CMDB-import", + "Created": "Aangemaakt", + "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de fabrikant en een gebruik dat de applicatie aan de gekozen gemeente koppelt. Eigenaren worden contactpersonen van de gemeente in Nextcloud Contacten.", + "Error code: {code}": "Foutcode: {code}", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Invoer AIA data\" of \"Invoer APP data\".", + "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.", + "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.", + "Failed": "Mislukt", + "Import": "Importeren", + "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt", + "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voltooid. De gemeente {name} is aangemaakt. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", + "Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.", + "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", + "Import progress": "Voortgang van de import", + "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het Middel-ID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.", + "Importing the export…": "De export wordt geïmporteerd…", + "Importing…": "Importeren…", + "Middel-ID": "Middel-ID", + "Municipality": "Gemeente", + "No file was uploaded.": "Er is geen bestand geüpload.", + "No rows with this outcome": "Geen rijen met dit resultaat", + "Nothing more is known on this page; the Nextcloud log has the details.": "Op deze pagina is niet meer bekend; het Nextcloud-logboek bevat de details.", + "Only Nextcloud administrators can import a CMDB export.": "Alleen Nextcloud-beheerders kunnen een CMDB-export importeren.", + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt of een mappingbestand is ongeldig. Werk OpenRegister bij en bekijk het Nextcloud-logboek.", + "Outcome": "Resultaat", + "Pick an existing municipality or type the name of a new one.": "Kies een bestaande gemeente of typ de naam van een nieuwe.", + "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Kies een bestaande organisatie van het type Gemeente, of typ een nieuwe naam en druk op Enter.", + "Pick an organisation of type Municipality, or type the name of a new one.": "Kies een organisatie van het type Gemeente, of typ de naam van een nieuwe.", + "Reasons and warnings": "Redenen en waarschuwingen", + "Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.", + "Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.", + "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.", + "Row": "Rij", + "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"Middel-ID\" en \"Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.", + "Rows": "Rijen", + "Rows read": "Rijen gelezen", + "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Sla de TOPdesk-export op als Excel-werkmap (.xlsx). CSV-, .xls- en .xlsm-bestanden met macro's worden niet geaccepteerd.", + "Sheet": "Tabblad", + "Show rows with outcome": "Toon rijen met resultaat", + "Sign in again and retry the import.": "Meld u opnieuw aan en probeer de import nog eens.", + "Skipped": "Overgeslagen", + "The chosen organisation is not a municipality.": "De gekozen organisatie is geen gemeente.", + "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"Middel-ID\" en \"Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.", + "The Excel reader is not available.": "De Excel-lezer is niet beschikbaar.", + "The file": "Het bestand", + "The file is larger than 10 MB.": "Het bestand is groter dan 10 MB.", + "The import failed unexpectedly.": "De import is onverwacht mislukt.", + "The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.", + "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.", + "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.", + "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.", + "The server could not be reached.": "De server is niet bereikbaar.", + "The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".", + "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.": "De tabbladen \"Invoer AIA data\" en \"Invoer APP data\" worden gelezen; andere tabbladen worden genegeerd.", + "The workbook has none of the sheets the import reads.": "De werkmap bevat geen van de tabbladen die de import leest.", + "This file is not an Excel workbook (.xlsx).": "Dit bestand is geen Excel-werkmap (.xlsx).", + "This import is no longer running.": "Deze import loopt niet meer.", + "Unchanged": "Ongewijzigd", + "Update existing records": "Bestaande records bijwerken", + "Updated": "Bijgewerkt", + "Warnings": "Waarschuwingen", + "Warnings for the whole file": "Waarschuwingen voor het hele bestand", + "When off, applications imported before are left as they are and reported as skipped.": "Als dit uit staat, blijven eerder geïmporteerde applicaties zoals ze zijn en worden ze als overgeslagen gemeld.", + "You are not signed in.": "U bent niet aangemeld.", + "Your session has expired.": "Uw sessie is verlopen.", + "Stackiq is not configured for the import.": "Stackiq is niet ingesteld voor de import.", + "The sheet \"{sheet}\" has more rows than the import can process.": "Het tabblad \"{sheet}\" heeft meer rijen dan de import kan verwerken.", + "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "Het stackiq-register of de schema's ervan zijn niet gevonden. Voer bovenaan deze pagina Auto Configure uit en probeer het daarna opnieuw.", + "Choose a municipality or enter the name of a new one.": "Kies een gemeente of voer de naam van een nieuwe in.", + "No running CMDB import has this id.": "Er loopt geen CMDB-import met deze id.", + "Only keeping records that are missing from the export is supported.": "Alleen het behouden van records die in de export ontbreken, wordt ondersteund.", + "Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.", + "Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".", + "Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.", + "The Excel reader is not available: OpenRegister is missing or incomplete.": "De Excel-lezer is niet beschikbaar: OpenRegister ontbreekt of is onvolledig.", + "The file is larger than the maximum of %s MB.": "Het bestand is groter dan het maximum van %s MB.", + "The file is not an Excel workbook (.xlsx).": "Het bestand is geen Excel-werkmap (.xlsx).", + "The import failed. The details are in the Nextcloud log.": "De import is mislukt. De details staan in het Nextcloud-logboek.", + "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt of een mappingbestand is ongeldig.", + "The workbook has neither of the sheets %s.": "De werkmap bevat geen van de tabbladen %s.", + "Column \"%1$s\": %2$s": "Kolom \"%1$s\": %2$s", + "Optional column \"%s\" not found": "Optionele kolom \"%s\" niet gevonden", + "Owner from column \"%s\" could not be resolved": "Eigenaar uit kolom \"%s\" kon niet worden gevonden", + "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Eigenaar uit kolom \"%s\" kon niet worden gevonden in Nextcloud Contacten", + "Owners skipped: Nextcloud Contacts is unavailable": "Eigenaren overgeslagen: Nextcloud Contacten is niet beschikbaar", + "duplicate %s in file": "dubbele %s in het bestand", + "exists": "bestaat al", + "missing %s": "%s ontbreekt", + "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s", + "step \"%s\" failed": "stap \"%s\" mislukt", + "unsupported %1$s \"%2$s\"": "niet-ondersteunde %1$s \"%2$s\"" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index f84eed6e..f9de4372 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -970,6 +970,109 @@ "Scored on": "Gescoord op", "The date the scores were set.": "De datum waarop de scores zijn vastgesteld.", "Suggested TIME classification": "Voorgestelde TIME-classificatie", - "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit." + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit.", + "{processed} of {total} rows processed": "{processed} van {total} rijen verwerkt", + "{size} KB": "{size} kB", + "{size} MB": "{size} MB", + "A new municipality \"{name}\" is created, unless one with this name already exists.": "Er wordt een nieuwe gemeente \"{name}\" aangemaakt, tenzij er al een gemeente met deze naam bestaat.", + "A sheet has more rows than the import can process.": "Een tabblad heeft meer rijen dan de import kan verwerken.", + "A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens 10.000 rijen bevatten. Splits de export en importeer de delen na elkaar.", + "All outcomes": "Alle resultaten", + "Check the connection and try again.": "Controleer de verbinding en probeer het opnieuw.", + "Choose a municipality first.": "Kies eerst een gemeente.", + "Choose or type a municipality": "Kies of typ een gemeente", + "Choose the TOPdesk export": "Kies de TOPdesk-export", + "Choose the TOPdesk export and try again.": "Kies de TOPdesk-export en probeer het opnieuw.", + "CMDB import": "CMDB-import", + "Created": "Aangemaakt", + "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de fabrikant en een gebruik dat de applicatie aan de gekozen gemeente koppelt. Eigenaren worden contactpersonen van de gemeente in Nextcloud Contacten.", + "Error code: {code}": "Foutcode: {code}", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Invoer AIA data\" of \"Invoer APP data\".", + "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.", + "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.", + "Failed": "Mislukt", + "Import": "Importeren", + "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt", + "Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voltooid. De gemeente {name} is aangemaakt. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", + "Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.", + "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", + "Import progress": "Voortgang van de import", + "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het Middel-ID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.", + "Importing the export…": "De export wordt geïmporteerd…", + "Importing…": "Importeren…", + "Middel-ID": "Middel-ID", + "Municipality": "Gemeente", + "No file was uploaded.": "Er is geen bestand geüpload.", + "No rows with this outcome": "Geen rijen met dit resultaat", + "Nothing more is known on this page; the Nextcloud log has the details.": "Op deze pagina is niet meer bekend; het Nextcloud-logboek bevat de details.", + "Only Nextcloud administrators can import a CMDB export.": "Alleen Nextcloud-beheerders kunnen een CMDB-export importeren.", + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.": "De mapping-engine van OpenRegister ontbreekt of een mappingbestand is ongeldig. Werk OpenRegister bij en bekijk het Nextcloud-logboek.", + "Outcome": "Resultaat", + "Pick an existing municipality or type the name of a new one.": "Kies een bestaande gemeente of typ de naam van een nieuwe.", + "Pick an existing organisation of type Municipality, or type a new name and press Enter.": "Kies een bestaande organisatie van het type Gemeente, of typ een nieuwe naam en druk op Enter.", + "Pick an organisation of type Municipality, or type the name of a new one.": "Kies een organisatie van het type Gemeente, of typ de naam van een nieuwe.", + "Reasons and warnings": "Redenen en waarschuwingen", + "Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.", + "Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.", + "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.", + "Row": "Rij", + "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"Middel-ID\" en \"Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.", + "Rows": "Rijen", + "Rows read": "Rijen gelezen", + "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Sla de TOPdesk-export op als Excel-werkmap (.xlsx). CSV-, .xls- en .xlsm-bestanden met macro's worden niet geaccepteerd.", + "Sheet": "Tabblad", + "Show rows with outcome": "Toon rijen met resultaat", + "Sign in again and retry the import.": "Meld u opnieuw aan en probeer de import nog eens.", + "Skipped": "Overgeslagen", + "The chosen organisation is not a municipality.": "De gekozen organisatie is geen gemeente.", + "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"Middel-ID\" en \"Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.", + "The Excel reader is not available.": "De Excel-lezer is niet beschikbaar.", + "The file": "Het bestand", + "The file is larger than 10 MB.": "Het bestand is groter dan 10 MB.", + "The import failed unexpectedly.": "De import is onverwacht mislukt.", + "The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.", + "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.", + "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.", + "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.", + "The server could not be reached.": "De server is niet bereikbaar.", + "The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".", + "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.": "De tabbladen \"Invoer AIA data\" en \"Invoer APP data\" worden gelezen; andere tabbladen worden genegeerd.", + "The workbook has none of the sheets the import reads.": "De werkmap bevat geen van de tabbladen die de import leest.", + "This file is not an Excel workbook (.xlsx).": "Dit bestand is geen Excel-werkmap (.xlsx).", + "This import is no longer running.": "Deze import loopt niet meer.", + "Unchanged": "Ongewijzigd", + "Update existing records": "Bestaande records bijwerken", + "Updated": "Bijgewerkt", + "Warnings": "Waarschuwingen", + "Warnings for the whole file": "Waarschuwingen voor het hele bestand", + "When off, applications imported before are left as they are and reported as skipped.": "Als dit uit staat, blijven eerder geïmporteerde applicaties zoals ze zijn en worden ze als overgeslagen gemeld.", + "You are not signed in.": "U bent niet aangemeld.", + "Your session has expired.": "Uw sessie is verlopen.", + "Stackiq is not configured for the import.": "Stackiq is niet ingesteld voor de import.", + "The sheet \"{sheet}\" has more rows than the import can process.": "Het tabblad \"{sheet}\" heeft meer rijen dan de import kan verwerken.", + "The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.": "Het stackiq-register of de schema's ervan zijn niet gevonden. Voer bovenaan deze pagina Auto Configure uit en probeer het daarna opnieuw.", + "Choose a municipality or enter the name of a new one.": "Kies een gemeente of voer de naam van een nieuwe in.", + "No running CMDB import has this id.": "Er loopt geen CMDB-import met deze id.", + "Only keeping records that are missing from the export is supported.": "Alleen het behouden van records die in de export ontbreken, wordt ondersteund.", + "Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.", + "Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".", + "Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.", + "The Excel reader is not available: OpenRegister is missing or incomplete.": "De Excel-lezer is niet beschikbaar: OpenRegister ontbreekt of is onvolledig.", + "The file is larger than the maximum of %s MB.": "Het bestand is groter dan het maximum van %s MB.", + "The file is not an Excel workbook (.xlsx).": "Het bestand is geen Excel-werkmap (.xlsx).", + "The import failed. The details are in the Nextcloud log.": "De import is mislukt. De details staan in het Nextcloud-logboek.", + "The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden uitgevoerd: OpenRegister ontbreekt of een mappingbestand is ongeldig.", + "The workbook has neither of the sheets %s.": "De werkmap bevat geen van de tabbladen %s.", + "Column \"%1$s\": %2$s": "Kolom \"%1$s\": %2$s", + "Optional column \"%s\" not found": "Optionele kolom \"%s\" niet gevonden", + "Owner from column \"%s\" could not be resolved": "Eigenaar uit kolom \"%s\" kon niet worden gevonden", + "Owner from column \"%s\" could not be resolved in Nextcloud Contacts": "Eigenaar uit kolom \"%s\" kon niet worden gevonden in Nextcloud Contacten", + "Owners skipped: Nextcloud Contacts is unavailable": "Eigenaren overgeslagen: Nextcloud Contacten is niet beschikbaar", + "duplicate %s in file": "dubbele %s in het bestand", + "exists": "bestaat al", + "missing %s": "%s ontbreekt", + "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s", + "step \"%s\" failed": "stap \"%s\" mislukt", + "unsupported %1$s \"%2$s\"": "niet-ondersteunde %1$s \"%2$s\"" } } diff --git a/lib/Controller/CmdbImportController.php b/lib/Controller/CmdbImportController.php new file mode 100644 index 00000000..337b4a81 --- /dev/null +++ b/lib/Controller/CmdbImportController.php @@ -0,0 +1,288 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Controller; + +use OCA\Stackiq\AppInfo\Application; +use OCA\Stackiq\Exception\CmdbImportException; +use OCA\Stackiq\Service\CmdbExportImportService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IL10N; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +/** + * CMDB import and cancel, admin-only and CSRF-protected. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ +class CmdbImportController extends Controller { + /** + * The multipart field of the export. + */ + public const FILE_FIELD = 'cmdbFile'; + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param CmdbExportImportService $importService The import service. + * @param IL10N $l10n Translations of the error messages. + * @param LoggerInterface $logger Logger. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function __construct( + IRequest $request, + private readonly CmdbExportImportService $importService, + private readonly IL10N $l10n, + private readonly LoggerInterface $logger, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * Import a TOPdesk CMDB export for one municipality. + * + * Multipart fields: `cmdbFile`, `municipalityUuid` or `municipalityName`, + * `updateExisting` (default true), `missingRecords` (only `keep`) and + * `operationId` (pattern `cmdb-` plus 8 to 64 letters, digits or hyphens). + * + * @return JSONResponse The report (200), or an error envelope with the contract code. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function import(): JSONResponse { + $validated = $this->validateRequest(); + if ($validated instanceof JSONResponse) { + return $validated; + } + + try { + $report = $this->importService->import(path: $validated['path'], options: $validated['options']); + } catch (CmdbImportException $e) { + $this->logger->info( + 'CmdbImportController: import refused', + ['error' => $e->getErrorCode(), 'details' => $e->getDetails(), 'reason' => $e->getMessage()] + ); + return $this->fromException(e: $e); + } catch (\Exception $e) { + $this->logger->error('CmdbImportController: import failed', ['exception' => $e]); + return $this->error(code: 'IMPORT_FAILED', status: Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return new JSONResponse(data: $report, statusCode: Http::STATUS_OK); + }//end import() + + /** + * Check the request in the order of design D10, before anything is parsed. + * + * Present, size, xlsx, `missingRecords`, municipality. + * + * @return array{path: string, options: array}|JSONResponse The import input, or the first error. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + private function validateRequest(): array|JSONResponse { + $upload = $this->uploadedFile(); + if ($upload === null) { + return $this->error(code: 'NO_FILE_UPLOADED', status: Http::STATUS_BAD_REQUEST); + } + + $maxBytes = $this->importService->maxFileBytes(); + if ($upload['tooLarge'] === true || $upload['size'] > $maxBytes) { + return $this->error(code: 'FILE_TOO_LARGE', status: Http::STATUS_REQUEST_ENTITY_TOO_LARGE, details: ['maxBytes' => $maxBytes]); + } + + try { + $this->importService->assertXlsx(path: $upload['tmpName'], fileName: $upload['name']); + } catch (CmdbImportException $e) { + return $this->fromException(e: $e); + } + + $missingRecords = (string)$this->request->getParam('missingRecords', 'keep'); + if ($this->importService->supportsMissingRecords(mode: $missingRecords) === false) { + return $this->error(code: 'MISSING_RECORDS_UNSUPPORTED', status: Http::STATUS_UNPROCESSABLE_ENTITY, details: ['accepted' => ['keep']]); + } + + $municipalityUuid = trim((string)$this->request->getParam('municipalityUuid', '')); + $municipalityName = trim((string)$this->request->getParam('municipalityName', '')); + if ($municipalityUuid === '' && $municipalityName === '') { + return $this->error(code: CmdbImportException::MUNICIPALITY_REQUIRED, status: Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return [ + 'path' => $upload['tmpName'], + 'options' => [ + 'municipalityUuid' => $municipalityUuid, + 'municipalityName' => $municipalityName, + 'updateExisting' => $this->booleanParam(name: 'updateExisting', default: true), + 'operationId' => $this->request->getParam('operationId'), + ], + ]; + }//end validateRequest() + + /** + * Ask a running CMDB import to stop between rows. + * + * @param string $operationId The operation id. + * + * @return JSONResponse `{success, cancelRequested}`, or 404 OPERATION_NOT_FOUND. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function cancel(string $operationId): JSONResponse { + if ($this->importService->requestCancel(operationId: $operationId) === false) { + return $this->error(code: 'OPERATION_NOT_FOUND', status: Http::STATUS_NOT_FOUND); + } + + return new JSONResponse(data: ['success' => true, 'cancelRequested' => true], statusCode: Http::STATUS_OK); + }//end cancel() + + /** + * Translate a CmdbImportException into its contract response. + * + * @param CmdbImportException $e The exception. + * + * @return JSONResponse + */ + private function fromException(CmdbImportException $e): JSONResponse { + return $this->error(code: $e->getErrorCode(), status: $e->getHttpStatus(), details: $e->getDetails()); + }//end fromException() + + /** + * The error envelope of contract.md. + * + * @param string $code The machine error code. + * @param int $status The HTTP status. + * @param array $details Details, e.g. sheet and column. + * + * @return JSONResponse + */ + private function error(string $code, int $status, array $details = []): JSONResponse { + return new JSONResponse( + data: [ + 'success' => false, + 'error' => $code, + 'message' => $this->message(code: $code, details: $details), + 'details' => (object)$details, + ], + statusCode: $status + ); + }//end error() + + /** + * The translated message of an error code. + * + * @param string $code The machine error code. + * @param array $details The details. + * + * @return string + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per contract error code. + */ + private function message(string $code, array $details): string { + $megabytes = (string)intdiv($this->importService->maxFileBytes(), 1048576); + $expected = implode(', ', array_map('strval', ($details['expected'] ?? []))); + + return match ($code) { + 'NO_FILE_UPLOADED' => $this->l10n->t('No file was uploaded.'), + 'NOT_XLSX' => $this->l10n->t('The file is not an Excel workbook (.xlsx).'), + 'FILE_TOO_LARGE' => $this->l10n->t('The file is larger than the maximum of %s MB.', [$megabytes]), + 'MISSING_RECORDS_UNSUPPORTED' => $this->l10n->t('Only keeping records that are missing from the export is supported.'), + 'MUNICIPALITY_REQUIRED' => $this->l10n->t('Choose a municipality or enter the name of a new one.'), + 'MUNICIPALITY_INVALID' => $this->l10n->t('The chosen organisation is not a municipality.'), + 'NO_SOURCE_SHEET' => $this->l10n->t('The workbook has neither of the sheets %s.', [$expected]), + 'MISSING_COLUMN' => $this->l10n->t('Sheet "%1$s" has no column "%2$s".', [(string)($details['sheet'] ?? ''), (string)($details['column'] ?? '')]), + 'TOO_MANY_ROWS' => $this->l10n->t('Sheet "%1$s" has more than %2$s rows.', [(string)($details['sheet'] ?? ''), (string)($details['limit'] ?? '')]), + 'MAPPING_UNAVAILABLE' => $this->l10n->t('The import mapping cannot run: OpenRegister is missing or a mapping file is invalid.'), + 'READER_UNAVAILABLE' => $this->l10n->t('The Excel reader is not available: OpenRegister is missing or incomplete.'), + 'NOT_CONFIGURED' => $this->l10n->t('Stackiq is not configured: the register or its schemas cannot be found.'), + 'OPERATION_NOT_FOUND' => $this->l10n->t('No running CMDB import has this id.'), + default => $this->l10n->t('The import failed. The details are in the Nextcloud log.'), + }; + }//end message() + + /** + * A boolean form field (`true`/`false`, `1`/`0`). + * + * @param string $name The field. + * @param bool $default The value when absent. + * + * @return bool + */ + private function booleanParam(string $name, bool $default): bool { + $value = $this->request->getParam($name); + if ($value === null || $value === '') { + return $default; + } + + if (is_bool($value) === true) { + return $value; + } + + return in_array(strtolower((string)$value), ['false', '0', 'no', 'off'], true) === false; + }//end booleanParam() + + /** + * The uploaded export, or null when none was sent. + * + * @return array{tmpName: string, name: string, size: int, tooLarge: bool}|null + */ + private function uploadedFile(): ?array { + $file = $this->request->getUploadedFile(self::FILE_FIELD); + if (is_array($file) === false || $file === []) { + return null; + } + + $error = (int)($file['error'] ?? UPLOAD_ERR_OK); + if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) { + return ['tmpName' => '', 'name' => (string)($file['name'] ?? ''), 'size' => 0, 'tooLarge' => true]; + } + + $tmpName = (string)($file['tmp_name'] ?? ''); + if ($error !== UPLOAD_ERR_OK || $tmpName === '') { + return null; + } + + $size = (int)($file['size'] ?? 0); + if ($size === 0 && is_file($tmpName) === true) { + $size = (int)filesize($tmpName); + } + + return ['tmpName' => $tmpName, 'name' => (string)($file['name'] ?? ''), 'size' => $size, 'tooLarge' => false]; + }//end uploadedFile() +}//end class diff --git a/lib/Exception/CmdbImportException.php b/lib/Exception/CmdbImportException.php new file mode 100644 index 00000000..4e96756e --- /dev/null +++ b/lib/Exception/CmdbImportException.php @@ -0,0 +1,118 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Exception; + +use RuntimeException; +use Throwable; + +/** + * A CMDB import failure with a contract error code and an HTTP status. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ +class CmdbImportException extends RuntimeException { + public const NOT_XLSX = 'NOT_XLSX'; + public const NO_SOURCE_SHEET = 'NO_SOURCE_SHEET'; + public const MISSING_COLUMN = 'MISSING_COLUMN'; + public const TOO_MANY_ROWS = 'TOO_MANY_ROWS'; + public const MUNICIPALITY_REQUIRED = 'MUNICIPALITY_REQUIRED'; + public const MUNICIPALITY_INVALID = 'MUNICIPALITY_INVALID'; + public const MAPPING_UNAVAILABLE = 'MAPPING_UNAVAILABLE'; + public const READER_UNAVAILABLE = 'READER_UNAVAILABLE'; + public const NOT_CONFIGURED = 'NOT_CONFIGURED'; + + /** + * HTTP status per error code. + * + * @var array + */ + private const STATUS = [ + self::NOT_XLSX => 400, + self::NO_SOURCE_SHEET => 422, + self::MISSING_COLUMN => 422, + self::TOO_MANY_ROWS => 422, + self::MUNICIPALITY_REQUIRED => 422, + self::MUNICIPALITY_INVALID => 422, + self::MAPPING_UNAVAILABLE => 503, + self::READER_UNAVAILABLE => 503, + self::NOT_CONFIGURED => 503, + ]; + + /** + * Constructor. + * + * @param string $errorCode One of the class constants. + * @param string $message English log message, no person data. + * @param array $details Contract details, e.g. sheet and column. + * @param Throwable|null $previous The cause, if any. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function __construct( + private readonly string $errorCode, + string $message, + private readonly array $details = [], + ?Throwable $previous = null, + ) { + parent::__construct(message: $message, code: 0, previous: $previous); + }//end __construct() + + /** + * The contract error code, e.g. `MISSING_COLUMN`. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function getErrorCode(): string { + return $this->errorCode; + }//end getErrorCode() + + /** + * The HTTP status the controller answers with. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function getHttpStatus(): int { + return (self::STATUS[$this->errorCode] ?? 500); + }//end getHttpStatus() + + /** + * The contract details of the error. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function getDetails(): array { + return $this->details; + }//end getDetails() +}//end class diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php new file mode 100644 index 00000000..ce6d5d92 --- /dev/null +++ b/lib/Service/Cmdb/CmdbImportProfile.php @@ -0,0 +1,537 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use OCA\Stackiq\Exception\CmdbImportException; +use Psr\Container\ContainerInterface; +use Throwable; + +/** + * The validated import profile plus its packs. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + * + * @SuppressWarnings(PHPMD.TooManyPublicMethods) One small accessor per profile setting, so + * callers never read the raw JSON. + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The accessors each guard against a + * malformed profile value; the sum passes the threshold, no single method is complex. + */ +class CmdbImportProfile { + /** + * OpenRegister's pack validator (not a public contract). + */ + public const VALIDATOR_CLASS = 'OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator'; + + /** + * The targets every profile must name a pack for. + * + * @var array + */ + public const TARGETS = ['module', 'manufacturer', 'municipality', 'usage', 'businessOwner', 'technicalOwner']; + + /** + * Default upload limit when the profile file cannot be read (10 MB). + */ + public const DEFAULT_MAX_FILE_BYTES = 10485760; + + /** + * Sources of the municipality pack that come from the request, not from a sheet. + * + * @var array + */ + private const OPTION_SOURCES = ['municipalityName']; + + /** + * The decoded profile, once loaded. + * + * @var array|null + */ + private ?array $profile = null; + + /** + * The validated packs per target, once loaded. + * + * @var array> + */ + private array $packs = []; + + /** + * Constructor. + * + * @param ContainerInterface $container Resolves OpenRegister's validator. + * @param string|null $directory Directory of the profile and packs; null is the shipped one. + * @param string $profileFile File name of the profile inside the directory. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function __construct( + private readonly ContainerInterface $container, + private ?string $directory = null, + private readonly string $profileFile = 'topdesk-profile.json', + ) { + if ($this->directory === null) { + $this->directory = __DIR__ . '/../../Settings/cmdb-import'; + } + }//end __construct() + + /** + * Load the profile and validate every pack it names. + * + * @return void + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing, + * or the profile or a pack is unreadable or invalid. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function load(): void { + $validator = $this->resolveValidator(); + $profile = $this->decodeFile(fileName: $this->profileFile); + + $packs = []; + foreach (self::TARGETS as $target) { + $fileName = $profile['packs'][$target] ?? null; + if (is_string($fileName) === false || $fileName === '' || basename($fileName) !== $fileName) { + throw new CmdbImportException( + errorCode: CmdbImportException::MAPPING_UNAVAILABLE, + message: 'CMDB import profile names no pack for target ' . $target + ); + } + + $pack = $this->decodeFile(fileName: $fileName); + $errors = $validator->validate($pack); + if (is_array($errors) === true && $errors !== []) { + throw new CmdbImportException( + errorCode: CmdbImportException::MAPPING_UNAVAILABLE, + message: 'CMDB mapping pack ' . $fileName . ' is invalid: ' . implode('; ', array_map('strval', $errors)) + ); + } + + $packs[$target] = $pack; + } + + $this->profile = $profile; + $this->packs = $packs; + }//end load() + + /** + * The upload limit in bytes, readable without validating the packs. + * + * The controller checks the size before anything else, so this must not + * depend on OpenRegister being available. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function maxFileBytes(): int { + try { + $profile = $this->profile ?? $this->decodeFile(fileName: $this->profileFile); + } catch (CmdbImportException $e) { + return self::DEFAULT_MAX_FILE_BYTES; + } + + $limit = $profile['maxFileBytes'] ?? null; + if (is_int($limit) === true && $limit > 0) { + return $limit; + } + + return self::DEFAULT_MAX_FILE_BYTES; + }//end maxFileBytes() + + /** + * The maximum number of non-empty rows per source sheet. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function maxRowsPerSheet(): int { + $limit = $this->profile()['maxRowsPerSheet'] ?? 10000; + if (is_int($limit) === false || $limit < 0) { + return 10000; + } + + return $limit; + }//end maxRowsPerSheet() + + /** + * The source sheets with the Soort values each accepts. + * + * @return array}> + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function sheets(): array { + $sheets = []; + foreach (($this->profile()['sheets'] ?? []) as $sheet) { + if (is_array($sheet) === false || is_string($sheet['name'] ?? null) === false) { + continue; + } + + $sheets[] = [ + 'name' => $sheet['name'], + 'acceptedKinds' => array_values(array_map('strval', ($sheet['acceptedKinds'] ?? []))), + ]; + } + + return $sheets; + }//end sheets() + + /** + * The names of the source sheets. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function sheetNames(): array { + return array_column($this->sheets(), 'name'); + }//end sheetNames() + + /** + * The Soort values a sheet accepts. + * + * @param string $sheetName The sheet name. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function acceptedKinds(string $sheetName): array { + foreach ($this->sheets() as $sheet) { + if ($sheet['name'] === $sheetName) { + return $sheet['acceptedKinds']; + } + } + + return []; + }//end acceptedKinds() + + /** + * The match key column ("Middel-ID"). + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function keyColumn(): string { + return (string)($this->profile()['keyColumn'] ?? 'Middel-ID'); + }//end keyColumn() + + /** + * The application name column ("Naam"). + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function nameColumn(): string { + return (string)($this->profile()['nameColumn'] ?? 'Naam'); + }//end nameColumn() + + /** + * The row-kind column ("Soort"). + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function kindColumn(): string { + return (string)($this->profile()['kindColumn'] ?? 'Soort'); + }//end kindColumn() + + /** + * Columns whose absence stops the import with MISSING_COLUMN. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function requiredColumns(): array { + return $this->stringList(key: 'requiredColumns'); + }//end requiredColumns() + + /** + * Columns that hold Excel serial dates. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function dateColumns(): array { + return $this->stringList(key: 'dateColumns'); + }//end dateColumns() + + /** + * Columns that hold identifiers which must not carry a decimal part. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function idColumns(): array { + return $this->stringList(key: 'idColumns'); + }//end idColumns() + + /** + * The prefix of the module match key ("topdesk"). + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function externalKeyPrefix(): string { + return (string)($this->profile()['externalKeyPrefix'] ?? 'topdesk'); + }//end externalKeyPrefix() + + /** + * The validated pack for a target. + * + * @param string $target One of self::TARGETS. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function pack(string $target): array { + $this->profile(); + return ($this->packs[$target] ?? []); + }//end pack() + + /** + * Values set on create only, per target, as field => value. + * + * @param string $target The target. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function createOnlyDefaults(string $target): array { + $value = $this->profile()['createOnly'][$target] ?? []; + if (is_array($value) === false || array_is_list($value) === true) { + return []; + } + + return $value; + }//end createOnlyDefaults() + + /** + * Mapped fields written on create, or on update only when the stored value is empty. + * + * @param string $target The target. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function createOnlyFields(string $target): array { + $value = $this->profile()['createOnly'][$target] ?? []; + if (is_array($value) === false) { + return []; + } + + if (array_is_list($value) === true) { + return array_values(array_map('strval', $value)); + } + + return array_map('strval', array_keys($value)); + }//end createOnlyFields() + + /** + * Fields the import never writes on an existing object. + * + * @param string $target The target. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function neverWrittenOnUpdate(string $target): array { + $value = $this->profile()['neverWritten'][$target] ?? []; + if (is_array($value) === false) { + return []; + } + + return array_values(array_map('strval', $value)); + }//end neverWrittenOnUpdate() + + /** + * The accepted values of the missingRecords option. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function missingRecordsModes(): array { + $modes = $this->stringList(key: 'missingRecords'); + if ($modes === []) { + return ['keep']; + } + + return $modes; + }//end missingRecordsModes() + + /** + * Every column the profile or a pack references: the read allowlist. + * + * The municipality pack maps the request options, not a sheet, so its + * sources are left out. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + public function referencedColumns(): array { + $columns = array_merge( + [$this->keyColumn(), $this->nameColumn(), $this->kindColumn()], + $this->requiredColumns(), + $this->dateColumns(), + $this->idColumns() + ); + + foreach (self::TARGETS as $target) { + foreach (($this->pack(target: $target)['fieldMappings'] ?? []) as $mapping) { + $columns[] = (string)($mapping['source'] ?? ''); + foreach (($mapping['transform']['fields'] ?? []) as $extra) { + $columns[] = (string)$extra; + } + } + } + + $columns = array_filter( + $columns, + fn (string $column): bool => $column !== '' && $column[0] !== '/' && in_array($column, self::OPTION_SOURCES, true) === false + ); + + return array_values(array_unique($columns)); + }//end referencedColumns() + + /** + * The loaded profile. + * + * @return array + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when load() failed. + */ + private function profile(): array { + if ($this->profile === null) { + $this->load(); + } + + return ($this->profile ?? []); + }//end profile() + + /** + * A list of strings from the profile. + * + * @param string $key The profile key. + * + * @return array + */ + private function stringList(string $key): array { + $value = $this->profile()[$key] ?? []; + if (is_array($value) === false) { + return []; + } + + return array_values(array_map('strval', $value)); + }//end stringList() + + /** + * Resolve OpenRegister's pack validator. + * + * @return object The validator, with a `validate(array): array` method. + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when it is not available. + */ + private function resolveValidator(): object { + $class = static::VALIDATOR_CLASS; + + try { + if ($this->container->has($class) === true) { + $validator = $this->container->get($class); + if (is_object($validator) === true && method_exists($validator, 'validate') === true) { + return $validator; + } + } + } catch (Throwable $e) { + // Fall through to the class check below. + $validator = null; + } + + if (class_exists($class) === true) { + $validator = new $class(); + if (method_exists($validator, 'validate') === true) { + return $validator; + } + } + + throw new CmdbImportException( + errorCode: CmdbImportException::MAPPING_UNAVAILABLE, + message: 'OpenRegister PackDefinitionValidator is not available' + ); + }//end resolveValidator() + + /** + * Read and decode one JSON file from the profile directory. + * + * @param string $fileName The file name. + * + * @return array + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when the file is missing or not a JSON object. + */ + private function decodeFile(string $fileName): array { + $path = $this->directory . '/' . $fileName; + $content = false; + if (is_readable($path) === true) { + $content = file_get_contents($path); + } + + $decoded = null; + if (is_string($content) === true) { + $decoded = json_decode($content, true); + } + + if (is_array($decoded) === false) { + throw new CmdbImportException( + errorCode: CmdbImportException::MAPPING_UNAVAILABLE, + message: 'CMDB import file ' . $fileName . ' is missing or not valid JSON' + ); + } + + return $decoded; + }//end decodeFile() +}//end class diff --git a/lib/Service/Cmdb/CmdbImportReport.php b/lib/Service/Cmdb/CmdbImportReport.php new file mode 100644 index 00000000..ca5856bb --- /dev/null +++ b/lib/Service/Cmdb/CmdbImportReport.php @@ -0,0 +1,220 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +/** + * The per-row report of one CMDB import run. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ +class CmdbImportReport { + public const CREATED = 'created'; + public const UPDATED = 'updated'; + public const UNCHANGED = 'unchanged'; + public const SKIPPED = 'skipped'; + public const FAILED = 'failed'; + + /** + * The row entries, in processing order. + * + * @var array> + */ + private array $rows = []; + + /** + * Import-level warnings. + * + * @var array + */ + private array $importWarnings = []; + + /** + * Whether the run stopped on a cancel. + * + * @var bool + */ + private bool $cancelled = false; + + /** + * The consuming municipality. + * + * @var array{uuid: string, name: string, created: bool}|null + */ + private ?array $municipality = null; + + /** + * Constructor. + * + * @param string $operationId The progress operation id. + * @param int $rowsRead Non-empty rows read from the workbook. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function __construct( + private readonly string $operationId, + private readonly int $rowsRead, + ) { + }//end __construct() + + /** + * Add one row outcome. + * + * @param string $sheet The sheet name. + * @param int $row The 1-based sheet row number. + * @param string $middelId The Middel-ID ('' when missing). + * @param string $name The application name ('' when missing). + * @param string $outcome One of the outcome constants. + * @param array $reasons Why the row was skipped or failed. + * @param array $warnings Row warnings. + * @param string|null $moduleUuid The module, when there is one. + * @param string|null $usageUuid The usage, when there is one. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function addRow( + string $sheet, + int $row, + string $middelId, + string $name, + string $outcome, + array $reasons = [], + array $warnings = [], + ?string $moduleUuid = null, + ?string $usageUuid = null, + ): void { + $this->rows[] = [ + 'sheet' => $sheet, + 'row' => $row, + 'middelId' => $middelId, + 'name' => $name, + 'outcome' => $outcome, + 'reasons' => array_values($reasons), + 'warnings' => array_values($warnings), + 'moduleUuid' => $moduleUuid, + 'usageUuid' => $usageUuid, + ]; + }//end addRow() + + /** + * Add import-level warnings, such as a missing optional column. + * + * @param array $warnings The warnings. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function addImportWarnings(array $warnings): void { + foreach ($warnings as $warning) { + $this->importWarnings[] = ['sheet' => (string)$warning['sheet'], 'message' => (string)$warning['message']]; + } + }//end addImportWarnings() + + /** + * Record the consuming municipality. + * + * @param string $uuid The organisation uuid. + * @param string $name Its name. + * @param bool $created Whether this run created it. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function setMunicipality(string $uuid, string $name, bool $created): void { + $this->municipality = ['uuid' => $uuid, 'name' => $name, 'created' => $created]; + }//end setMunicipality() + + /** + * Mark the run as stopped on a cancel. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function markCancelled(): void { + $this->cancelled = true; + }//end markCancelled() + + /** + * The number of rows processed so far. + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function processed(): int { + return count($this->rows); + }//end processed() + + /** + * The summary counts. + * + * @return array{rowsRead: int, processed: int, created: int, updated: int, unchanged: int, skipped: int, failed: int, warnings: int} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function summary(): array { + $summary = [ + 'rowsRead' => $this->rowsRead, + 'processed' => count($this->rows), + self::CREATED => 0, + self::UPDATED => 0, + self::UNCHANGED => 0, + self::SKIPPED => 0, + self::FAILED => 0, + 'warnings' => 0, + ]; + + foreach ($this->rows as $row) { + $summary[$row['outcome']]++; + $summary['warnings'] += count($row['warnings']); + } + + return $summary; + }//end summary() + + /** + * The report in the contract shape. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function toArray(): array { + return [ + 'success' => true, + 'operationId' => $this->operationId, + 'cancelled' => $this->cancelled, + 'municipality' => $this->municipality, + 'summary' => $this->summary(), + 'importWarnings' => $this->importWarnings, + 'rows' => $this->rows, + ]; + }//end toArray() +}//end class diff --git a/lib/Service/Cmdb/CmdbRowNormaliser.php b/lib/Service/Cmdb/CmdbRowNormaliser.php new file mode 100644 index 00000000..31a713a4 --- /dev/null +++ b/lib/Service/Cmdb/CmdbRowNormaliser.php @@ -0,0 +1,177 @@ + string` row OpenRegister's `MappingEngine` expects (design D4): + * + * - Date columns: an Excel serial number becomes `Y-m-d` (1900 date system, + * or 1904 when the workbook says so). A non-numeric value stays as it is, so + * the pack's `date` transform accepts it or reports a warning. + * - Id columns: a whole number becomes a string without a decimal part + * (`1234.0` becomes `"1234"`). + * - Every value is trimmed; an empty value becomes the empty string. + * + * The conversion lives here and not in the packs, so every pack stays a plain + * OpenRegister migration pack. + * + * @category Service + * @package OCA\Stackiq\Service\Cmdb + * @author Conduction b.v. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use DateInterval; +use DateTimeImmutable; +use DateTimeZone; + +/** + * Normalises reader rows into string rows for the mapping engine. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * @SuppressWarnings(PHPMD.BooleanArgumentFlag) The date system (1900 or 1904) is a property + * of the workbook that the reader reports; it is data, not a mode switch. + */ +class CmdbRowNormaliser { + /** + * Highest serial Excel accepts (9999-12-31). + */ + private const MAX_SERIAL = 2958465; + + /** + * Normalise one row. + * + * @param array $cells Column name => raw cell value. + * @param array $dateColumns Columns holding Excel serial dates. + * @param array $idColumns Columns holding identifiers. + * @param bool $date1904 Whether the workbook uses the 1904 date system. + * + * @return array Column name => normalised value. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function normalise(array $cells, array $dateColumns, array $idColumns, bool $date1904 = false): array { + $row = []; + foreach ($cells as $column => $value) { + $column = (string)$column; + if (in_array($column, $dateColumns, true) === true) { + $row[$column] = $this->normaliseDate(value: $value, date1904: $date1904); + continue; + } + + if (in_array($column, $idColumns, true) === true) { + $row[$column] = $this->normaliseId(value: $value); + continue; + } + + $row[$column] = $this->toText(value: $value); + } + + return $row; + }//end normalise() + + /** + * An Excel serial date to `Y-m-d`; any other value trimmed as text. + * + * @param mixed $value The raw value. + * @param bool $date1904 Whether the workbook uses the 1904 date system. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function normaliseDate(mixed $value, bool $date1904 = false): string { + $text = $this->toText(value: $value); + if (is_numeric($text) === false) { + return $text; + } + + $serial = (float)$text; + if ($serial < 1 || $serial > self::MAX_SERIAL) { + return $text; + } + + $days = (int)floor($serial); + // 1900 system: 1899-12-30 plus the serial, which absorbs Excel's + // phantom 1900-02-29 for every serial after it. Before it (serial < 61) + // the base is one day later. 1904 system: serial 0 is 1904-01-01. + $base = '1899-12-30'; + if ($days < 61) { + $base = '1899-12-31'; + } + + if ($date1904 === true) { + $base = '1904-01-01'; + } + + $base = new DateTimeImmutable($base, new DateTimeZone('UTC')); + + return $base->add(new DateInterval('P' . $days . 'D'))->format('Y-m-d'); + }//end normaliseDate() + + /** + * A numeric identifier without a decimal part, as a string. + * + * @param mixed $value The raw value. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function normaliseId(mixed $value): string { + if (is_float($value) === true && floor($value) === $value && abs($value) < PHP_INT_MAX) { + return (string)(int)$value; + } + + $text = $this->toText(value: $value); + if (preg_match('/^(\d+)\.0+$/', $text, $matches) === 1) { + return $matches[1]; + } + + return $text; + }//end normaliseId() + + /** + * Any scalar as trimmed text; null as the empty string. + * + * @param mixed $value The raw value. + * + * @return string + */ + private function toText(mixed $value): string { + if ($value === null) { + return ''; + } + + if (is_bool($value) === true) { + if ($value === true) { + return 'TRUE'; + } + + return 'FALSE'; + } + + if (is_float($value) === true && floor($value) === $value && abs($value) < PHP_INT_MAX) { + return (string)(int)$value; + } + + if (is_scalar($value) === false) { + return ''; + } + + return trim((string)$value); + }//end toText() +}//end class diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php new file mode 100644 index 00000000..a822de15 --- /dev/null +++ b/lib/Service/Cmdb/CmdbWorkbookReader.php @@ -0,0 +1,427 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service\Cmdb; + +use OCA\Stackiq\Exception\CmdbImportException; +use Throwable; +use ZipArchive; + +/** + * Reads the allowlisted columns of the profile's source sheets. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The file checks before parsing and the + * header resolution are each a chain of small guards; together they pass the threshold. + */ +class CmdbWorkbookReader { + /** + * PhpSpreadsheet's Xlsx reader, shipped in OpenRegister's vendor directory. + */ + public const READER_CLASS = 'PhpOffice\PhpSpreadsheet\Reader\Xlsx'; + + /** + * The ZIP local-file-header signature every xlsx package starts with. + */ + private const ZIP_SIGNATURE = "PK\x03\x04"; + + /** + * Check that an upload is an xlsx workbook, without parsing it. + * + * @param string $path The uploaded temporary file. + * @param string $fileName The original file name. + * + * @return void + * + * @throws CmdbImportException NOT_XLSX when the name, signature or package is wrong. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function assertXlsx(string $path, string $fileName): void { + if (strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION)) !== 'xlsx') { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The file name does not end in .xlsx'); + } + + $head = false; + if (is_file($path) === true && is_readable($path) === true) { + $head = file_get_contents($path, false, null, 0, 4); + } + + if ($head !== self::ZIP_SIGNATURE) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The file is not a ZIP package'); + } + + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::RDONLY) !== true) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The ZIP package cannot be opened'); + } + + $hasWorkbook = ($zip->locateName('xl/workbook.xml') !== false); + $zip->close(); + + if ($hasWorkbook === false) { + throw new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'The package holds no xl/workbook.xml'); + } + }//end assertXlsx() + + /** + * Whether PhpSpreadsheet's Xlsx reader can be loaded. + * + * @return bool + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function isAvailable(): bool { + return class_exists(static::READER_CLASS) === true; + }//end isAvailable() + + /** + * Read the source sheets of an xlsx workbook. + * + * @param string $path The xlsx file, already checked by assertXlsx(). + * @param CmdbImportProfile $profile The import profile. + * + * @return array `rows` (list of {sheet, row, cells}), `importWarnings` + * (list of {sheet, message}) and `date1904` (bool). + * + * @throws CmdbImportException READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function read(string $path, CmdbImportProfile $profile): array { + if ($this->isAvailable() === false) { + throw new CmdbImportException( + errorCode: CmdbImportException::READER_UNAVAILABLE, + message: 'PhpSpreadsheet Xlsx reader is not available' + ); + } + + $readerClass = static::READER_CLASS; + $reader = new $readerClass(); + + try { + $available = $reader->listWorksheetNames($path); + } catch (Throwable $e) { + throw new CmdbImportException( + errorCode: CmdbImportException::NOT_XLSX, + message: 'The workbook cannot be read: ' . get_class($e), + previous: $e + ); + } + + $expected = $profile->sheetNames(); + $present = array_values(array_intersect($expected, $available)); + if ($present === []) { + throw new CmdbImportException( + errorCode: CmdbImportException::NO_SOURCE_SHEET, + message: 'The workbook holds none of the source sheets', + details: ['expected' => $expected] + ); + } + + $reader->setReadDataOnly(true); + $reader->setReadEmptyCells(false); + $reader->setLoadSheetsOnly($present); + + try { + $spreadsheet = $reader->load($path); + } catch (Throwable $e) { + throw new CmdbImportException( + errorCode: CmdbImportException::NOT_XLSX, + message: 'The workbook cannot be loaded: ' . get_class($e), + previous: $e + ); + } + + try { + $result = $this->readSheets(spreadsheet: $spreadsheet, sheetNames: $present, profile: $profile); + } finally { + $spreadsheet->disconnectWorksheets(); + } + + return $result; + }//end read() + + /** + * Normalise a header or column name for matching. + * + * @param string $header The raw header. + * + * @return string The normalised name. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public static function normaliseHeader(string $header): string { + $header = (string)preg_replace('/\s+/u', ' ', trim($header)); + $header = (string)preg_replace('/\s*(?::|⚡)+$/u', '', $header); + + return mb_strtolower(trim($header)); + }//end normaliseHeader() + + /** + * Read every present source sheet. + * + * @param object $spreadsheet The loaded PhpSpreadsheet workbook. + * @param array $sheetNames The present source sheets, in profile order. + * @param CmdbImportProfile $profile The import profile. + * + * @return array `rows` (list of {sheet, row, cells}), `importWarnings` + * (list of {sheet, message}) and `date1904` (bool). + * + * @throws CmdbImportException MISSING_COLUMN or TOO_MANY_ROWS. + */ + private function readSheets(object $spreadsheet, array $sheetNames, CmdbImportProfile $profile): array { + $referenced = $profile->referencedColumns(); + $mapped = $this->packSources(profile: $profile); + $required = $profile->requiredColumns(); + + // Resolve every sheet's columns first, so a missing required column + // stops the import before a single row is read. + $columnsPerSheet = []; + $warnings = []; + foreach ($sheetNames as $sheetName) { + $worksheet = $spreadsheet->getSheetByName($sheetName); + $columns = $this->resolveColumns(worksheet: $worksheet, referenced: $referenced); + + foreach ($required as $column) { + if (in_array($column, $columns, true) === false) { + throw new CmdbImportException( + errorCode: CmdbImportException::MISSING_COLUMN, + message: 'A source sheet lacks a required column', + details: ['sheet' => $sheetName, 'column' => $column] + ); + } + } + + foreach ($mapped as $column) { + if (in_array($column, $columns, true) === false && in_array($column, $required, true) === false) { + $warnings[] = ['sheet' => $sheetName, 'column' => $column, 'message' => sprintf('Optional column "%s" not found', $column)]; + } + } + + $columnsPerSheet[$sheetName] = $columns; + } + + $rows = []; + $limit = $profile->maxRowsPerSheet(); + foreach ($sheetNames as $sheetName) { + $worksheet = $spreadsheet->getSheetByName($sheetName); + $sheetRows = $this->readRows(worksheet: $worksheet, columns: $columnsPerSheet[$sheetName], sheetName: $sheetName, limit: $limit); + array_push($rows, ...$sheetRows); + } + + $date1904 = false; + if (method_exists($spreadsheet, 'getExcelCalendar') === true) { + $date1904 = ((int)$spreadsheet->getExcelCalendar() === 1904); + } + + return ['rows' => $rows, 'importWarnings' => $warnings, 'date1904' => $date1904]; + }//end readSheets() + + /** + * Map the header row to the referenced column names. + * + * @param object $worksheet The worksheet. + * @param array $referenced The allowlisted column names. + * + * @return array Column letter => referenced column name. + */ + private function resolveColumns(object $worksheet, array $referenced): array { + $wanted = []; + foreach ($referenced as $column) { + $wanted[self::normaliseHeader(header: $column)] = $column; + } + + $columns = []; + $lastColumn = self::columnIndex(letters: (string)$worksheet->getHighestDataColumn(1)); + for ($index = 1; $index <= $lastColumn; $index++) { + $letters = self::columnLetters(index: $index); + $coordinate = $letters . '1'; + if ($worksheet->cellExists($coordinate) === false) { + continue; + } + + $header = $this->cellValue(cell: $worksheet->getCell($coordinate)); + if (is_scalar($header) === false) { + continue; + } + + $name = $wanted[self::normaliseHeader(header: (string)$header)] ?? null; + // The first column with a matching header wins. + if ($name !== null && in_array($name, $columns, true) === false) { + $columns[$letters] = $name; + } + } + + return $columns; + }//end resolveColumns() + + /** + * Read the non-empty data rows of one sheet. + * + * @param object $worksheet The worksheet. + * @param array $columns Column letter => column name. + * @param string $sheetName The sheet name. + * @param int $limit The maximum number of non-empty rows. + * + * @return array}> + * + * @throws CmdbImportException TOO_MANY_ROWS. + */ + private function readRows(object $worksheet, array $columns, string $sheetName, int $limit): array { + $rows = []; + $lastRow = (int)$worksheet->getHighestDataRow(); + for ($rowNumber = 2; $rowNumber <= $lastRow; $rowNumber++) { + $cells = []; + $empty = true; + foreach ($columns as $letters => $name) { + $value = null; + $coordinate = $letters . $rowNumber; + if ($worksheet->cellExists($coordinate) === true) { + $value = $this->cellValue(cell: $worksheet->getCell($coordinate)); + } + + $cells[$name] = $value; + if ($value !== null && (is_string($value) === false || trim($value) !== '')) { + $empty = false; + } + } + + if ($empty === true) { + continue; + } + + if (count($rows) >= $limit) { + throw new CmdbImportException( + errorCode: CmdbImportException::TOO_MANY_ROWS, + message: 'A source sheet has more rows than the profile allows', + details: ['sheet' => $sheetName, 'limit' => $limit] + ); + } + + $rows[] = ['sheet' => $sheetName, 'row' => $rowNumber, 'cells' => $cells]; + }//end for + + return $rows; + }//end readRows() + + /** + * The stored value of a cell; for a formula, the value Excel cached. + * + * @param object $cell The PhpSpreadsheet cell. + * + * @return mixed A scalar or null. + */ + private function cellValue(object $cell): mixed { + $value = $cell->getValue(); + if ($cell->getDataType() === 'f') { + // The value Excel cached; the formula itself is never evaluated. + $value = $cell->getOldCalculatedValue(); + } + + if (is_object($value) === true && method_exists($value, 'getPlainText') === true) { + return (string)$value->getPlainText(); + } + + if (is_scalar($value) === false) { + return null; + } + + return $value; + }//end cellValue() + + /** + * The sources of every sheet-mapped pack field. + * + * @param CmdbImportProfile $profile The import profile. + * + * @return array + */ + private function packSources(CmdbImportProfile $profile): array { + $sources = []; + foreach (CmdbImportProfile::TARGETS as $target) { + if ($target === 'municipality') { + continue; + } + + foreach (($profile->pack(target: $target)['fieldMappings'] ?? []) as $mapping) { + $sources[] = (string)($mapping['source'] ?? ''); + } + } + + return array_values(array_unique(array_filter($sources, fn (string $source): bool => $source !== ''))); + }//end packSources() + + /** + * Column letters to a 1-based index ("A" = 1, "AA" = 27). + * + * @param string $letters The column letters. + * + * @return int + */ + private static function columnIndex(string $letters): int { + $index = 0; + foreach (str_split(strtoupper($letters)) as $char) { + $index = (($index * 26) + (ord($char) - 64)); + } + + return $index; + }//end columnIndex() + + /** + * A 1-based column index to its letters. + * + * @param int $index The column index. + * + * @return string + */ + private static function columnLetters(int $index): string { + $letters = ''; + while ($index > 0) { + $remainder = (($index - 1) % 26); + $letters = chr(65 + $remainder) . $letters; + $index = intdiv(($index - 1), 26); + } + + return $letters; + }//end columnLetters() +}//end class diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php new file mode 100644 index 00000000..bdfd081a --- /dev/null +++ b/lib/Service/CmdbExportImportService.php @@ -0,0 +1,1411 @@ +:`, so a second import of a newer + * export updates the same records. + * + * What each column becomes is declarative: the migration packs under + * `lib/Settings/cmdb-import/`, executed by OpenRegister's + * `MigrationPack\MappingEngine` (ADR-031). This class is the imperative glue + * around the file: reading it, splitting a row over four linked objects, + * resolving contacts, progress and cancel. Every read and write goes through + * OpenRegister's `ObjectServiceInterface` (ADR-022). + * + * Rules stated once and enforced here: + * - A module matches on `externalKey`; a usage on (consumer, module); a + * supplier on its normalised name and type Supplier; a contact person on + * (contactsUid, organization). + * - `publicationDate` is set to the import's start on create and never + * written on update; neither is `depublicationDate`. + * - Records missing from a newer export are left untouched. + * - Owners become contact persons, never Nextcloud user accounts, and no + * report entry or log line carries an owner name or e-mail address. + * + * @category Service + * @package OCA\Stackiq\Service + * @author Conduction b.v. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Service; + +use DateTimeImmutable; +use DateTimeZone; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\Stackiq\Exception\CmdbImportException; +use OCA\Stackiq\Service\Cmdb\CmdbImportProfile; +use OCA\Stackiq\Service\Cmdb\CmdbImportReport; +use OCA\Stackiq\Service\Cmdb\CmdbRowNormaliser; +use OCA\Stackiq\Service\Cmdb\CmdbWorkbookReader; +use OCP\IL10N; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Imports a TOPdesk CMDB export for one municipality. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One import run resolves four linked + * object kinds per row (supplier, module, owners, usage), each with its own match rule, + * create-only fields and run cache. Splitting them over several classes would hand the + * same run state from class to class without making any one rule simpler to read. + * @SuppressWarnings(PHPMD.TooManyFields) The run caches are one field per matched kind. + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) Reader, normaliser, profile, report, + * contacts, progress and OpenRegister are the import's collaborators by design. + * @SuppressWarnings(PHPMD.TooManyMethods) Each match rule and each step of a row is its own + * small method; merging them back would only make the steps longer. + * @SuppressWarnings(PHPMD.ExcessiveClassLength) Most of the length is docblocks that state + * the matching rules; the code itself is under the threshold. + */ +class CmdbExportImportService { + /** + * The ProgressTracker operation type of an import. + */ + public const OPERATION_TYPE = 'cmdb_import'; + + /** + * Operation ids a client may choose; anything else gets a generated id. + */ + public const OPERATION_ID_PATTERN = '/^cmdb-[A-Za-z0-9-]{8,64}$/'; + + /** + * OpenRegister's migration-pack mapping engine (not a public contract). + */ + public const ENGINE_CLASS = 'OCA\OpenRegister\Service\MigrationPack\MappingEngine'; + + /** + * Page size for loading the organisations a name may match. + */ + private const PAGE_SIZE = 500; + + /** + * Separator of the concat mapping for the internal note. + */ + private const NOTE_SEPARATOR = ' / '; + + /** + * The mapping engine of the current run. + * + * @var object|null + */ + private ?object $engine = null; + + /** + * OpenRegister and the register/schema ids of the current run. + * + * @var array{objectService: ObjectServiceInterface, register: int, module: int, organization: int, usage: int, contactPerson: int}|null + */ + private ?array $coordinates = null; + + /** + * Suppliers by normalised name, loaded once per run. + * + * @var array|null + */ + private ?array $suppliers = null; + + /** + * Middel-IDs seen in this upload. + * + * @var array + */ + private array $seenKeys = []; + + /** + * Contact UIDs by e-mail or display name, per run. + * + * @var array + */ + private array $contactUids = []; + + /** + * Contact person uuids by contactsUid and organisation, per run. + * + * @var array + */ + private array $contactPersons = []; + + /** + * Constructor. + * + * @param ContainerInterface $container Resolves OpenRegister services. + * @param SettingsService $settingsService Register and schema ids. + * @param StackiqContactSyncService $contactSync Nextcloud Contacts bridge. + * @param ProgressTracker $progressTracker Progress and cancel. + * @param CmdbImportProfile $profile The import profile and packs. + * @param CmdbWorkbookReader $reader The xlsx reader. + * @param CmdbRowNormaliser $normaliser Dates and ids to strings. + * @param IL10N $l10n Translates report reasons and warnings. + * @param LoggerInterface $logger Logger; never handed person data. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function __construct( + private readonly ContainerInterface $container, + private readonly SettingsService $settingsService, + private readonly StackiqContactSyncService $contactSync, + private readonly ProgressTracker $progressTracker, + private readonly CmdbImportProfile $profile, + private readonly CmdbWorkbookReader $reader, + private readonly CmdbRowNormaliser $normaliser, + private readonly IL10N $l10n, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * The upload limit in bytes (the profile's `maxFileBytes`). + * + * @return int + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function maxFileBytes(): int { + return $this->profile->maxFileBytes(); + }//end maxFileBytes() + + /** + * Check that an upload is an xlsx workbook, without parsing it. + * + * @param string $path The uploaded temporary file. + * @param string $fileName The original file name. + * + * @return void + * + * @throws CmdbImportException NOT_XLSX. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function assertXlsx(string $path, string $fileName): void { + $this->reader->assertXlsx(path: $path, fileName: $fileName); + }//end assertXlsx() + + /** + * Whether a `missingRecords` value is supported (only `keep`). + * + * @param string $mode The requested value. + * + * @return bool + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + */ + public function supportsMissingRecords(string $mode): bool { + return $mode === 'keep'; + }//end supportsMissingRecords() + + /** + * Ask a running import to stop between rows. + * + * @param string $operationId The operation id. + * + * @return bool False when no `cmdb_import` operation has this id. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function requestCancel(string $operationId): bool { + if (preg_match(self::OPERATION_ID_PATTERN, $operationId) !== 1) { + return false; + } + + $progress = $this->progressTracker->getProgress(operationId: $operationId); + if (is_array($progress) === false || ($progress['operation_type'] ?? null) !== self::OPERATION_TYPE) { + return false; + } + + $this->progressTracker->setCancelRequested(operationId: $operationId); + return true; + }//end requestCancel() + + /** + * Import an export for one municipality. + * + * Validation that can fail the whole import runs before any object is + * written: the packs and the engine, the configuration, the workbook and + * the municipality uuid. After that every row is processed in its own + * error boundary. + * + * @param string $path The xlsx file, already checked by assertXlsx(). + * @param array $options municipalityUuid, municipalityName, updateExisting, operationId. + * + * @return array The report (contract.md). + * + * @throws CmdbImportException MAPPING_UNAVAILABLE, NOT_CONFIGURED, READER_UNAVAILABLE, NOT_XLSX, + * NO_SOURCE_SHEET, MISSING_COLUMN, TOO_MANY_ROWS, MUNICIPALITY_REQUIRED + * or MUNICIPALITY_INVALID. + * @throws \Exception An unexpected OpenRegister error outside a row, such as + * creating the municipality; rows catch their own. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + public function import(string $path, array $options): array { + $this->resetRun(); + $startedAt = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format(DATE_ATOM); + + $this->profile->load(); + $this->engine = $this->resolveEngine(); + $this->coordinates = $this->resolveCoordinates(); + + $workbook = $this->reader->read(path: $path, profile: $this->profile); + $municipality = $this->resolveMunicipality(options: $options); + + $operationId = $this->operationIdFrom(options: $options); + $rows = $workbook['rows']; + $report = new CmdbImportReport(operationId: $operationId, rowsRead: count($rows)); + $report->setMunicipality(uuid: $municipality['uuid'], name: $municipality['name'], created: $municipality['created']); + $report->addImportWarnings(warnings: $this->translateImportWarnings(warnings: $workbook['importWarnings'])); + + $this->progressTracker->startOperation( + operationType: self::OPERATION_TYPE, + options: ['total_items' => count($rows)], + operationId: $operationId + ); + $this->progressTracker->setPhase(phase: 'processing_elements', data: ['total_items' => count($rows)]); + + $updateExisting = (($options['updateExisting'] ?? true) !== false); + foreach ($rows as $index => $row) { + if ($this->progressTracker->isCancelRequested(operationId: $operationId) === true) { + $report->markCancelled(); + break; + } + + $this->processRow( + row: $row, + municipalityUuid: $municipality['uuid'], + updateExisting: $updateExisting, + startedAt: $startedAt, + date1904: $workbook['date1904'], + report: $report + ); + $this->progressTracker->updateProgress(processedItems: ($index + 1)); + } + + $result = $report->toArray(); + $this->finishOperation(report: $result); + + $this->logger->info( + 'CmdbExportImportService: import finished', + ['operationId' => $operationId, 'summary' => $result['summary'], 'cancelled' => $result['cancelled']] + ); + + return $result; + }//end import() + + /** + * Process one row in its own error boundary and add its outcome. + * + * @param array{sheet: string, row: int, cells: array} $row The reader row. + * @param string $municipalityUuid The consumer. + * @param bool $updateExisting Whether matched rows are updated. + * @param string $startedAt ISO start time of the import. + * @param bool $date1904 The workbook's date system. + * @param CmdbImportReport $report The report. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function processRow( + array $row, + string $municipalityUuid, + bool $updateExisting, + string $startedAt, + bool $date1904, + CmdbImportReport $report, + ): void { + $values = $this->normaliser->normalise( + cells: $row['cells'], + dateColumns: $this->profile->dateColumns(), + idColumns: $this->profile->idColumns(), + date1904: $date1904 + ); + $sheet = $row['sheet']; + $rowNumber = $row['row']; + $middelId = ($values[$this->profile->keyColumn()] ?? ''); + $name = ($values[$this->profile->nameColumn()] ?? ''); + + $entry = ['sheet' => $sheet, 'row' => $rowNumber, 'middelId' => $middelId, 'name' => $name]; + + $skipReason = $this->skipReason(sheet: $sheet, values: $values, middelId: $middelId); + if ($skipReason !== null) { + $this->addRow(report: $report, entry: $entry, outcome: CmdbImportReport::SKIPPED, reasons: [$skipReason]); + return; + } + + $step = 'mapping'; + $moduleUuid = null; + $usageUuid = null; + $warnings = []; + + try { + $module = $this->map(target: 'module', values: $values, rowNumber: $rowNumber, warnings: $warnings); + if ($module['missing'] !== []) { + $reasons = array_map(fn (string $column): string => $this->l10n->t('missing %s', [$column]), $module['missing']); + $this->addRow(report: $report, entry: $entry, outcome: CmdbImportReport::SKIPPED, reasons: $reasons, warnings: $warnings); + return; + } + + $step = 'manufacturer'; + $providerUuid = $this->resolveManufacturer(values: $values, rowNumber: $rowNumber); + + $step = 'module'; + $externalKey = $this->profile->externalKeyPrefix() . ':' . $municipalityUuid . ':' . $middelId; + $moduleResult = $this->upsertModule( + data: $module['data'], + externalKey: $externalKey, + providerUuid: $providerUuid, + startedAt: $startedAt, + updateExisting: $updateExisting + ); + $moduleUuid = $moduleResult['uuid']; + if ($moduleResult['outcome'] === 'exists') { + $this->addRow( + report: $report, + entry: $entry, + outcome: CmdbImportReport::SKIPPED, + reasons: [$this->l10n->t('exists')], + warnings: $warnings, + moduleUuid: $moduleUuid + ); + return; + } + + $step = 'owners'; + $owners = $this->resolveOwners(values: $values, rowNumber: $rowNumber, municipalityUuid: $municipalityUuid, warnings: $warnings); + + $step = 'usage'; + $usage = $this->map(target: 'usage', values: $values, rowNumber: $rowNumber, warnings: $warnings); + $usageResult = $this->upsertUsage( + data: $usage['data'], + municipalityUuid: $municipalityUuid, + moduleUuid: $moduleUuid, + providerUuid: $providerUuid, + owners: $owners + ); + $usageUuid = $usageResult['uuid']; + } catch (Throwable $e) { + $this->failRow(report: $report, entry: $entry, step: $step, e: $e, warnings: $warnings, uuids: [$moduleUuid, $usageUuid]); + return; + }//end try + + $outcome = self::rowOutcome(module: $moduleResult['outcome'], usage: $usageResult['outcome']); + $this->addRow(report: $report, entry: $entry, outcome: $outcome, warnings: $warnings, moduleUuid: $moduleUuid, usageUuid: $usageUuid); + }//end processRow() + + /** + * Report a row as failed at a step, and log it without person data. + * + * @param CmdbImportReport $report The report. + * @param array{sheet: string, row: int, middelId: string, name: string} $entry Where the row is. + * @param string $step The step that failed. + * @param Throwable $e The cause. + * @param array $warnings Row warnings so far. + * @param array{0: string|null, 1: string|null} $uuids Module and usage, when saved. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function failRow(CmdbImportReport $report, array $entry, string $step, Throwable $e, array $warnings, array $uuids): void { + $detail = $this->safeMessage(step: $step, e: $e); + $this->logger->warning( + 'CmdbExportImportService: row failed', + array_merge( + ['sheet' => $entry['sheet'], 'row' => $entry['row'], 'middelId' => $entry['middelId']], + ['step' => $step, 'exception' => get_class($e), 'error' => $detail] + ) + ); + + $reason = $this->l10n->t('step "%s" failed', [$step]); + if ($detail !== '') { + $reason = $this->l10n->t('step "%1$s" failed: %2$s', [$step, $detail]); + } + + $this->addRow( + report: $report, + entry: $entry, + outcome: CmdbImportReport::FAILED, + reasons: [$reason], + warnings: $warnings, + moduleUuid: $uuids[0], + usageUuid: $uuids[1] + ); + }//end failRow() + + /** + * Translate the reader's import-level warnings. + * + * @param array $warnings The reader warnings. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function translateImportWarnings(array $warnings): array { + $translated = []; + foreach ($warnings as $warning) { + $message = $warning['message']; + if (isset($warning['column']) === true) { + $message = $this->l10n->t('Optional column "%s" not found', [$warning['column']]); + } + + $translated[] = ['sheet' => $warning['sheet'], 'message' => $message]; + } + + return $translated; + }//end translateImportWarnings() + + /** + * The row outcome from the module and usage outcomes. + * + * @param string $module The module outcome. + * @param string $usage The usage outcome. + * + * @return string created when the module was created, updated when anything was saved, else unchanged. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private static function rowOutcome(string $module, string $usage): string { + if ($module === CmdbImportReport::CREATED) { + return CmdbImportReport::CREATED; + } + + if ($module !== CmdbImportReport::UNCHANGED || $usage !== CmdbImportReport::UNCHANGED) { + return CmdbImportReport::UPDATED; + } + + return CmdbImportReport::UNCHANGED; + }//end rowOutcome() + + /** + * Store the report with the operation and close it, as completed or cancelled. + * + * @param array $report The report. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function finishOperation(array $report): void { + if ($report['cancelled'] === true) { + $this->progressTracker->updateStatistics(statistics: ['report' => $report]); + $this->progressTracker->cancelOperation(); + return; + } + + $this->progressTracker->completeOperation(finalStatistics: ['report' => $report]); + }//end finishOperation() + + /** + * Add a row outcome to the report. + * + * @param CmdbImportReport $report The report. + * @param array{sheet: string, row: int, middelId: string, name: string} $entry Where the row is. + * @param string $outcome The outcome. + * @param array $reasons Reasons. + * @param array $warnings Warnings. + * @param string|null $moduleUuid The module. + * @param string|null $usageUuid The usage. + * + * @return void + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function addRow( + CmdbImportReport $report, + array $entry, + string $outcome, + array $reasons = [], + array $warnings = [], + ?string $moduleUuid = null, + ?string $usageUuid = null, + ): void { + $report->addRow( + sheet: $entry['sheet'], + row: $entry['row'], + middelId: $entry['middelId'], + name: $entry['name'], + outcome: $outcome, + reasons: $reasons, + warnings: $warnings, + moduleUuid: $moduleUuid, + usageUuid: $usageUuid + ); + }//end addRow() + + /** + * Why a row is skipped before mapping, or null when it is imported. + * + * @param string $sheet The sheet name. + * @param array $values The normalised row. + * @param string $middelId The Middel-ID. + * + * @return string|null + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + */ + private function skipReason(string $sheet, array $values, string $middelId): ?string { + if ($middelId === '') { + return $this->l10n->t('missing %s', [$this->profile->keyColumn()]); + } + + if (isset($this->seenKeys[$middelId]) === true) { + return $this->l10n->t('duplicate %s in file', [$this->profile->keyColumn()]); + } + + $this->seenKeys[$middelId] = true; + + $kindColumn = $this->profile->kindColumn(); + $accepted = $this->profile->acceptedKinds(sheetName: $sheet); + if ($accepted !== [] && array_key_exists($kindColumn, $values) === true + && in_array($values[$kindColumn], $accepted, true) === false + ) { + return $this->l10n->t('unsupported %1$s "%2$s"', [$kindColumn, $values[$kindColumn]]); + } + + return null; + }//end skipReason() + + /** + * Map a row through a target's pack. + * + * Errors on required mappings are returned as missing columns. Other + * errors drop the field and become a warning naming the column and the + * value, except for the owner and manufacturer packs, whose errors are + * silent: such a row simply has no owner or no manufacturer. + * + * @param string $target The pack target. + * @param array $values The normalised row. + * @param int $rowNumber The sheet row number, for the engine's errors. + * @param array $warnings Row warnings, appended to. + * + * @return array{data: array, missing: array} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function map(string $target, array $values, int $rowNumber, array &$warnings): array { + $pack = $this->profile->pack(target: $target); + $result = $this->engine->mapRow($pack, $values, $rowNumber); + + $required = []; + foreach (($pack['fieldMappings'] ?? []) as $mapping) { + if (($mapping['required'] ?? false) === true) { + $required[] = (string)($mapping['source'] ?? ''); + } + } + + $silent = in_array($target, ['manufacturer', 'businessOwner', 'technicalOwner', 'municipality'], true); + $missing = []; + foreach (($result['errors'] ?? []) as $error) { + $source = (string)($error['source'] ?? ''); + if (in_array($source, $required, true) === true) { + $missing[] = $source; + continue; + } + + if ($silent === false) { + $warnings[] = $this->l10n->t('Column "%1$s": %2$s', [$source, (string)($error['message'] ?? '')]); + } + } + + $data = ($result['data'] ?? []); + unset($data['id']); + + return ['data' => $data, 'missing' => array_values(array_unique($missing))]; + }//end map() + + /** + * Find or create the Supplier organisation for the row's manufacturer. + * + * @param array $values The normalised row. + * @param int $rowNumber The sheet row number. + * + * @return string|null The supplier uuid, or null when the row names no manufacturer. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function resolveManufacturer(array $values, int $rowNumber): ?string { + $warnings = []; + $mapped = $this->map(target: 'manufacturer', values: $values, rowNumber: $rowNumber, warnings: $warnings); + $name = trim((string)($mapped['data']['name'] ?? '')); + if ($mapped['missing'] !== [] || $name === '') { + return null; + } + + $key = self::normaliseName(name: $name); + $suppliers = $this->suppliers(); + if (isset($suppliers[$key]) === true) { + return $suppliers[$key]; + } + + $data = $mapped['data']; + $data['name'] = (string)preg_replace('/\s+/u', ' ', $name); + $uuid = $this->save(schemaKey: 'organization', data: $data, uuid: null); + $this->suppliers[$key] = $uuid; + + return $uuid; + }//end resolveManufacturer() + + /** + * Create, update, or leave the module matched on its external key. + * + * @param array $data The mapped module fields. + * @param string $externalKey The match key. + * @param string|null $providerUuid The supplier, when there is one. + * @param string $startedAt ISO start time of the import. + * @param bool $updateExisting Whether a match is updated. + * + * @return array{uuid: string, outcome: string} Outcome created, updated, unchanged or exists. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function upsertModule(array $data, string $externalKey, ?string $providerUuid, string $startedAt, bool $updateExisting): array { + $data['externalKey'] = $externalKey; + if ($providerUuid !== null) { + $data['provider'] = $providerUuid; + } + + $existing = $this->findOne(schemaKey: 'module', filters: ['externalKey' => $externalKey]); + if ($existing === null) { + $create = array_merge($this->profile->createOnlyDefaults(target: 'module'), $data); + $create['publicationDate'] = $startedAt; + return ['uuid' => $this->save(schemaKey: 'module', data: $create, uuid: null), 'outcome' => CmdbImportReport::CREATED]; + } + + $uuid = (string)$existing->getUuid(); + if ($updateExisting === false) { + return ['uuid' => $uuid, 'outcome' => 'exists']; + } + + $merged = $this->merge(target: 'module', stored: $existing->getObject(), mapped: $data); + if ($merged === null) { + return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED]; + } + + $this->save(schemaKey: 'module', data: $merged, uuid: $uuid); + return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED]; + }//end upsertModule() + + /** + * Create, update, or leave the usage of the module for the municipality. + * + * @param array $data The mapped usage fields. + * @param string $municipalityUuid The consumer. + * @param string $moduleUuid The module. + * @param string|null $providerUuid The supplier, when there is one. + * @param array{businessOwner: string|null, technicalOwner: string|null} $owners The owner contact persons. + * + * @return array{uuid: string, outcome: string} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function upsertUsage(array $data, string $municipalityUuid, string $moduleUuid, ?string $providerUuid, array $owners): array { + if (isset($data['interneAnnotation']) === true && is_string($data['interneAnnotation']) === true) { + $note = $data['interneAnnotation']; + if (str_ends_with($note, self::NOTE_SEPARATOR) === true) { + $note = substr($note, 0, -strlen(self::NOTE_SEPARATOR)); + } + + $data['interneAnnotation'] = trim($note); + } + + $data['consumer'] = $municipalityUuid; + $data['module'] = $moduleUuid; + if ($providerUuid !== null) { + $data['provider'] = $providerUuid; + } + + foreach ($owners as $field => $contactPersonUuid) { + if ($contactPersonUuid !== null) { + $data[$field] = $contactPersonUuid; + } + } + + $existing = $this->findOne(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]); + if ($existing === null) { + return ['uuid' => $this->save(schemaKey: 'usage', data: $data, uuid: null), 'outcome' => CmdbImportReport::CREATED]; + } + + $uuid = (string)$existing->getUuid(); + $merged = $this->merge(target: 'usage', stored: $existing->getObject(), mapped: $data); + if ($merged === null) { + return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED]; + } + + $this->save(schemaKey: 'usage', data: $merged, uuid: $uuid); + return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED]; + }//end upsertUsage() + + /** + * Merge mapped fields onto a stored object. + * + * Fields the pack does not map stay as they are. Create-only fields are + * written only when the stored value is empty; never-written fields are + * never touched. + * + * @param string $target The profile target. + * @param array $stored The stored object data. + * @param array $mapped The mapped fields. + * + * @return array|null The merged object, or null when nothing changes. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function merge(string $target, array $stored, array $mapped): ?array { + $createOnly = $this->profile->createOnlyFields(target: $target); + $never = $this->profile->neverWrittenOnUpdate(target: $target); + $merged = $stored; + unset($merged['@self']); + $changed = false; + + foreach ($mapped as $field => $value) { + if (in_array($field, $never, true) === true) { + continue; + } + + $current = ($stored[$field] ?? null); + if (in_array($field, $createOnly, true) === true && self::isEmptyValue(value: $current) === false) { + continue; + } + + if (self::sameValue(stored: $current, value: $value) === true) { + continue; + } + + $merged[$field] = $value; + $changed = true; + } + + if ($changed === false) { + return null; + } + + return $merged; + }//end merge() + + /** + * Resolve the business and technical owner of a row as contact persons. + * + * @param array $values The normalised row. + * @param int $rowNumber The sheet row number. + * @param string $municipalityUuid The municipality the contact persons belong to. + * @param array $warnings Row warnings, appended to. + * + * @return array{businessOwner: string|null, technicalOwner: string|null} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + private function resolveOwners(array $values, int $rowNumber, string $municipalityUuid, array &$warnings): array { + $owners = ['businessOwner' => null, 'technicalOwner' => null]; + $identities = []; + foreach (array_keys($owners) as $target) { + $silent = []; + $mapped = $this->map(target: $target, values: $values, rowNumber: $rowNumber, warnings: $silent); + $name = trim((string)($mapped['data']['name'] ?? '')); + if ($mapped['missing'] === [] && $name !== '') { + $identities[$target] = $mapped['data']; + } + } + + if ($identities === []) { + return $owners; + } + + if ($this->contactSync->isAvailable() === false) { + $warnings[] = $this->l10n->t('Owners skipped: Nextcloud Contacts is unavailable'); + return $owners; + } + + foreach ($identities as $target => $identity) { + $column = $this->ownerColumn(target: $target); + try { + $contactsUid = $this->resolveContactUid(identity: $identity); + if ($contactsUid === null) { + $warnings[] = $this->l10n->t('Owner from column "%s" could not be resolved in Nextcloud Contacts', [$column]); + continue; + } + + $owners[$target] = $this->resolveContactPerson( + contactsUid: $contactsUid, + municipalityUuid: $municipalityUuid, + role: trim((string)($identity['role'] ?? '')) + ); + } catch (Throwable $e) { + $this->logger->warning( + 'CmdbExportImportService: owner could not be resolved', + ['row' => $rowNumber, 'column' => $column, 'exception' => get_class($e)] + ); + $warnings[] = $this->l10n->t('Owner from column "%s" could not be resolved', [$column]); + } + }//end foreach + + return $owners; + }//end resolveOwners() + + /** + * Resolve the Nextcloud contact of an owner identity. + * + * With an e-mail address, StackiqContactSyncService matches on it or + * creates the contact. Without one, only a contact whose display name is + * exactly the owner's name (case-insensitive) is reused, so an owner + * known by name alone is not created again on every import. + * + * @param array $identity name, email and role from the owner pack. + * + * @return string|null The contact UID, or null. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + private function resolveContactUid(array $identity): ?string { + $parts = self::splitPersonName(name: (string)$identity['name']); + $displayName = trim($parts['voornaam'] . ' ' . $parts['achternaam']); + $email = trim((string)($identity['email'] ?? '')); + + $cacheKey = 'name:' . mb_strtolower($displayName); + if ($email !== '') { + $cacheKey = 'email:' . mb_strtolower($email); + } + + if (array_key_exists($cacheKey, $this->contactUids) === true) { + return $this->contactUids[$cacheKey]; + } + + $uid = null; + if ($email === '') { + $uid = $this->contactByDisplayName(displayName: $displayName); + } + + if ($uid === null) { + $record = ['voornaam' => $parts['voornaam'], 'achternaam' => $parts['achternaam']]; + if ($email !== '') { + $record['email'] = $email; + } + + $role = trim((string)($identity['role'] ?? '')); + if ($role !== '') { + $record['role'] = $role; + } + + $uid = $this->contactSync->syncToContacts(objectType: 'contactPerson', record: $record); + if ($uid === '') { + $uid = null; + } + } + + $this->contactUids[$cacheKey] = $uid; + return $uid; + }//end resolveContactUid() + + /** + * The contact whose display name is exactly this one, case-insensitive. + * + * @param string $displayName The display name. + * + * @return string|null The contact UID, or null. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + private function contactByDisplayName(string $displayName): ?string { + $needle = mb_strtolower($displayName); + foreach ($this->contactSync->searchContacts(query: $displayName) as $contact) { + if (mb_strtolower(trim((string)($contact['name'] ?? ''))) === $needle) { + return (string)$contact['uid']; + } + } + + return null; + }//end contactByDisplayName() + + /** + * Find or create the contact person of a contact for the municipality. + * + * The object carries only `contactsUid`, `organization` and `role`: no + * e-mail and no username, so neither the contact-person listener nor + * OrganizationSyncService::performUserSync() provisions a user for it. + * + * @param string $contactsUid The Nextcloud contact UID. + * @param string $municipalityUuid The organisation. + * @param string $role The owner's function, or ''. + * + * @return string The contact person uuid. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + private function resolveContactPerson(string $contactsUid, string $municipalityUuid, string $role): string { + $cacheKey = $contactsUid . '|' . $municipalityUuid; + if (isset($this->contactPersons[$cacheKey]) === true) { + return $this->contactPersons[$cacheKey]; + } + + $existing = $this->findOne(schemaKey: 'contactPerson', filters: ['contactsUid' => $contactsUid, 'organization' => $municipalityUuid]); + $data = ['contactsUid' => $contactsUid, 'organization' => $municipalityUuid]; + if ($role !== '') { + $data['role'] = $role; + } + + $uuid = (string)$existing?->getUuid(); + if ($existing === null) { + $uuid = $this->save(schemaKey: 'contactPerson', data: $data, uuid: null); + } + + $this->contactPersons[$cacheKey] = $uuid; + return $uuid; + }//end resolveContactPerson() + + /** + * Resolve the consuming municipality from the options. + * + * @param array $options municipalityUuid or municipalityName. + * + * @return array{uuid: string, name: string, created: bool} + * + * @throws CmdbImportException MUNICIPALITY_REQUIRED or MUNICIPALITY_INVALID. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function resolveMunicipality(array $options): array { + $uuid = trim((string)($options['municipalityUuid'] ?? '')); + if ($uuid !== '') { + return $this->municipalityByUuid(uuid: $uuid); + } + + $warnings = []; + $values = ['municipalityName' => trim((string)($options['municipalityName'] ?? ''))]; + $mapped = $this->map(target: 'municipality', values: $values, rowNumber: 0, warnings: $warnings); + $name = trim((string)preg_replace('/\s+/u', ' ', (string)($mapped['data']['name'] ?? ''))); + if ($mapped['missing'] !== [] || $name === '') { + throw new CmdbImportException(errorCode: CmdbImportException::MUNICIPALITY_REQUIRED, message: 'No municipality given'); + } + + $key = self::normaliseName(name: $name); + foreach ($this->organisationsOfType(type: 'Municipality') as $organisation) { + if (self::normaliseName(name: (string)($organisation['name'] ?? '')) === $key) { + return ['uuid' => $organisation['uuid'], 'name' => (string)$organisation['name'], 'created' => false]; + } + } + + $data = $mapped['data']; + $data['name'] = $name; + $created = $this->save(schemaKey: 'organization', data: $data, uuid: null); + + return ['uuid' => $created, 'name' => $name, 'created' => true]; + }//end resolveMunicipality() + + /** + * Resolve a municipality uuid, which must be an organisation of type Municipality. + * + * @param string $uuid The organisation uuid. + * + * @return array{uuid: string, name: string, created: bool} + * + * @throws CmdbImportException MUNICIPALITY_INVALID. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function municipalityByUuid(string $uuid): array { + $coordinates = $this->coordinates(); + $organisation = null; + try { + $organisation = $coordinates['objectService']->find( + id: $uuid, + register: $coordinates['register'], + schema: $coordinates['organization'], + _rbac: false, + _multitenancy: false + ); + } catch (Throwable $e) { + // OpenRegister throws DoesNotExistException for an unknown uuid. + $organisation = null; + } + + $data = []; + if ($organisation !== null) { + $data = $organisation->getObject(); + } + + if ($organisation === null || ($data['type'] ?? null) !== 'Municipality') { + throw new CmdbImportException( + errorCode: CmdbImportException::MUNICIPALITY_INVALID, + message: 'The municipality uuid is not an organisation of type Municipality' + ); + } + + return ['uuid' => (string)$organisation->getUuid(), 'name' => (string)($data['name'] ?? ''), 'created' => false]; + }//end municipalityByUuid() + + /** + * Suppliers by normalised name, loaded once per run. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function suppliers(): array { + if ($this->suppliers === null) { + $this->suppliers = []; + foreach ($this->organisationsOfType(type: 'Supplier') as $organisation) { + $key = self::normaliseName(name: (string)($organisation['name'] ?? '')); + if ($key !== '' && isset($this->suppliers[$key]) === false) { + $this->suppliers[$key] = $organisation['uuid']; + } + } + } + + return $this->suppliers; + }//end suppliers() + + /** + * Every organisation of a type, as uuid and name. + * + * @param string $type Municipality or Supplier. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function organisationsOfType(string $type): array { + $coordinates = $this->coordinates(); + $found = []; + $offset = 0; + do { + $page = $coordinates['objectService']->searchObjects( + query: [ + '@self' => ['register' => $coordinates['register'], 'schema' => $coordinates['organization']], + 'type' => $type, + '_limit' => self::PAGE_SIZE, + '_offset' => $offset, + ], + _rbac: false, + _multitenancy: false + ); + if (is_array($page) === false) { + $page = []; + } + + foreach ($page as $entity) { + $data = $entity->getObject(); + // The filter is checked again: a filter OpenRegister cannot apply must not widen the match. + if (($data['type'] ?? null) === $type && $entity->getUuid() !== null) { + $found[] = ['uuid' => (string)$entity->getUuid(), 'name' => ($data['name'] ?? '')]; + } + } + + $offset += self::PAGE_SIZE; + $pageSize = count($page); + } while ($pageSize === self::PAGE_SIZE); + + return $found; + }//end organisationsOfType() + + /** + * The one object matching every filter, or null. + * + * @param string $schemaKey The coordinates key of the schema. + * @param array $filters Field => exact value. + * + * @return object|null The entity (ObjectEntityInterface). + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function findOne(string $schemaKey, array $filters): ?object { + $coordinates = $this->coordinates(); + $results = $coordinates['objectService']->searchObjects( + query: array_merge( + ['@self' => ['register' => $coordinates['register'], 'schema' => $coordinates[$schemaKey]], '_limit' => 10], + $filters + ), + _rbac: false, + _multitenancy: false + ); + if (is_array($results) === false) { + return null; + } + + foreach ($results as $entity) { + $data = $entity->getObject(); + $matches = true; + foreach ($filters as $field => $value) { + if (self::relationUuid(value: ($data[$field] ?? null)) !== $value) { + $matches = false; + break; + } + } + + if ($matches === true) { + return $entity; + } + } + + return null; + }//end findOne() + + /** + * Save an object through OpenRegister and return its uuid. + * + * @param string $schemaKey The coordinates key of the schema. + * @param array $data The object data. + * @param string|null $uuid The uuid to update, or null to create. + * + * @return string The uuid. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function save(string $schemaKey, array $data, ?string $uuid): string { + $coordinates = $this->coordinates(); + $entity = $coordinates['objectService']->saveObject( + object: $data, + register: $coordinates['register'], + schema: $coordinates[$schemaKey], + uuid: $uuid, + _rbac: false, + _multitenancy: false + ); + + return (string)$entity->getUuid(); + }//end save() + + /** + * Resolve OpenRegister's mapping engine. + * + * @return object The engine, with `mapRow(array, array, int): array`. + * + * @throws CmdbImportException MAPPING_UNAVAILABLE. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + */ + private function resolveEngine(): object { + $class = static::ENGINE_CLASS; + try { + if ($this->container->has($class) === true) { + $engine = $this->container->get($class); + if (is_object($engine) === true && method_exists($engine, 'mapRow') === true) { + return $engine; + } + } + } catch (Throwable $e) { + // Fall through to the class check below. + $engine = null; + } + + if (class_exists($class) === true) { + $engine = new $class(); + if (method_exists($engine, 'mapRow') === true) { + return $engine; + } + } + + throw new CmdbImportException(errorCode: CmdbImportException::MAPPING_UNAVAILABLE, message: 'OpenRegister MappingEngine is not available'); + }//end resolveEngine() + + /** + * Resolve OpenRegister and the register and schema ids, failing closed. + * + * @return array{objectService: ObjectServiceInterface, register: int, module: int, organization: int, usage: int, contactPerson: int} + * + * @throws CmdbImportException NOT_CONFIGURED when OpenRegister or a schema is not configured. + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + private function resolveCoordinates(): array { + try { + $objectService = $this->container->get(ObjectServiceInterface::class); + } catch (Throwable $e) { + $objectService = null; + } + + $register = (int)($this->settingsService->getVoorzieningenConfig()['register'] ?? 0); + $schemas = []; + foreach (['module', 'organization', 'usage', 'contactPerson'] as $type) { + $schemas[$type] = (int)($this->settingsService->getSchemaIdForObjectType($type) ?? 0); + } + + if ($objectService instanceof ObjectServiceInterface === false || $register <= 0 || in_array(0, $schemas, true) === true) { + throw new CmdbImportException( + errorCode: CmdbImportException::NOT_CONFIGURED, + message: 'OpenRegister or the stackiq register and schemas are not configured' + ); + } + + return array_merge(['objectService' => $objectService, 'register' => $register], $schemas); + }//end resolveCoordinates() + + /** + * The coordinates of the current run. + * + * @return array{objectService: ObjectServiceInterface, register: int, module: int, organization: int, usage: int, contactPerson: int} + * + * @throws CmdbImportException NOT_CONFIGURED. + */ + private function coordinates(): array { + if ($this->coordinates === null) { + $this->coordinates = $this->resolveCoordinates(); + } + + return $this->coordinates; + }//end coordinates() + + /** + * The operation id the client chose, or a new one. + * + * @param array $options The import options. + * + * @return string + */ + private function operationIdFrom(array $options): string { + $operationId = $options['operationId'] ?? null; + if (is_string($operationId) === true && preg_match(self::OPERATION_ID_PATTERN, $operationId) === 1) { + return $operationId; + } + + return 'cmdb-' . bin2hex(random_bytes(16)); + }//end operationIdFrom() + + /** + * Clear the run caches. + * + * @return void + */ + private function resetRun(): void { + $this->engine = null; + $this->coordinates = null; + $this->suppliers = null; + $this->seenKeys = []; + $this->contactUids = []; + $this->contactPersons = []; + }//end resetRun() + + /** + * The source column of an owner target, for warnings. + * + * @param string $target businessOwner or technicalOwner. + * + * @return string + */ + private function ownerColumn(string $target): string { + foreach (($this->profile->pack(target: $target)['fieldMappings'] ?? []) as $mapping) { + if (($mapping['target'] ?? null) === 'name') { + return (string)($mapping['source'] ?? $target); + } + } + + return $target; + }//end ownerColumn() + + /** + * An exception message that is safe for the report. + * + * Owner steps get no detail, so no contact data can leak into the report. + * + * @param string $step The step that failed. + * @param Throwable $e The exception. + * + * @return string + */ + private function safeMessage(string $step, Throwable $e): string { + if ($step === 'owners') { + return ''; + } + + return mb_substr(trim($e->getMessage()), 0, 300); + }//end safeMessage() + + /** + * Split a TOPdesk person name ("Achternaam, Voornaam"). + * + * @param string $name The name. + * + * @return array{voornaam: string, achternaam: string} + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + */ + public static function splitPersonName(string $name): array { + $name = trim((string)preg_replace('/\s+/u', ' ', $name)); + if (str_contains($name, ',') === true) { + [$last, $first] = array_map('trim', explode(',', $name, 2)); + return ['voornaam' => $first, 'achternaam' => $last]; + } + + return ['voornaam' => '', 'achternaam' => $name]; + }//end splitPersonName() + + /** + * Normalise an organisation name for matching: trim, collapse whitespace, lower case. + * + * @param string $name The name. + * + * @return string + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public static function normaliseName(string $name): string { + return mb_strtolower(trim((string)preg_replace('/\s+/u', ' ', $name))); + }//end normaliseName() + + /** + * A relation value (uuid string, or array/object with uuid or id) as a string. + * + * @param mixed $value The stored value. + * + * @return string|null + */ + private static function relationUuid(mixed $value): ?string { + if (is_scalar($value) === true) { + return (string)$value; + } + + if (is_array($value) === true) { + $uuid = ($value['uuid'] ?? ($value['id'] ?? null)); + if (is_scalar($uuid) === true) { + return (string)$uuid; + } + } + + return null; + }//end relationUuid() + + /** + * Whether a stored value equals a mapped value. + * + * @param mixed $stored The stored value. + * @param mixed $value The mapped value. + * + * @return bool + */ + private static function sameValue(mixed $stored, mixed $value): bool { + if (is_scalar($value) === true && (is_scalar($stored) === true || is_array($stored) === true)) { + return self::relationUuid(value: $stored) === (string)$value; + } + + return $stored === $value; + }//end sameValue() + + /** + * Whether a stored value counts as empty. + * + * @param mixed $value The stored value. + * + * @return bool + */ + private static function isEmptyValue(mixed $value): bool { + return $value === null || $value === '' || $value === []; + }//end isEmptyValue() +}//end class diff --git a/lib/Settings/cmdb-import/topdesk-business-owner.json b/lib/Settings/cmdb-import/topdesk-business-owner.json new file mode 100644 index 00000000..6f90d2ac --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-business-owner.json @@ -0,0 +1,13 @@ +{ + "id": "stackiq-topdesk-business-owner", + "name": "TOPdesk CMDB export to business owner identity", + "description": "The business owner of the application. The import resolves the identity in Nextcloud Contacts and links a contactPerson of the municipality as usage.businessOwner. No other person column is read.", + "sourceFormat": "excel", + "version": "1.0.0", + "fieldMappings": [ + { "source": "Eigenaar", "target": "name", "required": true, "transform": { "type": "trim" } }, + { "source": "Eigenaar e-mail", "target": "email", "transform": { "type": "trim" } }, + { "source": "Eigenaar functie", "target": "role", "transform": { "type": "trim" } } + ], + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/cmdb-import/topdesk-manufacturer.json b/lib/Settings/cmdb-import/topdesk-manufacturer.json new file mode 100644 index 00000000..ae720f77 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-manufacturer.json @@ -0,0 +1,12 @@ +{ + "id": "stackiq-topdesk-manufacturer", + "name": "TOPdesk CMDB export to stackiq supplier organisation", + "description": "The Fabrikant column becomes one organisation of type Supplier per distinct name. An empty Fabrikant means the row has no provider.", + "sourceFormat": "excel", + "version": "1.0.0", + "fieldMappings": [ + { "source": "Fabrikant", "target": "name", "required": true, "transform": { "type": "trim" } } + ], + "defaults": { "type": "Supplier", "status": "Active" }, + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/cmdb-import/topdesk-module.json b/lib/Settings/cmdb-import/topdesk-module.json new file mode 100644 index 00000000..81a057e8 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-module.json @@ -0,0 +1,28 @@ +{ + "id": "stackiq-topdesk-module", + "name": "TOPdesk CMDB export to stackiq module", + "description": "One application row of a TOPdesk CMDB export becomes a stackiq module. A mapping marked required that fails skips the row; any other failing mapping drops that field with a warning.", + "sourceFormat": "excel", + "version": "1.0.0", + "fieldMappings": [ + { "source": "Naam", "target": "name", "required": true, "transform": { "type": "trim" } }, + { "source": "Middel-ID", "target": "externalId", "required": true, "transform": { "type": "trim" } }, + { "source": "ICT Applicatienummer", "target": "externalNumber", "transform": { "type": "trim" } }, + { "source": "Functionele omschrijving", "target": "longDescription", "transform": { "type": "trim" } }, + { + "source": "ICT BBN Classificatie", + "target": "bbnLevel", + "transform": { + "type": "lookup", + "map": { + "BBN1": "BBN1", "BBN 1": "BBN1", "bbn1": "BBN1", "bbn 1": "BBN1", + "BBN2": "BBN2", "BBN 2": "BBN2", "bbn2": "BBN2", "bbn 2": "BBN2", + "BBN3": "BBN3", "BBN 3": "BBN3", "bbn3": "BBN3", "bbn 3": "BBN3" + } + } + }, + { "source": "Aanmaakdatum", "target": "externalCreatedAt", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } }, + { "source": "Wijzigingsdatum", "target": "externalModifiedAt", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } } + ], + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/cmdb-import/topdesk-municipality.json b/lib/Settings/cmdb-import/topdesk-municipality.json new file mode 100644 index 00000000..eb16fe82 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-municipality.json @@ -0,0 +1,12 @@ +{ + "id": "stackiq-topdesk-municipality", + "name": "CMDB import options to stackiq municipality", + "description": "Maps the import options row (municipalityName), not a sheet row, to the consuming organisation of type Municipality.", + "sourceFormat": "excel", + "version": "1.0.0", + "fieldMappings": [ + { "source": "municipalityName", "target": "name", "required": true, "transform": { "type": "trim" } } + ], + "defaults": { "type": "Municipality", "status": "Active" }, + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/cmdb-import/topdesk-profile.json b/lib/Settings/cmdb-import/topdesk-profile.json new file mode 100644 index 00000000..211e9587 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-profile.json @@ -0,0 +1,35 @@ +{ + "id": "topdesk-cmdb", + "name": "TOPdesk CMDB export", + "version": "1.0.0", + "description": "How stackiq reads a TOPdesk CMDB export (xlsx). Sheets, key and required columns, date and id columns, the pack per target and the limits. Columns that neither this profile nor a pack names are never read.", + "maxFileBytes": 10485760, + "maxRowsPerSheet": 10000, + "sheets": [ + { "name": "Invoer AIA data", "acceptedKinds": ["Application Inventory"] }, + { "name": "Invoer APP data", "acceptedKinds": ["Applicatie"] } + ], + "keyColumn": "Middel-ID", + "nameColumn": "Naam", + "kindColumn": "Soort", + "requiredColumns": ["Middel-ID", "Naam"], + "dateColumns": ["Aanmaakdatum", "Wijzigingsdatum", "End of Life Business", "Einddatum"], + "idColumns": ["Middel-ID", "ICT Applicatienummer"], + "externalKeyPrefix": "topdesk", + "packs": { + "module": "topdesk-module.json", + "manufacturer": "topdesk-manufacturer.json", + "municipality": "topdesk-municipality.json", + "usage": "topdesk-usage.json", + "businessOwner": "topdesk-business-owner.json", + "technicalOwner": "topdesk-technical-owner.json" + }, + "createOnly": { + "module": { "type": "Application" }, + "usage": ["interneAnnotation"] + }, + "neverWritten": { + "module": ["publicationDate", "depublicationDate"] + }, + "missingRecords": ["keep"] +} diff --git a/lib/Settings/cmdb-import/topdesk-technical-owner.json b/lib/Settings/cmdb-import/topdesk-technical-owner.json new file mode 100644 index 00000000..317a062e --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-technical-owner.json @@ -0,0 +1,11 @@ +{ + "id": "stackiq-topdesk-technical-owner", + "name": "TOPdesk CMDB export to technical owner identity", + "description": "The functional administrator (FB contactpersoon 1). Without an e-mail address the contact is matched on its exact display name, and linked as usage.technicalOwner.", + "sourceFormat": "excel", + "version": "1.0.0", + "fieldMappings": [ + { "source": "FB contactpersoon 1", "target": "name", "required": true, "transform": { "type": "trim" } } + ], + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/cmdb-import/topdesk-usage.json b/lib/Settings/cmdb-import/topdesk-usage.json new file mode 100644 index 00000000..3e9f9d79 --- /dev/null +++ b/lib/Settings/cmdb-import/topdesk-usage.json @@ -0,0 +1,39 @@ +{ + "id": "stackiq-topdesk-usage", + "name": "TOPdesk CMDB export to stackiq usage", + "description": "The usage that links the application to the municipality. The Status lookup is provisional until the municipality confirms its values; an unknown value drops the field with a warning.", + "sourceFormat": "excel", + "version": "1.0.0", + "fieldMappings": [ + { + "source": "Status", + "target": "status", + "transform": { + "type": "lookup", + "map": { + "In productie": "In production", + "In voorraad": "Planned", + "In ontwikkeling": "Acquisition", + "Uit te faseren": "To be phased out", + "Uitgefaseerd": "Phased out" + } + } + }, + { + "source": "ICT TIME Classificatie", + "target": "timeClassification", + "transform": { + "type": "lookup", + "map": { + "Tolerate": "Tolerate", "Tolereren": "Tolerate", + "Invest": "Invest", "Investeren": "Invest", + "Migrate": "Migrate", "Migreren": "Migrate", + "Eliminate": "Eliminate", "Elimineren": "Eliminate" + } + } + }, + { "source": "End of Life Business", "target": "startDateOutPhased", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } }, + { "source": "Eigenaar afdeling", "target": "interneAnnotation", "transform": { "type": "concat", "fields": ["Eigenaar cluster"], "separator": " / " } } + ], + "idStrategy": { "type": "generate" } +} diff --git a/lib/Settings/register.d/topdesk-cmdb-import.json b/lib/Settings/register.d/topdesk-cmdb-import.json new file mode 100644 index 00000000..d2ff7205 --- /dev/null +++ b/lib/Settings/register.d/topdesk-cmdb-import.json @@ -0,0 +1,109 @@ +{ + "components": { + "schemas": { + "module": { + "version": "0.3.5", + "properties": { + "externalId": { + "type": "string", + "title": "Source id", + "description": "The identifier of the application in the source system it was imported from, such as the TOPdesk Middel-ID.", + "maxLength": 100, + "visible": true, + "facetable": false, + "order": 60 + }, + "externalNumber": { + "type": "string", + "title": "Source number", + "description": "The application number in the source system, such as TOPdesk's ICT Applicatienummer. Shown for reference; not used to match records.", + "maxLength": 50, + "visible": true, + "facetable": false, + "order": 61 + }, + "externalKey": { + "type": "string", + "title": "Import key", + "description": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.", + "maxLength": 200, + "visible": true, + "facetable": false, + "table": { + "default": false + }, + "order": 62 + }, + "externalCreatedAt": { + "type": "string", + "format": "date", + "title": "Created in source", + "description": "The date the application was registered in the source system.", + "visible": true, + "facetable": false, + "order": 63 + }, + "externalModifiedAt": { + "type": "string", + "format": "date", + "title": "Changed in source", + "description": "The date the application was last changed in the source system.", + "visible": true, + "facetable": false, + "order": 64 + } + } + } + }, + "objects": [ + { + "@self": { + "register": "stackiq", + "schema": "module", + "slug": "voorbeeld-zaaksysteem", + "version": "0.0.1" + }, + "name": "Voorbeeld Zaaksysteem", + "type": "Application", + "longDescription": "Registreert en volgt zaken van intake tot archivering.", + "externalId": "APP-00001", + "externalNumber": "101", + "externalCreatedAt": "2023-07-04", + "externalModifiedAt": "2026-07-29", + "bbnLevel": "BBN2" + }, + { + "@self": { + "register": "stackiq", + "schema": "module", + "slug": "voorbeeld-afsprakenplanner", + "version": "0.0.1" + }, + "name": "Voorbeeld Afsprakenplanner", + "type": "Application", + "longDescription": "Laat inwoners online een afspraak maken bij de balie.", + "externalId": "APP-00002", + "externalNumber": "102", + "externalCreatedAt": "2022-03-16", + "externalModifiedAt": "2026-09-01", + "bbnLevel": "BBN1" + }, + { + "@self": { + "register": "stackiq", + "schema": "module", + "slug": "voorbeeld-belastingapplicatie", + "version": "0.0.1" + }, + "name": "Voorbeeld Belastingapplicatie", + "type": "Application", + "longDescription": "Berekent en verstuurt gemeentelijke belastingaanslagen.", + "externalId": "AIA-00003", + "externalNumber": "103", + "externalCreatedAt": "2024-01-15", + "externalModifiedAt": "2026-05-20", + "bbnLevel": "BBN2" + } + ] + } +} diff --git a/openapi.json b/openapi.json index 9746b759..b2932900 100644 --- a/openapi.json +++ b/openapi.json @@ -7,5 +7,390 @@ "license": { "name": "agpl" } + }, + "paths": { + "/index.php/apps/stackiq/api/cmdb-import": { + "post": { + "operationId": "cmdbImport-import", + "summary": "Import a TOPdesk CMDB export (xlsx) for one municipality", + "description": "Admin-only, CSRF-protected (requesttoken header or OCS-APIRequest: true). Creates or updates modules, supplier organisations, usages and owner contact persons, matched on topdesk::. See openspec/changes/cmdb-export-import/contract.md.", + "tags": [ + "cmdb-import" + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "cmdbFile" + ], + "properties": { + "cmdbFile": { + "type": "string", + "format": "binary", + "description": "The TOPdesk export, .xlsx, at most 10 MB" + }, + "municipalityUuid": { + "type": "string", + "format": "uuid", + "description": "An existing organization of type Municipality; wins over municipalityName" + }, + "municipalityName": { + "type": "string", + "description": "Name of a Municipality to reuse (same normalised name) or create" + }, + "updateExisting": { + "type": "string", + "enum": [ + "true", + "false" + ], + "default": "true", + "description": "false reports matched rows as skipped (exists)" + }, + "missingRecords": { + "type": "string", + "enum": [ + "keep" + ], + "default": "keep", + "description": "Only keep is accepted; mark and remove are reserved" + }, + "operationId": { + "type": "string", + "pattern": "^cmdb-[A-Za-z0-9-]{8,64}$", + "description": "Progress operation id, readable through GET /api/progress/{operationId}" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The import report", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportReport" + } + } + } + }, + "400": { + "description": "NO_FILE_UPLOADED or NOT_XLSX", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "401": { + "description": "Not signed in" + }, + "403": { + "description": "Not a Nextcloud admin" + }, + "412": { + "description": "Missing or invalid CSRF token" + }, + "413": { + "description": "FILE_TOO_LARGE", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "422": { + "description": "MISSING_RECORDS_UNSUPPORTED, MUNICIPALITY_REQUIRED, MUNICIPALITY_INVALID, NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "500": { + "description": "IMPORT_FAILED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "503": { + "description": "MAPPING_UNAVAILABLE, READER_UNAVAILABLE or NOT_CONFIGURED", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + } + } + } + }, + "/index.php/apps/stackiq/api/cmdb-import/{operationId}/cancel": { + "post": { + "operationId": "cmdbImport-cancel", + "summary": "Ask a running CMDB import to stop between rows", + "description": "Admin-only, CSRF-protected. No body.", + "tags": [ + "cmdb-import" + ], + "parameters": [ + { + "name": "operationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Cancel requested", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "success", + "cancelRequested" + ], + "properties": { + "success": { + "type": "boolean" + }, + "cancelRequested": { + "type": "boolean" + } + } + } + } + } + }, + "403": { + "description": "Not a Nextcloud admin" + }, + "404": { + "description": "OPERATION_NOT_FOUND", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CmdbImportError" + } + } + } + }, + "412": { + "description": "Missing or invalid CSRF token" + } + } + } + } + }, + "components": { + "schemas": { + "CmdbImportReport": { + "type": "object", + "required": [ + "success", + "operationId", + "cancelled", + "municipality", + "summary", + "importWarnings", + "rows" + ], + "properties": { + "success": { + "type": "boolean" + }, + "operationId": { + "type": "string" + }, + "cancelled": { + "type": "boolean" + }, + "municipality": { + "type": "object", + "required": [ + "uuid", + "name", + "created" + ], + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "created": { + "type": "boolean" + } + } + }, + "summary": { + "type": "object", + "required": [ + "rowsRead", + "processed", + "created", + "updated", + "unchanged", + "skipped", + "failed", + "warnings" + ], + "properties": { + "rowsRead": { + "type": "integer" + }, + "processed": { + "type": "integer" + }, + "created": { + "type": "integer" + }, + "updated": { + "type": "integer" + }, + "unchanged": { + "type": "integer" + }, + "skipped": { + "type": "integer" + }, + "failed": { + "type": "integer" + }, + "warnings": { + "type": "integer" + } + } + }, + "importWarnings": { + "type": "array", + "items": { + "type": "object", + "required": [ + "sheet", + "message" + ], + "properties": { + "sheet": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "required": [ + "sheet", + "row", + "middelId", + "name", + "outcome", + "reasons", + "warnings", + "moduleUuid", + "usageUuid" + ], + "properties": { + "sheet": { + "type": "string" + }, + "row": { + "type": "integer" + }, + "middelId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "created", + "updated", + "unchanged", + "skipped", + "failed" + ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "moduleUuid": { + "type": "string", + "nullable": true + }, + "usageUuid": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "CmdbImportError": { + "type": "object", + "required": [ + "success", + "error", + "message", + "details" + ], + "properties": { + "success": { + "type": "boolean", + "enum": [ + false + ] + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": { + "type": "object", + "additionalProperties": true + } + } + } + } } -} \ No newline at end of file +} diff --git a/openspec/changes/cmdb-export-import/contract.md b/openspec/changes/cmdb-export-import/contract.md index 2b8a969a..6760d2eb 100644 --- a/openspec/changes/cmdb-export-import/contract.md +++ b/openspec/changes/cmdb-export-import/contract.md @@ -21,7 +21,7 @@ Paths are relative to `/index.php/apps/stackiq`. | `municipalityName` | string | one of the two | | name of a Municipality to reuse (same normalised name) or create | | `updateExisting` | `true`/`false` | no | `true` | `false` reports matched rows as skipped (`exists`) | | `missingRecords` | string | no | `keep` | only `keep` is accepted; `mark` and `remove` are reserved | -| `operationId` | string | no | generated | progress operation id, readable through `GET /api/progress/{operationId}` | +| `operationId` | string | no | generated | progress operation id, readable through `GET /api/progress/{operationId}`; `cmdb-` followed by 8 to 64 letters, digits or hyphens (for example `cmdb-` plus a uuid v4). Any other value is replaced by a generated id, returned as `operationId` | **Response (200):** ```json @@ -30,7 +30,7 @@ Paths are relative to `/index.php/apps/stackiq`. "operationId": "cmdb-00000000-0000-0000-0000-000000000000", "cancelled": false, "municipality": { "uuid": "00000000-0000-0000-0000-000000000001", "name": "Gemeente Voorbeeldstad", "created": false }, - "summary": { "rowsRead": 2, "created": 2, "updated": 0, "unchanged": 0, "skipped": 0, "failed": 0, "warnings": 0 }, + "summary": { "rowsRead": 2, "processed": 2, "created": 2, "updated": 0, "unchanged": 0, "skipped": 0, "failed": 0, "warnings": 0 }, "importWarnings": [ { "sheet": "Invoer AIA data", "message": "Optional column \"ICT TIME Classificatie\" not found" } ], @@ -50,7 +50,7 @@ Paths are relative to `/index.php/apps/stackiq`. } ``` -`outcome` is one of `created`, `updated`, `unchanged`, `skipped`, `failed`. `reasons` and `warnings` are strings that name columns and values. They never contain owner names, e-mail addresses or other person data. +`outcome` is one of `created`, `updated`, `unchanged`, `skipped`, `failed`. `reasons` and `warnings` are translated strings that name columns and values. They never contain owner names, e-mail addresses or other person data. `summary.rowsRead` counts the non-empty rows in the workbook; `summary.processed` counts the rows in `rows`, which is lower than `rowsRead` only after a cancel. `summary.warnings` counts row warnings; `importWarnings` are not included. **Errors:** | Code | Condition | @@ -62,9 +62,9 @@ Paths are relative to `/index.php/apps/stackiq`. | 413 | `FILE_TOO_LARGE` | | 422 | `MISSING_RECORDS_UNSUPPORTED`, `MUNICIPALITY_REQUIRED`, `MUNICIPALITY_INVALID`, `NO_SOURCE_SHEET`, `MISSING_COLUMN`, `TOO_MANY_ROWS` | | 500 | `IMPORT_FAILED` (unexpected; generic message, details only in the log) | -| 503 | `MAPPING_UNAVAILABLE`, `READER_UNAVAILABLE` | +| 503 | `MAPPING_UNAVAILABLE`, `READER_UNAVAILABLE`, `NOT_CONFIGURED` | -Error body: `{"success": false, "error": "", "message": "", "details": {...}}`. For `MISSING_COLUMN`, `details` is `{"sheet": "...", "column": "..."}`. For `NO_SOURCE_SHEET`, it is `{"expected": ["Invoer AIA data", "Invoer APP data"]}`. +Error body: `{"success": false, "error": "", "message": "", "details": {...}}`. `details` is always an object, empty when the code has none. For `MISSING_COLUMN`, `details` is `{"sheet": "...", "column": "..."}`. For `NO_SOURCE_SHEET`, it is `{"expected": ["Invoer AIA data", "Invoer APP data"]}`. For `TOO_MANY_ROWS`, it is `{"sheet": "...", "limit": 10000}`. For `FILE_TOO_LARGE`, it is `{"maxBytes": 10485760}`. For `MISSING_RECORDS_UNSUPPORTED`, it is `{"accepted": ["keep"]}`. ### `POST /api/cmdb-import/{operationId}/cancel` **Auth**: Nextcloud admin session plus CSRF token. @@ -84,7 +84,7 @@ Error body: `{"success": false, "error": "", "message": " | 412 | missing or invalid CSRF token | ### `GET /api/progress/{operationId}` (existing, unchanged) -Returns the `ProgressTracker` snapshot for the `cmdb_import` operation. After completion, `progress.statistics.report` holds the report from the 200 response above, for as long as the tracker keeps the entry (one hour). +Returns the `ProgressTracker` snapshot for the `cmdb_import` operation: `progress.total_items` is the number of non-empty rows read and `progress.processed_items` the rows done so far, updated after every row. `progress.status` is `running`, `completed` or `cancelled`. After completion, `progress.statistics.report` holds the report from the 200 response above, for as long as the tracker keeps the entry (one hour). ## Error Codes @@ -101,6 +101,7 @@ Returns the `ProgressTracker` snapshot for the `cmdb_import` operation. After co | `TOO_MANY_ROWS` | file too large to process | a source sheet has more non-empty rows than `maxRowsPerSheet` (10,000) | | `MAPPING_UNAVAILABLE` | mapping cannot run | OpenRegister's `MappingEngine`/`PackDefinitionValidator` missing, or a shipped pack is invalid | | `READER_UNAVAILABLE` | xlsx reader missing | PhpSpreadsheet's Xlsx reader cannot be loaded | +| `NOT_CONFIGURED` | stackiq not configured (503) | OpenRegister's object service, the stackiq register, or the `module`, `organization`, `usage` or `contactPerson` schema cannot be resolved; checked before the file is read | | `OPERATION_NOT_FOUND` | unknown operation | cancel for an id without a `cmdb_import` operation | | `IMPORT_FAILED` | unexpected error | anything not listed above | diff --git a/openspec/changes/cmdb-export-import/tasks.md b/openspec/changes/cmdb-export-import/tasks.md index 2ebf44ff..c61621cd 100644 --- a/openspec/changes/cmdb-export-import/tasks.md +++ b/openspec/changes/cmdb-export-import/tasks.md @@ -12,8 +12,8 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - GIVEN the sanitised fixture WHEN every shared string and cell value is scanned THEN no real person name, municipality domain, personnel number or phone number remains, only the placeholder values (`Achternaam, Voornaam`, `letter.achternaam@gemeente.nl`, `123456`) - GIVEN `build-fixtures.py` WHEN it runs (Python stdlib zipfile only) THEN it derives the three variant fixtures from the sanitised one: no "Middel-ID" header on "Invoer APP data"; shuffled columns with header `Groepseigenaar mail⚡`; a formula cell in "Naam" with cached value `Rekenmodel` plus a synthetic `xl/connections.xml` - The original export of the municipality is never used or committed -- [ ] Implement -- [ ] Test (the scan is a PHPUnit test `tests/Unit/Fixtures/CmdbFixtureHygieneTest.php` that fails on metadata or non-placeholder person data) +- [x] Implement +- [x] Test (the scan is a PHPUnit test `tests/Unit/Fixtures/CmdbFixtureHygieneTest.php` that fails on metadata or non-placeholder person data) ### Task 2: Register fragment with external-id properties and seed modules - **spec_ref**: `SPEC#requirement-req-cmdb-006-a-module-shall-be-matched-on-its-topdesk-middel-id-so-a-re-import-updates-instead-of-duplicating` (cmdb-export-import#REQ-CMDB-006) @@ -21,8 +21,8 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - **acceptance_criteria**: - GIVEN all `register.d` fragments WHEN they are merged in filename order the way `SettingsService` does THEN `module.version` is `0.3.5` and `externalId`, `externalNumber`, `externalKey`, `externalCreatedAt`, `externalModifiedAt` exist, none required, with titles (hydra gate schema-property-titles) - GIVEN the fragment WHEN the register is imported on the rig THEN existing modules load and save unchanged, and the seed modules `voorbeeld-zaaksysteem`, `voorbeeld-afsprakenplanner` and `voorbeeld-belastingapplicatie` exist without `publicationDate` or `externalKey` (design.md, Seed Data) -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 3: Import profile, mapping packs and their loader - **spec_ref**: `SPEC#requirement-req-cmdb-005-field-mapping-shall-be-declarative-and-executed-by-openregisters-mapping-engine` (cmdb-export-import#REQ-CMDB-005) @@ -31,8 +31,8 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - GIVEN the six packs WHEN each is passed to OpenRegister's `PackDefinitionValidator` THEN all are valid with `sourceFormat: excel` and `idStrategy: generate`, and they implement the column table in design.md - GIVEN a pack with an unknown transform, or no `MappingEngine` in the container WHEN the profile loads THEN it throws `CmdbImportException` with code `MAPPING_UNAVAILABLE` and status 503 - GIVEN the profile WHEN its referenced columns are listed THEN Personeelsnummer, phone, group-owner and group-mailbox columns are not among them -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 4: Workbook reader and row normaliser - **spec_ref**: `SPEC#requirement-req-cmdb-002-…` and `SPEC#requirement-req-cmdb-003-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422` (cmdb-export-import#REQ-CMDB-002, #REQ-CMDB-003, #REQ-CMDB-005) @@ -43,8 +43,8 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - GIVEN the shuffled fixture WHEN it is read THEN rows equal those of the original; GIVEN the missing-column fixture THEN `MISSING_COLUMN` names `Middel-ID` and `Invoer APP data`; GIVEN only "Blad1" THEN `NO_SOURCE_SHEET`; GIVEN more than `maxRowsPerSheet` rows THEN `TOO_MANY_ROWS` - GIVEN a text file named `.xlsx`, or a `.xlsm` WHEN checked THEN `NOT_XLSX` before PhpSpreadsheet is touched; GIVEN PhpSpreadsheet absent THEN `READER_UNAVAILABLE` - GIVEN serials `45111.380322627316`, `46232.552113113423`, `53359` and id `1234.0` WHEN normalised THEN `2023-07-04`, `2026-07-29`, `2046-02-01` and `"1234"` -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 5: Import service: municipality, manufacturer, module upsert, usage - **spec_ref**: `SPEC#requirement-req-cmdb-004-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin`, `SPEC#requirement-req-cmdb-006-…`, `SPEC#requirement-req-cmdb-007-a-newly-created-module-shall-get-a-publicationdate-and-an-existing-one-shall-keep-its-own`, `SPEC#requirement-req-cmdb-008-a-manufacturer-shall-become-one-supplier-organisation-however-many-rows-name-it`, `SPEC#requirement-req-cmdb-009-each-imported-application-shall-have-one-usage-that-links-it-to-the-municipality`, `SPEC#requirement-req-cmdb-012-records-missing-from-a-newer-export-shall-be-left-untouched` @@ -57,8 +57,8 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - GIVEN `updateExisting=false` THEN matched rows are `skipped` (`exists`); GIVEN a second export without one Middel-ID THEN that module and usage are unchanged; GIVEN an unknown "Status" THEN `status` is dropped with a warning naming column and value - GIVEN the module pack mapping "Roepnaam" to shortDescription (test-only pack) THEN the module carries it, with no code change - Every new method carries `@spec openspec/changes/cmdb-export-import/tasks.md#task-5` (hydra gate spec-coverage) -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 6: Owners as contact persons through Nextcloud Contacts - **spec_ref**: `SPEC#requirement-req-cmdb-010-owners-shall-become-contact-persons-of-the-municipality-through-nextcloud-contacts-never-user-accounts` (cmdb-export-import#REQ-CMDB-010) @@ -69,8 +69,8 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - GIVEN Contacts disabled WHEN imported THEN modules and usages are saved, owners skipped with a warning - GIVEN an imported `contactPerson` WHEN `OrganizationSyncService::performUserSync`'s selection is applied THEN it is not selected, and no Nextcloud user is created (if it would be, add an exclusion marker before shipping) - GIVEN any import WHEN the report and log lines are inspected THEN no owner name or e-mail appears -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 7: Row isolation, report, progress and cancel - **spec_ref**: `SPEC#requirement-req-cmdb-011-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome`, `SPEC#requirement-req-cmdb-013-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled` @@ -80,8 +80,8 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - GIVEN duplicate Middel-ID rows, a missing Middel-ID and a Soort `Hardware` THEN they are `skipped` with the reasons in the spec - GIVEN an `operationId` WHEN the import runs THEN a `cmdb_import` operation reports per-row progress, and after completion its statistics hold the report - GIVEN cancel requested after row 1 of three THEN one processed row, `cancelled: true`, row 1's objects kept -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 8: Controller, routes and API tests - **spec_ref**: `SPEC#requirement-req-cmdb-001-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin` (cmdb-export-import#REQ-CMDB-001, #REQ-CMDB-012, #REQ-CMDB-013) @@ -90,7 +90,7 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - GIVEN `cmdbImport#import` and `cmdbImport#cancel` WHEN their attributes are inspected THEN neither has `NoAdminRequired` or `NoCSRFRequired` (hydra gates route-auth, csrf-cochange, no-admin-idor) - GIVEN the validation order in design.md D10 THEN each error code from contract.md is returned with its status, and every service exception is translated (hydra gate controller-exception-translation) - GIVEN Newman WHEN run against the rig THEN 403 for a non-admin and for a `software-catalog-admins` member, 412 without requesttoken, 413 for an oversized file, 422 `MISSING_RECORDS_UNSUPPORTED`, and 200 with the report for the fixture -- [ ] Implement +- [x] Implement - [ ] Test ### Task 9: CMDB import section in admin settings, l10n and Playwright e2e @@ -102,7 +102,7 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - GIVEN the section WHEN the hydra gates run THEN admin-router, form-label-association, nc-input-labels, button-name, table-headers and modal-isolation pass, and no `v-html` renders report values - GIVEN a Dutch and an English locale THEN every new string, error message and report reason is translated - The e2e file references every `@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts` scenario in the spec (hydra gate e2e-coverage) -- [ ] Implement +- [x] Implement - [ ] Test ### Task 10: Administrator documentation with screenshots diff --git a/postman/stackiq-tests.json b/postman/stackiq-tests.json index 319fbf64..dacc0d08 100644 --- a/postman/stackiq-tests.json +++ b/postman/stackiq-tests.json @@ -22547,6 +22547,497 @@ ] } ] + }, + { + "name": "12 - CMDB import", + "description": "openspec/changes/cmdb-export-import (contract.md). Run newman from the app root so the fixture path tests/fixtures/cmdb/ resolves.", + "item": [ + { + "name": "CMDB import: 403 for a user who is not a Nextcloud admin", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + } + ], + "url": { + "raw": "{{stackiq_api}}/cmdb-import", + "host": [ + "{{stackiq_api}}" + ], + "path": [ + "cmdb-import" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "mark.jansen@test.nl" + }, + { + "key": "password", + "value": "{{test_password}}" + } + ] + }, + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "cmdbFile", + "type": "file", + "src": "tests/fixtures/cmdb/topdesk-export-anonymised.xlsx" + }, + { + "key": "municipalityName", + "value": "Gemeente Voorbeeldstad", + "type": "text" + } + ] + } + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"A non-admin cannot import\", function () {", + " pm.response.to.have.status(403);", + "});" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "CMDB import: 403 for a software-catalog-admins member", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + } + ], + "url": { + "raw": "{{stackiq_api}}/cmdb-import", + "host": [ + "{{stackiq_api}}" + ], + "path": [ + "cmdb-import" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "peter.vandijk@test.nl" + }, + { + "key": "password", + "value": "{{test_password}}" + } + ] + }, + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "cmdbFile", + "type": "file", + "src": "tests/fixtures/cmdb/topdesk-export-anonymised.xlsx" + }, + { + "key": "municipalityName", + "value": "Gemeente Voorbeeldstad", + "type": "text" + } + ] + } + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"The catalogue admin group is not enough\", function () {", + " pm.response.to.have.status(403);", + "});" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "CMDB import: 412 without a CSRF token", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + } + ], + "url": { + "raw": "{{stackiq_api}}/cmdb-import", + "host": [ + "{{stackiq_api}}" + ], + "path": [ + "cmdb-import" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}" + }, + { + "key": "password", + "value": "{{admin_pass}}" + } + ] + }, + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "cmdbFile", + "type": "file", + "src": "tests/fixtures/cmdb/topdesk-export-anonymised.xlsx" + }, + { + "key": "municipalityName", + "value": "Gemeente Voorbeeldstad", + "type": "text" + } + ] + } + }, + "response": [], + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "// The collection adds OCS-APIRequest, which satisfies the CSRF check; this request must go without it.", + "pm.request.headers.remove(\"OCS-APIRequest\");" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Nextcloud refuses the request without a CSRF token\", function () {", + " pm.response.to.have.status(412);", + "});" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "CMDB import: 413 for a file over 10 MB", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + } + ], + "url": { + "raw": "{{stackiq_api}}/cmdb-import", + "host": [ + "{{stackiq_api}}" + ], + "path": [ + "cmdb-import" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}" + }, + { + "key": "password", + "value": "{{admin_pass}}" + } + ] + }, + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "cmdbFile", + "type": "file", + "src": "{{cmdb_oversized_file}}" + }, + { + "key": "municipalityName", + "value": "Gemeente Voorbeeldstad", + "type": "text" + } + ] + }, + "description": "Set cmdb_oversized_file to a file of 10485761 bytes; the request is skipped when it is not set." + }, + "response": [], + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "// Needs a file of 10 MB plus one byte, e.g. `head -c 10485761 /dev/zero > /tmp/cmdb-oversized.xlsx`,", + "// passed as --env-var cmdb_oversized_file=/tmp/cmdb-oversized.xlsx. Without it the request is skipped, not passed.", + "if (!pm.variables.get(\"cmdb_oversized_file\")) {", + " pm.execution.skipRequest();", + "}" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"An oversized upload is refused before it is read\", function () {", + " pm.response.to.have.status(413);", + " pm.expect(pm.response.json().error).to.eql(\"FILE_TOO_LARGE\");", + "});" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "CMDB import: 422 MISSING_RECORDS_UNSUPPORTED for missingRecords=remove", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + } + ], + "url": { + "raw": "{{stackiq_api}}/cmdb-import", + "host": [ + "{{stackiq_api}}" + ], + "path": [ + "cmdb-import" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}" + }, + { + "key": "password", + "value": "{{admin_pass}}" + } + ] + }, + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "cmdbFile", + "type": "file", + "src": "tests/fixtures/cmdb/topdesk-export-anonymised.xlsx" + }, + { + "key": "municipalityName", + "value": "Gemeente Voorbeeldstad", + "type": "text" + }, + { + "key": "missingRecords", + "value": "remove", + "type": "text" + } + ] + } + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Only missingRecords=keep is accepted\", function () {", + " pm.response.to.have.status(422);", + " pm.expect(pm.response.json().error).to.eql(\"MISSING_RECORDS_UNSUPPORTED\");", + "});" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "CMDB import: 200 with the report for the anonymised export", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + } + ], + "url": { + "raw": "{{stackiq_api}}/cmdb-import", + "host": [ + "{{stackiq_api}}" + ], + "path": [ + "cmdb-import" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}" + }, + { + "key": "password", + "value": "{{admin_pass}}" + } + ] + }, + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "cmdbFile", + "type": "file", + "src": "tests/fixtures/cmdb/topdesk-export-anonymised.xlsx" + }, + { + "key": "municipalityName", + "value": "Gemeente Voorbeeldstad", + "type": "text" + }, + { + "key": "updateExisting", + "value": "true", + "type": "text" + }, + { + "key": "missingRecords", + "value": "keep", + "type": "text" + } + ] + } + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"The export is imported with a per-row report\", function () {", + " pm.response.to.have.status(200);", + " var json = pm.response.json();", + " pm.expect(json.success).to.eql(true);", + " pm.expect(json.municipality.name).to.eql(\"Gemeente Voorbeeldstad\");", + " pm.expect(json.summary.rowsRead).to.eql(2);", + " pm.expect(json.summary.created + json.summary.unchanged + json.summary.updated).to.eql(2);", + " pm.expect(json.summary.failed).to.eql(0);", + " pm.expect(json.rows.map(function (r) { return r.middelId; })).to.eql([\"AIA-AangetekendMailen\", \"APP-test123\"]);", + " pm.environment.set(\"cmdb_operation_id\", json.operationId);", + "});" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "CMDB import: 404 OPERATION_NOT_FOUND when cancelling an unknown import", + "request": { + "method": "POST", + "header": [ + { + "key": "OCS-APIRequest", + "value": "true", + "type": "text" + } + ], + "url": { + "raw": "{{stackiq_api}}/cmdb-import/cmdb-00000000-0000-4000-8000-000000000000/cancel", + "host": [ + "{{stackiq_api}}" + ], + "path": [ + "cmdb-import", + "cmdb-00000000-0000-4000-8000-000000000000", + "cancel" + ] + }, + "auth": { + "type": "basic", + "basic": [ + { + "key": "username", + "value": "{{admin_user}}" + }, + { + "key": "password", + "value": "{{admin_pass}}" + } + ] + } + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Cancel needs a running cmdb_import operation\", function () {", + " pm.response.to.have.status(404);", + " pm.expect(pm.response.json().error).to.eql(\"OPERATION_NOT_FOUND\");", + "});" + ], + "type": "text/javascript" + } + } + ] + } + ] } ], "auth": { diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js new file mode 100644 index 00000000..6c3fa1fa --- /dev/null +++ b/src/utils/cmdbImport.js @@ -0,0 +1,494 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. + +/** + * Client side of the CMDB import: request building, the checks the page can + * make before uploading, and the words for every error code and outcome. + * + * The routes, field names, report shape and error codes are fixed by + * openspec/changes/cmdb-export-import/contract.md. The server stays the + * authority: every check here is repeated there. + * + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-014-the-admin-settings-shall-offer-a-cmdb-import-section + */ + +import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' + +/** Largest upload the import accepts (contract: profile `maxFileBytes`). */ +export const MAX_FILE_BYTES = 10 * 1024 * 1024 + +/** The two sheets the import reads (contract: NO_SOURCE_SHEET details). */ +export const SOURCE_SHEETS = ['Invoer AIA data', 'Invoer APP data'] + +/** Every row outcome the report can carry, in display order. */ +export const OUTCOMES = ['created', 'updated', 'unchanged', 'skipped', 'failed'] + +/** + * The words for one row outcome. + * + * @param {string} outcome The outcome key from the report + * @return {string} The label + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-011-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome + */ +export function outcomeLabel(outcome) { + switch (outcome) { + case 'created': + return t('stackiq', 'Created') + case 'updated': + return t('stackiq', 'Updated') + case 'unchanged': + return t('stackiq', 'Unchanged') + case 'skipped': + return t('stackiq', 'Skipped') + case 'failed': + return t('stackiq', 'Failed') + default: + return String(outcome ?? '') + } +} + +/** + * Make a fresh operation id, in the form the contract's example uses + * (`cmdb-` plus a random version 4 uuid). + * + * `crypto.randomUUID()` exists only in a secure context, and an instance + * served over plain http is not one, so the uuid is built from + * `getRandomValues()`, which is available everywhere. + * + * @return {string} The id + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-013-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled + */ +export function makeCmdbOperationId() { + const bytes = new Uint8Array(16) + globalThis.crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + return ( + 'cmdb-' + + hex.slice(0, 8) + + '-' + + hex.slice(8, 12) + + '-' + + hex.slice(12, 16) + + '-' + + hex.slice(16, 20) + + '-' + + hex.slice(20) + ) +} + +/** + * The check the page makes on a chosen file before it uploads it. + * + * Only the name and size are checked here; the content check (ZIP signature, + * `xl/workbook.xml`) is the server's. + * + * @param {File|null} file The chosen file + * @return {{error: string, details: object}|null} An error in the server's shape, or null when the file may be sent + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-001-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin + */ +export function checkFile(file) { + if (!file) { + return { error: 'NO_FILE_UPLOADED', details: {} } + } + if (!/\.xlsx$/i.test(file.name || '')) { + return { error: 'NOT_XLSX', details: {} } + } + if (file.size > MAX_FILE_BYTES) { + return { error: 'FILE_TOO_LARGE', details: {} } + } + return null +} + +/** + * The multipart body for `POST /api/cmdb-import`. + * + * @param {object} options The options + * @param {File} options.file The export + * @param {{uuid: string|null, name: string}} options.municipality The chosen municipality: an existing one has a uuid, a new one only a name + * @param {boolean} options.updateExisting Whether matched rows are updated + * @param {string} options.operationId The progress operation id + * @return {FormData} The body + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-004-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin + */ +export function buildImportForm({ + file, + municipality, + updateExisting, + operationId, +}) { + const form = new FormData() + form.append('cmdbFile', file) + if (municipality?.uuid) { + form.append('municipalityUuid', municipality.uuid) + } else if (municipality?.name) { + form.append('municipalityName', municipality.name) + } + form.append('updateExisting', updateExisting ? 'true' : 'false') + form.append('missingRecords', 'keep') + form.append('operationId', operationId) + return form +} + +/** + * The URL of the import endpoint. + * + * @return {string} The URL + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-001-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin + */ +export function importUrl() { + return generateUrl('/apps/stackiq/api/cmdb-import') +} + +/** + * Ask the server to stop a running import between two rows. + * + * @param {object} options The options + * @param {string} options.operationId The operation to cancel + * @param {object} options.http An axios-like client with post + * @return {Promise} The server's answer + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-013-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled + */ +export async function cancelCmdbImport({ operationId, http }) { + const response = await http.post( + generateUrl('/apps/stackiq/api/cmdb-import/{operationId}/cancel', { + operationId, + }), + ) + return response.data +} + +/** + * The link to a module's detail page in the app. + * + * The settings page is outside the app's router, so this is a plain URL. + * + * @param {string} uuid The module uuid + * @return {string} The URL + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-011-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome + */ +export function moduleUrl(uuid) { + return generateUrl('/apps/stackiq/modules/{id}', { id: uuid }) +} + +/** + * Turn a failed request into the server's error shape. + * + * Errors raised by Nextcloud itself (not signed in, not an admin, CSRF) come + * without a CMDB error code, so they get one here from the HTTP status. + * + * @param {object} error The axios error + * @return {{error: string, message: string, details: object, status: number}} The error + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-001-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin + */ +export function normaliseError(error) { + const status = error?.response?.status ?? 0 + const body = error?.response?.data + const fromBody = + body && typeof body === 'object' && typeof body.error === 'string' + ? body.error + : '' + let code = fromBody + if (code === '') { + if (status === 401) { + code = 'NOT_SIGNED_IN' + } else if (status === 403) { + code = 'NOT_ADMIN' + } else if (status === 412) { + code = 'CSRF_FAILED' + } else if (status === 413) { + code = 'FILE_TOO_LARGE' + } else if (status === 0) { + code = 'NETWORK_ERROR' + } else { + code = 'IMPORT_FAILED' + } + } + return { + error: code, + message: + body && typeof body === 'object' && typeof body.message === 'string' + ? body.message + : '', + details: + body + && typeof body === 'object' + && body.details + && typeof body.details === 'object' + ? body.details + : {}, + status, + } +} + +/** Every error code the page has its own words for. */ +const KNOWN_ERRORS = new Set([ + 'NO_FILE_UPLOADED', + 'NOT_XLSX', + 'FILE_TOO_LARGE', + 'MISSING_RECORDS_UNSUPPORTED', + 'MUNICIPALITY_REQUIRED', + 'MUNICIPALITY_INVALID', + 'NO_SOURCE_SHEET', + 'MISSING_COLUMN', + 'TOO_MANY_ROWS', + 'MAPPING_UNAVAILABLE', + 'READER_UNAVAILABLE', + 'NOT_CONFIGURED', + 'OPERATION_NOT_FOUND', + 'NOT_SIGNED_IN', + 'NOT_ADMIN', + 'CSRF_FAILED', + 'NETWORK_ERROR', +]) + +/** + * Whether the page has its own words for an error code. For any other code + * (including `IMPORT_FAILED`) the page also shows the server's message. + * + * @param {string} code The error code + * @return {boolean} True for a code with its own text + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-001-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin + */ +export function isKnownError(code) { + return KNOWN_ERRORS.has(code) +} + +/** + * What the page says for an error: a title and, where the code has one, a + * hint on what to do. The text is the page's own, so it is translated even + * when the server's message is not. + * + * @param {{error: string, message?: string, details?: object}} error The error in the server's shape + * @return {{title: string, hint: string}} The words + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-003-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422 + */ +export function errorText(error) { + const details = error?.details || {} + switch (error?.error) { + case 'NO_FILE_UPLOADED': + return { + title: t('stackiq', 'No file was uploaded.'), + hint: t('stackiq', 'Choose the TOPdesk export and try again.'), + } + case 'NOT_XLSX': + return { + title: t('stackiq', 'This file is not an Excel workbook (.xlsx).'), + hint: t( + 'stackiq', + 'Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.', + ), + } + case 'FILE_TOO_LARGE': + return { + title: t('stackiq', 'The file is larger than 10 MB.'), + hint: t( + 'stackiq', + 'Remove sheets the import does not read, or split the export, and try again.', + ), + } + case 'MISSING_RECORDS_UNSUPPORTED': + return { + title: t( + 'stackiq', + 'Records missing from the export can only be kept.', + ), + hint: '', + } + case 'MUNICIPALITY_REQUIRED': + return { + title: t('stackiq', 'Choose a municipality first.'), + hint: t( + 'stackiq', + 'Pick an existing municipality or type the name of a new one.', + ), + } + case 'MUNICIPALITY_INVALID': + return { + title: t( + 'stackiq', + 'The chosen organisation is not a municipality.', + ), + hint: t( + 'stackiq', + 'Pick an organisation of type Municipality, or type the name of a new one.', + ), + } + case 'NO_SOURCE_SHEET': { + const expected = + Array.isArray(details.expected) && details.expected.length > 0 + ? details.expected + : SOURCE_SHEETS + return { + title: t( + 'stackiq', + 'The workbook has none of the sheets the import reads.', + ), + hint: t( + 'stackiq', + 'Expected a sheet named "{first}" or "{second}". Sheet names must match exactly.', + { + first: String(expected[0] ?? SOURCE_SHEETS[0]), + second: String(expected[1] ?? SOURCE_SHEETS[1]), + }, + ), + } + } + case 'MISSING_COLUMN': + return { + title: t( + 'stackiq', + 'The sheet "{sheet}" has no column "{column}".', + { + sheet: String(details.sheet ?? ''), + column: String(details.column ?? ''), + }, + ), + hint: t( + 'stackiq', + 'The columns "Middel-ID" and "Naam" are required on every source sheet. Add the column to the export and try again. Nothing was imported.', + ), + } + case 'TOO_MANY_ROWS': + return { + title: details.sheet + ? t( + 'stackiq', + 'The sheet "{sheet}" has more rows than the import can process.', + { sheet: String(details.sheet) }, + ) + : t( + 'stackiq', + 'A sheet has more rows than the import can process.', + ), + hint: t( + 'stackiq', + 'A source sheet may hold at most 10,000 rows. Split the export and import the parts one after the other.', + ), + } + case 'MAPPING_UNAVAILABLE': + return { + title: t('stackiq', 'The import mapping cannot run.'), + hint: t( + 'stackiq', + "OpenRegister's mapping engine is missing or a mapping file is invalid. Update OpenRegister and check the Nextcloud log.", + ), + } + case 'READER_UNAVAILABLE': + return { + title: t('stackiq', 'The Excel reader is not available.'), + hint: t( + 'stackiq', + 'The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.', + ), + } + case 'NOT_CONFIGURED': + return { + title: t('stackiq', 'Stackiq is not configured for the import.'), + hint: t( + 'stackiq', + 'The stackiq register or its schemas cannot be found. Run Auto Configure at the top of this page, then try again.', + ), + } + case 'OPERATION_NOT_FOUND': + return { + title: t('stackiq', 'This import is no longer running.'), + hint: '', + } + case 'NOT_SIGNED_IN': + return { + title: t('stackiq', 'You are not signed in.'), + hint: t('stackiq', 'Sign in again and retry the import.'), + } + case 'NOT_ADMIN': + return { + title: t( + 'stackiq', + 'Only Nextcloud administrators can import a CMDB export.', + ), + hint: '', + } + case 'CSRF_FAILED': + return { + title: t('stackiq', 'Your session has expired.'), + hint: t('stackiq', 'Reload the page and try again.'), + } + case 'NETWORK_ERROR': + return { + title: t('stackiq', 'The server could not be reached.'), + hint: t('stackiq', 'Check the connection and try again.'), + } + case 'IMPORT_FAILED': + default: + return { + title: t('stackiq', 'The import failed unexpectedly.'), + hint: t( + 'stackiq', + 'Nothing more is known on this page; the Nextcloud log has the details.', + ), + } + } +} + +/** + * What the page shows for a progress snapshot of the running import. + * + * The percentage comes from the processed and total row counts when the + * server has set them, because the tracker's own percentage is weighted by + * the phases of the ArchiMate import. + * + * @param {object|null} progress The snapshot from `GET /api/progress/{operationId}` + * @return {{percentage: number, detail: string}|null} The view, or null before any progress + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-013-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled + */ +export function cmdbProgressView(progress) { + if (!progress) { + return null + } + const processed = Number(progress.processed_items) || 0 + const total = Number(progress.total_items) || 0 + const percentage = + total > 0 + ? Math.min(100, Math.round((processed / total) * 100)) + : Number(progress.percentage) || 0 + return { + percentage, + detail: + total > 0 + ? t('stackiq', '{processed} of {total} rows processed', { + processed, + total, + }) + : '', + } +} + +/** + * The rows of the report as the table shows them. + * + * @param {Array} rows The report's `rows` + * @return {Array} The table rows + * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-req-cmdb-011-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome + */ +export function reportRows(rows) { + if (!Array.isArray(rows)) { + return [] + } + return rows.map((row, index) => ({ + key: `${row.sheet ?? ''}:${row.row ?? index}:${index}`, + sheet: String(row.sheet ?? ''), + row: row.row ?? '', + middelId: String(row.middelId ?? ''), + name: String(row.name ?? ''), + outcome: String(row.outcome ?? ''), + notes: [ + ...(Array.isArray(row.reasons) ? row.reasons : []), + ...(Array.isArray(row.warnings) ? row.warnings : []), + ] + .map((note) => String(note)) + .join('; '), + moduleUuid: row.moduleUuid ? String(row.moduleUuid) : '', + })) +} diff --git a/src/views/settings/StackiqSettings.vue b/src/views/settings/StackiqSettings.vue index 00b3c6d8..15f7375a 100644 --- a/src/views/settings/StackiqSettings.vue +++ b/src/views/settings/StackiqSettings.vue @@ -85,6 +85,9 @@ + + + @@ -131,6 +134,7 @@ import { defineComponent } from 'vue' import Web from 'vue-material-design-icons/Web.vue' import AlwaysVisibleSection from '../../components/AlwaysVisibleSection.vue' import ArchiMateImportExport from './sections/ArchiMateImportExport.vue' +import CmdbImport from './sections/CmdbImport.vue' import CronjobConfiguration from './sections/CronjobConfiguration.vue' import EmailConfiguration from './sections/EmailConfiguration.vue' import EolSyncSettings from './sections/EolSyncSettings.vue' @@ -160,6 +164,7 @@ export default defineComponent({ UserGroupsConfiguration, OrganizationSynchronization, ArchiMateImportExport, + CmdbImport, EmailConfiguration, CronjobConfiguration, ModerationQueue, diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue new file mode 100644 index 00000000..cc236046 --- /dev/null +++ b/src/views/settings/sections/CmdbImport.vue @@ -0,0 +1,983 @@ + + + + + + + diff --git a/tests/Unit/Controller/CmdbImportControllerTest.php b/tests/Unit/Controller/CmdbImportControllerTest.php new file mode 100644 index 00000000..c75cbdfc --- /dev/null +++ b/tests/Unit/Controller/CmdbImportControllerTest.php @@ -0,0 +1,341 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Controller; + +use OCA\Stackiq\Controller\CmdbImportController; +use OCA\Stackiq\Exception\CmdbImportException; +use OCA\Stackiq\Service\CmdbExportImportService; +use OCP\IL10N; +use OCP\IRequest; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use ReflectionMethod; +use RuntimeException; + +/** + * The controller in front of CmdbExportImportService. + */ +class CmdbImportControllerTest extends TestCase { + /** + * Nextcloud's annotation regex (ControllerMethodReflector), as AdminAuthPostureTest uses it. + */ + private const ANNOTATION = '/^\h+\*\h+@(?P[A-Z]\w+)((?P.*))?$/m'; + + /** + * Temporary files of the test. + * + * @var array + */ + private array $files = []; + + /** + * Remove temporary files. + * + * @return void + */ + protected function tearDown(): void { + foreach ($this->files as $file) { + if (is_file($file) === true) { + unlink($file); + } + } + }//end tearDown() + + /** + * A temporary upload. + * + * @param string $content The file content. + * + * @return string The path. + */ + private function upload(string $content = "PK\x03\x04"): string { + $path = (string)tempnam(sys_get_temp_dir(), 'cmdb'); + file_put_contents($path, $content); + $this->files[] = $path; + return $path; + }//end upload() + + /** + * The controller with a request carrying the given file and params. + * + * @param array|null $file The uploaded file entry, or null. + * @param array $params Form fields. + * @param CmdbExportImportService|MockObject|null $service The service. + * + * @return CmdbImportController + */ + private function controller(?array $file, array $params, CmdbExportImportService|MockObject|null $service = null): CmdbImportController { + $request = $this->createMock(IRequest::class); + $request->method('getUploadedFile')->willReturnCallback(fn (string $key) => $key === 'cmdbFile' ? $file : null); + $request->method('getParam')->willReturnCallback(fn (string $key, $default = null) => ($params[$key] ?? $default)); + + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(fn (string $text, $parameters = []): string => vsprintf($text, (array)$parameters)); + + if ($service === null) { + $service = $this->createMock(CmdbExportImportService::class); + $service->method('maxFileBytes')->willReturn(10485760); + $service->method('supportsMissingRecords')->willReturnCallback(fn (string $mode): bool => $mode === 'keep'); + } + + return new CmdbImportController(request: $request, importService: $service, l10n: $l10n, logger: $this->createMock(LoggerInterface::class)); + }//end controller() + + /** + * A service double with the defaults the controller reads before import(). + * + * @return CmdbExportImportService|MockObject + */ + private function service(): CmdbExportImportService|MockObject { + $service = $this->createMock(CmdbExportImportService::class); + $service->method('maxFileBytes')->willReturn(10485760); + $service->method('supportsMissingRecords')->willReturnCallback(fn (string $mode): bool => $mode === 'keep'); + return $service; + }//end service() + + /** + * A file entry as PHP puts it in $_FILES. + * + * @param string $path The temporary file. + * @param string $name The original name. + * @param int $size The size. + * + * @return array + */ + private function file(string $path, string $name = 'export.xlsx', int $size = 4): array { + return ['tmp_name' => $path, 'name' => $name, 'size' => $size, 'error' => UPLOAD_ERR_OK]; + }//end file() + + /** + * Neither method declares NoAdminRequired or NoCSRFRequired, as attribute or annotation. + * + * @return void + */ + public function testBothRoutesAreAdminOnlyWithCsrf(): void { + foreach (['import', 'cancel'] as $method) { + $reflection = new ReflectionMethod(CmdbImportController::class, $method); + $this->assertSame([], $reflection->getAttributes(), $method); + + preg_match_all(self::ANNOTATION, (string)$reflection->getDocComment(), $matches); + foreach (['NoAdminRequired', 'NoCSRFRequired', 'PublicPage'] as $annotation) { + $this->assertNotContains($annotation, $matches['annotation'], $method); + } + } + + $routes = require __DIR__ . '/../../../appinfo/routes.php'; + $byName = array_column($routes['routes'], null, 'name'); + $this->assertSame(['name' => 'cmdbImport#import', 'url' => '/api/cmdb-import', 'verb' => 'POST'], $byName['cmdbImport#import']); + $this->assertSame(['name' => 'cmdbImport#cancel', 'url' => '/api/cmdb-import/{operationId}/cancel', 'verb' => 'POST'], $byName['cmdbImport#cancel']); + }//end testBothRoutesAreAdminOnlyWithCsrf() + + /** + * No file is 400 NO_FILE_UPLOADED; a failed upload too. + * + * @return void + */ + public function testNoFileIsRefused(): void { + $response = $this->controller(file: null, params: ['municipalityName' => 'Gemeente Voorbeeldstad'])->import(); + $this->assertSame(400, $response->getStatus()); + $this->assertSame('NO_FILE_UPLOADED', $response->getData()['error']); + $this->assertFalse($response->getData()['success']); + $this->assertNotSame('', $response->getData()['message']); + + $partial = ['tmp_name' => '', 'name' => 'export.xlsx', 'size' => 0, 'error' => UPLOAD_ERR_PARTIAL]; + $this->assertSame('NO_FILE_UPLOADED', $this->controller(file: $partial, params: [])->import()->getData()['error']); + }//end testNoFileIsRefused() + + /** + * A file of 10 MB plus one byte is 413 FILE_TOO_LARGE and the reader is never invoked. + * + * @return void + */ + public function testAnOversizedFileIsRefusedBeforeReading(): void { + $service = $this->service(); + $service->expects($this->never())->method('assertXlsx'); + $service->expects($this->never())->method('import'); + + $response = $this->controller(file: $this->file(path: $this->upload(), size: 10485761), params: ['municipalityName' => 'X'], service: $service)->import(); + $this->assertSame(413, $response->getStatus()); + $this->assertSame('FILE_TOO_LARGE', $response->getData()['error']); + + $tooBig = ['tmp_name' => '', 'name' => 'export.xlsx', 'size' => 0, 'error' => UPLOAD_ERR_INI_SIZE]; + $this->assertSame(413, $this->controller(file: $tooBig, params: [], service: $service)->import()->getStatus()); + }//end testAnOversizedFileIsRefusedBeforeReading() + + /** + * A file that is not xlsx is 400 NOT_XLSX, checked before missingRecords and the municipality. + * + * @return void + */ + public function testANonXlsxFileIsRefused(): void { + $service = $this->service(); + $service->method('assertXlsx')->willThrowException(new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'no')); + $service->expects($this->never())->method('import'); + + $response = $this->controller(file: $this->file(path: $this->upload(content: 'Naam;Middel-ID'), name: 'applications.csv'), params: ['missingRecords' => 'remove'], service: $service)->import(); + + $this->assertSame(400, $response->getStatus()); + $this->assertSame('NOT_XLSX', $response->getData()['error']); + }//end testANonXlsxFileIsRefused() + + /** + * A reserved missingRecords value is 422 MISSING_RECORDS_UNSUPPORTED, before the municipality check. + * + * @return void + */ + public function testAReservedMissingRecordsValueIsRefused(): void { + $service = $this->service(); + $service->expects($this->never())->method('import'); + + foreach (['remove', 'mark'] as $mode) { + $response = $this->controller(file: $this->file(path: $this->upload()), params: ['missingRecords' => $mode], service: $service)->import(); + $this->assertSame(422, $response->getStatus(), $mode); + $this->assertSame('MISSING_RECORDS_UNSUPPORTED', $response->getData()['error'], $mode); + } + }//end testAReservedMissingRecordsValueIsRefused() + + /** + * Without a municipality the answer is 422 MUNICIPALITY_REQUIRED and nothing is imported. + * + * @return void + */ + public function testAMunicipalityIsRequired(): void { + $service = $this->service(); + $service->expects($this->never())->method('import'); + + $response = $this->controller(file: $this->file(path: $this->upload()), params: ['municipalityName' => ' '], service: $service)->import(); + $this->assertSame(422, $response->getStatus()); + $this->assertSame('MUNICIPALITY_REQUIRED', $response->getData()['error']); + }//end testAMunicipalityIsRequired() + + /** + * Every service exception keeps its contract code, status and details. + * + * @return array}> + */ + public static function serviceErrors(): array { + return [ + 'mapping' => [CmdbImportException::MAPPING_UNAVAILABLE, 503, []], + 'reader' => [CmdbImportException::READER_UNAVAILABLE, 503, []], + 'config' => [CmdbImportException::NOT_CONFIGURED, 503, []], + 'no sheet' => [CmdbImportException::NO_SOURCE_SHEET, 422, ['expected' => ['Invoer AIA data', 'Invoer APP data']]], + 'column' => [CmdbImportException::MISSING_COLUMN, 422, ['sheet' => 'Invoer APP data', 'column' => 'Middel-ID']], + 'rows' => [CmdbImportException::TOO_MANY_ROWS, 422, ['sheet' => 'Invoer APP data', 'limit' => 10000]], + 'municipality' => [CmdbImportException::MUNICIPALITY_INVALID, 422, []], + 'corrupt' => [CmdbImportException::NOT_XLSX, 400, []], + ]; + }//end serviceErrors() + + /** + * A service exception becomes its contract response. + * + * @param string $code The error code. + * @param int $status The HTTP status. + * @param array $details The details. + * + * @return void + */ + #[DataProvider('serviceErrors')] + public function testServiceErrorsAreTranslated(string $code, int $status, array $details): void { + $service = $this->service(); + $service->method('import')->willThrowException(new CmdbImportException(errorCode: $code, message: 'internal', details: $details)); + + $response = $this->controller(file: $this->file(path: $this->upload()), params: ['municipalityUuid' => '00000000-0000-0000-0000-000000000001'], service: $service)->import(); + + $this->assertSame($status, $response->getStatus()); + $this->assertSame($code, $response->getData()['error']); + $this->assertEquals((object)$details, $response->getData()['details']); + $this->assertStringNotContainsString('internal', $response->getData()['message']); + if ($code === CmdbImportException::MISSING_COLUMN) { + $this->assertStringContainsString('Invoer APP data', $response->getData()['message']); + $this->assertStringContainsString('Middel-ID', $response->getData()['message']); + } + }//end testServiceErrorsAreTranslated() + + /** + * An unexpected error is 500 IMPORT_FAILED with a generic message. + * + * @return void + */ + public function testAnUnexpectedErrorIsAGeneric500(): void { + $service = $this->service(); + $service->method('import')->willThrowException(new RuntimeException('SQLSTATE secret detail')); + + $response = $this->controller(file: $this->file(path: $this->upload()), params: ['municipalityName' => 'Gemeente Voorbeeldstad'], service: $service)->import(); + + $this->assertSame(500, $response->getStatus()); + $this->assertSame('IMPORT_FAILED', $response->getData()['error']); + $this->assertStringNotContainsString('SQLSTATE', $response->getData()['message']); + }//end testAnUnexpectedErrorIsAGeneric500() + + /** + * A valid upload passes the options through and answers 200 with the report. + * + * @return void + */ + public function testAValidUploadReturnsTheReport(): void { + $path = $this->upload(); + $service = $this->service(); + $service->expects($this->once())->method('assertXlsx')->with($path, 'export.xlsx'); + $service->expects($this->once())->method('import') + ->with( + $path, + [ + 'municipalityUuid' => '', + 'municipalityName' => 'Gemeente Voorbeeldstad', + 'updateExisting' => false, + 'operationId' => 'cmdb-00000000-0000-0000-0000-000000000000', + ] + ) + ->willReturn(['success' => true, 'summary' => ['created' => 2]]); + + $response = $this->controller( + file: $this->file(path: $path), + params: ['municipalityName' => 'Gemeente Voorbeeldstad', 'updateExisting' => 'false', 'missingRecords' => 'keep', 'operationId' => 'cmdb-00000000-0000-0000-0000-000000000000'], + service: $service + )->import(); + + $this->assertSame(200, $response->getStatus()); + $this->assertSame(['success' => true, 'summary' => ['created' => 2]], $response->getData()); + }//end testAValidUploadReturnsTheReport() + + /** + * Cancel answers 200 for a running import and 404 OPERATION_NOT_FOUND otherwise. + * + * @return void + */ + public function testCancel(): void { + $service = $this->service(); + $service->method('requestCancel')->willReturnCallback(fn (string $id): bool => $id === 'cmdb-running-1'); + $controller = $this->controller(file: null, params: [], service: $service); + + $ok = $controller->cancel(operationId: 'cmdb-running-1'); + $this->assertSame(200, $ok->getStatus()); + $this->assertSame(['success' => true, 'cancelRequested' => true], $ok->getData()); + + $missing = $controller->cancel(operationId: 'cmdb-unknown-1'); + $this->assertSame(404, $missing->getStatus()); + $this->assertSame('OPERATION_NOT_FOUND', $missing->getData()['error']); + }//end testCancel() +}//end class diff --git a/tests/Unit/Fixtures/CmdbFixtureHygieneTest.php b/tests/Unit/Fixtures/CmdbFixtureHygieneTest.php new file mode 100644 index 00000000..5118aa0d --- /dev/null +++ b/tests/Unit/Fixtures/CmdbFixtureHygieneTest.php @@ -0,0 +1,303 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-1 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Fixtures; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use ZipArchive; + +/** + * Scans every fixture package. + */ +class CmdbFixtureHygieneTest extends TestCase { + /** + * The only e-mail addresses a fixture may hold. + * + * @var array + */ + private const PLACEHOLDER_EMAILS = ['letter.achternaam@gemeente.nl', 'groepsmail.test@gemeente.nl']; + + /** + * The only hosts a fixture's content may link to. + * + * @var array + */ + private const PLACEHOLDER_HOSTS = ['wiki.gemeente.nl', 'example.invalid']; + + /** + * The only runs of six or more digits (personnel numbers, phone numbers, ids) a fixture may hold. + * + * @var array + */ + private const PLACEHOLDER_NUMBERS = ['123456', '123457', '612345678', '0612345678', '143211234', '10000000001']; + + /** + * The only values a person-name column may hold, besides a placeholder e-mail address + * (TOPdesk puts the address of the configuration coordinator in that column). + * + * @var array + */ + private const PLACEHOLDER_NAMES = ['', 'Achternaam, Voornaam', 'Achternaam, voornaam']; + + /** + * Columns that hold a person's name. + * + * @var array + */ + private const NAME_COLUMNS = [ + 'Eigenaar', + 'FB contactpersoon 1', + 'FB contactpersoon 2', + 'Groepseigenaar naam⚡', + 'Configuratie coördinator⚡', + 'Applicatie Eigenaar (Persoon)', + '|Asset eigenaar', + ]; + + /** + * Hosts of XML namespaces and schemas, which are not content. + * + * @var array + */ + private const SCHEMA_HOSTS = ['schemas.openxmlformats.org', 'schemas.microsoft.com', 'purl.org', 'www.w3.org']; + + /** + * Every fixture. + * + * @return array + */ + public static function fixtures(): array { + $cases = []; + foreach (glob(__DIR__ . '/../../fixtures/cmdb/*.xlsx') as $path) { + $cases[basename($path)] = [$path]; + } + + return $cases; + }//end fixtures() + + /** + * Every part of a package, by name. + * + * @param string $path The package. + * + * @return array + */ + private function parts(string $path): array { + $zip = new ZipArchive(); + $this->assertTrue($zip->open($path, ZipArchive::RDONLY), basename($path)); + $parts = []; + for ($index = 0; $index < $zip->numFiles; $index++) { + $name = (string)$zip->getNameIndex($index); + $parts[$name] = (string)$zip->getFromIndex($index); + } + + $zip->close(); + return $parts; + }//end parts() + + /** + * There are fixtures to scan, including the four the reader tests use. + * + * @return void + */ + public function testTheFixturesExist(): void { + $names = array_keys(self::fixtures()); + foreach (['topdesk-export-anonymised.xlsx', 'topdesk-missing-middel-id.xlsx', 'topdesk-shuffled-columns.xlsx', 'topdesk-formula-and-connection.xlsx'] as $expected) { + $this->assertContains($expected, $names); + } + }//end testTheFixturesExist() + + /** + * No author, no custom properties, no customXml, no absolute save path; connections only the synthetic one. + * + * @param string $path The fixture. + * + * @return void + */ + #[DataProvider('fixtures')] + public function testNoDocumentMetadata(string $path): void { + $parts = $this->parts(path: $path); + $name = basename($path); + + $this->assertArrayNotHasKey('docProps/custom.xml', $parts, $name); + foreach (array_keys($parts) as $part) { + $this->assertStringStartsNotWith('customXml/', $part, $name); + } + + if (isset($parts['docProps/core.xml']) === true) { + $core = $parts['docProps/core.xml']; + $this->assertDoesNotMatchRegularExpression('#[^<]+#', $core, $name); + $this->assertDoesNotMatchRegularExpression('#[^<]+#', $core, $name); + } + + $this->assertStringNotContainsString('absPath', ($parts['xl/workbook.xml'] ?? ''), $name); + foreach (['[Content_Types].xml', '_rels/.rels', 'xl/_rels/workbook.xml.rels'] as $index) { + $this->assertStringNotContainsString('custom.xml', ($parts[$index] ?? ''), $name . ' ' . $index); + $this->assertStringNotContainsString('customXml', ($parts[$index] ?? ''), $name . ' ' . $index); + } + + if ($name === 'topdesk-formula-and-connection.xlsx') { + $this->assertStringContainsString('https://example.invalid/', $parts['xl/connections.xml'], 'the synthetic connection'); + return; + } + + $this->assertArrayNotHasKey('xl/connections.xml', $parts, $name); + $this->assertStringNotContainsString('connections.xml', $parts['[Content_Types].xml'], $name); + $this->assertStringNotContainsString('connections.xml', ($parts['xl/_rels/workbook.xml.rels'] ?? ''), $name); + }//end testNoDocumentMetadata() + + /** + * Every e-mail address, linked host and long digit run is a known placeholder. + * + * @param string $path The fixture. + * + * @return void + */ + #[DataProvider('fixtures')] + public function testOnlyPlaceholderContactData(string $path): void { + $name = basename($path); + foreach ($this->parts(path: $path) as $part => $content) { + if (preg_match('/\.(xml|rels)$/', $part) !== 1) { + continue; + } + + preg_match_all('/[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/', $content, $emails); + foreach (array_unique($emails[0]) as $email) { + $this->assertContains(strtolower($email), self::PLACEHOLDER_EMAILS, $name . ' ' . $part); + } + + preg_match_all('#https?://([^/"<\s]+)#', $content, $urls); + foreach (array_unique($urls[1]) as $host) { + if (in_array($host, self::SCHEMA_HOSTS, true) === false) { + $this->assertContains($host, self::PLACEHOLDER_HOSTS, $name . ' ' . $part); + } + } + + // Digit runs in cell values and shared strings; attributes such as + // widths, ids and dates are not content. + preg_match_all('#>(\+?\d[\d\s-]{5,}\d)<#', $content, $numbers); + foreach (array_unique($numbers[1]) as $number) { + $digits = (string)preg_replace('/\D/', '', $number); + if (strlen($digits) >= 6) { + $this->assertContains($digits, self::PLACEHOLDER_NUMBERS, $name . ' ' . $part); + } + } + }//end foreach + }//end testOnlyPlaceholderContactData() + + /** + * Every person-name cell of the source sheets holds a placeholder. + * + * @param string $path The fixture. + * + * @return void + */ + #[DataProvider('fixtures')] + public function testPersonNameColumnsHoldPlaceholders(string $path): void { + $parts = $this->parts(path: $path); + $strings = $this->sharedStrings(xml: ($parts['xl/sharedStrings.xml'] ?? '')); + + foreach ($parts as $part => $content) { + if (preg_match('#^xl/worksheets/sheet\d+\.xml$#', $part) !== 1) { + continue; + } + + $sheet = simplexml_load_string($content); + $this->assertNotFalse($sheet, $part); + $headers = []; + foreach ($sheet->sheetData->row as $row) { + foreach ($row->c as $cell) { + preg_match('/^([A-Z]+)(\d+)$/', (string)$cell['r'], $ref); + $value = $this->cellText(cell: $cell, strings: $strings); + if ($ref[2] === '1') { + $headers[$ref[1]] = $value; + continue; + } + + // Only the raw TOPdesk sheets have their headers in row 1; the + // derived sheets are covered by the e-mail and number scan. + if (in_array('Middel-ID', $headers, true) === false) { + break 2; + } + + $header = ($headers[$ref[1]] ?? ''); + if (in_array($header, self::NAME_COLUMNS, true) === true) { + $this->assertContains(trim($value), array_merge(self::PLACEHOLDER_NAMES, self::PLACEHOLDER_EMAILS), basename($path) . ' ' . $part . ' ' . $cell['r']); + } + } + } + }//end foreach + }//end testPersonNameColumnsHoldPlaceholders() + + /** + * The shared strings table as a list. + * + * @param string $xml The sharedStrings part. + * + * @return array + */ + private function sharedStrings(string $xml): array { + if ($xml === '') { + return []; + } + + $table = simplexml_load_string($xml); + $strings = []; + foreach ($table->si as $item) { + $text = (string)$item->t; + foreach ($item->r as $run) { + $text .= (string)$run->t; + } + + $strings[] = $text; + } + + return $strings; + }//end sharedStrings() + + /** + * The text of a cell. + * + * @param \SimpleXMLElement $cell The cell. + * @param array $strings The shared strings. + * + * @return string + */ + private function cellText(\SimpleXMLElement $cell, array $strings): string { + $type = (string)$cell['t']; + if ($type === 's') { + return ($strings[(int)$cell->v] ?? ''); + } + + if ($type === 'inlineStr') { + return (string)$cell->is->t; + } + + return (string)$cell->v; + }//end cellText() +}//end class diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php new file mode 100644 index 00000000..1326f416 --- /dev/null +++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php @@ -0,0 +1,270 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-3 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service\Cmdb; + +require_once __DIR__ . '/../../Support/CmdbTestSupport.php'; + +use OCA\OpenRegister\Service\MigrationPack\MappingEngine; +use OCA\OpenRegister\Service\MigrationPack\PackDefinitionValidator; +use OCA\Stackiq\Exception\CmdbImportException; +use OCA\Stackiq\Service\Cmdb\CmdbImportProfile; +use OCA\Stackiq\Tests\Unit\Support\CmdbTestSupport; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +/** + * The packs are valid OpenRegister packs that implement design.md's column table. + */ +class CmdbImportProfileTest extends TestCase { + /** + * A container that knows nothing, so the validator comes from class_exists. + * + * @return ContainerInterface + */ + private function emptyContainer(): ContainerInterface { + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->willReturn(false); + return $container; + }//end emptyContainer() + + /** + * A copy of the shipped profile directory, to break on purpose. + * + * @return string The directory. + */ + private function copyOfShippedDirectory(): string { + $directory = sys_get_temp_dir() . '/stackiq-cmdb-profile-' . bin2hex(random_bytes(4)); + mkdir($directory); + foreach (glob(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/*.json') as $file) { + copy($file, $directory . '/' . basename($file)); + } + + return $directory; + }//end copyOfShippedDirectory() + + /** + * Remove a directory made by copyOfShippedDirectory(). + * + * @param string $directory The directory. + * + * @return void + */ + private function remove(string $directory): void { + array_map('unlink', glob($directory . '/*.json')); + rmdir($directory); + }//end remove() + + /** + * Every pack passes OpenRegister's validator as an excel pack with a generated id. + * + * @return void + */ + public function testEveryPackIsAValidOpenRegisterPack(): void { + CmdbTestSupport::loadMigrationPack(); + $profile = new CmdbImportProfile(container: $this->emptyContainer()); + $profile->load(); + + $validator = new PackDefinitionValidator(); + foreach (CmdbImportProfile::TARGETS as $target) { + $pack = $profile->pack(target: $target); + $this->assertSame([], $validator->validate($pack), $target); + $this->assertSame('excel', $pack['sourceFormat'], $target); + $this->assertSame(['type' => 'generate'], $pack['idStrategy'], $target); + } + }//end testEveryPackIsAValidOpenRegisterPack() + + /** + * The packs implement the column table of design.md. + * + * @return void + */ + public function testThePacksImplementTheColumnTable(): void { + CmdbTestSupport::loadMigrationPack(); + $profile = new CmdbImportProfile(container: $this->emptyContainer()); + $profile->load(); + + $targets = function (string $pack) use ($profile): array { + $map = []; + foreach ($profile->pack(target: $pack)['fieldMappings'] as $mapping) { + $map[$mapping['source']] = $mapping['target']; + } + + return $map; + }; + + $this->assertSame( + [ + 'Naam' => 'name', + 'Middel-ID' => 'externalId', + 'ICT Applicatienummer' => 'externalNumber', + 'Functionele omschrijving' => 'longDescription', + 'ICT BBN Classificatie' => 'bbnLevel', + 'Aanmaakdatum' => 'externalCreatedAt', + 'Wijzigingsdatum' => 'externalModifiedAt', + ], + $targets('module') + ); + $this->assertSame(['Fabrikant' => 'name'], $targets('manufacturer')); + $this->assertSame(['municipalityName' => 'name'], $targets('municipality')); + $this->assertSame( + ['Status' => 'status', 'ICT TIME Classificatie' => 'timeClassification', 'End of Life Business' => 'startDateOutPhased', 'Eigenaar afdeling' => 'interneAnnotation'], + $targets('usage') + ); + $this->assertSame(['Eigenaar' => 'name', 'Eigenaar e-mail' => 'email', 'Eigenaar functie' => 'role'], $targets('businessOwner')); + $this->assertSame(['FB contactpersoon 1' => 'name'], $targets('technicalOwner')); + + $this->assertSame(['type' => 'Supplier', 'status' => 'Active'], $profile->pack(target: 'manufacturer')['defaults']); + $this->assertSame(['type' => 'Municipality', 'status' => 'Active'], $profile->pack(target: 'municipality')['defaults']); + $this->assertSame(['type' => 'Application'], $profile->createOnlyDefaults(target: 'module')); + $this->assertSame(['interneAnnotation'], $profile->createOnlyFields(target: 'usage')); + $this->assertSame(['publicationDate', 'depublicationDate'], $profile->neverWrittenOnUpdate(target: 'module')); + $this->assertSame(['Middel-ID', 'Naam'], $profile->requiredColumns()); + $this->assertSame(['Invoer AIA data', 'Invoer APP data'], $profile->sheetNames()); + $this->assertSame(10485760, $profile->maxFileBytes()); + $this->assertSame(10000, $profile->maxRowsPerSheet()); + }//end testThePacksImplementTheColumnTable() + + /** + * The lookups map the TOPdesk values through the real engine, and an unknown value errors instead of passing through. + * + * @return void + */ + public function testTheLookupsMapThroughTheEngine(): void { + CmdbTestSupport::loadMigrationPack(); + $profile = new CmdbImportProfile(container: $this->emptyContainer()); + $profile->load(); + $engine = new MappingEngine(); + + $usage = $engine->mapRow( + $profile->pack(target: 'usage'), + ['Status' => 'In voorraad', 'ICT TIME Classificatie' => 'Tolereren', 'End of Life Business' => '2046-02-01', 'Eigenaar afdeling' => 'H10 Accounting', 'Eigenaar cluster' => 'H10 Bestuur'], + 2 + ); + $this->assertSame([], $usage['errors']); + $this->assertSame( + ['status' => 'Planned', 'timeClassification' => 'Tolerate', 'startDateOutPhased' => '2046-02-01', 'interneAnnotation' => 'H10 Accounting / H10 Bestuur'], + $usage['data'] + ); + + $module = $engine->mapRow($profile->pack(target: 'module'), ['Naam' => 'X', 'Middel-ID' => 'APP-1', 'ICT BBN Classificatie' => 'BBN 2'], 2); + $this->assertSame('BBN2', $module['data']['bbnLevel']); + + $unknown = $engine->mapRow($profile->pack(target: 'usage'), ['Status' => 'Onbekende status'], 3); + $this->assertArrayNotHasKey('status', $unknown['data']); + $this->assertSame('Status', $unknown['errors'][0]['source']); + $this->assertStringContainsString('Onbekende status', $unknown['errors'][0]['message']); + }//end testTheLookupsMapThroughTheEngine() + + /** + * The read allowlist leaves out personnel numbers, phones, group owners and group mailboxes. + * + * @return void + */ + public function testPersonColumnsAreNeverReferenced(): void { + CmdbTestSupport::loadMigrationPack(); + $profile = new CmdbImportProfile(container: $this->emptyContainer()); + $columns = $profile->referencedColumns(); + + foreach ([ + 'Personeelsnummer', + 'Eigenaar mobiel nummer', + 'Groepseigenaar mail⚡', + 'Groepseigenaar naam⚡', + 'Groepseigenaar telefoon⚡', + 'Groepsmail⚡', + 'Groepsnummer⚡', + 'Configuratie coördinator⚡', + 'FB contactpersoon 2', + 'Opmerkingen', + 'municipalityName', + ] as $never) { + $this->assertNotContains($never, $columns); + } + + $this->assertContains('Eigenaar e-mail', $columns); + $this->assertContains('Eigenaar cluster', $columns, 'the concat field is read too'); + }//end testPersonColumnsAreNeverReferenced() + + /** + * A pack with an unknown transform stops the import with MAPPING_UNAVAILABLE (503). + * + * @return void + */ + public function testAnInvalidPackIsMappingUnavailable(): void { + CmdbTestSupport::loadMigrationPack(); + $directory = $this->copyOfShippedDirectory(); + $pack = json_decode((string)file_get_contents($directory . '/topdesk-usage.json'), true); + $pack['fieldMappings'][0]['transform'] = ['type' => 'uppercase']; + file_put_contents($directory . '/topdesk-usage.json', json_encode($pack)); + + try { + (new CmdbImportProfile(container: $this->emptyContainer(), directory: $directory))->load(); + $this->fail('MAPPING_UNAVAILABLE expected'); + } catch (CmdbImportException $e) { + $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode()); + $this->assertSame(503, $e->getHttpStatus()); + $this->assertStringContainsString('topdesk-usage.json', $e->getMessage()); + } finally { + $this->remove(directory: $directory); + } + }//end testAnInvalidPackIsMappingUnavailable() + + /** + * A missing pack file, or a validator the container cannot give and that does not exist, is MAPPING_UNAVAILABLE. + * + * @return void + */ + public function testAMissingPackOrValidatorIsMappingUnavailable(): void { + CmdbTestSupport::loadMigrationPack(); + $directory = $this->copyOfShippedDirectory(); + unlink($directory . '/topdesk-module.json'); + + try { + (new CmdbImportProfile(container: $this->emptyContainer(), directory: $directory))->load(); + $this->fail('MAPPING_UNAVAILABLE expected'); + } catch (CmdbImportException $e) { + $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode()); + } finally { + $this->remove(directory: $directory); + } + + $profile = new class(container: $this->emptyContainer()) extends CmdbImportProfile { + public const VALIDATOR_CLASS = 'OCA\OpenRegister\Service\MigrationPack\NoSuchValidator'; + }; + try { + $profile->load(); + $this->fail('MAPPING_UNAVAILABLE expected without a validator'); + } catch (CmdbImportException $e) { + $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode()); + $this->assertSame(503, $e->getHttpStatus()); + } + }//end testAMissingPackOrValidatorIsMappingUnavailable() + + /** + * The upload limit is readable without OpenRegister. + * + * @return void + */ + public function testTheUploadLimitNeedsNoOpenRegister(): void { + $profile = new CmdbImportProfile(container: $this->emptyContainer(), directory: '/nonexistent'); + $this->assertSame(CmdbImportProfile::DEFAULT_MAX_FILE_BYTES, $profile->maxFileBytes()); + }//end testTheUploadLimitNeedsNoOpenRegister() +}//end class diff --git a/tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php b/tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php new file mode 100644 index 00000000..705e936d --- /dev/null +++ b/tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php @@ -0,0 +1,117 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service\Cmdb; + +use OCA\Stackiq\Service\Cmdb\CmdbRowNormaliser; +use PHPUnit\Framework\TestCase; + +/** + * Excel serial dates, ids and trimming. + */ +class CmdbRowNormaliserTest extends TestCase { + /** + * The serials of the anonymised export become the dates design.md names; ids lose their decimal part. + * + * @return void + */ + public function testSerialDatesAndIdsAreNormalised(): void { + $row = (new CmdbRowNormaliser())->normalise( + cells: [ + 'Aanmaakdatum' => 45111.380322627316, + 'Wijzigingsdatum' => 46232.552113113423, + 'End of Life Business' => 53359, + 'ICT Applicatienummer' => 1234.0, + 'Middel-ID' => ' APP-test123 ', + 'Naam' => ' naamtest123 ', + 'Fabrikant' => null, + ], + dateColumns: ['Aanmaakdatum', 'Wijzigingsdatum', 'End of Life Business'], + idColumns: ['Middel-ID', 'ICT Applicatienummer'] + ); + + $this->assertSame( + [ + 'Aanmaakdatum' => '2023-07-04', + 'Wijzigingsdatum' => '2026-07-29', + 'End of Life Business' => '2046-02-01', + 'ICT Applicatienummer' => '1234', + 'Middel-ID' => 'APP-test123', + 'Naam' => 'naamtest123', + 'Fabrikant' => '', + ], + $row + ); + }//end testSerialDatesAndIdsAreNormalised() + + /** + * The string forms of serials and ids convert the same way. + * + * @return void + */ + public function testStringSerialsAndIdsConvertToo(): void { + $normaliser = new CmdbRowNormaliser(); + + $this->assertSame('2023-07-04', $normaliser->normaliseDate(value: '45111.380322627316')); + $this->assertSame('1234', $normaliser->normaliseId(value: '1234.0')); + $this->assertSame('1234', $normaliser->normaliseId(value: 1234)); + $this->assertSame('12.5', $normaliser->normaliseId(value: 12.5)); + $this->assertSame('APP-1.0', $normaliser->normaliseId(value: 'APP-1.0')); + }//end testStringSerialsAndIdsConvertToo() + + /** + * A non-numeric date stays as it is, for the pack's date transform to judge; out-of-range serials too. + * + * @return void + */ + public function testNonSerialDatesStayAsTheyAre(): void { + $normaliser = new CmdbRowNormaliser(); + + $this->assertSame('2026-10-01', $normaliser->normaliseDate(value: ' 2026-10-01 ')); + $this->assertSame('onbekend', $normaliser->normaliseDate(value: 'onbekend')); + $this->assertSame('0', $normaliser->normaliseDate(value: 0)); + $this->assertSame('', $normaliser->normaliseDate(value: null)); + }//end testNonSerialDatesStayAsTheyAre() + + /** + * Excel's 1900 leap-year bug and the 1904 date system. + * + * @return void + */ + public function testDateSystemsAndTheLeapYearBug(): void { + $normaliser = new CmdbRowNormaliser(); + + $this->assertSame('1900-01-01', $normaliser->normaliseDate(value: 1)); + $this->assertSame('1900-02-28', $normaliser->normaliseDate(value: 59)); + $this->assertSame('1900-03-01', $normaliser->normaliseDate(value: 61)); + $this->assertSame('2023-07-04', $normaliser->normaliseDate(value: 43649, date1904: true)); + }//end testDateSystemsAndTheLeapYearBug() + + /** + * Booleans become TRUE/FALSE text; whole floats lose their decimal part. + * + * @return void + */ + public function testOtherScalarsBecomeText(): void { + $row = (new CmdbRowNormaliser())->normalise(cells: ['a' => true, 'b' => false, 'c' => 2.0, 'd' => 2.25], dateColumns: [], idColumns: []); + + $this->assertSame(['a' => 'TRUE', 'b' => 'FALSE', 'c' => '2', 'd' => '2.25'], $row); + }//end testOtherScalarsBecomeText() +}//end class diff --git a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php new file mode 100644 index 00000000..696baae8 --- /dev/null +++ b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php @@ -0,0 +1,308 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service\Cmdb; + +require_once __DIR__ . '/../../Support/CmdbTestSupport.php'; + +use OCA\Stackiq\Exception\CmdbImportException; +use OCA\Stackiq\Service\Cmdb\CmdbImportProfile; +use OCA\Stackiq\Service\Cmdb\CmdbWorkbookReader; +use OCA\Stackiq\Tests\Unit\Support\CmdbTestSupport; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +/** + * Reads the fixtures through PhpSpreadsheet as OpenRegister ships it. + */ +class CmdbWorkbookReaderTest extends TestCase { + /** + * The shipped profile, validated with OpenRegister's validator. + * + * @param string|null $directory A profile directory other than the shipped one. + * + * @return CmdbImportProfile + */ + private function profile(?string $directory = null): CmdbImportProfile { + CmdbTestSupport::loadMigrationPack(); + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->willReturn(false); + + $profile = new CmdbImportProfile(container: $container, directory: $directory); + $profile->load(); + return $profile; + }//end profile() + + /** + * Skip unless PhpSpreadsheet can be loaded from an OpenRegister checkout. + * + * @return void + */ + private function requireSpreadsheet(): void { + if (CmdbTestSupport::loadPhpSpreadsheet() === false) { + $this->markTestSkipped('PhpSpreadsheet not found: set OPENREGISTER_DIR to an OpenRegister app with its vendor/ installed.'); + } + }//end requireSpreadsheet() + + /** + * Read a fixture. + * + * @param string $name The fixture file. + * + * @return array + */ + private function read(string $name): array { + $this->requireSpreadsheet(); + $path = CmdbTestSupport::fixtures() . '/' . $name; + $reader = new CmdbWorkbookReader(); + $reader->assertXlsx(path: $path, fileName: $name); + return $reader->read(path: $path, profile: $this->profile()); + }//end read() + + /** + * One data row per source sheet; the formatted empty rows are dropped; only allowlisted columns. + * + * @return void + */ + public function testTheSanitisedExportYieldsOneRowPerSheet(): void { + $result = $this->read(name: 'topdesk-export-anonymised.xlsx'); + $rows = $result['rows']; + + $this->assertCount(2, $rows); + $this->assertSame(['Invoer AIA data', 2], [$rows[0]['sheet'], $rows[0]['row']]); + $this->assertSame(['Invoer APP data', 2], [$rows[1]['sheet'], $rows[1]['row']]); + $this->assertSame('AIA-AangetekendMailen', $rows[0]['cells']['Middel-ID']); + $this->assertSame('Aangetekend Mailen', $rows[0]['cells']['Naam']); + $this->assertSame('naamtest123', $rows[1]['cells']['Naam']); + $this->assertSame(53359, (int)$rows[1]['cells']['End of Life Business']); + + $allowed = $this->profile()->referencedColumns(); + foreach ($rows as $row) { + foreach (array_keys($row['cells']) as $column) { + $this->assertContains($column, $allowed); + } + + foreach (['Personeelsnummer', 'Eigenaar mobiel nummer', 'Groepseigenaar mail', 'Groepsmail', 'Opmerkingen', 'FB contactpersoon 2'] as $never) { + $this->assertArrayNotHasKey($never, $row['cells']); + } + } + + $this->assertFalse($result['date1904']); + $this->assertContains( + ['sheet' => 'Invoer AIA data', 'column' => 'ICT TIME Classificatie', 'message' => 'Optional column "ICT TIME Classificatie" not found'], + $result['importWarnings'] + ); + }//end testTheSanitisedExportYieldsOneRowPerSheet() + + /** + * A formula cell yields the value Excel cached, not its result, and the connection is never contacted. + * + * @return void + */ + public function testAFormulaYieldsItsCachedValue(): void { + $rows = $this->read(name: 'topdesk-formula-and-connection.xlsx')['rows']; + + // The formula evaluates to "Evaluated"; the cached value is "Rekenmodel". + $this->assertSame('Rekenmodel', $rows[1]['cells']['Naam']); + $this->assertSame('APP-test123', $rows[1]['cells']['Middel-ID']); + }//end testAFormulaYieldsItsCachedValue() + + /** + * The reader source never calls the calculation engine nor an HTTP client. + * + * @return void + */ + public function testTheReaderNeverEvaluatesOrFetches(): void { + $source = (string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Service/Cmdb/CmdbWorkbookReader.php'); + $code = (string)preg_replace('#/\*.*?\*/|//[^\n]*#s', '', $source); + + $this->assertStringNotContainsString('getCalculatedValue', $code); + $this->assertStringNotContainsString('toArray', $code); + $this->assertStringNotContainsString('Calculation', $code); + $this->assertDoesNotMatchRegularExpression('/Http|Guzzle|curl_|file_get_contents\(\s*\$url/i', $code); + $this->assertStringContainsString('getOldCalculatedValue', $code); + $this->assertStringContainsString('setReadDataOnly(true)', $code); + }//end testTheReaderNeverEvaluatesOrFetches() + + /** + * Shuffled columns and decorated headers map to the same rows. + * + * @return void + */ + public function testShuffledColumnsMapTheSame(): void { + $original = $this->read(name: 'topdesk-export-anonymised.xlsx')['rows']; + $shuffled = $this->read(name: 'topdesk-shuffled-columns.xlsx')['rows']; + + $this->assertCount(count($original), $shuffled); + foreach ($original as $index => $row) { + $expected = $row['cells']; + $actual = $shuffled[$index]['cells']; + ksort($expected); + ksort($actual); + $this->assertSame($expected, $actual); + } + }//end testShuffledColumnsMapTheSame() + + /** + * A source sheet without Middel-ID stops the import, naming column and sheet. + * + * @return void + */ + public function testAMissingRequiredColumnIsNamed(): void { + try { + $this->read(name: 'topdesk-missing-middel-id.xlsx'); + $this->fail('MISSING_COLUMN expected'); + } catch (CmdbImportException $e) { + $this->assertSame('MISSING_COLUMN', $e->getErrorCode()); + $this->assertSame(422, $e->getHttpStatus()); + $this->assertSame(['sheet' => 'Invoer APP data', 'column' => 'Middel-ID'], $e->getDetails()); + } + }//end testAMissingRequiredColumnIsNamed() + + /** + * A workbook with only "Blad1" names both expected sheets. + * + * @return void + */ + public function testAWorkbookWithoutSourceSheetsIsRefused(): void { + try { + $this->read(name: 'topdesk-no-source-sheet.xlsx'); + $this->fail('NO_SOURCE_SHEET expected'); + } catch (CmdbImportException $e) { + $this->assertSame('NO_SOURCE_SHEET', $e->getErrorCode()); + $this->assertSame(['expected' => ['Invoer AIA data', 'Invoer APP data']], $e->getDetails()); + } + }//end testAWorkbookWithoutSourceSheetsIsRefused() + + /** + * More non-empty rows than the profile allows stops the import. + * + * @return void + */ + public function testTooManyRowsIsRefused(): void { + $this->requireSpreadsheet(); + $directory = sys_get_temp_dir() . '/stackiq-cmdb-profile-' . bin2hex(random_bytes(4)); + mkdir($directory); + $shipped = CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import'; + foreach (glob($shipped . '/*.json') as $file) { + copy($file, $directory . '/' . basename($file)); + } + + $profile = json_decode((string)file_get_contents($directory . '/topdesk-profile.json'), true); + $profile['maxRowsPerSheet'] = 0; + file_put_contents($directory . '/topdesk-profile.json', json_encode($profile)); + + try { + (new CmdbWorkbookReader())->read(path: CmdbTestSupport::fixtures() . '/topdesk-export-anonymised.xlsx', profile: $this->profile(directory: $directory)); + $this->fail('TOO_MANY_ROWS expected'); + } catch (CmdbImportException $e) { + $this->assertSame('TOO_MANY_ROWS', $e->getErrorCode()); + $this->assertSame(422, $e->getHttpStatus()); + } finally { + array_map('unlink', glob($directory . '/*.json')); + rmdir($directory); + } + }//end testTooManyRowsIsRefused() + + /** + * A text file named .xlsx, a .xlsm and a CSV are refused before PhpSpreadsheet is touched. + * + * @return void + */ + public function testNonXlsxIsRefusedBeforeParsing(): void { + $reader = new CmdbWorkbookReader(); + $text = tempnam(sys_get_temp_dir(), 'cmdb'); + file_put_contents($text, "Naam;Middel-ID\nVoorbeeld;APP-1\n"); + $cases = [ + [$text, 'export.xlsx'], + [CmdbTestSupport::fixtures() . '/topdesk-export-anonymised.xlsx', 'export.xlsm'], + [$text, 'applications.csv'], + [CmdbTestSupport::fixtures() . '/topdesk-export-anonymised.xlsx', 'export.xls'], + ]; + + try { + foreach ($cases as [$path, $name]) { + try { + $reader->assertXlsx(path: $path, fileName: $name); + $this->fail('NOT_XLSX expected for ' . $name); + } catch (CmdbImportException $e) { + $this->assertSame('NOT_XLSX', $e->getErrorCode(), $name); + $this->assertSame(400, $e->getHttpStatus(), $name); + } + } + + // A ZIP without xl/workbook.xml. + $zipPath = tempnam(sys_get_temp_dir(), 'cmdb') . '.xlsx'; + $zip = new \ZipArchive(); + $zip->open($zipPath, \ZipArchive::CREATE); + $zip->addFromString('word/document.xml', ''); + $zip->close(); + try { + $reader->assertXlsx(path: $zipPath, fileName: 'export.xlsx'); + $this->fail('NOT_XLSX expected for a zip without a workbook'); + } catch (CmdbImportException $e) { + $this->assertSame('NOT_XLSX', $e->getErrorCode()); + } finally { + unlink($zipPath); + } + } finally { + unlink($text); + } + + $this->assertTrue(true); + }//end testNonXlsxIsRefusedBeforeParsing() + + /** + * Without PhpSpreadsheet the reader answers READER_UNAVAILABLE. + * + * @return void + */ + public function testAMissingReaderIsReported(): void { + $reader = new class extends CmdbWorkbookReader { + /** + * PhpSpreadsheet is absent. + * + * @return bool + */ + public function isAvailable(): bool { + return false; + }//end isAvailable() + }; + + try { + $reader->read(path: CmdbTestSupport::fixtures() . '/topdesk-export-anonymised.xlsx', profile: $this->profile()); + $this->fail('READER_UNAVAILABLE expected'); + } catch (CmdbImportException $e) { + $this->assertSame('READER_UNAVAILABLE', $e->getErrorCode()); + $this->assertSame(503, $e->getHttpStatus()); + } + }//end testAMissingReaderIsReported() + + /** + * Headers match after trimming, collapsing whitespace, dropping a trailing ":" or "⚡" and lower-casing. + * + * @return void + */ + public function testHeadersAreNormalised(): void { + $this->assertSame('groepseigenaar mail', CmdbWorkbookReader::normaliseHeader(header: 'Groepseigenaar mail⚡')); + $this->assertSame('ib bewaartermijn', CmdbWorkbookReader::normaliseHeader(header: ' IB Bewaartermijn: ')); + $this->assertSame('middel-id', CmdbWorkbookReader::normaliseHeader(header: 'MIDDEL-ID')); + }//end testHeadersAreNormalised() +}//end class diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php new file mode 100644 index 00000000..96a71f77 --- /dev/null +++ b/tests/Unit/Service/CmdbExportImportServiceTest.php @@ -0,0 +1,1085 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +require_once __DIR__ . '/../Support/CmdbTestSupport.php'; + +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\Stackiq\Exception\CmdbImportException; +use OCA\Stackiq\Service\Cmdb\CmdbImportProfile; +use OCA\Stackiq\Service\Cmdb\CmdbRowNormaliser; +use OCA\Stackiq\Service\Cmdb\CmdbWorkbookReader; +use OCA\Stackiq\Service\CmdbExportImportService; +use OCA\Stackiq\Service\ProgressTracker; +use OCA\Stackiq\Service\SettingsService; +use OCA\Stackiq\Service\StackiqContactSyncService; +use OCA\Stackiq\Tests\Unit\Support\CmdbTestSupport; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IL10N; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\AbstractLogger; +use RuntimeException; + +/** + * The import, row by row, against an in-memory OpenRegister. + * + * @SuppressWarnings(PHPMD.ExcessiveClassLength) + * @SuppressWarnings(PHPMD.TooManyPublicMethods) + */ +class CmdbExportImportServiceTest extends TestCase { + private const REGISTER = 20; + private const MODULE = 43; + private const ORGANIZATION = 33; + private const USAGE = 34; + private const CONTACT_PERSON = 32; + + /** + * Objects per schema id, by uuid. + * + * @var array>> + */ + private array $store = []; + + /** + * Every saveObject() call: schema, uuid, data, create. + * + * @var array, create: bool}> + */ + private array $saves = []; + + /** + * Called before every save; may throw. + * + * @var callable|null + */ + private $beforeSave = null; + + /** + * Contacts in the fake address book: uid => name, email. + * + * @var array + */ + private array $contacts = []; + + /** + * Whether Contacts is enabled. + * + * @var bool + */ + private bool $contactsEnabled = true; + + /** + * The in-memory distributed cache behind the ProgressTracker. + * + * @var array + */ + private array $cache = []; + + /** + * Every log line, message plus encoded context. + * + * @var array + */ + private array $logLines = []; + + /** + * The ProgressTracker of the current service. + * + * @var ProgressTracker|null + */ + private ?ProgressTracker $tracker = null; + + /** + * Reset the doubles. + * + * @return void + */ + protected function setUp(): void { + CmdbTestSupport::loadMigrationPack(); + $this->store = [self::MODULE => [], self::ORGANIZATION => [], self::USAGE => [], self::CONTACT_PERSON => []]; + $this->saves = []; + $this->beforeSave = null; + $this->contacts = []; + $this->contactsEnabled = true; + $this->cache = []; + $this->logLines = []; + }//end setUp() + + // ------------------------------------------------------------------ + // Doubles + // ------------------------------------------------------------------ + + /** + * An entity as OpenRegister returns it. + * + * @param string $uuid The uuid. + * @param array $data The object data. + * + * @return ObjectEntityInterface + */ + private function entity(string $uuid, array $data): ObjectEntityInterface { + return new class($uuid, $data) implements ObjectEntityInterface { + /** + * Constructor. + * + * @param string $uuid The uuid. + * @param array $data The data. + */ + public function __construct( + private string $uuid, + private array $data, + ) { + } + + public function getUuid(): ?string { + return $this->uuid; + } + + public function getObject(): array { + return $this->data; + } + + public function getRegister(): ?string { + return '20'; + } + + public function getSchema(): ?string { + return null; + } + + public function getOrganisation(): ?string { + return null; + } + + public function getOwner(): ?string { + return null; + } + + public function jsonSerialize(): array { + return $this->data; + } + }; + }//end entity() + + /** + * The in-memory OpenRegister. + * + * @return ObjectServiceInterface + */ + private function objectService(): ObjectServiceInterface { + $service = $this->createMock(ObjectServiceInterface::class); + $service->method('saveObject')->willReturnCallback( + function (array $object, ?array $extend = [], $register = null, $schema = null, ?string $uuid = null): ObjectEntityInterface { + $schema = (int)$schema; + if ($this->beforeSave !== null) { + ($this->beforeSave)($schema, $object); + } + + $create = ($uuid === null); + if ($create === true) { + $uuid = sprintf('00000000-0000-4000-8000-%012d', count($this->saves) + 1); + } + + $object['id'] = $uuid; + $this->store[$schema][$uuid] = $object; + $this->saves[] = ['schema' => $schema, 'uuid' => $uuid, 'data' => $object, 'create' => $create]; + return $this->entity(uuid: $uuid, data: $object); + } + ); + $service->method('searchObjects')->willReturnCallback( + function (array $query = []): array { + $schema = (int)($query['@self']['schema'] ?? 0); + $limit = (int)($query['_limit'] ?? 30); + $offset = (int)($query['_offset'] ?? 0); + $filters = array_filter($query, fn ($key): bool => $key !== '@self' && str_starts_with((string)$key, '_') === false, ARRAY_FILTER_USE_KEY); + $found = []; + foreach (($this->store[$schema] ?? []) as $uuid => $data) { + foreach ($filters as $field => $value) { + if ((string)($data[$field] ?? '') !== (string)$value) { + continue 2; + } + } + + $found[] = $this->entity(uuid: $uuid, data: $data); + } + + return array_slice($found, $offset, $limit); + } + ); + $service->method('find')->willReturnCallback( + function ($id, ?array $_extend = [], bool $files = false, $register = null, $schema = null): ?ObjectEntityInterface { + $data = ($this->store[(int)$schema][(string)$id] ?? null); + if ($data === null) { + return null; + } + + return $this->entity(uuid: (string)$id, data: $data); + } + ); + + return $service; + }//end objectService() + + /** + * The Contacts bridge over a fake address book. + * + * @return StackiqContactSyncService + */ + private function contactSync(): StackiqContactSyncService { + $sync = $this->createMock(StackiqContactSyncService::class); + $sync->method('isAvailable')->willReturnCallback(fn (): bool => $this->contactsEnabled); + $sync->method('searchContacts')->willReturnCallback( + function (string $query): array { + $found = []; + foreach ($this->contacts as $uid => $contact) { + if (str_contains(mb_strtolower($contact['name']), mb_strtolower($query)) === true) { + $found[] = ['uid' => $uid, 'name' => $contact['name'], 'email' => $contact['email']]; + } + } + + return $found; + } + ); + $sync->method('syncToContacts')->willReturnCallback( + function (string $objectType, array $record): ?string { + $email = (string)($record['email'] ?? ''); + foreach ($this->contacts as $uid => $contact) { + if ($email !== '' && strcasecmp($contact['email'], $email) === 0) { + return $uid; + } + } + + $uid = 'contact-' . (count($this->contacts) + 1); + $this->contacts[$uid] = ['name' => trim(($record['voornaam'] ?? '') . ' ' . ($record['achternaam'] ?? '')), 'email' => $email]; + return $uid; + } + ); + + return $sync; + }//end contactSync() + + /** + * A ProgressTracker on an in-memory distributed cache. + * + * @return ProgressTracker + */ + private function progressTracker(): ProgressTracker { + $cache = $this->createMock(ICache::class); + $cache->method('get')->willReturnCallback(fn ($key) => ($this->cache[$key] ?? null)); + $cache->method('set')->willReturnCallback( + function ($key, $value): bool { + $this->cache[$key] = $value; + return true; + } + ); + $cache->method('remove')->willReturnCallback( + function ($key): bool { + unset($this->cache[$key]); + return true; + } + ); + $factory = $this->createMock(ICacheFactory::class); + $factory->method('createDistributed')->willReturn($cache); + + return new ProgressTracker(cacheFactory: $factory, userSession: $this->createMock(IUserSession::class), logger: $this->logger()); + }//end progressTracker() + + /** + * An IL10N that returns the English source with its parameters filled in. + * + * @return IL10N + */ + private function l10n(): IL10N { + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(fn (string $text, $parameters = []): string => vsprintf($text, (array)$parameters)); + return $l10n; + }//end l10n() + + /** + * A logger that keeps every line. + * + * @return AbstractLogger + */ + private function logger(): AbstractLogger { + $lines = &$this->logLines; + return new class($lines) extends AbstractLogger { + /** + * Constructor. + * + * @param array $lines The collected lines. + */ + public function __construct( + private array &$lines, + ) { + } + + /** + * Keep a line. + * + * @param mixed $level The level. + * @param string|\Stringable $message The message. + * @param array $context The context. + * + * @return void + */ + public function log($level, string|\Stringable $message, array $context = []): void { + array_walk_recursive( + $context, + function (&$value): void { + if (is_object($value) === true) { + $value = get_class($value) . ($value instanceof \Throwable ? ': ' . $value->getMessage() : ''); + } + } + ); + $this->lines[] = $message . ' ' . json_encode($context, JSON_UNESCAPED_UNICODE); + } + }; + }//end logger() + + /** + * A reader that hands out given rows, for tests that do not need the fixture. + * + * @param array}> $rows The rows. + * + * @return CmdbWorkbookReader + */ + private function rowsReader(array $rows): CmdbWorkbookReader { + return new class($rows) extends CmdbWorkbookReader { + /** + * Constructor. + * + * @param array> $rows The rows. + */ + public function __construct( + private array $rows, + ) { + } + + /** + * The given rows. + * + * @param string $path Ignored. + * @param CmdbImportProfile $profile Ignored. + * + * @return array + */ + public function read(string $path, CmdbImportProfile $profile): array { + return ['rows' => $this->rows, 'importWarnings' => [], 'date1904' => false]; + } + }; + }//end rowsReader() + + /** + * The service under test. + * + * @param CmdbWorkbookReader|null $reader The reader; null is the real one. + * @param string|null $profileDir A profile directory other than the shipped one. + * @param array $config The voorzieningen config. + * + * @return CmdbExportImportService + */ + private function service(?CmdbWorkbookReader $reader = null, ?string $profileDir = null, array $config = ['register' => '20']): CmdbExportImportService { + $objectService = $this->objectService(); + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->willReturn(false); + $container->method('get')->willReturnCallback( + function (string $id) use ($objectService) { + if ($id === ObjectServiceInterface::class) { + return $objectService; + } + + throw new RuntimeException('not in this container: ' . $id); + } + ); + + $settings = $this->createMock(SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturn($config); + $settings->method('getSchemaIdForObjectType')->willReturnCallback( + fn (string $type): ?int => ['module' => self::MODULE, 'organization' => self::ORGANIZATION, 'usage' => self::USAGE, 'contactPerson' => self::CONTACT_PERSON][$type] ?? null + ); + + $this->tracker = $this->progressTracker(); + + return new CmdbExportImportService( + container: $container, + settingsService: $settings, + contactSync: $this->contactSync(), + progressTracker: $this->tracker, + profile: new CmdbImportProfile(container: $container, directory: $profileDir), + reader: ($reader ?? new CmdbWorkbookReader()), + normaliser: new CmdbRowNormaliser(), + l10n: $this->l10n(), + logger: $this->logger() + ); + }//end service() + + /** + * Skip unless the fixture can be read. + * + * @return string The fixture path. + */ + private function fixture(): string { + if (CmdbTestSupport::loadPhpSpreadsheet() === false) { + $this->markTestSkipped('PhpSpreadsheet not found: set OPENREGISTER_DIR to an OpenRegister app with its vendor/ installed.'); + } + + return CmdbTestSupport::fixtures() . '/topdesk-export-anonymised.xlsx'; + }//end fixture() + + /** + * A synthetic application row. + * + * @param string $middelId The Middel-ID. + * @param array $cells Overrides. + * @param int $row The row number. + * @param string $sheet The sheet. + * + * @return array{sheet: string, row: int, cells: array} + */ + private function row(string $middelId, array $cells = [], int $row = 2, string $sheet = 'Invoer APP data'): array { + return [ + 'sheet' => $sheet, + 'row' => $row, + 'cells' => array_merge( + ['Soort' => 'Applicatie', 'Middel-ID' => $middelId, 'Naam' => 'Applicatie ' . $middelId, 'Fabrikant' => 'Fabfrikant', 'Status' => 'In productie'], + $cells + ), + ]; + }//end row() + + /** + * The stored objects of a schema. + * + * @param int $schema The schema id. + * + * @return array> + */ + private function objects(int $schema): array { + return array_values($this->store[$schema]); + }//end objects() + + /** + * Seed an organisation. + * + * @param string $uuid The uuid. + * @param string $name The name. + * @param string $type The type. + * + * @return void + */ + private function seedOrganisation(string $uuid, string $name, string $type): void { + $this->store[self::ORGANIZATION][$uuid] = ['id' => $uuid, 'name' => $name, 'type' => $type, 'status' => 'Active']; + }//end seedOrganisation() + + // ------------------------------------------------------------------ + // Task 5: municipality, manufacturer, module upsert, usage + // ------------------------------------------------------------------ + + /** + * The sanitised export creates two modules, two suppliers, two usages and the municipality. + * + * @return void + */ + public function testTheFixtureCreatesModulesUsagesAndSuppliers(): void { + $path = $this->fixture(); + $before = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->modify('-1 second'); + $report = $this->service()->import(path: $path, options: ['municipalityName' => 'Gemeente Voorbeeldstad', 'operationId' => 'cmdb-test-0001']); + + $this->assertTrue($report['success']); + $this->assertFalse($report['cancelled']); + $this->assertSame('cmdb-test-0001', $report['operationId']); + $this->assertSame(['rowsRead' => 2, 'processed' => 2, 'created' => 2, 'updated' => 0, 'unchanged' => 0, 'skipped' => 0, 'failed' => 0, 'warnings' => 0], $report['summary']); + $this->assertSame('Gemeente Voorbeeldstad', $report['municipality']['name']); + $this->assertTrue($report['municipality']['created']); + $this->assertContains(['sheet' => 'Invoer AIA data', 'message' => 'Optional column "ICT TIME Classificatie" not found'], $report['importWarnings']); + + $municipality = $report['municipality']['uuid']; + $this->assertSame('Municipality', $this->store[self::ORGANIZATION][$municipality]['type']); + $this->assertSame('Active', $this->store[self::ORGANIZATION][$municipality]['status']); + + $suppliers = array_filter($this->objects(self::ORGANIZATION), fn (array $o): bool => $o['type'] === 'Supplier'); + $this->assertEqualsCanonicalizing(['Aangetekend B.V.', 'Fabfrikant'], array_column($suppliers, 'name')); + + $modules = []; + foreach ($this->objects(self::MODULE) as $module) { + $modules[$module['externalId']] = $module; + } + + $this->assertSame(['AIA-AangetekendMailen', 'APP-test123'], array_keys($modules)); + $aia = $modules['AIA-AangetekendMailen']; + $this->assertSame('topdesk:' . $municipality . ':AIA-AangetekendMailen', $aia['externalKey']); + $this->assertSame('Aangetekend Mailen', $aia['name']); + $this->assertSame('Application', $aia['type']); + $this->assertSame('1234', $aia['externalNumber']); + $this->assertSame('2023-07-04', $aia['externalCreatedAt']); + $this->assertSame('2026-07-29', $aia['externalModifiedAt']); + $this->assertSame('Functionele omschrijving test123', $aia['longDescription']); + $publication = new \DateTimeImmutable($aia['publicationDate']); + $this->assertGreaterThanOrEqual($before, $publication); + $this->assertLessThanOrEqual(new \DateTimeImmutable('now'), $publication); + $this->assertSame($aia['publicationDate'], $modules['APP-test123']['publicationDate'], 'one start time for the whole import'); + $this->assertSame('2', $modules['APP-test123']['externalNumber']); + + $supplierByName = array_column($suppliers, 'id', 'name'); + $this->assertSame($supplierByName['Aangetekend B.V.'], $aia['provider']); + $this->assertSame($supplierByName['Fabfrikant'], $modules['APP-test123']['provider']); + + $usages = $this->objects(self::USAGE); + $this->assertCount(2, $usages); + $usageByModule = array_column($usages, null, 'module'); + $this->assertSame($municipality, $usageByModule[$aia['id']]['consumer']); + $this->assertSame('Planned', $usageByModule[$aia['id']]['status']); + $this->assertSame('H10 Accounting / H10 Bestuurs- en Concernondersteuning', $usageByModule[$aia['id']]['interneAnnotation']); + $this->assertSame($supplierByName['Aangetekend B.V.'], $usageByModule[$aia['id']]['provider']); + $app = $usageByModule[$modules['APP-test123']['id']]; + $this->assertSame('In production', $app['status']); + $this->assertSame('2046-02-01', $app['startDateOutPhased']); + $this->assertArrayNotHasKey('businessOwner', $app, 'the APP row names no owner'); + + $this->assertSame($aia['id'], $report['rows'][0]['moduleUuid']); + $this->assertSame($usageByModule[$aia['id']]['id'], $report['rows'][0]['usageUuid']); + $this->assertSame(['Invoer AIA data', 2, 'AIA-AangetekendMailen', 'Aangetekend Mailen', 'created'], [$report['rows'][0]['sheet'], $report['rows'][0]['row'], $report['rows'][0]['middelId'], $report['rows'][0]['name'], $report['rows'][0]['outcome']]); + }//end testTheFixtureCreatesModulesUsagesAndSuppliers() + + /** + * The same export again: 0 created, 2 unchanged, no save at all, one municipality. + * + * @return void + */ + public function testReimportingTheSameExportChangesNothing(): void { + $path = $this->fixture(); + $service = $this->service(); + $service->import(path: $path, options: ['municipalityName' => 'Gemeente Voorbeeldstad']); + $counts = array_map('count', $this->store); + $savesAfterFirst = count($this->saves); + + $report = $service->import(path: $path, options: ['municipalityName' => ' gemeente VOORBEELDSTAD ']); + + $this->assertSame(0, $report['summary']['created']); + $this->assertSame(2, $report['summary']['unchanged']); + $this->assertFalse($report['municipality']['created']); + $this->assertSame($savesAfterFirst, count($this->saves), 'no saveObject() call for unchanged objects'); + $this->assertSame($counts, array_map('count', $this->store)); + $municipalities = array_filter($this->objects(self::ORGANIZATION), fn (array $o): bool => $o['type'] === 'Municipality'); + $this->assertCount(1, $municipalities); + }//end testReimportingTheSameExportChangesNothing() + + /** + * A changed Naam updates the module; website, publicationDate and depublicationDate stay. + * + * @return void + */ + public function testAChangedNameUpdatesOnlyTheMappedFields(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $service = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-test123', cells: ['Naam' => 'naamtest123'])])); + $service->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $uuid = array_key_first($this->store[self::MODULE]); + $this->store[self::MODULE][$uuid]['website'] = 'https://voorbeeld.example'; + $this->store[self::MODULE][$uuid]['depublicationDate'] = '2026-10-02T00:00:00+00:00'; + $published = $this->store[self::MODULE][$uuid]['publicationDate']; + + $service = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-test123', cells: ['Naam' => 'naamtest124'])])); + $report = $service->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame('updated', $report['rows'][0]['outcome']); + $this->assertCount(1, $this->store[self::MODULE]); + $module = $this->store[self::MODULE][$uuid]; + $this->assertSame('naamtest124', $module['name']); + $this->assertSame('https://voorbeeld.example', $module['website']); + $this->assertSame($published, $module['publicationDate']); + $this->assertSame('2026-10-02T00:00:00+00:00', $module['depublicationDate']); + $this->assertCount(1, $this->store[self::USAGE], 'still one usage'); + }//end testAChangedNameUpdatesOnlyTheMappedFields() + + /** + * An existing module without publicationDate does not get one on update. + * + * @return void + */ + public function testAnUpdateNeverWritesPublicationDate(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->store[self::MODULE]['mod-1'] = ['id' => 'mod-1', 'name' => 'Oud', 'externalKey' => 'topdesk:muni-1:APP-1']; + + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame('updated', $report['rows'][0]['outcome']); + $this->assertArrayNotHasKey('publicationDate', $this->store[self::MODULE]['mod-1']); + $this->assertArrayNotHasKey('type', $this->store[self::MODULE]['mod-1'], 'type is create-only'); + $this->assertSame('Applicatie APP-1', $this->store[self::MODULE]['mod-1']['name']); + }//end testAnUpdateNeverWritesPublicationDate() + + /** + * A municipality uuid must be an organisation of type Municipality. + * + * @return void + */ + public function testTheMunicipalityMustBeAMunicipality(): void { + $this->seedOrganisation(uuid: 'supplier-1', name: 'Voorbeeld Software B.V.', type: 'Supplier'); + foreach ([['municipalityUuid' => 'supplier-1'], ['municipalityUuid' => 'unknown-uuid']] as $options) { + try { + $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]))->import(path: '', options: $options); + $this->fail('MUNICIPALITY_INVALID expected'); + } catch (CmdbImportException $e) { + $this->assertSame('MUNICIPALITY_INVALID', $e->getErrorCode()); + $this->assertSame(422, $e->getHttpStatus()); + } + } + + try { + $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]))->import(path: '', options: ['municipalityName' => ' ']); + $this->fail('MUNICIPALITY_REQUIRED expected'); + } catch (CmdbImportException $e) { + $this->assertSame('MUNICIPALITY_REQUIRED', $e->getErrorCode()); + } + + $this->assertSame([], $this->saves, 'nothing is written'); + }//end testTheMunicipalityMustBeAMunicipality() + + /** + * "Fabfrikant", "Fabfrikant " and "FABFRIKANT" are one supplier; an existing supplier is reused. + * + * @return void + */ + public function testAManufacturerIsOneSupplier(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->seedOrganisation(uuid: 'aangetekend', name: 'Aangetekend B.V.', type: 'Supplier'); + $rows = [ + $this->row(middelId: 'APP-1', cells: ['Fabrikant' => 'Fabfrikant'], row: 2), + $this->row(middelId: 'APP-2', cells: ['Fabrikant' => 'Fabfrikant '], row: 3), + $this->row(middelId: 'APP-3', cells: ['Fabrikant' => 'FABFRIKANT'], row: 4), + $this->row(middelId: 'APP-4', cells: ['Fabrikant' => 'aangetekend b.v.'], row: 5), + $this->row(middelId: 'APP-5', cells: ['Fabrikant' => ''], row: 6), + ]; + + $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame(5, $report['summary']['created']); + $suppliers = array_filter($this->objects(self::ORGANIZATION), fn (array $o): bool => $o['type'] === 'Supplier'); + $this->assertCount(2, $suppliers); + $fabfrikant = array_values(array_filter($suppliers, fn (array $o): bool => $o['name'] === 'Fabfrikant'))[0]['id']; + $providers = array_column($this->objects(self::MODULE), 'provider', 'externalId'); + $this->assertSame(['APP-1' => $fabfrikant, 'APP-2' => $fabfrikant, 'APP-3' => $fabfrikant, 'APP-4' => 'aangetekend'], $providers); + }//end testAManufacturerIsOneSupplier() + + /** + * updateExisting=false reports a match as skipped "exists" and writes nothing. + * + * @return void + */ + public function testUpdateExistingFalseSkipsMatches(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + $saves = count($this->saves); + + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: ['Naam' => 'Anders'])])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1', 'updateExisting' => false]); + + $this->assertSame('skipped', $report['rows'][0]['outcome']); + $this->assertSame(['exists'], $report['rows'][0]['reasons']); + $this->assertSame($saves, count($this->saves)); + }//end testUpdateExistingFalseSkipsMatches() + + /** + * A module missing from a newer export, and its usage, are left as they are. + * + * @return void + */ + public function testRecordsMissingFromTheExportStay(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', row: 2), $this->row(middelId: 'AIA-1', row: 3)])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + $modules = $this->store[self::MODULE]; + $usages = $this->store[self::USAGE]; + + $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: ['Naam' => 'Nieuw'])])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + foreach ($modules as $uuid => $module) { + if ($module['externalId'] === 'AIA-1') { + $this->assertSame($module, $this->store[self::MODULE][$uuid]); + } + } + + $this->assertSame($usages, $this->store[self::USAGE]); + $this->assertCount(2, $this->store[self::MODULE]); + }//end testRecordsMissingFromTheExportStay() + + /** + * An unknown Status drops only that field and warns with column and value. + * + * @return void + */ + public function testAnUnknownStatusDropsOnlyThatField(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: ['Status' => 'Onbekende status'])])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame('created', $report['rows'][0]['outcome']); + $this->assertCount(1, $report['rows'][0]['warnings']); + $this->assertStringContainsString('"Status"', $report['rows'][0]['warnings'][0]); + $this->assertStringContainsString('Onbekende status', $report['rows'][0]['warnings'][0]); + $this->assertSame(1, $report['summary']['warnings']); + $usage = $this->objects(self::USAGE)[0]; + $this->assertArrayNotHasKey('status', $usage); + $this->assertCount(1, $this->store[self::MODULE]); + }//end testAnUnknownStatusDropsOnlyThatField() + + /** + * A test-only module pack that maps Roepnaam to shortDescription changes the import without code. + * + * @return void + */ + public function testAPackChangeChangesTheMapping(): void { + $directory = sys_get_temp_dir() . '/stackiq-cmdb-pack-' . bin2hex(random_bytes(4)); + mkdir($directory); + foreach (glob(CmdbTestSupport::appRoot() . '/lib/Settings/cmdb-import/*.json') as $file) { + copy($file, $directory . '/' . basename($file)); + } + + $pack = json_decode((string)file_get_contents($directory . '/topdesk-module.json'), true); + $pack['fieldMappings'][] = ['source' => 'Roepnaam', 'target' => 'shortDescription', 'transform' => ['type' => 'trim']]; + file_put_contents($directory . '/topdesk-module.json', json_encode($pack)); + + try { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'AIA-AangetekendMailen', cells: ['Roepnaam' => 'Mailen'])]), profileDir: $directory) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + } finally { + array_map('unlink', glob($directory . '/*.json')); + rmdir($directory); + } + + $this->assertSame('Mailen', $this->objects(self::MODULE)[0]['shortDescription']); + }//end testAPackChangeChangesTheMapping() + + // ------------------------------------------------------------------ + // Task 6: owners as contact persons + // ------------------------------------------------------------------ + + /** + * The AIA owner becomes the usage's business owner: one contact person of the municipality, role Afdelingshoofd. + * + * @return void + */ + public function testTheOwnerBecomesTheBusinessOwner(): void { + $path = $this->fixture(); + $report = $this->service()->import(path: $path, options: ['municipalityName' => 'Gemeente Voorbeeldstad']); + $municipality = $report['municipality']['uuid']; + + $this->assertCount(1, $this->contacts); + $contactsUid = array_key_first($this->contacts); + $this->assertSame('letter.achternaam@gemeente.nl', $this->contacts[$contactsUid]['email']); + $this->assertSame('Voornaam Achternaam', $this->contacts[$contactsUid]['name']); + + $people = $this->objects(self::CONTACT_PERSON); + $this->assertCount(1, $people); + $this->assertSame(['contactsUid' => $contactsUid, 'organization' => $municipality, 'role' => 'Afdelingshoofd'], array_diff_key($people[0], ['id' => true])); + + $usage = array_column($this->objects(self::USAGE), null, 'module')[$report['rows'][0]['moduleUuid']]; + $this->assertSame($people[0]['id'], $usage['businessOwner']); + }//end testTheOwnerBecomesTheBusinessOwner() + + /** + * The same owner on two rows is one contact person, referenced by both usages. + * + * @return void + */ + public function testTheSameOwnerOnTwoRowsIsOneContactPerson(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $owner = ['Eigenaar' => 'Achternaam, Voornaam', 'Eigenaar e-mail' => 'letter.achternaam@gemeente.nl', 'Eigenaar functie' => 'Afdelingshoofd']; + $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: $owner, row: 2), $this->row(middelId: 'APP-2', cells: $owner, row: 3)])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertCount(1, $this->objects(self::CONTACT_PERSON)); + $owners = array_unique(array_column($this->objects(self::USAGE), 'businessOwner')); + $this->assertSame([$this->objects(self::CONTACT_PERSON)[0]['id']], array_values($owners)); + }//end testTheSameOwnerOnTwoRowsIsOneContactPerson() + + /** + * A technical owner without an e-mail, imported twice, is one contact and one contact person. + * + * @return void + */ + public function testATechnicalOwnerByNameIsMatchedExactly(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + // A contact whose name merely contains the owner's name must not match. + $this->contacts['contact-other'] = ['name' => 'Voornaam Achternaam-Anders', 'email' => '']; + $rows = [$this->row(middelId: 'APP-1', cells: ['FB contactpersoon 1' => 'Achternaam, Voornaam'])]; + + $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertCount(2, $this->contacts, 'one new contact next to the near-namesake'); + $people = $this->objects(self::CONTACT_PERSON); + $this->assertCount(1, $people); + $this->assertNotSame('contact-other', $people[0]['contactsUid']); + $this->assertArrayNotHasKey('role', $people[0]); + $this->assertSame($people[0]['id'], $this->objects(self::USAGE)[0]['technicalOwner']); + }//end testATechnicalOwnerByNameIsMatchedExactly() + + /** + * With Contacts disabled the modules and usages are saved without owners, with a warning on the row that has owners. + * + * @return void + */ + public function testContactsDisabledDoesNotBlockTheImport(): void { + $path = $this->fixture(); + $this->contactsEnabled = false; + $report = $this->service()->import(path: $path, options: ['municipalityName' => 'Gemeente Voorbeeldstad']); + + $this->assertSame(2, $report['summary']['created']); + $this->assertCount(2, $this->objects(self::USAGE)); + $this->assertSame([], $this->objects(self::CONTACT_PERSON)); + $this->assertSame(['Owners skipped: Nextcloud Contacts is unavailable'], $report['rows'][0]['warnings']); + $this->assertSame([], $report['rows'][1]['warnings'], 'the APP row names no owner'); + }//end testContactsDisabledDoesNotBlockTheImport() + + /** + * An imported contact person has no e-mail and no username, so neither user-provisioning path picks it up. + * + * @return void + */ + public function testAnImportedContactPersonIsNeverAUser(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: ['Eigenaar' => 'Achternaam, Voornaam', 'Eigenaar e-mail' => 'letter.achternaam@gemeente.nl', 'FB contactpersoon 1' => 'Achternaam, Voornaam'])])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $people = $this->objects(self::CONTACT_PERSON); + $this->assertNotEmpty($people); + foreach ($people as $person) { + $this->assertSame([], array_diff(array_keys($person), ['id', 'contactsUid', 'organization', 'role'])); + } + + // OrganizationSyncService::performUserSync() selects contact persons with a username. + $sync = (string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Service/OrganizationSyncService.php'); + $this->assertStringContainsString('o.username IS NOT NULL', $sync, 'the selection changed: re-check that imported contact persons stay out of it'); + // ContactpersoonService::processContactpersoon() provisions only from an e-mail on the object. + $listener = (string)file_get_contents(CmdbTestSupport::appRoot() . '/lib/Service/ContactpersoonService.php'); + $this->assertStringContainsString("\$email = (\$contactData['email'] ?? \$contactData['e-mailadres'] ?? '');", $listener); + }//end testAnImportedContactPersonIsNeverAUser() + + /** + * Neither the report nor any log line names an owner or an e-mail address. + * + * @return void + */ + public function testNoPersonDataInReportOrLog(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $owner = ['Eigenaar' => 'Achternaam, Voornaam', 'Eigenaar e-mail' => 'letter.achternaam@gemeente.nl', 'FB contactpersoon 1' => 'Achternaam, Voornaam']; + $this->beforeSave = function (int $schema, array $data): void { + if ($schema === self::USAGE && ($data['module'] ?? '') !== '' && count($this->objects(self::USAGE)) === 1) { + throw new RuntimeException('usage refused'); + } + }; + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: $owner, row: 2), $this->row(middelId: 'APP-2', cells: $owner, row: 3)])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $text = json_encode($report, JSON_UNESCAPED_UNICODE) . "\n" . implode("\n", $this->logLines); + foreach (['Achternaam', 'Voornaam', 'letter.achternaam', '@gemeente.nl'] as $personData) { + $this->assertStringNotContainsString($personData, $text); + } + + $this->assertSame('failed', $report['rows'][1]['outcome'], 'the injected failure ran'); + }//end testNoPersonDataInReportOrLog() + + // ------------------------------------------------------------------ + // Task 7: row isolation, report, progress and cancel + // ------------------------------------------------------------------ + + /** + * A failing module save fails only its row, naming the step. + * + * @return void + */ + public function testOneBadRowDoesNotStopTheOthers(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->beforeSave = function (int $schema, array $data): void { + if ($schema === self::MODULE && ($data['externalId'] ?? '') === 'APP-2') { + throw new RuntimeException('Validation failed for name'); + } + }; + $rows = [$this->row(middelId: 'APP-1', row: 2), $this->row(middelId: 'APP-2', row: 3), $this->row(middelId: 'APP-3', row: 4)]; + + $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame(['created', 'failed', 'created'], array_column($report['rows'], 'outcome')); + $this->assertStringStartsWith('step "module" failed', $report['rows'][1]['reasons'][0]); + $this->assertSame(['rowsRead' => 3, 'processed' => 3, 'created' => 2, 'updated' => 0, 'unchanged' => 0, 'skipped' => 0, 'failed' => 1, 'warnings' => 0], $report['summary']); + $this->assertCount(2, $this->store[self::MODULE]); + }//end testOneBadRowDoesNotStopTheOthers() + + /** + * Duplicate Middel-IDs, a missing Middel-ID, a missing Naam and another Soort are skipped with their reasons. + * + * @return void + */ + public function testRowsAreSkippedWithTheirReasons(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $rows = [ + $this->row(middelId: 'APP-test123', row: 2), + $this->row(middelId: 'APP-test123', row: 7), + $this->row(middelId: '', row: 8), + $this->row(middelId: 'HW-1', cells: ['Soort' => 'Hardware'], row: 9), + $this->row(middelId: 'APP-9', cells: ['Naam' => ' '], row: 10), + $this->row(middelId: 'APP-test123', cells: ['Soort' => 'Application Inventory'], row: 2, sheet: 'Invoer AIA data'), + ]; + + $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame( + [ + ['created', []], + ['skipped', ['duplicate Middel-ID in file']], + ['skipped', ['missing Middel-ID']], + ['skipped', ['unsupported Soort "Hardware"']], + ['skipped', ['missing Naam']], + ['skipped', ['duplicate Middel-ID in file']], + ], + array_map(fn (array $row): array => [$row['outcome'], $row['reasons']], $report['rows']) + ); + $this->assertCount(1, $this->store[self::MODULE]); + }//end testRowsAreSkippedWithTheirReasons() + + /** + * The import runs as a cmdb_import operation with per-row progress; afterwards its statistics hold the report. + * + * @return void + */ + public function testProgressIsRecordedAndHoldsTheReport(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $seen = []; + $this->beforeSave = function (int $schema) use (&$seen): void { + if ($schema === self::MODULE) { + $seen[] = $this->tracker->getProgress(operationId: 'cmdb-progress-1')['processed_items']; + } + }; + $rows = [$this->row(middelId: 'APP-1', row: 2), $this->row(middelId: 'APP-2', row: 3)]; + + $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1', 'operationId' => 'cmdb-progress-1']); + + $this->assertSame([0, 1], $seen, 'progress advances after every row'); + $stored = $this->cache['progress_cmdb-progress-1']; + $this->assertSame('cmdb_import', $stored['operation_type']); + $this->assertSame('completed', $stored['status']); + $this->assertSame($report, $stored['statistics']['report']); + }//end testProgressIsRecordedAndHoldsTheReport() + + /** + * A cancel after row 1 of 3 keeps row 1 and reports cancelled with one processed row. + * + * @return void + */ + public function testACancelStopsBetweenRows(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $service = null; + $this->beforeSave = function (int $schema) use (&$service): void { + if ($schema === self::USAGE) { + $this->assertTrue($service->requestCancel(operationId: 'cmdb-cancel-01')); + } + }; + $rows = [$this->row(middelId: 'APP-1', row: 2), $this->row(middelId: 'APP-2', row: 3), $this->row(middelId: 'APP-3', row: 4)]; + $service = $this->service(reader: $this->rowsReader(rows: $rows)); + + $report = $service->import(path: '', options: ['municipalityUuid' => 'muni-1', 'operationId' => 'cmdb-cancel-01']); + + $this->assertTrue($report['cancelled']); + $this->assertSame(1, $report['summary']['processed']); + $this->assertSame(3, $report['summary']['rowsRead']); + $this->assertCount(1, $report['rows']); + $this->assertCount(1, $this->store[self::MODULE], 'row 1 stays'); + $this->assertSame('cancelled', $this->cache['progress_cmdb-cancel-01']['status']); + $this->assertSame($report, $this->cache['progress_cmdb-cancel-01']['statistics']['report']); + }//end testACancelStopsBetweenRows() + + /** + * Cancel answers false for an unknown id, a malformed id or another operation type. + * + * @return void + */ + public function testCancelNeedsACmdbOperation(): void { + $service = $this->service(reader: $this->rowsReader(rows: [])); + $this->tracker->startOperation(operationType: 'archimate_import', operationId: 'cmdb-not-mine-1'); + + $this->assertFalse($service->requestCancel(operationId: 'cmdb-unknown-1')); + $this->assertFalse($service->requestCancel(operationId: 'archimate_import_abcdefgh')); + $this->assertFalse($service->requestCancel(operationId: 'cmdb-not-mine-1')); + }//end testCancelNeedsACmdbOperation() + + /** + * Without a mapping engine, or without configuration, nothing is read or written. + * + * @return void + */ + public function testMissingEngineOrConfigurationStopsBeforeReading(): void { + try { + $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]), config: [])->import(path: '', options: ['municipalityName' => 'Gemeente Voorbeeldstad']); + $this->fail('NOT_CONFIGURED expected'); + } catch (CmdbImportException $e) { + $this->assertSame('NOT_CONFIGURED', $e->getErrorCode()); + $this->assertSame(503, $e->getHttpStatus()); + } + + $base = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')])); + $reflection = new \ReflectionClass($base); + $args = []; + foreach ($reflection->getConstructor()->getParameters() as $parameter) { + $property = $reflection->getProperty($parameter->getName()); + $args[$parameter->getName()] = $property->getValue($base); + } + + $withoutEngine = new class(...$args) extends CmdbExportImportService { + public const ENGINE_CLASS = 'OCA\OpenRegister\Service\MigrationPack\NoSuchEngine'; + }; + + try { + $withoutEngine->import(path: '', options: ['municipalityName' => 'Gemeente Voorbeeldstad']); + $this->fail('MAPPING_UNAVAILABLE expected'); + } catch (CmdbImportException $e) { + $this->assertSame('MAPPING_UNAVAILABLE', $e->getErrorCode()); + $this->assertSame(503, $e->getHttpStatus()); + } + + $this->assertSame([], $this->saves); + }//end testMissingEngineOrConfigurationStopsBeforeReading() + + /** + * Person names split as TOPdesk writes them ("Achternaam, Voornaam"). + * + * @return void + */ + public function testPersonNamesSplit(): void { + $this->assertSame(['voornaam' => 'Voornaam', 'achternaam' => 'Achternaam'], CmdbExportImportService::splitPersonName(name: 'Achternaam, Voornaam ')); + $this->assertSame(['voornaam' => '', 'achternaam' => 'Functioneel Beheer'], CmdbExportImportService::splitPersonName(name: 'Functioneel Beheer')); + }//end testPersonNamesSplit() +}//end class diff --git a/tests/Unit/Settings/TopdeskCmdbFragmentTest.php b/tests/Unit/Settings/TopdeskCmdbFragmentTest.php new file mode 100644 index 00000000..71badecf --- /dev/null +++ b/tests/Unit/Settings/TopdeskCmdbFragmentTest.php @@ -0,0 +1,132 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-2 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Settings; + +use OCA\Stackiq\Service\SettingsService; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * Merges every fragment in filename order, exactly as SettingsService::loadSettings() does. + */ +class TopdeskCmdbFragmentTest extends TestCase { + /** + * The external-id properties the fragment adds. + * + * @var array + */ + private const PROPERTIES = ['externalId', 'externalNumber', 'externalKey', 'externalCreatedAt', 'externalModifiedAt']; + + /** + * The register after merging every fragment in sorted filename order. + * + * @return array + */ + private function mergedRegister(): array { + $dir = __DIR__ . '/../../../lib/Settings'; + $register = json_decode((string)file_get_contents($dir . '/softwarecatalogus_register.json'), true); + $merge = new ReflectionMethod(SettingsService::class, 'deepMergeConfig'); + + $files = glob($dir . '/register.d/*.json'); + sort($files); + foreach ($files as $file) { + $register = $merge->invoke(null, $register, json_decode((string)file_get_contents($file), true)); + } + + return $register; + }//end mergedRegister() + + /** + * The merged module is 0.3.5 and carries the five optional, titled properties. + * + * @return void + */ + public function testTheMergedModuleIsVersion035WithTheExternalIds(): void { + $module = $this->mergedRegister()['components']['schemas']['module']; + + $this->assertSame('0.3.5', $module['version'], 'a fragment sorting after topdesk-cmdb-import.json overwrote the bump'); + foreach (self::PROPERTIES as $property) { + $this->assertArrayHasKey($property, $module['properties']); + $this->assertNotEmpty($module['properties'][$property]['title'] ?? '', $property); + $this->assertNotEmpty($module['properties'][$property]['description'] ?? '', $property); + $this->assertSame('string', $module['properties'][$property]['type'], $property); + $this->assertNotContains($property, $module['required'] ?? [], $property); + $this->assertNotTrue($module['properties'][$property]['required'] ?? false, $property); + } + + $this->assertSame(100, $module['properties']['externalId']['maxLength']); + $this->assertSame(50, $module['properties']['externalNumber']['maxLength']); + $this->assertSame(200, $module['properties']['externalKey']['maxLength']); + $this->assertSame(['default' => false], $module['properties']['externalKey']['table']); + $this->assertSame('date', $module['properties']['externalCreatedAt']['format']); + $this->assertSame('date', $module['properties']['externalModifiedAt']['format']); + $this->assertArrayHasKey('roadmapStatement', $module['properties'], 'the 0.3.4 fragment still applies'); + $this->assertSame(['name'], $module['required']); + }//end testTheMergedModuleIsVersion035WithTheExternalIds() + + /** + * The fragment sorts after the fragment that set module 0.3.4. + * + * @return void + */ + public function testTheFragmentSortsAfterTheRoadmapFragment(): void { + $names = ['maintenance-and-roadmap.json', 'topdesk-cmdb-import.json']; + $sorted = $names; + sort($sorted); + $this->assertSame($names, $sorted); + }//end testTheFragmentSortsAfterTheRoadmapFragment() + + /** + * The three seed modules exist without publicationDate or externalKey, and every seed field is a schema property. + * + * @return void + */ + public function testTheSeedModulesShowTheNewProperties(): void { + $register = $this->mergedRegister(); + $properties = $register['components']['schemas']['module']['properties']; + $seeds = []; + foreach ($register['components']['objects'] as $object) { + if (($object['@self']['schema'] ?? null) === 'module') { + $seeds[$object['@self']['slug']] = $object; + } + } + + $this->assertSame(['voorbeeld-zaaksysteem', 'voorbeeld-afsprakenplanner', 'voorbeeld-belastingapplicatie'], array_keys($seeds)); + $this->assertSame(['APP-00001', 'APP-00002', 'AIA-00003'], array_column(array_values($seeds), 'externalId')); + foreach ($seeds as $slug => $seed) { + $this->assertArrayNotHasKey('publicationDate', $seed, $slug); + $this->assertArrayNotHasKey('externalKey', $seed, $slug); + $this->assertSame('stackiq', $seed['@self']['register'], $slug); + foreach (array_keys($seed) as $field) { + if ($field !== '@self') { + $this->assertArrayHasKey($field, $properties, $slug . '.' . $field); + } + } + + $this->assertContains($seed['bbnLevel'], $properties['bbnLevel']['enum'], $slug); + $this->assertContains($seed['type'], $properties['type']['enum'], $slug); + } + + // The base seeds are still there: the fragment appends, it does not replace. + $this->assertGreaterThan(3, count($register['components']['objects'])); + }//end testTheSeedModulesShowTheNewProperties() +}//end class diff --git a/tests/Unit/Support/CmdbTestSupport.php b/tests/Unit/Support/CmdbTestSupport.php new file mode 100644 index 00000000..90f51c0e --- /dev/null +++ b/tests/Unit/Support/CmdbTestSupport.php @@ -0,0 +1,159 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Support; + +/** + * Locates and loads the OpenRegister pieces the CMDB import uses. + */ +final class CmdbTestSupport { + /** + * Which migration-pack classes were loaded: "real" or "copy". + * + * @var string|null + */ + private static ?string $packSource = null; + + /** + * The app root. + * + * @return string + */ + public static function appRoot(): string { + return dirname(__DIR__, 3); + }//end appRoot() + + /** + * The fixture directory. + * + * @return string + */ + public static function fixtures(): string { + return self::appRoot() . '/tests/fixtures/cmdb'; + }//end fixtures() + + /** + * The OpenRegister app directory, when one is available. + * + * @return string|null + */ + public static function openRegisterDir(): ?string { + $candidates = []; + $env = getenv('OPENREGISTER_DIR'); + if (is_string($env) === true && $env !== '') { + $candidates[] = $env; + } + + // An app next to openregister, or a worktree two levels below the apps directory. + $candidates[] = dirname(self::appRoot()) . '/openregister'; + $candidates[] = dirname(self::appRoot(), 2) . '/openregister'; + + foreach ($candidates as $candidate) { + if (is_file($candidate . '/lib/Service/MigrationPack/MappingEngine.php') === true) { + return $candidate; + } + } + + return null; + }//end openRegisterDir() + + /** + * Load MappingEngine and PackDefinitionValidator. + * + * @return string "real" or "copy". + */ + public static function loadMigrationPack(): string { + if (self::$packSource !== null) { + return self::$packSource; + } + + $dir = self::openRegisterDir(); + $source = 'copy'; + $base = __DIR__ . '/OpenRegister'; + if ($dir !== null) { + $source = 'real'; + $base = $dir . '/lib/Service/MigrationPack'; + } + + foreach (['PackDefinitionValidator', 'MappingEngine'] as $class) { + if (class_exists('OCA\\OpenRegister\\Service\\MigrationPack\\' . $class, false) === false) { + require_once $base . '/' . $class . '.php'; + } + } + + self::$packSource = $source; + return $source; + }//end loadMigrationPack() + + /** + * Make PhpSpreadsheet loadable from OpenRegister's vendor directory. + * + * @return bool Whether the Xlsx reader can be loaded. + */ + public static function loadPhpSpreadsheet(): bool { + if (class_exists('PhpOffice\\PhpSpreadsheet\\Reader\\Xlsx') === true) { + return true; + } + + $dir = self::openRegisterDir(); + if ($dir === null || is_dir($dir . '/vendor/phpoffice/phpspreadsheet') === false) { + return false; + } + + $vendor = $dir . '/vendor'; + $prefixes = [ + 'PhpOffice\\PhpSpreadsheet\\' => $vendor . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet/', + 'Psr\\SimpleCache\\' => $vendor . '/psr/simple-cache/src/', + 'Composer\\Pcre\\' => $vendor . '/composer/pcre/src/', + 'Matrix\\' => $vendor . '/markbaker/matrix/classes/src/', + 'Complex\\' => $vendor . '/markbaker/complex/classes/src/', + ]; + + // Appended, so a library this app already ships keeps winning. + spl_autoload_register( + static function (string $class) use ($prefixes): void { + foreach ($prefixes as $prefix => $path) { + if (str_starts_with($class, $prefix) === false) { + continue; + } + + $file = $path . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; + if (is_file($file) === true) { + require_once $file; + } + + return; + } + } + ); + + return class_exists('PhpOffice\\PhpSpreadsheet\\Reader\\Xlsx') === true; + }//end loadPhpSpreadsheet() +}//end class diff --git a/tests/Unit/Support/OpenRegister/MappingEngine.php b/tests/Unit/Support/OpenRegister/MappingEngine.php new file mode 100644 index 00000000..475b263f --- /dev/null +++ b/tests/Unit/Support/OpenRegister/MappingEngine.php @@ -0,0 +1,353 @@ + + * value, with `id` recognised by the existing update-by-id convention), so + * the single write path (`ObjectService::saveObjects()`/`saveObject()`) is + * unchanged. + * + * Literal-leak guard (fleet lesson — a transform/template reference that + * doesn't resolve must ERROR the row, never pass the literal through): the + * `lookup` transform errors the row when the source value is present but + * has no entry in the map and no `default` is configured, rather than + * silently passing the raw, unmapped source value through to the target + * schema property. + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @category Service + * @package OCA\OpenRegister\Service\MigrationPack + * + * @author Conduction Development Team + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/specs/migration-mapping-packs/spec.md + */ + +declare(strict_types=1); + +/* + * TEST COPY, not loaded in production. Verbatim copy of OpenRegister + * lib/Service/MigrationPack/MappingEngine.php at version 2.1.34, so the + * CMDB import tests run where OpenRegister is not checked out (CI). Where it is, + * tests/Unit/Support/CmdbTestSupport.php loads the real class instead (set + * OPENREGISTER_DIR). Refresh this copy when OpenRegister changes the pack format. + */ + + +namespace OCA\OpenRegister\Service\MigrationPack; + +use DateTime; + +/** + * Maps one source row (CSV row / Excel row / decoded JSON object) onto a set + * of target schema-property values, per a migration-pack definition. + * + * @spec openspec/specs/migration-mapping-packs/spec.md + */ +class MappingEngine { + /** + * Apply a pack definition to one source row. + * + * @param array $pack The validated pack definition (decoded JSON). + * @param array $sourceRow The parsed source row (flat for CSV/Excel, possibly nested for JSON). + * @param int $rowNumber 1-based row number, used only to label errors. + * + * @return array{data: array, errors: list} + * + * @spec openspec/specs/migration-mapping-packs/spec.md#the-mapping-engine-must-apply-pack-transforms-per-row-and-never-leak-unresolved-references + */ + public function mapRow(array $pack, array $sourceRow, int $rowNumber): array { + $data = $pack['defaults'] ?? []; + $errors = []; + + foreach (($pack['fieldMappings'] ?? []) as $mapping) { + $source = (string)($mapping['source'] ?? ''); + $target = (string)($mapping['target'] ?? ''); + $required = ($mapping['required'] ?? false) === true; + $transform = $mapping['transform'] ?? null; + $transformId = null; + if (is_array($transform) === true) { + $transformId = ($transform['type'] ?? null); + } + + $rawValue = $this->resolveSource(row: $sourceRow, pointer: $source); + $isEmpty = ($rawValue === null || $rawValue === ''); + + if ($required === true && $isEmpty === true) { + $errors[] = [ + 'row' => $rowNumber, + 'source' => $source, + 'target' => $target, + 'transform' => $transformId, + 'message' => sprintf('Required source field "%s" is missing or empty', $source), + ]; + continue; + } + + // A `const` transform always applies, regardless of the source value. + // Every other transform is skipped (leaving any seeded default in + // place) when the source is empty and the mapping is optional — + // there is nothing to map, and nothing to error. + if ($isEmpty === true && $transformId !== 'const') { + continue; + } + + $result = $this->applyTransform( + value: $rawValue, + transform: $transform, + sourceRow: $sourceRow + ); + + if ($result['error'] !== null) { + $errors[] = [ + 'row' => $rowNumber, + 'source' => $source, + 'target' => $target, + 'transform' => $transformId, + 'message' => $result['error'], + ]; + continue; + } + + $data[$target] = $result['value']; + }//end foreach + + $data = $this->applyIdStrategy(pack: $pack, sourceRow: $sourceRow, data: $data); + + return [ + 'data' => $data, + 'errors' => $errors, + ]; + }//end mapRow() + + /** + * Whether a given (1-based) row number is listed in the pack's `skipRows`. + * + * @param array $pack The pack definition. + * @param int $rowNumber 1-based row number. + * + * @return bool + * + * @spec openspec/specs/migration-mapping-packs/spec.md + */ + public function isRowSkipped(array $pack, int $rowNumber): bool { + $skipRows = $pack['skipRows'] ?? []; + return in_array($rowNumber, $skipRows, true); + }//end isRowSkipped() + + /** + * Resolve the id/uuid target from `idStrategy`, mutating `data['id']` + * when the strategy is `sourceField` and a value is present. The + * `generate` strategy is a no-op here — leaving `data['id']` unset lets + * the existing import pipeline treat the row as a create, exactly as it + * already does for CSV/JSON rows with no id column. + * + * @param array $pack The pack definition. + * @param array $sourceRow The source row. + * @param array $data The mapped target data so far. + * + * @return array The (possibly id-augmented) target data. + */ + private function applyIdStrategy(array $pack, array $sourceRow, array $data): array { + $idStrategy = $pack['idStrategy'] ?? ['type' => 'generate']; + if (($idStrategy['type'] ?? 'generate') !== 'sourceField') { + return $data; + } + + $idValue = $this->resolveSource(row: $sourceRow, pointer: (string)($idStrategy['field'] ?? '')); + if ($idValue !== null && $idValue !== '') { + $data['id'] = (string)$idValue; + } + + return $data; + }//end applyIdStrategy() + + /** + * Resolve a source value from a row, given either a flat key or a + * JSON-Pointer-style `/a/b/c` path. + * + * @param array $row The source row. + * @param string $pointer A flat key or a leading-`/` pointer path. + * + * @return mixed The resolved value, or null when not found. + */ + private function resolveSource(array $row, string $pointer) { + if ($pointer === '') { + return null; + } + + if ($pointer[0] !== '/') { + return $row[$pointer] ?? null; + } + + $segments = explode('/', ltrim($pointer, '/')); + $cursor = $row; + foreach ($segments as $segment) { + $segment = str_replace(['~1', '~0'], ['/', '~'], $segment); + if (is_array($cursor) === false || array_key_exists($segment, $cursor) === false) { + return null; + } + + $cursor = $cursor[$segment]; + } + + return $cursor; + }//end resolveSource() + + /** + * Apply one transform to a resolved source value. + * + * @param mixed $value The resolved source value (never null/'' — callers filter + * that). + * @param array|null $transform The transform block, or null for identity passthrough. + * @param array $sourceRow The full source row (needed by `concat` to resolve extra fields). + * + * @return array{value: mixed, error: ?string} + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) One branch per transform type. + */ + private function applyTransform($value, ?array $transform, array $sourceRow): array { + if ($transform === null) { + return ['value' => $value, 'error' => null]; + } + + switch ($transform['type'] ?? null) { + case 'trim': + $stringValue = (string)$value; + if (is_string($value) === true) { + $stringValue = $value; + } + return ['value' => trim($stringValue), 'error' => null]; + case 'date': + return $this->applyDateTransform(value: $value, transform: $transform); + case 'bool-map': + return $this->applyMapTransform(value: $value, transform: $transform, coerceBool: true); + case 'lookup': + return $this->applyMapTransform(value: $value, transform: $transform, coerceBool: false); + case 'concat': + return $this->applyConcatTransform(value: $value, transform: $transform, sourceRow: $sourceRow); + case 'const': + return ['value' => ($transform['value'] ?? null), 'error' => null]; + default: + return ['value' => null, 'error' => 'Unknown transform type "' . (string)($transform['type'] ?? '') . '"']; + }//end switch + }//end applyTransform() + + /** + * `date` transform: parse the source value with `sourceFormat` (or a + * best-effort `DateTime` parse when omitted) and re-emit it as `targetFormat` + * (default `Y-m-d`). + * + * @param mixed $value The resolved source value. + * @param array $transform The transform block. + * + * @return array{value: mixed, error: ?string} + * + * @SuppressWarnings(PHPMD.StaticAccess) DateTime::createFromFormat is the standard PHP idiom for a strict-format parse. + */ + private function applyDateTransform($value, array $transform): array { + $sourceFormat = $transform['sourceFormat'] ?? null; + $targetFormat = $transform['targetFormat'] ?? 'Y-m-d'; + $stringValue = (string)$value; + + try { + $date = null; + if (is_string($sourceFormat) === true && $sourceFormat !== '') { + $date = DateTime::createFromFormat($sourceFormat, $stringValue); + if ($date === false) { + return [ + 'value' => null, + 'error' => sprintf('Could not parse date "%s" with format "%s"', $stringValue, $sourceFormat), + ]; + } + } + + if ($date === null) { + $date = new DateTime($stringValue); + } + } catch (\Throwable $e) { + return ['value' => null, 'error' => sprintf('Could not parse date "%s": %s', $stringValue, $e->getMessage())]; + } + + return ['value' => $date->format($targetFormat), 'error' => null]; + }//end applyDateTransform() + + /** + * Shared implementation for `bool-map` and `lookup` — both resolve the + * source value through a `map`, with an optional `default` and, absent a + * default, an error on an unresolved key (the literal-leak guard). + * + * @param mixed $value The resolved source value. + * @param array $transform The transform block. + * @param bool $coerceBool Whether to cast the mapped value to bool (bool-map) or return it as-is (lookup). + * + * @return array{value: mixed, error: ?string} + */ + private function applyMapTransform($value, array $transform, bool $coerceBool): array { + $map = $transform['map'] ?? []; + $key = (string)$value; + + if (array_key_exists($key, $map) === true) { + $mapped = $map[$key]; + if ($coerceBool === true) { + $mapped = (bool)$mapped; + } + + return ['value' => $mapped, 'error' => null]; + } + + if (array_key_exists('default', $transform) === true) { + $default = $transform['default']; + if ($coerceBool === true) { + $default = (bool)$default; + } + + return ['value' => $default, 'error' => null]; + } + + // Literal-leak guard: an unresolved map key is a data-quality problem the + // migration operator must see and fix, never a value that silently passes + // through unmapped into the target schema property. + return [ + 'value' => null, + 'error' => sprintf('Value "%s" has no mapping and no default is configured', $key), + ]; + }//end applyMapTransform() + + /** + * `concat` transform: join the primary source value with 0+ additional + * source fields, using `separator` (default a single space). Additional + * fields that resolve to nothing are treated as empty strings — a + * missing *optional* extra field is not itself a literal-leak case, since + * there is no map lookup involved. + * + * @param mixed $value The resolved primary source value. + * @param array $transform The transform block. + * @param array $sourceRow The full source row. + * + * @return array{value: mixed, error: ?string} + */ + private function applyConcatTransform($value, array $transform, array $sourceRow): array { + $separator = $transform['separator'] ?? ' '; + $parts = [(string)$value]; + + foreach (($transform['fields'] ?? []) as $extraSource) { + $extraValue = $this->resolveSource(row: $sourceRow, pointer: (string)$extraSource); + $parts[] = (string)($extraValue ?? ''); + } + + return ['value' => implode($separator, $parts), 'error' => null]; + }//end applyConcatTransform() +}//end class diff --git a/tests/Unit/Support/OpenRegister/PackDefinitionValidator.php b/tests/Unit/Support/OpenRegister/PackDefinitionValidator.php new file mode 100644 index 00000000..850fd8e1 --- /dev/null +++ b/tests/Unit/Support/OpenRegister/PackDefinitionValidator.php @@ -0,0 +1,383 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://OpenRegister.app + * + * @spec openspec/specs/migration-mapping-packs/spec.md + */ + +declare(strict_types=1); + +/* + * TEST COPY, not loaded in production. Verbatim copy of OpenRegister + * lib/Service/MigrationPack/PackDefinitionValidator.php at version 2.1.34, so the + * CMDB import tests run where OpenRegister is not checked out (CI). Where it is, + * tests/Unit/Support/CmdbTestSupport.php loads the real class instead (set + * OPENREGISTER_DIR). Refresh this copy when OpenRegister changes the pack format. + */ + + +namespace OCA\OpenRegister\Service\MigrationPack; + +use InvalidArgumentException; + +/** + * Structural + business-rule validator for a migration-pack JSON document. + * + * @spec openspec/specs/migration-mapping-packs/spec.md + * + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One small, independently-testable validate*() method + * per pack-document field keeps each check simple; the class total sums them, not any single method. + */ +class PackDefinitionValidator { + /** + * Source formats a pack may declare. + * + * @var string[] + */ + public const ALLOWED_SOURCE_FORMATS = ['csv', 'json', 'excel']; + + /** + * Transform types a field mapping may declare. + * + * @var string[] + */ + public const ALLOWED_TRANSFORM_TYPES = ['trim', 'date', 'bool-map', 'concat', 'lookup', 'const']; + + /** + * IdStrategy types a pack may declare. + * + * @var string[] + */ + public const ALLOWED_ID_STRATEGY_TYPES = ['sourceField', 'generate']; + + /** + * Validate a pack definition document. + * + * @param array $definition The decoded pack definition JSON. + * + * @return string[] List of validation error messages. Empty when valid. + * + * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + */ + public function validate(array $definition): array { + $errors = []; + + $errors = array_merge($errors, $this->validateId(definition: $definition)); + $errors = array_merge($errors, $this->validateName(definition: $definition)); + $errors = array_merge($errors, $this->validateSourceFormat(definition: $definition)); + $errors = array_merge($errors, $this->validateVersion(definition: $definition)); + $errors = array_merge($errors, $this->validateFieldMappings(definition: $definition)); + $errors = array_merge($errors, $this->validateDefaults(definition: $definition)); + $errors = array_merge($errors, $this->validateSkipRows(definition: $definition)); + $errors = array_merge($errors, $this->validateIdStrategy(definition: $definition)); + + return $errors; + }//end validate() + + /** + * Validate and throw on the first structural problem. + * + * @param array $definition The decoded pack definition JSON. + * + * @return void + * + * @throws InvalidArgumentException When the definition is invalid. The message joins every error found. + * + * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + */ + public function assertValid(array $definition): void { + $errors = $this->validate(definition: $definition); + if (empty($errors) === false) { + throw new InvalidArgumentException('Invalid migration pack definition: ' . implode('; ', $errors)); + } + }//end assertValid() + + /** + * Validate the `id` field (pack slug, used as the lookup key). + * + * @param array $definition The pack definition. + * + * @return string[] + */ + private function validateId(array $definition): array { + $id = $definition['id'] ?? null; + if (is_string($id) === false || $id === '') { + return ['"id" is required and must be a non-empty string']; + } + + if (preg_match('/^[a-z0-9][a-z0-9-]*$/', $id) !== 1) { + return ['"id" must be a lowercase slug (letters, digits, hyphens), got "' . $id . '"']; + } + + return []; + }//end validateId() + + /** + * Validate the `name` field. + * + * @param array $definition The pack definition. + * + * @return string[] + */ + private function validateName(array $definition): array { + $name = $definition['name'] ?? null; + if (is_string($name) === false || $name === '') { + return ['"name" is required and must be a non-empty string']; + } + + return []; + }//end validateName() + + /** + * Validate the `sourceFormat` field. + * + * @param array $definition The pack definition. + * + * @return string[] + */ + private function validateSourceFormat(array $definition): array { + $format = $definition['sourceFormat'] ?? null; + if (is_string($format) === false || in_array($format, self::ALLOWED_SOURCE_FORMATS, true) === false) { + return [ + '"sourceFormat" must be one of: ' . implode(', ', self::ALLOWED_SOURCE_FORMATS) + . ' (got ' . var_export($format, true) . ')', + ]; + } + + return []; + }//end validateSourceFormat() + + /** + * Validate the `version` field (strict semver: MAJOR.MINOR.PATCH). + * + * @param array $definition The pack definition. + * + * @return string[] + */ + private function validateVersion(array $definition): array { + $version = $definition['version'] ?? null; + if (is_string($version) === false || preg_match('/^\d+\.\d+\.\d+$/', $version) !== 1) { + return ['"version" must be a semver string (MAJOR.MINOR.PATCH), got ' . var_export($version, true)]; + } + + return []; + }//end validateVersion() + + /** + * Validate the `fieldMappings` array and every entry within it. + * + * @param array $definition The pack definition. + * + * @return string[] + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) Per-transform-type validation requires many branches. + * @SuppressWarnings(PHPMD.NPathComplexity) Per-transform-type validation requires many branches. + */ + private function validateFieldMappings(array $definition): array { + $mappings = $definition['fieldMappings'] ?? null; + if (is_array($mappings) === false || empty($mappings) === true) { + return ['"fieldMappings" is required and must be a non-empty array']; + } + + $errors = []; + foreach ($mappings as $index => $mapping) { + $path = 'fieldMappings[' . $index . ']'; + if (is_array($mapping) === false) { + $errors[] = $path . ' must be an object'; + continue; + } + + $source = $mapping['source'] ?? null; + if (is_string($source) === false || $source === '') { + $errors[] = $path . '.source is required and must be a non-empty string'; + } + + $target = $mapping['target'] ?? null; + if (is_string($target) === false || $target === '') { + $errors[] = $path . '.target is required and must be a non-empty string'; + } + + if (isset($mapping['required']) === true && is_bool($mapping['required']) === false) { + $errors[] = $path . '.required must be a boolean when present'; + } + + if (isset($mapping['transform']) === true) { + $errors = array_merge($errors, $this->validateTransform(transform: $mapping['transform'], path: $path . '.transform')); + } + }//end foreach + + return $errors; + }//end validateFieldMappings() + + /** + * Validate one `transform` block. + * + * @param mixed $transform The transform value to validate. + * @param string $path The error-message path prefix. + * + * @return string[] + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) Each transform type has its own required-field shape. + * @SuppressWarnings(PHPMD.NPathComplexity) Each transform type has its own required-field shape. + */ + private function validateTransform($transform, string $path): array { + if (is_array($transform) === false) { + return [$path . ' must be an object with a "type" key']; + } + + $type = $transform['type'] ?? null; + if (is_string($type) === false || in_array($type, self::ALLOWED_TRANSFORM_TYPES, true) === false) { + return [ + $path . '.type must be one of: ' . implode(', ', self::ALLOWED_TRANSFORM_TYPES) + . ' (got ' . var_export($type, true) . ')', + ]; + } + + switch ($type) { + case 'date': + if (isset($transform['sourceFormat']) === true && is_string($transform['sourceFormat']) === false) { + return [$path . '.sourceFormat must be a string when present']; + } + + if (isset($transform['targetFormat']) === true && is_string($transform['targetFormat']) === false) { + return [$path . '.targetFormat must be a string when present']; + } + break; + + case 'bool-map': + if (is_array($transform['map'] ?? null) === false || empty($transform['map']) === true) { + return [$path . '.map is required and must be a non-empty object for a bool-map transform']; + } + break; + + case 'lookup': + if (is_array($transform['map'] ?? null) === false || empty($transform['map']) === true) { + return [$path . '.map is required and must be a non-empty object for a lookup transform']; + } + break; + + case 'concat': + if (is_array($transform['fields'] ?? null) === false || empty($transform['fields']) === true) { + return [$path . '.fields is required and must be a non-empty array for a concat transform']; + } + + foreach ($transform['fields'] as $fieldIndex => $field) { + if (is_string($field) === false || $field === '') { + return [$path . '.fields[' . $fieldIndex . '] must be a non-empty string']; + } + } + break; + + case 'const': + if (array_key_exists('value', $transform) === false) { + return [$path . '.value is required for a const transform']; + } + break; + + case 'trim': + default: + // No extra fields required. + break; + }//end switch + + return []; + }//end validateTransform() + + /** + * Validate the optional `defaults` map. + * + * @param array $definition The pack definition. + * + * @return string[] + */ + private function validateDefaults(array $definition): array { + if (isset($definition['defaults']) === false) { + return []; + } + + if (is_array($definition['defaults']) === false) { + return ['"defaults" must be an object of target-property => default value when present']; + } + + return []; + }//end validateDefaults() + + /** + * Validate the optional `skipRows` array. + * + * @param array $definition The pack definition. + * + * @return string[] + */ + private function validateSkipRows(array $definition): array { + if (isset($definition['skipRows']) === false) { + return []; + } + + if (is_array($definition['skipRows']) === false) { + return ['"skipRows" must be an array of row numbers when present']; + } + + foreach ($definition['skipRows'] as $row) { + if (is_int($row) === false || $row < 1) { + return ['"skipRows" entries must be positive integers']; + } + } + + return []; + }//end validateSkipRows() + + /** + * Validate the required `idStrategy` block. + * + * @param array $definition The pack definition. + * + * @return string[] + */ + private function validateIdStrategy(array $definition): array { + $idStrategy = $definition['idStrategy'] ?? null; + if (is_array($idStrategy) === false) { + return ['"idStrategy" is required and must be an object with a "type" key']; + } + + $type = $idStrategy['type'] ?? null; + if (is_string($type) === false || in_array($type, self::ALLOWED_ID_STRATEGY_TYPES, true) === false) { + return [ + 'idStrategy.type must be one of: ' . implode(', ', self::ALLOWED_ID_STRATEGY_TYPES) + . ' (got ' . var_export($type, true) . ')', + ]; + } + + if ($type === 'sourceField' + && (is_string($idStrategy['field'] ?? null) === false || $idStrategy['field'] === '') + ) { + return ['idStrategy.field is required and must be a non-empty string when idStrategy.type is "sourceField"']; + } + + return []; + }//end validateIdStrategy() +}//end class diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts new file mode 100644 index 00000000..cfd0146a --- /dev/null +++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * E2e coverage for openspec/changes/cmdb-export-import (the "CMDB import" + * section of stackiq's Nextcloud admin settings). + * + * Every scenario the spec tags `@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts` + * is driven here through the REAL settings page: the NcSelect municipality + * chooser, the real file input (`setInputFiles`), the Import button and the + * rendered report. The API is used for setup (a run-unique municipality), + * for the "no object written" checks, and for cleanup. + * + * The municipality is created per run (`Gemeente Voorbeeldstad `), + * because the import's match key is scoped to the municipality: a fixed name + * would make the first import of a second run report `unchanged`, not + * `created`. + * + * The anonymised fixtures come from the backend task + * (tests/fixtures/cmdb/, see its README). When one is absent the test that + * needs it is skipped with a message naming the missing file. + * + * Owner contacts the import creates in the admin's Nextcloud address book + * are not removed by the cleanup below; the OpenRegister objects are. + */ + +import type { APIRequestContext, Locator, Page, Response } from '@playwright/test' +import type { VoorzieningenConfig } from '../workflows/_fixtures.ts' + +import { expect, test } from '@playwright/test' +import * as fs from 'fs' +import * as path from 'path' +import { + createObject, + deleteObject, + findAll, + newApiContext, + resolveConfig, + RUN_ID, +} from '../workflows/_fixtures.ts' + +const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/cmdb') +const EXPORT_FIXTURE = path.join(FIXTURES_DIR, 'topdesk-export-anonymised.xlsx') +const MISSING_COLUMN_FIXTURE = path.join( + FIXTURES_DIR, + 'topdesk-missing-middel-id.xlsx', +) + +type UploadFile = Parameters[0] + +const MUNICIPALITY_NAME = `Gemeente Voorbeeldstad ${RUN_ID}` +const IMPORT_PATH = '/index.php/apps/stackiq/api/cmdb-import' +// The page builds its URL with generateUrl(), which drops `/index.php` on an +// instance with pretty URLs, so the browser-side matchers use the path tail. +const IMPORT_ROUTE = '**/apps/stackiq/api/cmdb-import' + +/** + * Whether a response is the answer to the import upload. + * + * @param response The response + */ +function isImportAnswer(response: Response): boolean { + return ( + new URL(response.url()).pathname.endsWith('/apps/stackiq/api/cmdb-import') + && response.request().method() === 'POST' + ) +} + +let config: VoorzieningenConfig +let municipalityUuid = '' + +/** + * Skip the calling test when a fixture is not there yet. + * + * @param file The fixture path + */ +function requireFixture(file: string): void { + test.skip( + !fs.existsSync(file), + `Fixture ${path.relative(process.cwd(), file)} is missing; it is produced by Task 1 of openspec/changes/cmdb-export-import (tests/fixtures/cmdb/build-fixtures.py).`, + ) +} + +/** + * All objects of a schema whose data mentions the run's municipality: its + * usages (consumer), modules (externalKey) and contact persons (organization). + * + * @param ctx The API context + * @param schema The schema id + */ +async function objectsOfMunicipality( + ctx: APIRequestContext, + schema: string, +): Promise>> { + const rows = await findAll(ctx, config.register, schema) + return rows.filter((row) => JSON.stringify(row).includes(municipalityUuid)) +} + +/** + * Count the objects the import can write for the run's municipality. + * + * @param ctx The API context + */ +async function countWritten(ctx: APIRequestContext): Promise<{ + modules: number + usages: number + contactPersons: number + municipalities: number +}> { + const municipalities = ( + await findAll(ctx, config.register, config.organisatie_schema) + ).filter((org) => org.type === 'Municipality') + return { + modules: (await objectsOfMunicipality(ctx, config.module_schema)).length, + usages: (await objectsOfMunicipality(ctx, config.gebruik_schema)).length, + contactPersons: ( + await objectsOfMunicipality(ctx, config.contactpersoon_schema) + ).length, + municipalities: municipalities.length, + } +} + +/** + * Get Nextcloud's own first-run wizard out of the way. + * + * It opens on an admin's first visit to a fresh instance, and its modal mask + * intercepts every click, so the chooser below would time out on a click + * that reads like a broken select. It has no close button and ignores + * Escape until its last slide, so it is marked as seen through its own + * route (what finishing it does) and the page is loaded again. A bounded + * wait, because the wizard mounts after the page. + * + * @param page The page + * @return True when the page was reloaded + */ +async function dismissFirstRunWizard(page: Page): Promise { + const wizard = page.locator('.first-run-wizard[role="dialog"]') + try { + await wizard.waitFor({ state: 'visible', timeout: 3000 }) + } catch { + return false + } + await page.evaluate(async () => { + const oc = ( + window as unknown as { + OC: { requestToken: string; generateUrl: (u: string) => string } + } + ).OC + await fetch(oc.generateUrl('/apps/firstrunwizard/wizard'), { + method: 'DELETE', + headers: { requesttoken: oc.requestToken }, + }) + }) + await page.reload({ waitUntil: 'domcontentloaded' }) + return true +} + +/** + * Open stackiq's admin settings and return the CMDB import section. + * + * @param page The page + */ +async function gotoCmdbSection(page: Page) { + await page.goto('/settings/admin/stackiq', { waitUntil: 'domcontentloaded' }) + const section = page.locator('[data-testid="cmdb-import"]') + await expect(section).toBeVisible({ timeout: 30000 }) + if (await dismissFirstRunWizard(page)) { + await expect(section).toBeVisible({ timeout: 30000 }) + } + await section.scrollIntoViewIfNeeded() + return section +} + +/** + * Pick the run's municipality in the chooser, the way an admin does. + * + * @param page The page + */ +async function chooseMunicipality(page: Page): Promise { + const input = page.locator('#cmdb-import-municipality') + await input.click() + await input.fill(MUNICIPALITY_NAME) + await page + .getByRole('option') + // hasText, not the accessible name: NcSelect splits a long option + // into two spans for its middle ellipsis. + .filter({ hasText: MUNICIPALITY_NAME }) + .first() + .click() + await expect( + page.locator('[data-testid="cmdb-import-municipality"] .vs__selected'), + ).toContainText(MUNICIPALITY_NAME) +} + +/** + * Read one summary count from the rendered report. + * + * @param page The page + * @param key The summary key (created, unchanged, …) + */ +function summaryValue(page: Page, key: string) { + return page.locator( + `[data-testid="cmdb-import-summary-${key}"] .cmdb-import__tile-value`, + ) +} + +/** + * The rendered report rows. + * + * @param page The page + */ +function reportRows(page: Page) { + return page.locator( + '[data-testid="cmdb-import-rows"] [data-testid="cn-object-row"]', + ) +} + +/** + * Choose a file, press Import and wait for the import request to answer. + * + * @param page The page + * @param file The file to upload + */ +async function runImport(page: Page, file: UploadFile) { + await page.locator('[data-testid="cmdb-import-file"]').setInputFiles(file) + const answer = page.waitForResponse(isImportAnswer, { timeout: 120000 }) + await page.locator('[data-testid="cmdb-import-start"]').click() + return await answer +} + +test.describe.serial('CMDB import section', () => { + test.beforeAll(async () => { + const ctx = await newApiContext() + try { + config = await resolveConfig(ctx) + municipalityUuid = await createObject( + ctx, + config.register, + config.organisatie_schema, + { name: MUNICIPALITY_NAME, type: 'Municipality', status: 'Active' }, + ) + } finally { + await ctx.dispose() + } + }) + + test.afterAll(async () => { + if (!municipalityUuid) { + return + } + const ctx = await newApiContext() + try { + for (const schema of [ + config.gebruik_schema, + config.contactpersoon_schema, + config.module_schema, + ]) { + for (const row of await objectsOfMunicipality(ctx, schema)) { + const id = String( + row.id + ?? (row['@self'] as { id?: string } | undefined)?.id + ?? '', + ) + if (id !== '') { + await deleteObject(ctx, config.register, schema, id) + } + } + } + await deleteObject( + ctx, + config.register, + config.organisatie_schema, + municipalityUuid, + ) + } finally { + await ctx.dispose() + } + }) + + // @e2e cmdb-export-import::the-admin-runs-an-import-from-the-settings-page + // @e2e cmdb-export-import::the-admin-picks-an-existing-municipality + // @e2e cmdb-export-import::upload-with-a-per-row-report + test('an admin imports the anonymised export for an existing municipality', async ({ + page, + }) => { + requireFixture(EXPORT_FIXTURE) + const ctx = await newApiContext() + const before = await countWritten(ctx) + + const section = await gotoCmdbSection(page) + await chooseMunicipality(page) + + // Hold the import request for a moment so the running state is + // observable. route.continue() forwards the original multipart body; + // route.fetch() would re-send it without the file. + await page.route(IMPORT_ROUTE, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1500)) + await route.continue() + }) + await page + .locator('[data-testid="cmdb-import-file"]') + .setInputFiles(EXPORT_FIXTURE) + const answer = page.waitForResponse(isImportAnswer, { timeout: 120000 }) + await page.locator('[data-testid="cmdb-import-start"]').click() + + // A progress bar shows while the import runs. + const progress = section.locator('[data-testid="cmdb-import-progress"]') + await expect(progress).toBeVisible() + await expect(progress.getByRole('progressbar')).toBeVisible() + await expect( + section.locator('[data-testid="cmdb-import-cancel"]'), + ).toBeVisible() + + const response = await answer + expect(response.status()).toBe(200) + await page.unroute(IMPORT_ROUTE) + await expect(progress).toBeHidden({ timeout: 30000 }) + + // Summary: 2 rows read, 2 created. + await expect( + section.locator('[data-testid="cmdb-import-summary"]'), + ).toBeVisible() + await expect(summaryValue(page, 'rowsRead')).toHaveText('2') + await expect(summaryValue(page, 'created')).toHaveText('2') + + // The report lists exactly the two data rows, not the hundreds of + // formatted but empty rows below them, each created with a module link. + const rows = reportRows(page) + await expect(rows).toHaveCount(2) + for (const [sheet, middelId] of [ + ['Invoer AIA data', 'AIA-AangetekendMailen'], + ['Invoer APP data', 'APP-test123'], + ]) { + const row = rows.filter({ hasText: middelId }) + await expect(row).toHaveCount(1) + await expect(row).toContainText(sheet) + await expect(row.locator('td').nth(1)).toHaveText('2') + await expect(row.locator('[data-outcome="created"]')).toBeVisible() + await expect( + row.locator('[data-testid="cmdb-import-module-link"]'), + ).toHaveAttribute('href', /\/apps\/stackiq\/modules\/[0-9a-f-]{36}$/) + } + + // Filtering the table on `created` shows the two imported rows. + await section.locator('#cmdb-import-outcome-filter').click() + await page + .getByRole('option') + .filter({ hasText: /^\s*(Created|Aangemaakt)\s*$/ }) + .first() + .click() + await expect(rows).toHaveCount(2) + + // Both usages point at the chosen municipality, and no new + // municipality was created. + const after = await countWritten(ctx) + expect(after.usages - before.usages).toBe(2) + expect(after.modules - before.modules).toBe(2) + expect(after.municipalities).toBe(before.municipalities) + const usages = await objectsOfMunicipality(ctx, config.gebruik_schema) + for (const usage of usages) { + expect(String(usage.consumer)).toContain(municipalityUuid) + } + await ctx.dispose() + }) + + // @e2e cmdb-export-import::re-importing-the-same-export-creates-no-duplicates + test('importing the same export again reports both rows unchanged', async ({ + page, + }) => { + requireFixture(EXPORT_FIXTURE) + const ctx = await newApiContext() + const before = await countWritten(ctx) + test.skip( + before.modules === 0, + 'The first import (previous test) wrote nothing for this municipality, so there is nothing to re-import.', + ) + + const section = await gotoCmdbSection(page) + await chooseMunicipality(page) + const response = await runImport(page, EXPORT_FIXTURE) + expect(response.status()).toBe(200) + + await expect(summaryValue(page, 'rowsRead')).toHaveText('2') + await expect(summaryValue(page, 'created')).toHaveText('0') + await expect(summaryValue(page, 'unchanged')).toHaveText('2') + await expect( + reportRows(page).locator('[data-outcome="unchanged"]'), + ).toHaveCount(2) + await expect( + section.locator('[data-testid="cmdb-import-error"]'), + ).toHaveCount(0) + + // Same number of modules, usages, contact persons and municipalities. + expect(await countWritten(ctx)).toEqual(before) + await ctx.dispose() + }) + + // @e2e cmdb-export-import::a-missing-required-column-is-named-in-the-422-response + test('an export without a required column names the column and the sheet', async ({ + page, + }) => { + requireFixture(MISSING_COLUMN_FIXTURE) + const ctx = await newApiContext() + const before = await countWritten(ctx) + + const section = await gotoCmdbSection(page) + await chooseMunicipality(page) + const response = await runImport(page, MISSING_COLUMN_FIXTURE) + + expect(response.status()).toBe(422) + const body = await response.json() + expect(body.error).toBe('MISSING_COLUMN') + expect(body.details).toEqual({ + sheet: 'Invoer APP data', + column: 'Middel-ID', + }) + + const error = section.locator('[data-testid="cmdb-import-error"]') + await expect(error).toBeVisible() + await expect(error).toContainText('"Invoer APP data"') + await expect(error).toContainText('"Middel-ID"') + await expect(error).toContainText('MISSING_COLUMN') + await expect( + section.locator('[data-testid="cmdb-import-report"]'), + ).toHaveCount(0) + + expect(await countWritten(ctx)).toEqual(before) + await ctx.dispose() + }) + + // @e2e cmdb-export-import::a-file-that-is-not-xlsx-is-rejected + test('a CSV, or a text file named .xlsx, is rejected as not xlsx', async ({ + page, + }) => { + const ctx = await newApiContext() + const before = await countWritten(ctx) + const csv = { + name: 'applications.csv', + mimeType: 'text/csv', + buffer: Buffer.from('Middel-ID;Naam\nAPP-test123;naamtest123\n'), + } + const textAsXlsx = { + name: 'export.xlsx', + mimeType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + buffer: Buffer.from('Middel-ID;Naam\nAPP-test123;naamtest123\n'), + } + + // The endpoint answers 400 NOT_XLSX for both. + for (const file of [csv, textAsXlsx]) { + const res = await ctx.post(IMPORT_PATH, { + multipart: { + cmdbFile: file, + municipalityUuid, + }, + }) + expect(res.status(), file.name).toBe(400) + expect((await res.json()).error, file.name).toBe('NOT_XLSX') + } + + // The section shows the NOT_XLSX message for both: for the CSV before + // anything is sent, for the text file from the server's answer. + const section = await gotoCmdbSection(page) + await chooseMunicipality(page) + const error = section.locator('[data-testid="cmdb-import-error"]') + + await page.locator('[data-testid="cmdb-import-file"]').setInputFiles(csv) + await expect(error).toBeVisible() + await expect(error).toContainText('NOT_XLSX') + await expect(error).toContainText('.xlsx') + + const response = await runImport(page, textAsXlsx) + expect(response.status()).toBe(400) + await expect(error).toBeVisible() + await expect(error).toContainText('NOT_XLSX') + + // No module, usage, contact person or municipality was written. + expect(await countWritten(ctx)).toEqual(before) + await ctx.dispose() + }) +}) diff --git a/tests/fixtures/cmdb/README.md b/tests/fixtures/cmdb/README.md new file mode 100644 index 00000000..65226710 --- /dev/null +++ b/tests/fixtures/cmdb/README.md @@ -0,0 +1,36 @@ +# CMDB import fixtures + +Test workbooks for the TOPdesk CMDB import (`openspec/changes/cmdb-export-import`). +They are used by the PHPUnit tests under `tests/Unit/` and by the Playwright test +`tests/e2e/spec-coverage/cmdb-import.spec.ts`. + +| File | What it is | +|---|---| +| `topdesk-export-anonymised.xlsx` | An anonymised TOPdesk export with one fake data row per source sheet ("Invoer AIA data" and "Invoer APP data") and hundreds of formatted but empty rows below them. Document metadata, custom properties, `customXml/`, the workbook's absolute save path and `xl/connections.xml` are removed. | +| `topdesk-missing-middel-id.xlsx` | The same, but the "Middel-ID" header of "Invoer APP data" is renamed, so the required column is missing. | +| `topdesk-shuffled-columns.xlsx` | The same rows with the columns of both source sheets in reverse order, and the header "Eigenaar e-mail" written as `Eigenaar e-mail⚡`. Reads to the same rows as the original. | +| `topdesk-formula-and-connection.xlsx` | "Naam" of the APP row is a formula that would evaluate to `Evaluated` with the cached value `Rekenmodel`, and the package declares a synthetic external web connection to `https://example.invalid/`. | +| `topdesk-no-source-sheet.xlsx` | A minimal workbook with only a sheet "Blad1". | + +## Placeholder data only + +Every person value is a placeholder: `Achternaam, Voornaam`, +`letter.achternaam@gemeente.nl`, `groepsmail.test@gemeente.nl`, personnel +number `123456`. `tests/Unit/Fixtures/CmdbFixtureHygieneTest.php` fails when a +fixture holds document metadata, an e-mail address, a linked host or a long +number that is not on its placeholder list. Never commit a municipality's own +export, not even temporarily. + +## Rebuilding + +`build-fixtures.py` uses the Python standard library only: + +```bash +# Re-derive the variants from the committed sanitised export +python3 tests/fixtures/cmdb/build-fixtures.py + +# Sanitise a new anonymised export first, then derive the variants +python3 tests/fixtures/cmdb/build-fixtures.py --source path/to/anonymised-export.xlsx +``` + +Run the hygiene test afterwards. diff --git a/tests/fixtures/cmdb/build-fixtures.py b/tests/fixtures/cmdb/build-fixtures.py new file mode 100644 index 00000000..92bd3f68 --- /dev/null +++ b/tests/fixtures/cmdb/build-fixtures.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction B.V. +# SPDX-License-Identifier: EUPL-1.2 +"""Build the CMDB import test fixtures (cmdb-export-import, Task 1). + +Python standard library only (zipfile, re). openpyxl is deliberately not used: +re-saving through a spreadsheet library would rewrite every part of the +package, and the point of these fixtures is to stay byte-close to a real +TOPdesk export. + +Usage: + + python3 build-fixtures.py --source + Sanitise an already anonymised export into topdesk-export-anonymised.xlsx + (strip document metadata, custom properties, customXml, the workbook's + absolute path and xl/connections.xml), then derive the variants. + + python3 build-fixtures.py + Derive the variants from the committed topdesk-export-anonymised.xlsx. + +Never run this on a municipality's original export: the source must already +carry placeholder values only. tests/Unit/Fixtures/CmdbFixtureHygieneTest.php +fails when a fixture holds metadata or person data that is not a placeholder. +""" + +import argparse +import os +import re +import sys +import zipfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +SANITISED = os.path.join(HERE, 'topdesk-export-anonymised.xlsx') + +# Parts that never belong in a fixture. +DROP_PARTS = ('docProps/custom.xml', 'xl/connections.xml') +DROP_PREFIXES = ('customXml/',) + +AIA_SHEET = 'xl/worksheets/sheet1.xml' +APP_SHEET = 'xl/worksheets/sheet3.xml' + + +def read_package(path): + """Return the package as an ordered list of (name, bytes).""" + with zipfile.ZipFile(path) as package: + return [(info.filename, package.read(info.filename)) for info in package.infolist()] + + +def write_package(path, parts): + """Write (name, bytes) parts as a deflated zip, [Content_Types].xml first.""" + parts = sorted(parts, key=lambda item: item[0] != '[Content_Types].xml') + with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as package: + for name, data in parts: + info = zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + package.writestr(info, data) + + +def text(data): + return data.decode('utf-8') + + +def sanitise(parts): + """Remove metadata parts and every reference to them.""" + kept = [] + for name, data in parts: + if name in DROP_PARTS or name.startswith(DROP_PREFIXES): + continue + if name == 'docProps/core.xml': + xml = text(data) + xml = re.sub(r'.*?', '', xml) + xml = re.sub(r'.*?', '', xml) + data = xml.encode('utf-8') + elif name == 'xl/workbook.xml': + # The absolute path of the last save names a user profile directory. + xml = re.sub(r']*>.*?x15ac:absPath.*?', '', text(data)) + data = xml.encode('utf-8') + elif name == '[Content_Types].xml': + xml = text(data) + xml = re.sub(r']*/>', '', xml) + xml = re.sub(r']*/>', '', xml) + xml = re.sub(r']*/>', '', xml) + data = xml.encode('utf-8') + elif name == '_rels/.rels': + xml = re.sub(r']*Target="docProps/custom\.xml"[^>]*/>', '', text(data)) + xml = re.sub(r']*Target="\.\./customXml/[^"]*"[^>]*/>', '', xml) + data = xml.encode('utf-8') + elif name == 'xl/_rels/workbook.xml.rels': + xml = re.sub(r']*Target="connections\.xml"[^>]*/>', '', text(data)) + xml = re.sub(r']*Target="\.\./customXml/[^"]*"[^>]*/>', '', xml) + data = xml.encode('utf-8') + kept.append((name, data)) + return kept + + +def replace_part(parts, name, transform): + return [(n, transform(d) if n == name else d) for n, d in parts] + + +def missing_middel_id(parts): + """'Invoer APP data' loses its Middel-ID header (the column gets another name).""" + def transform(data): + xml = text(data) + new, count = re.subn( + r']*?) t="s"([^>]*)>\d+', + r'Middelnummer', + xml, + count=1, + ) + if count != 1: + sys.exit('missing-middel-id: header cell B1 not found on Invoer APP data') + return new.encode('utf-8') + return replace_part(parts, APP_SHEET, transform) + + +def col_to_index(col): + index = 0 + for char in col: + index = index * 26 + (ord(char) - 64) + return index + + +def index_to_col(index): + col = '' + while index > 0: + index, rem = divmod(index - 1, 26) + col = chr(65 + rem) + col + return col + + +def reverse_columns(xml): + """Mirror the column order of a worksheet: the last column becomes A.""" + dim = re.search(r'', xml) + width = col_to_index(dim.group(1)) + # Elements that address columns or ranges; they are not needed by the reader. + for tag in ('cols', 'hyperlinks', 'autoFilter', 'conditionalFormatting', 'dataValidations', 'mergeCells'): + xml = re.sub(r'<%s[ >].*?' % (tag, tag), '', xml, flags=re.S) + xml = re.sub(r'<%s [^>]*/>' % tag, '', xml) + xml = re.sub(r']*/>', '', xml) + + def flip_row(match): + row_open, body = match.group(1), match.group(2) + row_open = re.sub(r' spans="[^"]*"', '', row_open) + cells = re.findall(r']*?(?:/>|>.*?)', body, flags=re.S) + flipped = [] + for cell in cells: + ref = re.match(r'' + + return re.sub(r'(]*>)(.*?)', flip_row, xml, flags=re.S) + + +def shuffled_columns(parts): + """Both source sheets with their columns reversed ("Naam" before "Middel-ID").""" + parts = replace_part(parts, AIA_SHEET, lambda d: reverse_columns(text(d)).encode('utf-8')) + parts = replace_part(parts, APP_SHEET, lambda d: reverse_columns(text(d)).encode('utf-8')) + + # A referenced header with the decoration TOPdesk adds to computed fields. + def decorate(data): + xml = text(data) + xml, count = re.subn(r'Eigenaar e-mail', 'Eigenaar e-mail⚡', xml, count=1) + if count != 1: + sys.exit('shuffled-columns: shared string "Eigenaar e-mail" not found') + return xml.encode('utf-8') + return replace_part(parts, 'xl/sharedStrings.xml', decorate) + + +SYNTHETIC_CONNECTION = ( + '\n' + '' + '' + '' + '' +) + + +def formula_and_connection(parts): + """A formula in "Naam" whose cached value differs from its result, plus an external connection.""" + def formula(data): + xml = text(data) + new, count = re.subn( + r']*?) t="s"([^>]*)>\d+', + r'"Evaluated"Rekenmodel', + xml, + count=1, + ) + if count != 1: + sys.exit('formula-and-connection: cell BB2 not found on Invoer APP data') + return new.encode('utf-8') + + parts = replace_part(parts, APP_SHEET, formula) + parts = replace_part( + parts, + '[Content_Types].xml', + lambda d: text(d).replace( + '', + '', + ).encode('utf-8'), + ) + parts = replace_part( + parts, + 'xl/_rels/workbook.xml.rels', + lambda d: text(d).replace( + '', + '', + ).encode('utf-8'), + ) + return parts + [('xl/connections.xml', SYNTHETIC_CONNECTION.encode('utf-8'))] + + +def no_source_sheet(): + """A minimal workbook with one sheet "Blad1" and neither source sheet.""" + main = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main' + rel = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' + pkg = 'http://schemas.openxmlformats.org/package/2006/relationships' + return [ + ('[Content_Types].xml', ( + '\n' + '' + '' + '' + '' + '' + '').encode('utf-8')), + ('_rels/.rels', ( + '\n' + '' + % (pkg, rel)).encode('utf-8')), + ('xl/workbook.xml', ( + '\n' + '' + % (main, rel)).encode('utf-8')), + ('xl/_rels/workbook.xml.rels', ( + '\n' + '' + % (pkg, rel)).encode('utf-8')), + ('xl/worksheets/sheet1.xml', ( + '\n' + 'Naam' + 'Voorbeeld' + % main).encode('utf-8')), + ] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--source', help='anonymised export to sanitise into topdesk-export-anonymised.xlsx') + args = parser.parse_args() + + if args.source: + write_package(SANITISED, sanitise(read_package(args.source))) + print('wrote', os.path.relpath(SANITISED, HERE)) + + base = read_package(SANITISED) + variants = { + 'topdesk-missing-middel-id.xlsx': missing_middel_id(base), + 'topdesk-shuffled-columns.xlsx': shuffled_columns(base), + 'topdesk-formula-and-connection.xlsx': formula_and_connection(base), + 'topdesk-no-source-sheet.xlsx': no_source_sheet(), + } + for name, parts in variants.items(): + write_package(os.path.join(HERE, name), parts) + print('wrote', name) + + +if __name__ == '__main__': + main() diff --git a/tests/fixtures/cmdb/topdesk-export-anonymised.xlsx b/tests/fixtures/cmdb/topdesk-export-anonymised.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d4b6c34fc0bc9682ebab292a1f0c79f1b8871663 GIT binary patch literal 160471 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/cmdb/topdesk-formula-and-connection.xlsx b/tests/fixtures/cmdb/topdesk-formula-and-connection.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fb4642da1ec1eb422b1b3a2a72483f8802e02d00 GIT binary patch literal 160907 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 @@ -255,7 +255,7 @@ :href="moduleUrl(row.moduleUuid)" class="cmdb-import__module-link" data-testid="cmdb-import-module-link"> - {{ row.name || row.middelId }} + {{ row.name || row.appId }} {{ row.name || '—' }} @@ -281,7 +281,7 @@ {{ t( 'stackiq', - 'Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.', + 'Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.', ) }}

@@ -291,7 +291,7 @@ {{ t( 'stackiq', - 'The sheets "Invoer AIA data" and "Invoer APP data" are read; other sheets are ignored.', + 'The sheets "Onbeh Applicaties CMDB" (applications without arranged maintenance) and "Beheerde Applicaties CMDB" (with arranged maintenance) are read; other sheets, including the "Invoer" sheets, are ignored.', ) }} @@ -299,7 +299,7 @@ {{ t( 'stackiq', - 'Row 1 holds the column names. "Middel-ID" and "Naam" are required; column order does not matter.', + 'Row 1 holds the column names. "APPID" and "Applicatie Naam" are required; column order does not matter.', ) }} @@ -307,7 +307,15 @@ {{ t( 'stackiq', - 'Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.', + 'Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.', + ) + }} + +
  • + {{ + t( + 'stackiq', + 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.', ) }}
  • @@ -565,8 +573,8 @@ export default { { key: 'sheet', label: t('stackiq', 'Sheet'), sortable: true }, { key: 'row', label: t('stackiq', 'Row'), sortable: true }, { - key: 'middelId', - label: t('stackiq', 'Middel-ID'), + key: 'appId', + label: t('stackiq', 'APPID'), sortable: true, }, { key: 'name', label: t('stackiq', 'Application'), sortable: true }, diff --git a/tests/Unit/Controller/CmdbImportControllerTest.php b/tests/Unit/Controller/CmdbImportControllerTest.php index c75cbdfc..665a2317 100644 --- a/tests/Unit/Controller/CmdbImportControllerTest.php +++ b/tests/Unit/Controller/CmdbImportControllerTest.php @@ -193,7 +193,7 @@ public function testANonXlsxFileIsRefused(): void { $service->method('assertXlsx')->willThrowException(new CmdbImportException(errorCode: CmdbImportException::NOT_XLSX, message: 'no')); $service->expects($this->never())->method('import'); - $response = $this->controller(file: $this->file(path: $this->upload(content: 'Naam;Middel-ID'), name: 'applications.csv'), params: ['missingRecords' => 'remove'], service: $service)->import(); + $response = $this->controller(file: $this->file(path: $this->upload(content: 'Applicatie Naam;APPID'), name: 'applications.csv'), params: ['missingRecords' => 'remove'], service: $service)->import(); $this->assertSame(400, $response->getStatus()); $this->assertSame('NOT_XLSX', $response->getData()['error']); @@ -239,9 +239,9 @@ public static function serviceErrors(): array { 'mapping' => [CmdbImportException::MAPPING_UNAVAILABLE, 503, []], 'reader' => [CmdbImportException::READER_UNAVAILABLE, 503, []], 'config' => [CmdbImportException::NOT_CONFIGURED, 503, []], - 'no sheet' => [CmdbImportException::NO_SOURCE_SHEET, 422, ['expected' => ['Invoer AIA data', 'Invoer APP data']]], - 'column' => [CmdbImportException::MISSING_COLUMN, 422, ['sheet' => 'Invoer APP data', 'column' => 'Middel-ID']], - 'rows' => [CmdbImportException::TOO_MANY_ROWS, 422, ['sheet' => 'Invoer APP data', 'limit' => 10000]], + 'no sheet' => [CmdbImportException::NO_SOURCE_SHEET, 422, ['expected' => ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']]], + 'column' => [CmdbImportException::MISSING_COLUMN, 422, ['sheet' => 'Beheerde Applicaties CMDB', 'column' => 'APPID']], + 'rows' => [CmdbImportException::TOO_MANY_ROWS, 422, ['sheet' => 'Beheerde Applicaties CMDB', 'limit' => 10000]], 'municipality' => [CmdbImportException::MUNICIPALITY_INVALID, 422, []], 'corrupt' => [CmdbImportException::NOT_XLSX, 400, []], ]; @@ -268,8 +268,8 @@ public function testServiceErrorsAreTranslated(string $code, int $status, array $this->assertEquals((object)$details, $response->getData()['details']); $this->assertStringNotContainsString('internal', $response->getData()['message']); if ($code === CmdbImportException::MISSING_COLUMN) { - $this->assertStringContainsString('Invoer APP data', $response->getData()['message']); - $this->assertStringContainsString('Middel-ID', $response->getData()['message']); + $this->assertStringContainsString('Beheerde Applicaties CMDB', $response->getData()['message']); + $this->assertStringContainsString('APPID', $response->getData()['message']); } }//end testServiceErrorsAreTranslated() diff --git a/tests/Unit/Fixtures/CmdbFixtureHygieneTest.php b/tests/Unit/Fixtures/CmdbFixtureHygieneTest.php index 5118aa0d..b7d1c898 100644 --- a/tests/Unit/Fixtures/CmdbFixtureHygieneTest.php +++ b/tests/Unit/Fixtures/CmdbFixtureHygieneTest.php @@ -58,11 +58,12 @@ class CmdbFixtureHygieneTest extends TestCase { /** * The only values a person-name column may hold, besides a placeholder e-mail address - * (TOPdesk puts the address of the configuration coordinator in that column). + * (TOPdesk puts the address of the configuration coordinator in that column), and + * the placeholder function a CMDB sheet shows as owner when no person is set. * * @var array */ - private const PLACEHOLDER_NAMES = ['', 'Achternaam, Voornaam', 'Achternaam, voornaam']; + private const PLACEHOLDER_NAMES = ['', 'Achternaam, Voornaam', 'Achternaam, voornaam', 'Teamleider Applicatiebeheer']; /** * Columns that hold a person's name. @@ -127,7 +128,7 @@ private function parts(string $path): array { */ public function testTheFixturesExist(): void { $names = array_keys(self::fixtures()); - foreach (['topdesk-export-anonymised.xlsx', 'topdesk-missing-middel-id.xlsx', 'topdesk-shuffled-columns.xlsx', 'topdesk-formula-and-connection.xlsx'] as $expected) { + foreach (['topdesk-export-anonymised.xlsx', 'topdesk-missing-appid.xlsx', 'topdesk-shuffled-columns.xlsx', 'topdesk-formula-and-connection.xlsx'] as $expected) { $this->assertContains($expected, $names); } }//end testTheFixturesExist() @@ -211,7 +212,7 @@ public function testOnlyPlaceholderContactData(string $path): void { }//end testOnlyPlaceholderContactData() /** - * Every person-name cell of the source sheets holds a placeholder. + * Every person-name cell of the raw TOPdesk sheets and the CMDB sheets holds a placeholder. * * @param string $path The fixture. * @@ -239,9 +240,10 @@ public function testPersonNameColumnsHoldPlaceholders(string $path): void { continue; } - // Only the raw TOPdesk sheets have their headers in row 1; the - // derived sheets are covered by the e-mail and number scan. - if (in_array('Middel-ID', $headers, true) === false) { + // The raw TOPdesk sheets (Middel-ID) and the CMDB sheets (APPID) + // have their headers in row 1; the other sheets are covered by + // the e-mail and number scan. + if (in_array('Middel-ID', $headers, true) === false && in_array('APPID', $headers, true) === false) { break 2; } diff --git a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php index 1326f416..fe36e789 100644 --- a/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php +++ b/tests/Unit/Service/Cmdb/CmdbImportProfileTest.php @@ -112,32 +112,41 @@ public function testThePacksImplementTheColumnTable(): void { $this->assertSame( [ - 'Naam' => 'name', - 'Middel-ID' => 'externalId', - 'ICT Applicatienummer' => 'externalNumber', - 'Functionele omschrijving' => 'longDescription', - 'ICT BBN Classificatie' => 'bbnLevel', - 'Aanmaakdatum' => 'externalCreatedAt', - 'Wijzigingsdatum' => 'externalModifiedAt', + 'Applicatie Naam' => 'name', + 'APPID' => 'externalNumber', + 'Applicatie Code' => 'externalId', + 'Nickname' => 'shortDescription', + 'Roepnaam' => 'shortDescription', + 'Functionele Omschrijving' => 'longDescription', + 'Applicatiesoort' => 'cloudDienstverleningsmodel', + 'BNN Classificatie' => 'bbnLevel', + 'Datum' => 'externalCreatedAt', + 'Referentie datum wijziging' => 'externalModifiedAt', ], $targets('module') ); - $this->assertSame(['Fabrikant' => 'name'], $targets('manufacturer')); + $this->assertSame(['Vendor' => 'name'], $targets('manufacturer')); $this->assertSame(['municipalityName' => 'name'], $targets('municipality')); $this->assertSame( - ['Status' => 'status', 'ICT TIME Classificatie' => 'timeClassification', 'End of Life Business' => 'startDateOutPhased', 'Eigenaar afdeling' => 'interneAnnotation'], + ['Applicatie Status' => 'status', 'Classificatie' => 'timeClassification', 'End-of-Life Functioneel' => 'startDateOutPhased', 'Beheer' => 'interneAnnotation'], $targets('usage') ); - $this->assertSame(['Eigenaar' => 'name', 'Eigenaar e-mail' => 'email', 'Eigenaar functie' => 'role'], $targets('businessOwner')); - $this->assertSame(['FB contactpersoon 1' => 'name'], $targets('technicalOwner')); + $this->assertSame(['Applicatie Eigenaar (Persoon)' => 'name', 'Applicatie Eigenaar (Functie)' => 'role'], $targets('businessOwner')); + $this->assertSame(['module', 'manufacturer', 'municipality', 'usage', 'businessOwner'], CmdbImportProfile::TARGETS, 'no technical owner'); $this->assertSame(['type' => 'Supplier', 'status' => 'Active'], $profile->pack(target: 'manufacturer')['defaults']); $this->assertSame(['type' => 'Municipality', 'status' => 'Active'], $profile->pack(target: 'municipality')['defaults']); $this->assertSame(['type' => 'Application'], $profile->createOnlyDefaults(target: 'module')); $this->assertSame(['interneAnnotation'], $profile->createOnlyFields(target: 'usage')); $this->assertSame(['publicationDate', 'depublicationDate'], $profile->neverWrittenOnUpdate(target: 'module')); - $this->assertSame(['Middel-ID', 'Naam'], $profile->requiredColumns()); - $this->assertSame(['Invoer AIA data', 'Invoer APP data'], $profile->sheetNames()); + $this->assertSame(['APPID', 'Applicatie Naam'], $profile->requiredColumns()); + $this->assertSame('APPID', $profile->keyColumn()); + $this->assertSame(['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB'], $profile->sheetNames()); + $this->assertSame(['Beheer' => 'Beheer geregeld: nee'], $profile->sheetConstants(sheetName: 'Onbeh Applicaties CMDB')); + $this->assertSame(['Beheer' => 'Beheer geregeld: ja'], $profile->sheetConstants(sheetName: 'Beheerde Applicaties CMDB')); + $this->assertSame(['Nickname'], $profile->absentColumns(sheetName: 'Onbeh Applicaties CMDB')); + $this->assertSame([], $profile->absentColumns(sheetName: 'Beheerde Applicaties CMDB')); + $this->assertSame(['BNN Classificatie' => ['NB'], 'End-of-Life Functioneel' => ['49675']], $profile->emptyValues()); $this->assertSame(10485760, $profile->maxFileBytes()); $this->assertSame(10000, $profile->maxRowsPerSheet()); }//end testThePacksImplementTheColumnTable() @@ -155,26 +164,47 @@ public function testTheLookupsMapThroughTheEngine(): void { $usage = $engine->mapRow( $profile->pack(target: 'usage'), - ['Status' => 'In voorraad', 'ICT TIME Classificatie' => 'Tolereren', 'End of Life Business' => '2046-02-01', 'Eigenaar afdeling' => 'H10 Accounting', 'Eigenaar cluster' => 'H10 Bestuur'], + [ + 'Applicatie Status' => 'In voorraad', + 'Classificatie' => 'Tolereren', + 'End-of-Life Functioneel' => '2046-02-01', + 'Beheer' => 'Beheer geregeld: ja', + 'Cluster' => 'H10', + 'Applicatie Eigenaar (Afdeling)' => 'H10 Accounting', + ], 2 ); $this->assertSame([], $usage['errors']); $this->assertSame( - ['status' => 'Planned', 'timeClassification' => 'Tolerate', 'startDateOutPhased' => '2046-02-01', 'interneAnnotation' => 'H10 Accounting / H10 Bestuur'], + ['status' => 'Planned', 'timeClassification' => 'Tolerate', 'startDateOutPhased' => '2046-02-01', 'interneAnnotation' => 'Beheer geregeld: ja / H10 / H10 Accounting'], $usage['data'] ); - $module = $engine->mapRow($profile->pack(target: 'module'), ['Naam' => 'X', 'Middel-ID' => 'APP-1', 'ICT BBN Classificatie' => 'BBN 2'], 2); + $module = $engine->mapRow( + $profile->pack(target: 'module'), + ['Applicatie Naam' => 'X', 'APPID' => '1', 'BNN Classificatie' => 'BBN 2', 'Applicatiesoort' => 'Saas', 'Nickname' => 'Bijnaam', 'Roepnaam' => 'Roep'], + 2 + ); + $this->assertSame([], $module['errors']); $this->assertSame('BBN2', $module['data']['bbnLevel']); + $this->assertSame(['SaaS'], $module['data']['cloudDienstverleningsmodel']); + $this->assertSame('Roep', $module['data']['shortDescription'], 'Roepnaam wins over Nickname'); + $nickname = $engine->mapRow($profile->pack(target: 'module'), ['Applicatie Naam' => 'X', 'APPID' => '1', 'Nickname' => 'Bijnaam', 'Roepnaam' => ''], 2); + $this->assertSame('Bijnaam', $nickname['data']['shortDescription'], 'Nickname when Roepnaam is empty'); - $unknown = $engine->mapRow($profile->pack(target: 'usage'), ['Status' => 'Onbekende status'], 3); + $unknown = $engine->mapRow($profile->pack(target: 'usage'), ['Applicatie Status' => 'Onbekende status'], 3); $this->assertArrayNotHasKey('status', $unknown['data']); - $this->assertSame('Status', $unknown['errors'][0]['source']); + $this->assertSame('Applicatie Status', $unknown['errors'][0]['source']); $this->assertStringContainsString('Onbekende status', $unknown['errors'][0]['message']); + + $soort = $engine->mapRow($profile->pack(target: 'module'), ['Applicatie Naam' => 'X', 'APPID' => '1', 'Applicatiesoort' => 'Webapplicatie'], 3); + $this->assertArrayNotHasKey('cloudDienstverleningsmodel', $soort['data'], 'an application kind is not a hosting model'); + $this->assertSame('Applicatiesoort', $soort['errors'][0]['source']); }//end testTheLookupsMapThroughTheEngine() /** - * The read allowlist leaves out personnel numbers, phones, group owners and group mailboxes. + * The read allowlist holds the owner columns but no other person or group column, nor + * the unmapped columns of the CMDB sheets. * * @return void */ @@ -185,22 +215,27 @@ public function testPersonColumnsAreNeverReferenced(): void { foreach ([ 'Personeelsnummer', - 'Eigenaar mobiel nummer', - 'Groepseigenaar mail⚡', - 'Groepseigenaar naam⚡', - 'Groepseigenaar telefoon⚡', - 'Groepsmail⚡', - 'Groepsnummer⚡', - 'Configuratie coördinator⚡', + 'Eigenaar', + 'Eigenaar e-mail', + 'FB contactpersoon 1', 'FB contactpersoon 2', + 'Groepseigenaar mail⚡', + 'Behandelgroep', + 'Hostingpartij', + 'Leverancier', + 'Beschikbaarheid', + 'Rappelreden', 'Opmerkingen', 'municipalityName', + 'Beheer', ] as $never) { $this->assertNotContains($never, $columns); } - $this->assertContains('Eigenaar e-mail', $columns); - $this->assertContains('Eigenaar cluster', $columns, 'the concat field is read too'); + $this->assertContains('Applicatie Eigenaar (Persoon)', $columns); + $this->assertContains('Applicatie Eigenaar (Functie)', $columns); + $this->assertContains('Applicatie Eigenaar (Afdeling)', $columns, 'the concat field is read too'); + $this->assertContains('Cluster', $columns); }//end testPersonColumnsAreNeverReferenced() /** diff --git a/tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php b/tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php index 705e936d..390ffc4e 100644 --- a/tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php +++ b/tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php @@ -35,32 +35,49 @@ class CmdbRowNormaliserTest extends TestCase { public function testSerialDatesAndIdsAreNormalised(): void { $row = (new CmdbRowNormaliser())->normalise( cells: [ - 'Aanmaakdatum' => 45111.380322627316, - 'Wijzigingsdatum' => 46232.552113113423, - 'End of Life Business' => 53359, - 'ICT Applicatienummer' => 1234.0, - 'Middel-ID' => ' APP-test123 ', - 'Naam' => ' naamtest123 ', - 'Fabrikant' => null, + 'Datum' => 45111.380322627316, + 'Referentie datum wijziging' => 46232.552113113423, + 'End-of-Life Functioneel' => 53359, + 'APPID' => 1234.0, + 'Applicatie Code' => ' APP-test123 ', + 'Applicatie Naam' => ' naamtest123 ', + 'Vendor' => null, ], - dateColumns: ['Aanmaakdatum', 'Wijzigingsdatum', 'End of Life Business'], - idColumns: ['Middel-ID', 'ICT Applicatienummer'] + dateColumns: ['Datum', 'Referentie datum wijziging', 'End-of-Life Functioneel'], + idColumns: ['APPID'] ); $this->assertSame( [ - 'Aanmaakdatum' => '2023-07-04', - 'Wijzigingsdatum' => '2026-07-29', - 'End of Life Business' => '2046-02-01', - 'ICT Applicatienummer' => '1234', - 'Middel-ID' => 'APP-test123', - 'Naam' => 'naamtest123', - 'Fabrikant' => '', + 'Datum' => '2023-07-04', + 'Referentie datum wijziging' => '2026-07-29', + 'End-of-Life Functioneel' => '2046-02-01', + 'APPID' => '1234', + 'Applicatie Code' => 'APP-test123', + 'Applicatie Naam' => 'naamtest123', + 'Vendor' => '', ], $row ); }//end testSerialDatesAndIdsAreNormalised() + /** + * A value the profile lists as empty for its column becomes '', case-insensitively and before + * the date conversion; the same value in another column stays. + * + * @return void + */ + public function testEmptyValuesBecomeEmpty(): void { + $row = (new CmdbRowNormaliser())->normalise( + cells: ['BNN Classificatie' => 'nb', 'End-of-Life Functioneel' => 49675, 'Roepnaam' => 'NB', 'Datum' => 49675], + dateColumns: ['End-of-Life Functioneel', 'Datum'], + idColumns: [], + emptyValues: ['BNN Classificatie' => ['NB'], 'End-of-Life Functioneel' => ['49675']] + ); + + $this->assertSame(['BNN Classificatie' => '', 'End-of-Life Functioneel' => '', 'Roepnaam' => 'NB', 'Datum' => '2036-01-01'], $row); + }//end testEmptyValuesBecomeEmpty() + /** * The string forms of serials and ids convert the same way. * diff --git a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php index 696baae8..56d13386 100644 --- a/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php +++ b/tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php @@ -77,7 +77,8 @@ private function read(string $name): array { }//end read() /** - * One data row per source sheet; the formatted empty rows are dropped; only allowlisted columns. + * One data row per CMDB sheet, read from the cached formula values; the formatted + * empty rows and the rows whose formulas cached 0 are dropped; only allowlisted columns. * * @return void */ @@ -86,12 +87,25 @@ public function testTheSanitisedExportYieldsOneRowPerSheet(): void { $rows = $result['rows']; $this->assertCount(2, $rows); - $this->assertSame(['Invoer AIA data', 2], [$rows[0]['sheet'], $rows[0]['row']]); - $this->assertSame(['Invoer APP data', 2], [$rows[1]['sheet'], $rows[1]['row']]); - $this->assertSame('AIA-AangetekendMailen', $rows[0]['cells']['Middel-ID']); - $this->assertSame('Aangetekend Mailen', $rows[0]['cells']['Naam']); - $this->assertSame('naamtest123', $rows[1]['cells']['Naam']); - $this->assertSame(53359, (int)$rows[1]['cells']['End of Life Business']); + $this->assertSame(['Onbeh Applicaties CMDB', 2], [$rows[0]['sheet'], $rows[0]['row']]); + $this->assertSame(['Beheerde Applicaties CMDB', 2], [$rows[1]['sheet'], $rows[1]['row']]); + $this->assertSame(1234, (int)$rows[0]['cells']['APPID']); + $this->assertSame('AIA-AangetekendMailen', $rows[0]['cells']['Applicatie Code']); + $this->assertSame('Aangetekend Mailen', $rows[0]['cells']['Applicatie Naam']); + $this->assertSame('Mailen', $rows[0]['cells']['Roepnaam']); + $this->assertSame('Webapplicatie', $rows[0]['cells']['Applicatiesoort']); + $this->assertSame('Achternaam, Voornaam', $rows[0]['cells']['Applicatie Eigenaar (Persoon)']); + $this->assertArrayNotHasKey('Nickname', $rows[0]['cells'], 'Onbeh has no Nickname column'); + $this->assertSame('naamtest123', $rows[1]['cells']['Applicatie Naam']); + $this->assertSame(2, (int)$rows[1]['cells']['APPID']); + $this->assertSame(53359, (int)$rows[1]['cells']['End-of-Life Functioneel']); + $this->assertSame('Saas', $rows[1]['cells']['Applicatiesoort']); + $this->assertSame('BBN2', $rows[1]['cells']['BNN Classificatie']); + $this->assertSame('Tolereren', $rows[1]['cells']['Classificatie']); + $this->assertSame('NT123', $rows[1]['cells']['Nickname']); + $this->assertSame('Teamleider Applicatiebeheer', $rows[1]['cells']['Applicatie Eigenaar (Persoon)']); + $this->assertSame([], $rows[0]['uncached']); + $this->assertSame([], $rows[1]['uncached']); $allowed = $this->profile()->referencedColumns(); foreach ($rows as $row) { @@ -99,20 +113,18 @@ public function testTheSanitisedExportYieldsOneRowPerSheet(): void { $this->assertContains($column, $allowed); } - foreach (['Personeelsnummer', 'Eigenaar mobiel nummer', 'Groepseigenaar mail', 'Groepsmail', 'Opmerkingen', 'FB contactpersoon 2'] as $never) { + foreach (['Beschikbaarheid', 'Behandelgroep', 'Hostingpartij', 'Rappelreden', 'Locatie BIOToets', 'Beheer'] as $never) { $this->assertArrayNotHasKey($never, $row['cells']); } } $this->assertFalse($result['date1904']); - $this->assertContains( - ['sheet' => 'Invoer AIA data', 'column' => 'ICT TIME Classificatie', 'message' => 'Optional column "ICT TIME Classificatie" not found'], - $result['importWarnings'] - ); + $this->assertSame([], $result['importWarnings'], 'Nickname is listed as absent on Onbeh, so its absence is no warning'); }//end testTheSanitisedExportYieldsOneRowPerSheet() /** - * A formula cell yields the value Excel cached, not its result, and the connection is never contacted. + * A formula cell yields the value Excel cached, not its result; a formula without + * a cached value yields an empty cell and is listed; the connection is never contacted. * * @return void */ @@ -120,8 +132,11 @@ public function testAFormulaYieldsItsCachedValue(): void { $rows = $this->read(name: 'topdesk-formula-and-connection.xlsx')['rows']; // The formula evaluates to "Evaluated"; the cached value is "Rekenmodel". - $this->assertSame('Rekenmodel', $rows[1]['cells']['Naam']); - $this->assertSame('APP-test123', $rows[1]['cells']['Middel-ID']); + $this->assertSame('Rekenmodel', $rows[1]['cells']['Applicatie Naam']); + $this->assertSame('APP-test123', $rows[1]['cells']['Applicatie Code']); + $this->assertNull($rows[1]['cells']['Roepnaam'], 'no cached value: empty, never evaluated'); + $this->assertSame(['Roepnaam'], $rows[1]['uncached']); + $this->assertSame([], $rows[0]['uncached']); }//end testAFormulaYieldsItsCachedValue() /** @@ -161,18 +176,18 @@ public function testShuffledColumnsMapTheSame(): void { }//end testShuffledColumnsMapTheSame() /** - * A source sheet without Middel-ID stops the import, naming column and sheet. + * A CMDB sheet without APPID stops the import, naming column and sheet. * * @return void */ public function testAMissingRequiredColumnIsNamed(): void { try { - $this->read(name: 'topdesk-missing-middel-id.xlsx'); + $this->read(name: 'topdesk-missing-appid.xlsx'); $this->fail('MISSING_COLUMN expected'); } catch (CmdbImportException $e) { $this->assertSame('MISSING_COLUMN', $e->getErrorCode()); $this->assertSame(422, $e->getHttpStatus()); - $this->assertSame(['sheet' => 'Invoer APP data', 'column' => 'Middel-ID'], $e->getDetails()); + $this->assertSame(['sheet' => 'Beheerde Applicaties CMDB', 'column' => 'APPID'], $e->getDetails()); } }//end testAMissingRequiredColumnIsNamed() @@ -187,7 +202,7 @@ public function testAWorkbookWithoutSourceSheetsIsRefused(): void { $this->fail('NO_SOURCE_SHEET expected'); } catch (CmdbImportException $e) { $this->assertSame('NO_SOURCE_SHEET', $e->getErrorCode()); - $this->assertSame(['expected' => ['Invoer AIA data', 'Invoer APP data']], $e->getDetails()); + $this->assertSame(['expected' => ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']], $e->getDetails()); } }//end testAWorkbookWithoutSourceSheetsIsRefused() @@ -229,7 +244,7 @@ public function testTooManyRowsIsRefused(): void { public function testNonXlsxIsRefusedBeforeParsing(): void { $reader = new CmdbWorkbookReader(); $text = tempnam(sys_get_temp_dir(), 'cmdb'); - file_put_contents($text, "Naam;Middel-ID\nVoorbeeld;APP-1\n"); + file_put_contents($text, "Applicatie Naam;APPID\nVoorbeeld;1\n"); $cases = [ [$text, 'export.xlsx'], [CmdbTestSupport::fixtures() . '/topdesk-export-anonymised.xlsx', 'export.xlsm'], @@ -301,8 +316,8 @@ public function isAvailable(): bool { * @return void */ public function testHeadersAreNormalised(): void { - $this->assertSame('groepseigenaar mail', CmdbWorkbookReader::normaliseHeader(header: 'Groepseigenaar mail⚡')); - $this->assertSame('ib bewaartermijn', CmdbWorkbookReader::normaliseHeader(header: ' IB Bewaartermijn: ')); - $this->assertSame('middel-id', CmdbWorkbookReader::normaliseHeader(header: 'MIDDEL-ID')); + $this->assertSame('vendor', CmdbWorkbookReader::normaliseHeader(header: 'Vendor⚡')); + $this->assertSame('applicatie eigenaar (persoon)', CmdbWorkbookReader::normaliseHeader(header: ' Applicatie Eigenaar (Persoon): ')); + $this->assertSame('appid', CmdbWorkbookReader::normaliseHeader(header: 'APPID')); }//end testHeadersAreNormalised() }//end class diff --git a/tests/Unit/Service/CmdbExportImportServiceTest.php b/tests/Unit/Service/CmdbExportImportServiceTest.php index 96a71f77..cde666c2 100644 --- a/tests/Unit/Service/CmdbExportImportServiceTest.php +++ b/tests/Unit/Service/CmdbExportImportServiceTest.php @@ -457,23 +457,25 @@ private function fixture(): string { }//end fixture() /** - * A synthetic application row. + * A synthetic application row of a CMDB sheet. * - * @param string $middelId The Middel-ID. + * @param string $appId The APPID. * @param array $cells Overrides. * @param int $row The row number. * @param string $sheet The sheet. + * @param array $uncached Columns whose formula has no cached value. * - * @return array{sheet: string, row: int, cells: array} + * @return array{sheet: string, row: int, cells: array, uncached: array} */ - private function row(string $middelId, array $cells = [], int $row = 2, string $sheet = 'Invoer APP data'): array { + private function row(string $appId, array $cells = [], int $row = 2, string $sheet = 'Beheerde Applicaties CMDB', array $uncached = []): array { return [ 'sheet' => $sheet, 'row' => $row, 'cells' => array_merge( - ['Soort' => 'Applicatie', 'Middel-ID' => $middelId, 'Naam' => 'Applicatie ' . $middelId, 'Fabrikant' => 'Fabfrikant', 'Status' => 'In productie'], + ['APPID' => $appId, 'Applicatie Code' => 'APP-' . $appId, 'Applicatie Naam' => 'Applicatie ' . $appId, 'Vendor' => 'Fabfrikant', 'Applicatie Status' => 'In productie'], $cells ), + 'uncached' => $uncached, ]; }//end row() @@ -518,10 +520,12 @@ public function testTheFixtureCreatesModulesUsagesAndSuppliers(): void { $this->assertTrue($report['success']); $this->assertFalse($report['cancelled']); $this->assertSame('cmdb-test-0001', $report['operationId']); - $this->assertSame(['rowsRead' => 2, 'processed' => 2, 'created' => 2, 'updated' => 0, 'unchanged' => 0, 'skipped' => 0, 'failed' => 0, 'warnings' => 0], $report['summary']); + $this->assertSame(['rowsRead' => 2, 'processed' => 2, 'created' => 2, 'updated' => 0, 'unchanged' => 0, 'skipped' => 0, 'failed' => 0, 'warnings' => 1], $report['summary']); $this->assertSame('Gemeente Voorbeeldstad', $report['municipality']['name']); $this->assertTrue($report['municipality']['created']); - $this->assertContains(['sheet' => 'Invoer AIA data', 'message' => 'Optional column "ICT TIME Classificatie" not found'], $report['importWarnings']); + $this->assertSame([], $report['importWarnings']); + // "Webapplicatie" is an application kind, not a hosting model: the field is dropped with a warning. + $this->assertSame(['Column "Applicatiesoort": Value "Webapplicatie" has no mapping and no default is configured'], $report['rows'][0]['warnings']); $municipality = $report['municipality']['uuid']; $this->assertSame('Municipality', $this->store[self::ORGANIZATION][$municipality]['type']); @@ -532,43 +536,63 @@ public function testTheFixtureCreatesModulesUsagesAndSuppliers(): void { $modules = []; foreach ($this->objects(self::MODULE) as $module) { - $modules[$module['externalId']] = $module; + $modules[$module['externalNumber']] = $module; } - $this->assertSame(['AIA-AangetekendMailen', 'APP-test123'], array_keys($modules)); - $aia = $modules['AIA-AangetekendMailen']; - $this->assertSame('topdesk:' . $municipality . ':AIA-AangetekendMailen', $aia['externalKey']); - $this->assertSame('Aangetekend Mailen', $aia['name']); - $this->assertSame('Application', $aia['type']); - $this->assertSame('1234', $aia['externalNumber']); - $this->assertSame('2023-07-04', $aia['externalCreatedAt']); - $this->assertSame('2026-07-29', $aia['externalModifiedAt']); - $this->assertSame('Functionele omschrijving test123', $aia['longDescription']); - $publication = new \DateTimeImmutable($aia['publicationDate']); + $this->assertSame(['1234', '2'], array_map('strval', array_keys($modules))); + $onbeh = $modules[1234]; + $this->assertSame('topdesk:' . $municipality . ':1234', $onbeh['externalKey']); + $this->assertSame('AIA-AangetekendMailen', $onbeh['externalId']); + $this->assertSame('Aangetekend Mailen', $onbeh['name']); + $this->assertSame('Mailen', $onbeh['shortDescription']); + $this->assertSame('Application', $onbeh['type']); + $this->assertSame('2023-07-04', $onbeh['externalCreatedAt']); + $this->assertSame('2026-07-29', $onbeh['externalModifiedAt']); + $this->assertSame('Functionele omschrijving test123', $onbeh['longDescription']); + $this->assertArrayNotHasKey('bbnLevel', $onbeh, '"NB" means unknown'); + $this->assertArrayNotHasKey('cloudDienstverleningsmodel', $onbeh); + $publication = new \DateTimeImmutable($onbeh['publicationDate']); $this->assertGreaterThanOrEqual($before, $publication); $this->assertLessThanOrEqual(new \DateTimeImmutable('now'), $publication); - $this->assertSame($aia['publicationDate'], $modules['APP-test123']['publicationDate'], 'one start time for the whole import'); - $this->assertSame('2', $modules['APP-test123']['externalNumber']); + + $beheerd = $modules[2]; + $this->assertSame($onbeh['publicationDate'], $beheerd['publicationDate'], 'one start time for the whole import'); + $this->assertSame('topdesk:' . $municipality . ':2', $beheerd['externalKey']); + $this->assertSame('APP-test123', $beheerd['externalId']); + $this->assertSame('naamtest123', $beheerd['name']); + $this->assertSame('Naamtest', $beheerd['shortDescription'], 'Roepnaam wins over Nickname'); + $this->assertSame('Accomodatieplanning.', $beheerd['longDescription']); + $this->assertSame(['SaaS'], $beheerd['cloudDienstverleningsmodel']); + $this->assertSame('BBN2', $beheerd['bbnLevel']); $supplierByName = array_column($suppliers, 'id', 'name'); - $this->assertSame($supplierByName['Aangetekend B.V.'], $aia['provider']); - $this->assertSame($supplierByName['Fabfrikant'], $modules['APP-test123']['provider']); + $this->assertSame($supplierByName['Aangetekend B.V.'], $onbeh['provider']); + $this->assertSame($supplierByName['Fabfrikant'], $beheerd['provider']); $usages = $this->objects(self::USAGE); $this->assertCount(2, $usages); $usageByModule = array_column($usages, null, 'module'); - $this->assertSame($municipality, $usageByModule[$aia['id']]['consumer']); - $this->assertSame('Planned', $usageByModule[$aia['id']]['status']); - $this->assertSame('H10 Accounting / H10 Bestuurs- en Concernondersteuning', $usageByModule[$aia['id']]['interneAnnotation']); - $this->assertSame($supplierByName['Aangetekend B.V.'], $usageByModule[$aia['id']]['provider']); - $app = $usageByModule[$modules['APP-test123']['id']]; + $aia = $usageByModule[$onbeh['id']]; + $this->assertSame($municipality, $aia['consumer']); + $this->assertSame('Planned', $aia['status']); + $this->assertSame('Beheer geregeld: nee / H10 / H10 Accounting', $aia['interneAnnotation']); + $this->assertArrayNotHasKey('startDateOutPhased', $aia, 'the CMDB placeholder 2036-01-01 means no date'); + $this->assertArrayNotHasKey('timeClassification', $aia); + $this->assertSame($supplierByName['Aangetekend B.V.'], $aia['provider']); + $app = $usageByModule[$beheerd['id']]; $this->assertSame('In production', $app['status']); + $this->assertSame('Tolerate', $app['timeClassification']); $this->assertSame('2046-02-01', $app['startDateOutPhased']); - $this->assertArrayNotHasKey('businessOwner', $app, 'the APP row names no owner'); + $this->assertSame('Beheer geregeld: ja / B10 / B10 Maatschappelijke Ontwikkeling', $app['interneAnnotation']); + $this->assertArrayNotHasKey('technicalOwner', $app); - $this->assertSame($aia['id'], $report['rows'][0]['moduleUuid']); - $this->assertSame($usageByModule[$aia['id']]['id'], $report['rows'][0]['usageUuid']); - $this->assertSame(['Invoer AIA data', 2, 'AIA-AangetekendMailen', 'Aangetekend Mailen', 'created'], [$report['rows'][0]['sheet'], $report['rows'][0]['row'], $report['rows'][0]['middelId'], $report['rows'][0]['name'], $report['rows'][0]['outcome']]); + $this->assertSame($onbeh['id'], $report['rows'][0]['moduleUuid']); + $this->assertSame($aia['id'], $report['rows'][0]['usageUuid']); + $this->assertSame( + ['Onbeh Applicaties CMDB', 2, '1234', 'Aangetekend Mailen', 'created'], + [$report['rows'][0]['sheet'], $report['rows'][0]['row'], $report['rows'][0]['appId'], $report['rows'][0]['name'], $report['rows'][0]['outcome']] + ); + $this->assertSame('Beheerde Applicaties CMDB', $report['rows'][1]['sheet']); }//end testTheFixtureCreatesModulesUsagesAndSuppliers() /** @@ -595,13 +619,34 @@ public function testReimportingTheSameExportChangesNothing(): void { }//end testReimportingTheSameExportChangesNothing() /** - * A changed Naam updates the module; website, publicationDate and depublicationDate stay. + * The match key is the APPID: a changed Applicatie Code updates the same module. + * + * @return void + */ + public function testTheKeyIsTheAppIdNotTheCode(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '42', cells: ['Applicatie Code' => 'APP-Oud'])])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + $uuid = array_key_first($this->store[self::MODULE]); + + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '42', cells: ['Applicatie Code' => 'App-Nieuw'])])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame('updated', $report['rows'][0]['outcome']); + $this->assertCount(1, $this->store[self::MODULE]); + $this->assertSame('App-Nieuw', $this->store[self::MODULE][$uuid]['externalId']); + $this->assertSame('topdesk:muni-1:42', $this->store[self::MODULE][$uuid]['externalKey']); + $this->assertSame('42', $this->store[self::MODULE][$uuid]['externalNumber']); + }//end testTheKeyIsTheAppIdNotTheCode() + + /** + * A changed Applicatie Naam updates the module; website, publicationDate and depublicationDate stay. * * @return void */ public function testAChangedNameUpdatesOnlyTheMappedFields(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); - $service = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-test123', cells: ['Naam' => 'naamtest123'])])); + $service = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '2', cells: ['Applicatie Naam' => 'naamtest123'])])); $service->import(path: '', options: ['municipalityUuid' => 'muni-1']); $uuid = array_key_first($this->store[self::MODULE]); @@ -609,7 +654,7 @@ public function testAChangedNameUpdatesOnlyTheMappedFields(): void { $this->store[self::MODULE][$uuid]['depublicationDate'] = '2026-10-02T00:00:00+00:00'; $published = $this->store[self::MODULE][$uuid]['publicationDate']; - $service = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-test123', cells: ['Naam' => 'naamtest124'])])); + $service = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '2', cells: ['Applicatie Naam' => 'naamtest124'])])); $report = $service->import(path: '', options: ['municipalityUuid' => 'muni-1']); $this->assertSame('updated', $report['rows'][0]['outcome']); @@ -629,14 +674,14 @@ public function testAChangedNameUpdatesOnlyTheMappedFields(): void { */ public function testAnUpdateNeverWritesPublicationDate(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); - $this->store[self::MODULE]['mod-1'] = ['id' => 'mod-1', 'name' => 'Oud', 'externalKey' => 'topdesk:muni-1:APP-1']; + $this->store[self::MODULE]['mod-1'] = ['id' => 'mod-1', 'name' => 'Oud', 'externalKey' => 'topdesk:muni-1:1']; - $report = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')]))->import(path: '', options: ['municipalityUuid' => 'muni-1']); $this->assertSame('updated', $report['rows'][0]['outcome']); $this->assertArrayNotHasKey('publicationDate', $this->store[self::MODULE]['mod-1']); $this->assertArrayNotHasKey('type', $this->store[self::MODULE]['mod-1'], 'type is create-only'); - $this->assertSame('Applicatie APP-1', $this->store[self::MODULE]['mod-1']['name']); + $this->assertSame('Applicatie 1', $this->store[self::MODULE]['mod-1']['name']); }//end testAnUpdateNeverWritesPublicationDate() /** @@ -648,7 +693,7 @@ public function testTheMunicipalityMustBeAMunicipality(): void { $this->seedOrganisation(uuid: 'supplier-1', name: 'Voorbeeld Software B.V.', type: 'Supplier'); foreach ([['municipalityUuid' => 'supplier-1'], ['municipalityUuid' => 'unknown-uuid']] as $options) { try { - $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]))->import(path: '', options: $options); + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')]))->import(path: '', options: $options); $this->fail('MUNICIPALITY_INVALID expected'); } catch (CmdbImportException $e) { $this->assertSame('MUNICIPALITY_INVALID', $e->getErrorCode()); @@ -657,7 +702,7 @@ public function testTheMunicipalityMustBeAMunicipality(): void { } try { - $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]))->import(path: '', options: ['municipalityName' => ' ']); + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')]))->import(path: '', options: ['municipalityName' => ' ']); $this->fail('MUNICIPALITY_REQUIRED expected'); } catch (CmdbImportException $e) { $this->assertSame('MUNICIPALITY_REQUIRED', $e->getErrorCode()); @@ -671,15 +716,15 @@ public function testTheMunicipalityMustBeAMunicipality(): void { * * @return void */ - public function testAManufacturerIsOneSupplier(): void { + public function testAVendorIsOneSupplier(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); $this->seedOrganisation(uuid: 'aangetekend', name: 'Aangetekend B.V.', type: 'Supplier'); $rows = [ - $this->row(middelId: 'APP-1', cells: ['Fabrikant' => 'Fabfrikant'], row: 2), - $this->row(middelId: 'APP-2', cells: ['Fabrikant' => 'Fabfrikant '], row: 3), - $this->row(middelId: 'APP-3', cells: ['Fabrikant' => 'FABFRIKANT'], row: 4), - $this->row(middelId: 'APP-4', cells: ['Fabrikant' => 'aangetekend b.v.'], row: 5), - $this->row(middelId: 'APP-5', cells: ['Fabrikant' => ''], row: 6), + $this->row(appId: '1', cells: ['Vendor' => 'Fabfrikant'], row: 2), + $this->row(appId: '2', cells: ['Vendor' => 'Fabfrikant '], row: 3), + $this->row(appId: '3', cells: ['Vendor' => 'FABFRIKANT'], row: 4), + $this->row(appId: '4', cells: ['Vendor' => 'aangetekend b.v.'], row: 5), + $this->row(appId: '5', cells: ['Vendor' => ''], row: 6), ]; $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); @@ -688,9 +733,9 @@ public function testAManufacturerIsOneSupplier(): void { $suppliers = array_filter($this->objects(self::ORGANIZATION), fn (array $o): bool => $o['type'] === 'Supplier'); $this->assertCount(2, $suppliers); $fabfrikant = array_values(array_filter($suppliers, fn (array $o): bool => $o['name'] === 'Fabfrikant'))[0]['id']; - $providers = array_column($this->objects(self::MODULE), 'provider', 'externalId'); - $this->assertSame(['APP-1' => $fabfrikant, 'APP-2' => $fabfrikant, 'APP-3' => $fabfrikant, 'APP-4' => 'aangetekend'], $providers); - }//end testAManufacturerIsOneSupplier() + $providers = array_column($this->objects(self::MODULE), 'provider', 'externalNumber'); + $this->assertSame([1 => $fabfrikant, 2 => $fabfrikant, 3 => $fabfrikant, 4 => 'aangetekend'], $providers); + }//end testAVendorIsOneSupplier() /** * updateExisting=false reports a match as skipped "exists" and writes nothing. @@ -699,10 +744,10 @@ public function testAManufacturerIsOneSupplier(): void { */ public function testUpdateExistingFalseSkipsMatches(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); - $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')]))->import(path: '', options: ['municipalityUuid' => 'muni-1']); $saves = count($this->saves); - $report = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: ['Naam' => 'Anders'])])) + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: ['Applicatie Naam' => 'Anders'])])) ->import(path: '', options: ['municipalityUuid' => 'muni-1', 'updateExisting' => false]); $this->assertSame('skipped', $report['rows'][0]['outcome']); @@ -717,16 +762,16 @@ public function testUpdateExistingFalseSkipsMatches(): void { */ public function testRecordsMissingFromTheExportStay(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); - $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', row: 2), $this->row(middelId: 'AIA-1', row: 3)])) + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', row: 2), $this->row(appId: '7', row: 3, sheet: 'Onbeh Applicaties CMDB')])) ->import(path: '', options: ['municipalityUuid' => 'muni-1']); $modules = $this->store[self::MODULE]; $usages = $this->store[self::USAGE]; - $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: ['Naam' => 'Nieuw'])])) + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: ['Applicatie Naam' => 'Nieuw'])])) ->import(path: '', options: ['municipalityUuid' => 'muni-1']); foreach ($modules as $uuid => $module) { - if ($module['externalId'] === 'AIA-1') { + if ($module['externalNumber'] === '7') { $this->assertSame($module, $this->store[self::MODULE][$uuid]); } } @@ -736,18 +781,18 @@ public function testRecordsMissingFromTheExportStay(): void { }//end testRecordsMissingFromTheExportStay() /** - * An unknown Status drops only that field and warns with column and value. + * An unknown Applicatie Status drops only that field and warns with column and value. * * @return void */ public function testAnUnknownStatusDropsOnlyThatField(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); - $report = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: ['Status' => 'Onbekende status'])])) + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: ['Applicatie Status' => 'Onbekende status'])])) ->import(path: '', options: ['municipalityUuid' => 'muni-1']); $this->assertSame('created', $report['rows'][0]['outcome']); $this->assertCount(1, $report['rows'][0]['warnings']); - $this->assertStringContainsString('"Status"', $report['rows'][0]['warnings'][0]); + $this->assertStringContainsString('"Applicatie Status"', $report['rows'][0]['warnings'][0]); $this->assertStringContainsString('Onbekende status', $report['rows'][0]['warnings'][0]); $this->assertSame(1, $report['summary']['warnings']); $usage = $this->objects(self::USAGE)[0]; @@ -756,7 +801,66 @@ public function testAnUnknownStatusDropsOnlyThatField(): void { }//end testAnUnknownStatusDropsOnlyThatField() /** - * A test-only module pack that maps Roepnaam to shortDescription changes the import without code. + * The sheet a row comes from records whether maintenance is arranged, in the usage's internal note; + * empty Cluster or Afdeling leave no empty part behind. + * + * @return void + */ + public function testTheSheetRecordsWhetherMaintenanceIsArranged(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $rows = [ + $this->row(appId: '1', cells: ['Cluster' => 'H10', 'Applicatie Eigenaar (Afdeling)' => 'H10 Accounting'], sheet: 'Onbeh Applicaties CMDB'), + $this->row(appId: '2', cells: ['Cluster' => '', 'Applicatie Eigenaar (Afdeling)' => 'B10 Ontwikkeling'], row: 3), + $this->row(appId: '3', cells: ['Cluster' => '', 'Applicatie Eigenaar (Afdeling)' => ''], row: 4), + ]; + + $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $notes = array_column($this->objects(self::USAGE), 'interneAnnotation'); + $this->assertSame(['Beheer geregeld: nee / H10 / H10 Accounting', 'Beheer geregeld: ja / B10 Ontwikkeling', 'Beheer geregeld: ja'], $notes); + }//end testTheSheetRecordsWhetherMaintenanceIsArranged() + + /** + * "NB" in BNN Classificatie and the CMDB end-of-life placeholder (serial 49675) mean empty: no field, no warning. + * + * @return void + */ + public function testTheCmdbPlaceholdersMeanEmpty(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $rows = [ + $this->row(appId: '1', cells: ['BNN Classificatie' => 'NB', 'End-of-Life Functioneel' => 49675]), + $this->row(appId: '2', cells: ['BNN Classificatie' => 'BBN 3', 'End-of-Life Functioneel' => 53359, 'Classificatie' => 'Migreren'], row: 3), + ]; + + $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame(0, $report['summary']['warnings']); + $modules = array_column($this->objects(self::MODULE), null, 'externalNumber'); + $this->assertArrayNotHasKey('bbnLevel', $modules[1]); + $this->assertSame('BBN3', $modules[2]['bbnLevel']); + $usages = array_column($this->objects(self::USAGE), null, 'module'); + $this->assertArrayNotHasKey('startDateOutPhased', $usages[$modules[1]['id']]); + $this->assertSame('2046-02-01', $usages[$modules[2]['id']]['startDateOutPhased']); + $this->assertSame('Migrate', $usages[$modules[2]['id']]['timeClassification']); + }//end testTheCmdbPlaceholdersMeanEmpty() + + /** + * A formula without a cached value reads as empty and warns on its row; the row is still imported. + * + * @return void + */ + public function testAFormulaWithoutACachedValueWarns(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: ['Roepnaam' => null], uncached: ['Roepnaam'])])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertSame('created', $report['rows'][0]['outcome']); + $this->assertSame(['Column "Roepnaam": formula without a cached value, read as empty'], $report['rows'][0]['warnings']); + $this->assertArrayNotHasKey('shortDescription', $this->objects(self::MODULE)[0]); + }//end testAFormulaWithoutACachedValueWarns() + + /** + * A test-only module pack that maps one more column changes the import without code. * * @return void */ @@ -768,27 +872,28 @@ public function testAPackChangeChangesTheMapping(): void { } $pack = json_decode((string)file_get_contents($directory . '/topdesk-module.json'), true); - $pack['fieldMappings'][] = ['source' => 'Roepnaam', 'target' => 'shortDescription', 'transform' => ['type' => 'trim']]; + $pack['fieldMappings'][] = ['source' => 'Software Suite', 'target' => 'licentietype', 'transform' => ['type' => 'trim']]; file_put_contents($directory . '/topdesk-module.json', json_encode($pack)); try { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); - $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'AIA-AangetekendMailen', cells: ['Roepnaam' => 'Mailen'])]), profileDir: $directory) + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: ['Software Suite' => 'Suite'])]), profileDir: $directory) ->import(path: '', options: ['municipalityUuid' => 'muni-1']); } finally { array_map('unlink', glob($directory . '/*.json')); rmdir($directory); } - $this->assertSame('Mailen', $this->objects(self::MODULE)[0]['shortDescription']); + $this->assertSame('Suite', $this->objects(self::MODULE)[0]['licentietype']); }//end testAPackChangeChangesTheMapping() // ------------------------------------------------------------------ - // Task 6: owners as contact persons + // Task 6: the owner as contact person // ------------------------------------------------------------------ /** - * The AIA owner becomes the usage's business owner: one contact person of the municipality, role Afdelingshoofd. + * Each row's Applicatie Eigenaar (Persoon) becomes the usage's business owner, by display name; a + * function in that column is used as the display name too; the function becomes the role. * * @return void */ @@ -797,17 +902,22 @@ public function testTheOwnerBecomesTheBusinessOwner(): void { $report = $this->service()->import(path: $path, options: ['municipalityName' => 'Gemeente Voorbeeldstad']); $municipality = $report['municipality']['uuid']; - $this->assertCount(1, $this->contacts); - $contactsUid = array_key_first($this->contacts); - $this->assertSame('letter.achternaam@gemeente.nl', $this->contacts[$contactsUid]['email']); - $this->assertSame('Voornaam Achternaam', $this->contacts[$contactsUid]['name']); + $this->assertEqualsCanonicalizing(['Voornaam Achternaam', 'Teamleider Applicatiebeheer'], array_column($this->contacts, 'name')); + $this->assertSame(['', ''], array_column($this->contacts, 'email'), 'the CMDB sheets carry no e-mail address'); $people = $this->objects(self::CONTACT_PERSON); - $this->assertCount(1, $people); - $this->assertSame(['contactsUid' => $contactsUid, 'organization' => $municipality, 'role' => 'Afdelingshoofd'], array_diff_key($people[0], ['id' => true])); + $this->assertCount(2, $people); + $uidByName = array_flip(array_map(fn (array $c): string => $c['name'], $this->contacts)); + $byUid = array_column($people, null, 'contactsUid'); + $this->assertSame( + ['contactsUid' => $uidByName['Voornaam Achternaam'], 'organization' => $municipality, 'role' => 'Afdelingshoofd'], + array_diff_key($byUid[$uidByName['Voornaam Achternaam']], ['id' => true]) + ); + $this->assertSame('Teamleider Applicatiebeheer', $byUid[$uidByName['Teamleider Applicatiebeheer']]['role']); - $usage = array_column($this->objects(self::USAGE), null, 'module')[$report['rows'][0]['moduleUuid']]; - $this->assertSame($people[0]['id'], $usage['businessOwner']); + $usages = array_column($this->objects(self::USAGE), null, 'module'); + $this->assertSame($byUid[$uidByName['Voornaam Achternaam']]['id'], $usages[$report['rows'][0]['moduleUuid']]['businessOwner']); + $this->assertSame($byUid[$uidByName['Teamleider Applicatiebeheer']]['id'], $usages[$report['rows'][1]['moduleUuid']]['businessOwner']); }//end testTheOwnerBecomesTheBusinessOwner() /** @@ -817,8 +927,8 @@ public function testTheOwnerBecomesTheBusinessOwner(): void { */ public function testTheSameOwnerOnTwoRowsIsOneContactPerson(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); - $owner = ['Eigenaar' => 'Achternaam, Voornaam', 'Eigenaar e-mail' => 'letter.achternaam@gemeente.nl', 'Eigenaar functie' => 'Afdelingshoofd']; - $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: $owner, row: 2), $this->row(middelId: 'APP-2', cells: $owner, row: 3)])) + $owner = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (Functie)' => 'Afdelingshoofd']; + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: $owner, row: 2), $this->row(appId: '2', cells: $owner, row: 3)])) ->import(path: '', options: ['municipalityUuid' => 'muni-1']); $this->assertCount(1, $this->objects(self::CONTACT_PERSON)); @@ -827,15 +937,15 @@ public function testTheSameOwnerOnTwoRowsIsOneContactPerson(): void { }//end testTheSameOwnerOnTwoRowsIsOneContactPerson() /** - * A technical owner without an e-mail, imported twice, is one contact and one contact person. + * An owner imported twice is one contact and one contact person; a near-namesake is not reused. * * @return void */ - public function testATechnicalOwnerByNameIsMatchedExactly(): void { + public function testAnOwnerByNameIsMatchedExactly(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); // A contact whose name merely contains the owner's name must not match. $this->contacts['contact-other'] = ['name' => 'Voornaam Achternaam-Anders', 'email' => '']; - $rows = [$this->row(middelId: 'APP-1', cells: ['FB contactpersoon 1' => 'Achternaam, Voornaam'])]; + $rows = [$this->row(appId: '1', cells: ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam'])]; $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); @@ -845,11 +955,27 @@ public function testATechnicalOwnerByNameIsMatchedExactly(): void { $this->assertCount(1, $people); $this->assertNotSame('contact-other', $people[0]['contactsUid']); $this->assertArrayNotHasKey('role', $people[0]); - $this->assertSame($people[0]['id'], $this->objects(self::USAGE)[0]['technicalOwner']); - }//end testATechnicalOwnerByNameIsMatchedExactly() + $this->assertSame($people[0]['id'], $this->objects(self::USAGE)[0]['businessOwner']); + }//end testAnOwnerByNameIsMatchedExactly() + + /** + * No technical owner is written, whatever the row holds. + * + * @return void + */ + public function testNoTechnicalOwnerIsWritten(): void { + $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: ['FB contactpersoon 1' => 'Achternaam, Voornaam'])])) + ->import(path: '', options: ['municipalityUuid' => 'muni-1']); + + $this->assertArrayNotHasKey('technicalOwner', $this->objects(self::USAGE)[0]); + $this->assertArrayNotHasKey('businessOwner', $this->objects(self::USAGE)[0]); + $this->assertSame([], $this->objects(self::CONTACT_PERSON)); + $this->assertSame([], $this->contacts); + }//end testNoTechnicalOwnerIsWritten() /** - * With Contacts disabled the modules and usages are saved without owners, with a warning on the row that has owners. + * With Contacts disabled the modules and usages are saved without owners, with a warning on each row that has an owner. * * @return void */ @@ -861,8 +987,8 @@ public function testContactsDisabledDoesNotBlockTheImport(): void { $this->assertSame(2, $report['summary']['created']); $this->assertCount(2, $this->objects(self::USAGE)); $this->assertSame([], $this->objects(self::CONTACT_PERSON)); - $this->assertSame(['Owners skipped: Nextcloud Contacts is unavailable'], $report['rows'][0]['warnings']); - $this->assertSame([], $report['rows'][1]['warnings'], 'the APP row names no owner'); + $this->assertContains('Owners skipped: Nextcloud Contacts is unavailable', $report['rows'][0]['warnings']); + $this->assertSame(['Owners skipped: Nextcloud Contacts is unavailable'], $report['rows'][1]['warnings']); }//end testContactsDisabledDoesNotBlockTheImport() /** @@ -872,7 +998,7 @@ public function testContactsDisabledDoesNotBlockTheImport(): void { */ public function testAnImportedContactPersonIsNeverAUser(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); - $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: ['Eigenaar' => 'Achternaam, Voornaam', 'Eigenaar e-mail' => 'letter.achternaam@gemeente.nl', 'FB contactpersoon 1' => 'Achternaam, Voornaam'])])) + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (Functie)' => 'Afdelingshoofd'])])) ->import(path: '', options: ['municipalityUuid' => 'muni-1']); $people = $this->objects(self::CONTACT_PERSON); @@ -890,23 +1016,23 @@ public function testAnImportedContactPersonIsNeverAUser(): void { }//end testAnImportedContactPersonIsNeverAUser() /** - * Neither the report nor any log line names an owner or an e-mail address. + * Neither the report nor any log line names an owner. * * @return void */ public function testNoPersonDataInReportOrLog(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); - $owner = ['Eigenaar' => 'Achternaam, Voornaam', 'Eigenaar e-mail' => 'letter.achternaam@gemeente.nl', 'FB contactpersoon 1' => 'Achternaam, Voornaam']; + $owner = ['Applicatie Eigenaar (Persoon)' => 'Achternaam, Voornaam', 'Applicatie Eigenaar (Functie)' => 'Afdelingshoofd']; $this->beforeSave = function (int $schema, array $data): void { if ($schema === self::USAGE && ($data['module'] ?? '') !== '' && count($this->objects(self::USAGE)) === 1) { throw new RuntimeException('usage refused'); } }; - $report = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1', cells: $owner, row: 2), $this->row(middelId: 'APP-2', cells: $owner, row: 3)])) + $report = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1', cells: $owner, row: 2), $this->row(appId: '2', cells: $owner, row: 3)])) ->import(path: '', options: ['municipalityUuid' => 'muni-1']); $text = json_encode($report, JSON_UNESCAPED_UNICODE) . "\n" . implode("\n", $this->logLines); - foreach (['Achternaam', 'Voornaam', 'letter.achternaam', '@gemeente.nl'] as $personData) { + foreach (['Achternaam', 'Voornaam'] as $personData) { $this->assertStringNotContainsString($personData, $text); } @@ -925,11 +1051,11 @@ public function testNoPersonDataInReportOrLog(): void { public function testOneBadRowDoesNotStopTheOthers(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); $this->beforeSave = function (int $schema, array $data): void { - if ($schema === self::MODULE && ($data['externalId'] ?? '') === 'APP-2') { + if ($schema === self::MODULE && ($data['externalNumber'] ?? '') === '2') { throw new RuntimeException('Validation failed for name'); } }; - $rows = [$this->row(middelId: 'APP-1', row: 2), $this->row(middelId: 'APP-2', row: 3), $this->row(middelId: 'APP-3', row: 4)]; + $rows = [$this->row(appId: '1', row: 2), $this->row(appId: '2', row: 3), $this->row(appId: '3', row: 4)]; $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); @@ -940,19 +1066,18 @@ public function testOneBadRowDoesNotStopTheOthers(): void { }//end testOneBadRowDoesNotStopTheOthers() /** - * Duplicate Middel-IDs, a missing Middel-ID, a missing Naam and another Soort are skipped with their reasons. + * A duplicate APPID (also across the two sheets), a missing APPID and a missing Applicatie Naam are skipped with their reasons. * * @return void */ public function testRowsAreSkippedWithTheirReasons(): void { $this->seedOrganisation(uuid: 'muni-1', name: 'Gemeente Voorbeeldstad', type: 'Municipality'); $rows = [ - $this->row(middelId: 'APP-test123', row: 2), - $this->row(middelId: 'APP-test123', row: 7), - $this->row(middelId: '', row: 8), - $this->row(middelId: 'HW-1', cells: ['Soort' => 'Hardware'], row: 9), - $this->row(middelId: 'APP-9', cells: ['Naam' => ' '], row: 10), - $this->row(middelId: 'APP-test123', cells: ['Soort' => 'Application Inventory'], row: 2, sheet: 'Invoer AIA data'), + $this->row(appId: '2', row: 2), + $this->row(appId: '2', row: 7), + $this->row(appId: '', row: 8), + $this->row(appId: '9', cells: ['Applicatie Naam' => ' '], row: 10), + $this->row(appId: '2', row: 2, sheet: 'Onbeh Applicaties CMDB'), ]; $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1']); @@ -960,11 +1085,10 @@ public function testRowsAreSkippedWithTheirReasons(): void { $this->assertSame( [ ['created', []], - ['skipped', ['duplicate Middel-ID in file']], - ['skipped', ['missing Middel-ID']], - ['skipped', ['unsupported Soort "Hardware"']], - ['skipped', ['missing Naam']], - ['skipped', ['duplicate Middel-ID in file']], + ['skipped', ['duplicate APPID in file']], + ['skipped', ['missing APPID']], + ['skipped', ['missing Applicatie Naam']], + ['skipped', ['duplicate APPID in file']], ], array_map(fn (array $row): array => [$row['outcome'], $row['reasons']], $report['rows']) ); @@ -984,7 +1108,7 @@ public function testProgressIsRecordedAndHoldsTheReport(): void { $seen[] = $this->tracker->getProgress(operationId: 'cmdb-progress-1')['processed_items']; } }; - $rows = [$this->row(middelId: 'APP-1', row: 2), $this->row(middelId: 'APP-2', row: 3)]; + $rows = [$this->row(appId: '1', row: 2), $this->row(appId: '2', row: 3)]; $report = $this->service(reader: $this->rowsReader(rows: $rows))->import(path: '', options: ['municipalityUuid' => 'muni-1', 'operationId' => 'cmdb-progress-1']); @@ -1008,7 +1132,7 @@ public function testACancelStopsBetweenRows(): void { $this->assertTrue($service->requestCancel(operationId: 'cmdb-cancel-01')); } }; - $rows = [$this->row(middelId: 'APP-1', row: 2), $this->row(middelId: 'APP-2', row: 3), $this->row(middelId: 'APP-3', row: 4)]; + $rows = [$this->row(appId: '1', row: 2), $this->row(appId: '2', row: 3), $this->row(appId: '3', row: 4)]; $service = $this->service(reader: $this->rowsReader(rows: $rows)); $report = $service->import(path: '', options: ['municipalityUuid' => 'muni-1', 'operationId' => 'cmdb-cancel-01']); @@ -1043,14 +1167,14 @@ public function testCancelNeedsACmdbOperation(): void { */ public function testMissingEngineOrConfigurationStopsBeforeReading(): void { try { - $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')]), config: [])->import(path: '', options: ['municipalityName' => 'Gemeente Voorbeeldstad']); + $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')]), config: [])->import(path: '', options: ['municipalityName' => 'Gemeente Voorbeeldstad']); $this->fail('NOT_CONFIGURED expected'); } catch (CmdbImportException $e) { $this->assertSame('NOT_CONFIGURED', $e->getErrorCode()); $this->assertSame(503, $e->getHttpStatus()); } - $base = $this->service(reader: $this->rowsReader(rows: [$this->row(middelId: 'APP-1')])); + $base = $this->service(reader: $this->rowsReader(rows: [$this->row(appId: '1')])); $reflection = new \ReflectionClass($base); $args = []; foreach ($reflection->getConstructor()->getParameters() as $parameter) { diff --git a/tests/Unit/Settings/CmdbPersonDataVisibilityTest.php b/tests/Unit/Settings/CmdbPersonDataVisibilityTest.php new file mode 100644 index 00000000..ccb8d6f2 --- /dev/null +++ b/tests/Unit/Settings/CmdbPersonDataVisibilityTest.php @@ -0,0 +1,134 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Settings; + +use OCA\Stackiq\Service\SettingsService; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * Pins the register rules that keep imported owners out of public reads. + */ +class CmdbPersonDataVisibilityTest extends TestCase { + /** + * The register after merging every fragment in sorted filename order. + * + * @return array + */ + private function mergedRegister(): array { + $dir = __DIR__ . '/../../../lib/Settings'; + $register = json_decode((string)file_get_contents($dir . '/softwarecatalogus_register.json'), true); + $merge = new ReflectionMethod(SettingsService::class, 'deepMergeConfig'); + + $files = glob($dir . '/register.d/*.json'); + sort($files); + foreach ($files as $file) { + $register = $merge->invoke(null, $register, json_decode((string)file_get_contents($file), true)); + } + + return $register; + }//end mergedRegister() + + /** + * Whether a read rule lets the public group in. + * + * @param mixed $rule A string group or a {group, match} rule. + * + * @return bool + */ + private static function isPublic(mixed $rule): bool { + if (is_string($rule) === true) { + return $rule === 'public'; + } + + return is_array($rule) === true && ($rule['group'] ?? null) === 'public'; + }//end isPublic() + + /** + * Neither usage nor contactPerson can be read anonymously. + * + * @return void + */ + public function testUsageAndContactPersonHaveNoPublicReadRule(): void { + $schemas = $this->mergedRegister()['components']['schemas']; + + foreach (['usage', 'contactPerson'] as $schema) { + $read = ($schemas[$schema]['authorization']['read'] ?? null); + $this->assertIsArray($read, $schema . ' must have an explicit read rule; without one OpenRegister does not restrict reads'); + $this->assertNotEmpty($read, $schema); + foreach ($read as $rule) { + $this->assertFalse(self::isPublic(rule: $rule), $schema . ' has a public read rule: imported owners would be readable anonymously'); + } + } + }//end testUsageAndContactPersonHaveNoPublicReadRule() + + /** + * A published module refers to its contact person and usages by relation only, and holds no person field. + * + * @return void + */ + public function testAModuleOnlyRefersToPeopleByRelation(): void { + $module = $this->mergedRegister()['components']['schemas']['module']; + + $this->assertTrue( + array_filter($module['authorization']['read'], fn (mixed $rule): bool => self::isPublic(rule: $rule)) !== [], + 'modules are public once published; that is why the person data must stay on other schemas' + ); + $this->assertSame('#/components/schemas/contactPerson', $module['properties']['contactPerson']['$ref']); + $this->assertSame('#/components/schemas/usage', $module['properties']['usages']['$ref']); + foreach (['businessOwner', 'technicalOwner', 'email', 'owner', 'eigenaar'] as $field) { + $this->assertArrayNotHasKey($field, $module['properties'], 'module.' . $field . ' would be public'); + } + }//end testAModuleOnlyRefersToPeopleByRelation() + + /** + * The import writes no person data onto a module; only the owner pack reads a person column. + * + * @return void + */ + public function testTheImportWritesNoPersonDataOntoAModule(): void { + $dir = __DIR__ . '/../../../lib/Settings/cmdb-import'; + $person = ['Applicatie Eigenaar (Persoon)', 'Applicatie Eigenaar (Functie)']; + + foreach (glob($dir . '/topdesk-*.json') as $file) { + $pack = json_decode((string)file_get_contents($file), true); + foreach (($pack['fieldMappings'] ?? []) as $mapping) { + if (basename($file) === 'topdesk-module.json') { + $this->assertNotContains($mapping['target'], ['contactPerson', 'usages'], 'the module pack writes ' . $mapping['target']); + } + + if (in_array($mapping['source'], $person, true) === true) { + $this->assertSame('topdesk-business-owner.json', basename($file), $mapping['source'] . ' is read outside the owner pack'); + } + } + } + }//end testTheImportWritesNoPersonDataOntoAModule() +}//end class diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts index cfd0146a..c9129ffd 100644 --- a/tests/e2e/spec-coverage/cmdb-import.spec.ts +++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -21,15 +21,20 @@ * * Owner contacts the import creates in the admin's Nextcloud address book * are not removed by the cleanup below; the OpenRegister objects are. + * + * The last test checks, without signing in, that the imported owners are + * not readable anonymously: neither through OpenRegister's objects API nor + * in an OpenCatalogi search hit (skipped when OpenCatalogi is not installed). */ import type { APIRequestContext, Locator, Page, Response } from '@playwright/test' import type { VoorzieningenConfig } from '../workflows/_fixtures.ts' -import { expect, test } from '@playwright/test' +import { expect, request as playwrightRequest, test } from '@playwright/test' import * as fs from 'fs' import * as path from 'path' import { + BASE_URL, createObject, deleteObject, findAll, @@ -42,8 +47,10 @@ const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/cmdb') const EXPORT_FIXTURE = path.join(FIXTURES_DIR, 'topdesk-export-anonymised.xlsx') const MISSING_COLUMN_FIXTURE = path.join( FIXTURES_DIR, - 'topdesk-missing-middel-id.xlsx', + 'topdesk-missing-appid.xlsx', ) +// The owner values the anonymised export holds (tests/fixtures/cmdb/README.md). +const OWNER_VALUES = ['Achternaam', 'Voornaam', 'Teamleider Applicatiebeheer'] type UploadFile = Parameters[0] @@ -326,14 +333,15 @@ test.describe.serial('CMDB import section', () => { // formatted but empty rows below them, each created with a module link. const rows = reportRows(page) await expect(rows).toHaveCount(2) - for (const [sheet, middelId] of [ - ['Invoer AIA data', 'AIA-AangetekendMailen'], - ['Invoer APP data', 'APP-test123'], + for (const [sheet, appId, name] of [ + ['Onbeh Applicaties CMDB', '1234', 'Aangetekend Mailen'], + ['Beheerde Applicaties CMDB', '2', 'naamtest123'], ]) { - const row = rows.filter({ hasText: middelId }) + const row = rows.filter({ hasText: name }) await expect(row).toHaveCount(1) await expect(row).toContainText(sheet) await expect(row.locator('td').nth(1)).toHaveText('2') + await expect(row.locator('td').nth(2)).toHaveText(appId) await expect(row.locator('[data-outcome="created"]')).toBeVisible() await expect( row.locator('[data-testid="cmdb-import-module-link"]'), @@ -410,14 +418,14 @@ test.describe.serial('CMDB import section', () => { const body = await response.json() expect(body.error).toBe('MISSING_COLUMN') expect(body.details).toEqual({ - sheet: 'Invoer APP data', - column: 'Middel-ID', + sheet: 'Beheerde Applicaties CMDB', + column: 'APPID', }) const error = section.locator('[data-testid="cmdb-import-error"]') await expect(error).toBeVisible() - await expect(error).toContainText('"Invoer APP data"') - await expect(error).toContainText('"Middel-ID"') + await expect(error).toContainText('"Beheerde Applicaties CMDB"') + await expect(error).toContainText('"APPID"') await expect(error).toContainText('MISSING_COLUMN') await expect( section.locator('[data-testid="cmdb-import-report"]'), @@ -436,13 +444,13 @@ test.describe.serial('CMDB import section', () => { const csv = { name: 'applications.csv', mimeType: 'text/csv', - buffer: Buffer.from('Middel-ID;Naam\nAPP-test123;naamtest123\n'), + buffer: Buffer.from('APPID;Applicatie Naam\n2;naamtest123\n'), } const textAsXlsx = { name: 'export.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - buffer: Buffer.from('Middel-ID;Naam\nAPP-test123;naamtest123\n'), + buffer: Buffer.from('APPID;Applicatie Naam\n2;naamtest123\n'), } // The endpoint answers 400 NOT_XLSX for both. @@ -477,4 +485,77 @@ test.describe.serial('CMDB import section', () => { expect(await countWritten(ctx)).toEqual(before) await ctx.dispose() }) + + // @e2e cmdb-export-import::imported-owners-are-never-readable-anonymously + test('the imported owners are not readable without signing in', async () => { + requireFixture(EXPORT_FIXTURE) + const ctx = await newApiContext() + const written = await countWritten(ctx) + await ctx.dispose() + test.skip( + written.contactPersons === 0, + 'The first import (first test) wrote no owner for this municipality, so there is nothing to look for.', + ) + + // Inside the test runner a new request context inherits the project's + // `use` options, including the admin storageState; clear it explicitly. + const anonymous = await playwrightRequest.newContext({ + baseURL: BASE_URL, + storageState: { cookies: [], origins: [] }, + }) + try { + // Prove the context is anonymous before trusting an empty answer. + const whoami = await anonymous.get('/ocs/v2.php/cloud/user?format=json', { + headers: { 'OCS-APIRequest': 'true' }, + }) + expect(whoami.status(), 'the context must not be signed in').toBe(401) + + // OpenRegister: no contact person and no usage for an anonymous caller. + for (const schema of [ + config.contactpersoon_schema, + config.gebruik_schema, + ]) { + const res = await anonymous.get( + `/index.php/apps/openregister/api/objects/${config.register}/${schema}?_limit=200`, + ) + if (res.ok()) { + const body = await res.json() + expect(body.total ?? (body.results ?? []).length, `schema ${schema}`).toBe(0) + } else { + expect([401, 403], `schema ${schema}`).toContain(res.status()) + } + } + + // OpenCatalogi: a search hit for an imported module names nobody. + const search = await anonymous.get( + '/index.php/apps/opencatalogi/api/search?_search=naamtest123&_limit=50', + ) + test.skip( + search.status() === 404, + 'OpenCatalogi is not installed on this instance.', + ) + expect(search.ok()).toBe(true) + const hits = ((await search.json()).results ?? []) as Array< + Record + > + for (const hit of hits) { + const text = JSON.stringify(hit) + for (const value of OWNER_VALUES) { + expect(text, `search hit ${String(hit.id)}`).not.toContain(value) + } + for (const field of ['contactPerson', 'usages']) { + const value = hit[field] + const ids = Array.isArray(value) ? value : [value] + for (const id of ids) { + expect( + id === null || id === undefined || typeof id === 'string', + `${field} of search hit ${String(hit.id)} is an id or empty`, + ).toBe(true) + } + } + } + } finally { + await anonymous.dispose() + } + }) }) diff --git a/tests/fixtures/cmdb/README.md b/tests/fixtures/cmdb/README.md index 65226710..c9483030 100644 --- a/tests/fixtures/cmdb/README.md +++ b/tests/fixtures/cmdb/README.md @@ -4,32 +4,41 @@ Test workbooks for the TOPdesk CMDB import (`openspec/changes/cmdb-export-import They are used by the PHPUnit tests under `tests/Unit/` and by the Playwright test `tests/e2e/spec-coverage/cmdb-import.spec.ts`. +The import reads the two CMDB sheets, "Onbeh Applicaties CMDB" and "Beheerde +Applicaties CMDB". Their cells are formulas over the "Invoer" sheets; the import +reads the value Excel cached for each formula. `build-fixtures.py` writes a +placeholder cached value into the formula cells of the mapped columns that the +anonymised export left empty (Roepnaam, Applicatiesoort, the owner's function and +person on "Beheerde", BNN Classificatie, Nickname, …); the list is `CACHED_VALUES` +in the script. The "Invoer" sheets are not read and stay as they are. + | File | What it is | |---|---| -| `topdesk-export-anonymised.xlsx` | An anonymised TOPdesk export with one fake data row per source sheet ("Invoer AIA data" and "Invoer APP data") and hundreds of formatted but empty rows below them. Document metadata, custom properties, `customXml/`, the workbook's absolute save path and `xl/connections.xml` are removed. | -| `topdesk-missing-middel-id.xlsx` | The same, but the "Middel-ID" header of "Invoer APP data" is renamed, so the required column is missing. | -| `topdesk-shuffled-columns.xlsx` | The same rows with the columns of both source sheets in reverse order, and the header "Eigenaar e-mail" written as `Eigenaar e-mail⚡`. Reads to the same rows as the original. | -| `topdesk-formula-and-connection.xlsx` | "Naam" of the APP row is a formula that would evaluate to `Evaluated` with the cached value `Rekenmodel`, and the package declares a synthetic external web connection to `https://example.invalid/`. | +| `topdesk-export-anonymised.xlsx` | An anonymised TOPdesk export with one fake data row per CMDB sheet (APPID 1234 on "Onbeh", APPID 2 on "Beheerde"), formatted but empty rows below them, and on "Beheerde" ten formula rows whose cached value is `0` (Excel's result for a reference to an empty cell). Document metadata, custom properties, `customXml/`, the workbook's absolute save path and `xl/connections.xml` are removed. | +| `topdesk-missing-appid.xlsx` | The same, but the "APPID" header of "Beheerde Applicaties CMDB" is renamed, so the required column is missing there. | +| `topdesk-shuffled-columns.xlsx` | The same rows with the columns of both CMDB sheets in reverse order, and the header "Vendor" written as `Vendor⚡`. Reads to the same rows as the original. | +| `topdesk-formula-and-connection.xlsx` | On "Beheerde", "Applicatie Naam" is a formula that would evaluate to `Evaluated` with the cached value `Rekenmodel`, "Roepnaam" is a formula without any cached value, and the package declares a synthetic external web connection to `https://example.invalid/`. | | `topdesk-no-source-sheet.xlsx` | A minimal workbook with only a sheet "Blad1". | ## Placeholder data only Every person value is a placeholder: `Achternaam, Voornaam`, `letter.achternaam@gemeente.nl`, `groepsmail.test@gemeente.nl`, personnel -number `123456`. `tests/Unit/Fixtures/CmdbFixtureHygieneTest.php` fails when a -fixture holds document metadata, an e-mail address, a linked host or a long -number that is not on its placeholder list. Never commit a municipality's own -export, not even temporarily. +number `123456`, and the function `Teamleider Applicatiebeheer` where the CMDB +sheet shows a function instead of an owner. `tests/Unit/Fixtures/CmdbFixtureHygieneTest.php` +fails when a fixture holds document metadata, an e-mail address, a linked host or +a long number that is not on its placeholder list. Never commit a municipality's +own export, not even temporarily. ## Rebuilding `build-fixtures.py` uses the Python standard library only: ```bash -# Re-derive the variants from the committed sanitised export +# Write the cached values into the committed export (idempotent) and derive the variants python3 tests/fixtures/cmdb/build-fixtures.py -# Sanitise a new anonymised export first, then derive the variants +# Sanitise a new anonymised export first, then write the cached values and derive the variants python3 tests/fixtures/cmdb/build-fixtures.py --source path/to/anonymised-export.xlsx ``` diff --git a/tests/fixtures/cmdb/build-fixtures.py b/tests/fixtures/cmdb/build-fixtures.py index 92bd3f68..8536f73e 100644 --- a/tests/fixtures/cmdb/build-fixtures.py +++ b/tests/fixtures/cmdb/build-fixtures.py @@ -13,10 +13,20 @@ python3 build-fixtures.py --source Sanitise an already anonymised export into topdesk-export-anonymised.xlsx (strip document metadata, custom properties, customXml, the workbook's - absolute path and xl/connections.xml), then derive the variants. + absolute path and xl/connections.xml), write the placeholder cached + values into the CMDB sheets, then derive the variants. python3 build-fixtures.py - Derive the variants from the committed topdesk-export-anonymised.xlsx. + Write the placeholder cached values into the committed + topdesk-export-anonymised.xlsx (idempotent) and derive the variants. + +The import reads the two CMDB sheets ("Onbeh Applicaties CMDB", +"Beheerde Applicaties CMDB"). Their cells are formulas that read the "Invoer" +sheets; the import reads the value Excel cached for each formula and never +evaluates one. The anonymised export had empty cached values for several +mapped columns, so CACHED_VALUES below writes a placeholder cached value into +those formula cells (the formula itself is kept). The "Invoer" sheets are not +read and are left as they are. Never run this on a municipality's original export: the source must already carry placeholder values only. tests/Unit/Fixtures/CmdbFixtureHygieneTest.php @@ -36,8 +46,31 @@ DROP_PARTS = ('docProps/custom.xml', 'xl/connections.xml') DROP_PREFIXES = ('customXml/',) -AIA_SHEET = 'xl/worksheets/sheet1.xml' -APP_SHEET = 'xl/worksheets/sheet3.xml' +ONBEH_SHEET = 'xl/worksheets/sheet2.xml' # "Onbeh Applicaties CMDB" (from AIA) +BEHEERDE_SHEET = 'xl/worksheets/sheet4.xml' # "Beheerde Applicaties CMDB" (from APP) + +# Placeholder cached values for formula cells of the CMDB sheets, per sheet and +# cell. A cell that does not exist yet is appended to its row (columns are in +# order: every cell named here lies right of the row's last cell). +CACHED_VALUES = { + ONBEH_SHEET: { + 'E2': 'Mailen', # Roepnaam + 'AE2': 'Herbeoordeling', # Rappelreden + 'AH2': 'Ja', # Locatie BIOToets + 'AI2': 'Geen', # Software Suite + }, + BEHEERDE_SHEET: { + 'E2': 'Naamtest', # Roepnaam + 'I2': 'Saas', # Applicatiesoort + 'AB2': 'Ja', # Cloud (IF(Applicatiesoort="Saas","Ja","Nee")) + 'L2': 'Teamleider Applicatiebeheer', # Applicatie Eigenaar (Functie) + 'M2': 'Teamleider Applicatiebeheer', # Applicatie Eigenaar (Persoon): no Eigenaar, so the function + 'AF2': 'Herbeoordeling', # Rappelreden + 'AH2': 'BBN2', # BNN Classificatie + 'AI2': 'Ja', # Locatie BIOToets + 'AM2': 'NT123', # Nickname (no cell in the export; appended) + }, +} def read_package(path): @@ -97,20 +130,59 @@ def replace_part(parts, name, transform): return [(n, transform(d) if n == name else d) for n, d in parts] -def missing_middel_id(parts): - """'Invoer APP data' loses its Middel-ID header (the column gets another name).""" +def xml_escape(value): + return value.replace('&', '&').replace('<', '<').replace('>', '>') + + +def set_cached_value(xml, ref, value): + """Give the formula cell `ref` the cached string `value`; append a plain string cell when it is missing.""" + cell = re.search(r']*?)(?:/>|>(.*?))' % ref, xml, flags=re.S) + cached = '%s' % xml_escape(value) + if cell is None: + row = re.match(r'[A-Z]+(\d+)$', ref).group(1) + row_match = re.search(r'(]*>.*?)()' % row, xml, flags=re.S) + if row_match is None: + sys.exit('cached values: row %s not found' % row) + new_cell = '%s' % (ref, xml_escape(value)) + return xml[:row_match.end(1)] + new_cell + xml[row_match.end(1):] + attrs, body = cell.group(1), cell.group(2) or '' + formula = re.search(r']*>.*?|]*/>', body, flags=re.S) + if formula is None: + # Not a formula (an earlier run may have written it): only the value changes. + if ' t="inlineStr"' in attrs: + return xml[:cell.start()] + '%s' % (ref, attrs, xml_escape(value)) + xml[cell.end():] + sys.exit('cached values: %s holds no formula' % ref) + attrs = re.sub(r' t="\w+"', '', attrs) + ' t="str"' + return xml[:cell.start()] + '%s%s' % (ref, attrs, formula.group(0), cached) + xml[cell.end():] + + +def cache_values(parts): + """Write CACHED_VALUES into the CMDB sheets; running it twice changes nothing.""" + for sheet, cells in CACHED_VALUES.items(): + def transform(data, cells=cells): + xml = text(data) + for ref, value in cells.items(): + xml = set_cached_value(xml, ref, value) + return xml.encode('utf-8') + parts = replace_part(parts, sheet, transform) + # Excel recalculates from calcChain on open; the cached values are what matter here. + return parts + + +def missing_appid(parts): + """'Beheerde Applicaties CMDB' loses its APPID header (the column gets another name).""" def transform(data): xml = text(data) new, count = re.subn( r']*?) t="s"([^>]*)>\d+', - r'Middelnummer', + r'Applicatienummer', xml, count=1, ) if count != 1: - sys.exit('missing-middel-id: header cell B1 not found on Invoer APP data') + sys.exit('missing-appid: header cell B1 not found on Beheerde Applicaties CMDB') return new.encode('utf-8') - return replace_part(parts, APP_SHEET, transform) + return replace_part(parts, BEHEERDE_SHEET, transform) def col_to_index(col): @@ -154,16 +226,16 @@ def flip_row(match): def shuffled_columns(parts): - """Both source sheets with their columns reversed ("Naam" before "Middel-ID").""" - parts = replace_part(parts, AIA_SHEET, lambda d: reverse_columns(text(d)).encode('utf-8')) - parts = replace_part(parts, APP_SHEET, lambda d: reverse_columns(text(d)).encode('utf-8')) + """Both CMDB sheets with their columns reversed ("Applicatie Naam" before "APPID").""" + parts = replace_part(parts, ONBEH_SHEET, lambda d: reverse_columns(text(d)).encode('utf-8')) + parts = replace_part(parts, BEHEERDE_SHEET, lambda d: reverse_columns(text(d)).encode('utf-8')) # A referenced header with the decoration TOPdesk adds to computed fields. def decorate(data): xml = text(data) - xml, count = re.subn(r'Eigenaar e-mail', 'Eigenaar e-mail⚡', xml, count=1) + xml, count = re.subn(r'Vendor', 'Vendor⚡', xml, count=1) if count != 1: - sys.exit('shuffled-columns: shared string "Eigenaar e-mail" not found') + sys.exit('shuffled-columns: shared string "Vendor" not found') return xml.encode('utf-8') return replace_part(parts, 'xl/sharedStrings.xml', decorate) @@ -178,20 +250,30 @@ def decorate(data): def formula_and_connection(parts): - """A formula in "Naam" whose cached value differs from its result, plus an external connection.""" + """On "Beheerde Applicaties CMDB": a formula in "Applicatie Naam" whose cached value + differs from its result, a "Roepnaam" formula without any cached value, plus an + external connection.""" def formula(data): xml = text(data) new, count = re.subn( - r']*?) t="s"([^>]*)>\d+', - r'"Evaluated"Rekenmodel', + r']*?) t="str"([^>]*)>[^<]*[^<]*', + r'"Evaluated"Rekenmodel', xml, count=1, ) if count != 1: - sys.exit('formula-and-connection: cell BB2 not found on Invoer APP data') + sys.exit('formula-and-connection: formula cell D2 not found on Beheerde Applicaties CMDB') + new, count = re.subn( + r']*?)>([^<]*)[^<]*', + r'\2', + new, + count=1, + ) + if count != 1: + sys.exit('formula-and-connection: formula cell E2 not found on Beheerde Applicaties CMDB') return new.encode('utf-8') - parts = replace_part(parts, APP_SHEET, formula) + parts = replace_part(parts, BEHEERDE_SHEET, formula) parts = replace_part( parts, '[Content_Types].xml', @@ -212,7 +294,7 @@ def formula(data): def no_source_sheet(): - """A minimal workbook with one sheet "Blad1" and neither source sheet.""" + """A minimal workbook with one sheet "Blad1" and neither CMDB sheet.""" main = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main' rel = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' pkg = 'http://schemas.openxmlformats.org/package/2006/relationships' @@ -250,13 +332,16 @@ def main(): parser.add_argument('--source', help='anonymised export to sanitise into topdesk-export-anonymised.xlsx') args = parser.parse_args() + source = args.source or SANITISED + parts = read_package(source) if args.source: - write_package(SANITISED, sanitise(read_package(args.source))) - print('wrote', os.path.relpath(SANITISED, HERE)) + parts = sanitise(parts) + write_package(SANITISED, cache_values(parts)) + print('wrote', os.path.relpath(SANITISED, HERE)) base = read_package(SANITISED) variants = { - 'topdesk-missing-middel-id.xlsx': missing_middel_id(base), + 'topdesk-missing-appid.xlsx': missing_appid(base), 'topdesk-shuffled-columns.xlsx': shuffled_columns(base), 'topdesk-formula-and-connection.xlsx': formula_and_connection(base), 'topdesk-no-source-sheet.xlsx': no_source_sheet(), diff --git a/tests/fixtures/cmdb/topdesk-export-anonymised.xlsx b/tests/fixtures/cmdb/topdesk-export-anonymised.xlsx index d4b6c34fc0bc9682ebab292a1f0c79f1b8871663..d8aac05354753e1986a7ac0b712f846a10b5345a 100644 GIT binary patch delta 11561 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�qBENs>+Agz`nYrB3BZg9W&+lN%7x!T&# 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 diff --git a/tests/fixtures/cmdb/topdesk-formula-and-connection.xlsx b/tests/fixtures/cmdb/topdesk-formula-and-connection.xlsx index fb4642da1ec1eb422b1b3a2a72483f8802e02d00..6189ace83a501c4656baa692782102268bb47daf 100644 GIT binary patch delta 40624 zcmeEPdtA%=|1ZSRD5N5kdx#2AM4MbYRPIBx5FxdM?(I!Vu7$`jk zNv&G<`&O&A)vmwKYGpgeISz;K`RDg|d>nayo}cII`F_4%@Avz)KA)Y><>rxZn@2ux zS68ICpf?^=l#?r5bu(XnB3PvB=5YLJxz#r62DK3#oTp{&RmJJ=?<*c>kuv1A4PWgv z#MXHl&b@S;%cSjb3-B{Hys+X`5N27us4$u0)v>eQhsmT6iv?H>lo7v{XIt)Jusi&I z;k`32(<2W(Ja_8$ruG1z5amYR@+0+YS4_#QJ#{R=M{BmS&`8LmDN!DP(h`2_U)q!m9yU)=^ht#@oaCE9L1(BJ%KgiarLQM_px`bCYfiNG4Cmk z-h)kBC2|Zt%%pz~;2BplBUxZ&Ja{C?X*nFzj(%xiHhgz*^u4t25Xh%+zdbqogsb_2U-rHA%X8r(jF|tPvF^Bhw>KdhJSJE(8l{oj37T z^_No33-R?=$qsX0PD`E`vMPD$o;X2!L1-|uXO2<5%N9}Wr36&jD+}xk?&&a$^@7{* z_)qy)YWzL$C#LFHy~Bd+tw$8{0oYE2($G*VDHYGvZ8M7@?HZVxBJb9q0Q5;Ej{}p z_wnZ7e7pL%wVL*w6R3~Rd5wJ=f_PBo>)+`vP`PYW@Mh#kwRyIAlkt1&vg>_2!jvNS z42zk4ZH>(ca3e~6qQ{*mmtfx`V<#+FZ${m2 z)%Bx|r;t6CsF0R7?HkuxziH>E#X~ zDF3F)$_j_W_8O=B&Q~^2_rI*B}|KoBf1(ljtaM1F7Fy`mtaTFSnK6=`vGmw?&1w5mvgi}zLoD{>~bI; zIsphz!Z{x+u~al%)89WVYZ2JuJS6BE>&CW7-)Fe)GvgAiored_(QcUhXl&~&y|d$z zt)1l)URr-d1vT9qN3!lzT(1ARxx@bX+ljricD*UN{48?1-Lq#=8g?%#5lbxaBHv@7 z78C_u?Wv|IMlV>woXuAkAJmG}uzTkixudDO5zN@EvdqDI$U_t7GVUm)%4Hu>XQqx@ z?$6cJ&tCRv-`lw(!-keBJU;(nd5h_ukrzW_G>hCDCdcU7-qLThIXx}Gmw3>(Y8)eA z?2C;_RJ_S{)dlLSv%=?xES|kKY+>^r=j)}$>$2LpdU_^&wmsn94t`QhcePsWX(423 zX`gEE1Z^6rwHYSh#OH}OSGHyr8gILeKfFBk5;pG6a=WUB*)JXgj8@=7dTU55Hr`c> zh%>Z9aP9W2S9urO3lEFF>G}SqJYwY&&yscqydYiEZS&H_MBJ&9Zx_sA9?yAWd3x%2 zXR-squ_Z|Ol6CvxZndn{jW^;B72%dQW0SAi#h(C+E?j$=yRd6%(CTfr;uAku#F-IO!Z?IJx)5QJ#V|*yx<(+wq|NY;s`^khv(?9;TC0D)DOkM zj^P;4!+_Ej2R zXte6M?_t4B53%Otps(&UtH{knRZSzVF!p~oTd!Hu3KTRl#wJFH^w`~D~jc&ywydwOQ)KzptC^X^@t=mr(AVU3TbMGXJ=BS_&!J zGO{8zLlHTD{4^)ccm>vl*m150yqSNAVH z68)r-b}IN@Uduh&i5tu2-9gVjiF$#Ubvf6zdgHA}w#j$qduJ=&e6kdKQ*ZaZ8?3`G z*t6C@syniHS?4Sq-fimLSZ8fd=E^yfSq8atry-1(=kH;_-DR`J{}x^86uNGR(@g8Y zVIywHO}~hnnRaKU-0w9z%$(+rth(X0|Hgw&E}Ko5y=#mRST~1pS79SgjnlInSN&Q( z@{LZ=`A1HQzdf*y3aWZ7A629iv~{o1)F2PPt>;5V$M+CMflC3sd@o1Ru z@A%CZRp!Y@ebm`rBYj$sM}z*|G;!}=oF|#8T09!!I}g9vPbEe!>YdK^YTtQ?N1cj0 zjG`3B#H4=hG=R=i-ukG-MB;Y;8Bptc4DpDqSZoxfF(!s&y2RqqaNlG2%~w@o&PR%DjM>pGw}JpX&VgjU1uFN*5b&p->#;b_A~U-o}E$AScTrzlWnZ#w5E|Y zseSk7HD$YdZ=3Nq-*3!L(l70dFH^5Sb6ty@SRLQ&sfUTS<=1oOPfPs$%oJlT z#kG0+5$~?Bqw8ExQ9Hb5tZ#^UZ@J{|4wkS9@Eh$S$egSaRBCTs7fsM3=uC^{&^!gi zK)8sdP2q90C?ZP=zf?KA613;yv;>S2EpKjbyEPE?2t*!&27!o)6>&tp1ra8q8h9n2 z&1%)7c@nE#(HOoEP2qQP;QWqsLoE@TWv4~zt?HstgcVdIrmI&y!n?bkD&l5PKu-e) zl`3p1L4ka`2+S@NDOd}`F3_fUib6)i`89f_%w9C6D?1A>=n52dl(y2mdqM6EJ!`-% zz@aHZLyDk|gCtqd+52IhNpH{PJerp@qjNB zp^>EUh}NF8o;x?Sx;tBXM{ErdRhjJ#FN=+?i~w8;O_Uvo5oA-_JCIuZN_9Nws^vW3 z=cfiOrHMMU;ao13-=&QgbvJm5dP#W9Oc5P)<%w!zMV&}Ayf~I%RhcU6W@VuSPN|#% zs)&_U!sZrQ(sVs}r_e$Qn*9l{hUsz4;fT6isesM0d`|<+N<2!`a1ZXqec&x@#+&rA za1;TP$KpnIBjW=&6i>PqTvRCX5@b_E>>NV-LjsqTm!%8@C?PjD3k`q`4YNfnbqa%{ApfzvT#dBAer9;c~;W}Cvh^6crvGfqvg#~ zrgWx9dvFg!xq=#H_(Bo*799!>v}CAGq#vUjv*j<>9H=J38^WR7L@lCnpX&I z^h!9*4x_mEK@cxLk1RTxnMCR7YNxn?1m0y)7hB-Vguzh< z?w-u*qrzq(%fxG~$P2KlJvYAh$N$b}$0^f>_BIQwMBN>oc?>-`raP}4-WsTU+EeF!mVXLGHCz+kBeZt& z=!wJfLiVp9djZ4uSvXOHu(KBD0#?8G*BX~hnbDBWTkD9v*jn9)5dXzIh(=#>=n|MS zdva4KI+_%KuxFlk3$>FwzhgfV-%VhOI9wSi|VD<@hi z)7FzjhYK_@X+`aNaQ>xcI*HlcvGFc}kXZ{q)Ghq*C{T}9IWbVQp_po1%Ut=GV3=8$ zj(=E5Q}3#dwnVpI&H~(KfdjL<=V=mULAWMxrP0(_Ok5W`(T#7uPE;)r67m59*!|&ps%ufVmmvmGHa0-(aL*pT?%DcSuLkk*w7T1=M7GF`$s4c zQCkCDFI8ARZp;eABt6h18xScg-$md+54*|JwYRva9!VjS-U;Nq_#;Iwk1zoJi0sjG zI-#Ay?U};jR$Sy1$CC1v!kbE1D=LMHtLP&4o-{$Tx5uYt@Y~*G4~!<=qgeAHSQnVD zg_#O4ycSKpH#l-P#iKdHtvN%)`gj=#pg$*hH!-P?MeGoyh~A3SvgQ%dpg9~(i}7z1 z@Ngy^cxrbIoZMXl^jZu>Gmp}ly1n%rcpLKqFPc)fQP{}Kt4zOy^6t80_xNdGZx647 zQbY}5RNM1PxOB@+vGuWmAZc8+e=9TDuW}9b+Q-0Vw-xKI>|AL*MTqkvN07Up-iRRe zwB(_@SZo(}{z@~)c3gDUJ?*NEGzM0kX4F|@yk6LlfdnFU22#ZEGrX1B^YFSlZHY6W zt`G+EYj}8e7F4WPy7TE9gv}Xwo}MKugxhvvPV+uz)v{35<$SyzImOyzN4?M*v*K7c zly+OXYT6gK=#f1T=?F+8BPAD8tZ?+~awH+!J!45~J?!vBJ*U%diPWqcmkH zV*ryLsYjmD&318H(ZN9bXQ5(264(()`KTvcdY9tHEJl%gS}wPb5HzZz1jU!}o6UILtvODB0dBZ7f zYPRd8_Ck88M@uP}ecanK(c!SebRL*q5O!RfHkaF6x~`=(lYRW4v$^zeOKAi9 z_(|FJhUU^|Me+Nn;=^IFy-KP2MTV_$uf>V5aQa-~UUUQwBu&7C>7 z&o0|l-X;vn20A<0(eruR9JkqPpFMQfW_W|miFn?&1|c}}dquK%p!LkA! zA$3Iyll^!WCkXpWM|EQE%6bg1NWhj>p^1(tbH*}s)F!c^cwf7EM&H%rLCgEEUmi^K zZuuJR_qN|6dY55%FJCH^irx>oe*k}#o| z$v&D)jiwVUsWLW|eX|{W?*;!D-wX+ACs@*C1aX;c*>)gFfj^!2XYJ_`WoO}uLxH2e zuu4ggJ%xMExZBRPNx6M8IwG9t&BgG#WsTJOLpvCl0DnC3FWRpYy)!YqD>8u(K!U#3 z4*u%I54I2Immca#&*_N9H3#YyMxY+TUu^R_*?-dxzW0Lf#=oXj`A}Xf0RPLvaq5Bvx?KCc-JGN*J!fbV-XX5> zZou%~%MKN^c-$d=Pdj$&!o}F_3m1z2EY)%4?d8!=(g}G|D_;qdo$O4=QviWegwu`r z<}TdWwr-zt zpojMjmOfZG@9K`D?|(mtwzgwrxCUqX8>y|P! z+ruAr8ze?AG`Y$;`rh&&;QGmd(`J>(pvZ^Am#lphXL@zwv9`?zFKBEm(!2j?V<-9U zgeAp~=B~fG^4O@Y2d`^ji$34SO#40T>DFzxf>VmdKg?XT&tco2OTz>9<7`J05lTda@+4{T^Ignb;$XgU zu%I|8IB-_7#o10HBBm1&Gi31FUCa_+z}~!o6}^zcI|m%b*)Aa>mJ$($GH@>!Gj=>| z*h|>>m(p;hqc~eLBI0)LriaN%+e061F#5g{ZZL^AkMZe}j8V7{+l zL9b-!eQ>s8QxW4*5h|0V^UT1_EHMrCCJk1UCJhhp#o5kEMa)h`XiLLE<6bv2_G{R% zblCWGX}pp@&URTUVtFdUSQ_8x>t?p}4Q$OD*!DNl@PLarTk}+eMJmEl2L1#Rp8@mD zfCXhp!>p0tssfbIdh|4nYneJvwvtetpVcWAMa3J0@oCB~p+pttbcq$@72EWPO%;g=- z_Z=+goecdgoNZhx;zcSVUIu>3-7GN&_9h2blp_rfxQ(;TNJV6(BC=%Qq3&jE@E$fS z7dAdu8m|it?rL0e5k>sw9LO384<*zra^`n6V3B z!#={sf0TwRJ;2${B_ZaK5IQn&TMx6Pg|Ib+uLgI^IZ;D|>#nSMACpgDI|<=ILU_vHhk2SU{R~_48MghiG+Y;gi?JkxmkfNkr`b9&7G^60e-?+c z4Im)`Nr-C$aL~80>3W)-FNFn_!tR&K(7(jl-X$UKkr4L>@O|)|o@SY4u%a?pU70le zSt8Chk%Si94F>~tTfdy2O)t%U zBNeY$j#Oa8N>_^jDi=G+J{X=caiv~4vIwocUA!YD)uwmNZr5odAe#v3GX%^;CC-Le-ICT@_P0M`fsv>U%I+}TMhW}UZAY z>^RGddF7oT`&%{hN@PA7*kPwRsDa@Em)ll0rm(dmujY@u7sjJ6C`F_5#fSgEZ`{K4 z%aUo8Hw5hA;^XJSLH=*QlKz|Q;O{!?>f&jggnvY{cr@_)$Uf(OrZ?cfv%b%p>E9aJ z|4$R~l=#~;`Pp;gD@prbrwRC0n*7W;@s%k7{zuEvSFG<-1OAi6@rxSpE7smG^xtF$ z{r6t}v!xpRulJ=tcmsa&w)Y46Z?c1*x=XGsMYrdR52-Dl3(at>fUP7xq__A89vy;% z@5KLXI2b5U`sMr#dgx}^JSUWD5}y5~&Jo{O%;qU_>FsUoR43FHO4w#`SyUSUl=DJT z@sYI3D4NojyM)~*DI{$keF|56e7Q-u4$h%^z7S)78XWvu`fsv><@>+cS>XrAJQzon zMICuloZf?8{tR!xPxSJeo&8E*LH`ZrZ;$LB6Xz%84fr4B*JY>FFqi12Gkr34PneEJ*AZ|KBTB@9 zSl_1x{EA8S9o~R1PgDLUa}fM@*7rFNe)4+u9oF|b5Bi=0l(k-ZC(lgoGPx1Pkry8_ zo0W04eDNW(S$!uQd?)^A!@<8&|L@g+|8{5Qfd63`^0hhv^gWdwrvEop&f?Nj z5&7anD$g3Aq6jK5M~V;Wv|y_APa3>6_tSRN?^6T*&Yb^&H{jou58v0>uk=-XYwnj@kC_$1P&cU1JCY*x+)_z$ zGt>_L>cs!3UHW;zenr1F@Xb2!9aZ96cl7>Wd;qC$f*Djuz8v+?Smw)7g#w?4QV)Wi z2n%5#Uo>M@{o{5X9r=QWSw#`}NIy_^0(x8u3I6KD|EL}ONzso57Js*nq0>T;`D$G^ zRZr$oxsqfWW)_dGF4YeDO0Tb+_#d@{KPmc`fh8$~kxikm4Sk={;43-tVrv{&9<+_`t+5F`a@iw!~j51$^C>Um8<7B>3hC{aPvDt6A}DV@ihv-%8M5ECqaRQvBkW(jmb&=I5`L z0^NW3)0*(BV@ihvf4@BaODTA0>AQsSTq~V%;_sAwX$FJcmu>%FH3KOvKR+fuhf@q| zk5#WkMtoSX>&X67)ngtynVcYUI=%fcJWFI8&&EQv({{wFDPx~pvu4H8Jeuw^_++tfARS2GXH0T>C$ZqOF zf=!fPf#2%euXjV=OY|s3{O$qrpE^F0u{uWRka1_I(z2wD%3YGKZterK<{D z-VY|$C+LAXYv`M=-_s1f5B}59pl?e2i1ME!1$>uVKPRSiNFZJj{=4q$K#ykq>m2$w zu7Llx`&{tfc7F``Pe-3i2mk5lk9i3G%jAc0A9^(X-z}iuNPdB4qD>UoAX()KN&A*o zObTX6J>Ip_S(X$HTN{2y>bpTB>wLw)6AyUvmdnd-5*GXIoR^=NEZ zK|NGRiu%iD@O|)~js|w$l=$_Sl6uhGRk|KLl4593@ueOt{b0^yLO(yIbVxAx;o%=7%P)`uzLlWAAf|LkF!0&wzv(^)d}Dt8 z;_ll(zyIy~==dLX-v#{r^7N~_A0_$x`JZ%Oz%Ic9y`c;((2?466>hv6yK%x^jj-j9 z%%D3=INHht6JZrL2kw3RsPo8k8}Dr!vE=c|LO~~SeMy#ey5|SS|Vqk zc1%HGIDBr;IZItpxBSEGS)+8XT1+Xr53O}M?V1Om5ZUz+CK(3FzG)8%9-j*?g6{tH zR$$vet+Ginzi|DX**N_qgXO0;W0xCN=utdFz4>X{IKW*|ST}AsZe6s}v8{~{dG)S& zF9YE{b)A-4$Ab61uKL(iw934t{1k=HNMkccMC=tdW6l52iwa3EQl)!A@66f2&(dnc zff*`z8L?=ZlOCzs+9Tb2xr|o&B>uLleD{Nfu1EV*`2!Xd{Cm-U+fFuf47UvEG2b$4mzmks^Q^$jx2 zVG`zW8D=11_8DMy={-l^p9O%)bvXLqoV()Hj4he;UGYrAJrdD9GR(CS<~kW>oP;@8hS^Uj$!Cl-@k_2mGFL(jjUZUZ z>l?yS%%6skBB6dQLp@8>PrVRGQyT~*ngueNITFo1GMXDD#4QjpkSs;eB+QU77sxQb z6iGyjq?tQZ&-BaC8WJfrztcH#m3@nW_qa2X>~QI=_m?*jXKBRn{6Ee^uNk1KFPjSO zhCGs5JvtHw=ts%WyAGAmFCU=q8%Wd`x4y-ud#t>)DP9{TTd}a2^|8Y3eU4N6XUy^1AN~oC<`Yl6b#D@%s zA1NdLrjJDYJQ-&=iW2r0((FnoT?si;mOOnxI~XOyzT}*Q-9U!jb+m-NY=9ja))7km z!)o;BVQn4It|Y@=Jiu-;$PT?vQy)2~?kGxP( zlI;2L0IL5K6GBI3qn5v>3%6!S!a(&PZcl~U>pea;>-5-tD z-)uZk33z|5J~#^PW&O-QjGTp_5A65s{r-82Qkm|_5c{H`GVJ*h_DQ3Qq{+7rkS~!Y zSF64^TEb41uy?!nwqk|XCJf8*uh@vyzdn9g=V;DH?EY)xHN4VE-OTeilgcwPHG>|_ z2`hhHby$vJ`43#sa!q$u`9^dSuF@&OLZ+bNoiEIqgn12lmCmdk{Sg~{?Eu#F`V8!m zYtu$J;%J!E>Ic~M*EOMkuLbRX8z=C!*GdW#tt@)F1?p7K$Otgm<1_QJ(nl#PxOdYx zqE5Y%U5u2Cy$h-z=qrEXW)u-t9dRC7cE;5tUZo;e+ys|<&O>6G^p?#Ix zXi4d&OA)+J--um$WBxjs^MjS0H@$hWvYvN?ylk09W_RE5$C-4=8Ve-_mU&$jK;jyz zwZ%y=1>Q9kN`hQ-kxMNeDsN~Q-fB!&x}m5ig52AsOcODP&@05~{wS{=HcIIH7QJ5B z6G0KNstG{Ih!9odM6N-38|@=NZz+y437nMTlm@W3O)kU9H>P1ArG8Az;yN2}`s@v7HdrZca5^)Rl+B;Q{oHng&A@^lC19uijGirx@mL z5xVO^;qizOmB%Fh1)AChkJM7X+j*@gx?BWMM5n~Pc1>zSpo=fvdF}Xg9kl4l&C`x zA><g}8p9%xMBQ{{AS~1qcIO6) zUIH#dv#3t%zYevXL z(|K2hM;*AoDDmI{GB0&w%%-_C!TjAY&SY-K<`@s;^^fpFRaqlMU1%CSnN!P(Zwy3- zW8C1Wo+m_=tU`aoKux%y%bx>8#f|OVJWe>xdZqU%R$W6g%bz2{2%s0X@tY_j0@O*5 zt~O#L%^F3kq?)7`HfmLh*le~x^oG3l-gGpcLh%qXa+OOg3GmL`-1v69BSFL}ji7l7 z)8kuX*Xv>OIhK`|)zw*~cDyE>B>zuGe;V5!AJ~ zcnTY7Cc^4oX1uqk!@;BiaFBUqUXF76iqu}AKYg2*peMVQN}=@J=d?ozL2qNUVcs(` z=0$~oa1F0n$)m>8MHzHabS=)k0Eh1u)cWT+hSR)G@)+zp?cwe#L8@UT(Zh|qsa1ex z9W|{a%fn4R!O!ANcJMDo#D5)L_ z37x9B%&%s$P!SY1oJJ(|RwGMjwD?B z99K&fh6z2#azeYw{uSP@$6($ynz=@3N0?YDwP#wJK4$XMDm%>HRcOz}Yc|wWOy1Z! zO?bbxr9%5jw55^oar;gp&x_vOd*BqRwB5uE(5-}yN7b9PHJ1T~3C)ew+euuo_eytR zz$o=B_0o)xyf(CGt|gvYVx3f7aZQg+BM=0>StSk|L{%!}W^7%pwmU0kbbSQ((-c%% zH4E2a$=#HNb97^sjiyJq-|?-zZ1}PSeveZ;DL{|b(ZUM3YT1=&>Efl}&*WNO1P$9V zYweB5Yz+$$D8kQT_GkoDCm?eml4#T^lprQ#x-i+h&~BOV#w3(rd{a-2ccJz&;rI)9 z!T1i*4Yfc#uczg-mN6jGx{n6vad)A326mLH)aLbCsH(W?K;Z}!uO}ryKxtd+=Cv;i ziEll{%;R%!1n9MPw;T!+CgV;oCqF4{)oZOsf{z4Ig2@zAMRNku)s=GpBeOotnv}{~ z%e@8X_M}+g1*YX;ylVk^S=&X`v(UWUW#n~*_V|JbgE7rL9_AHXVJh8jIeAlIkDfV)`YucTFoy1TGKmn9=L+m8K||-tD`ay zdOfB|-NBw~PQqysul(P1Dff;ynHdJ&+I38G;2bsa56o`V>Xb9N9}3>eo7@ivZ}0mr z6J0~4^2Z2zPjIhcgwrUgy!mui`#w-w(XGRetT1dW=~+cAq(s7Z5aD5zsK8P|FaL(g zVNQ`~l~%lG6RpzQU-v_}9S1?VXZez+!dCTu&&d1S-h9vnyc0Tc5Y;U87KXdjGW zw}YH5uJD7btHt*CvlX0{PDbMsVMDa;&-DH3L0)Jg!)Q27yWDlDsV zP#+Bsh*nYVDwlzH%xJCgu6q6(xf3UIXv4U3Cv(8?=0+zk)>RL*)#XClH6^xh;?WFx zJYCtVs{q}2E*&XiCpl1Q; zoF!{6<{>Xa;Kr)cLlQ~?88mMnnl*40bn`;imsq;!a6o5PD;oO1F%yT=)2c7-u2v6^ zDCDrm5ZqHzL|r&h{pHqHURHY!8{Xc~=|EYG(xfcuY9?ugSFGUXq*Jqqd8eIL)Rb$U z@Jz%zhVO+_h3$nrc$V^V6Harj%VI$yE!Q&8Qbdf6Z6r>ic(ypg>FqWC3-$$Fj%eIs zbygb)DwP~P0>t!UBvEXhk-?C$3$Iw7jZ(1(vUIouQOPya!#iYT*$yK;K@QhM`@^N%U+e|^R z2x#6807(Ez?ww7CdCyxtX)BV2)LJ~}T<6W&9KcFM31bU!qCC1?5sBKW6|oOqCaU3i zX4$uO5%}+@BQz<_WKL{sES$z8L?d1C&l)*o3Wi-#f<)$F5I|ag6jw`)vgHW<%wQ}oD7iFbNPcF zigX=%7p{QdMy|KKwejMooH})M?*cxnIgFu=svz=vDzzgjd6zIcPLI(i6Z5)zn%Set z!ivjzoSxqGTIHDT45Xe(j|X^rd`r^!xzH&IIK!i*#iJ!<1#9bl zT}x6>&ItSEPmbhMWo0-3j3wFoJOtS#r+Veey!}tvT)5?DrKV@T!z!! z&YxacGIM`L-Vz5(WtgJF@RO_0q1U9R+_k^)oY54V80|j`_$@t`{2vq!dmcy`p&W7nP|i*H*^(%WmPRq}Kn_RVSg66igI;}X@% zt~7kgtn%bS4Ku-siZZb}E11K&llBL@R|--R>~ADF(#7hY`YpYeoUpJr90P&O_7Ysg z;y#&%6qJ|U_O_%zpqbCCh+fJzcJZ=dctje}6u<_}+x+R*>G0UmRadNbF5a6sO410lnBG z_oi&_u;jdgR}K|!k~Hz3%ZkYdJ_c`xEldGRnFYZ!DPlp(}~_+K>#rOByJ2sHs<3Ju$~mtsYXdB9N%# zHG|&=KY2p_LmymM0?+J&JE^Nz?StT8DpP{L*N1-+?{&Np`AUD=ip`;oCtUr|p(yBm z@rqS?y00j^LabK}8UDsy(-mnn_6lsX{-@blE*OPX6UHcyZ*(u;SN%p`7m>azWHPl^ zb=U!fgh3@ENlx{kgEK@s=88NMaUwh`tgf!y@y4SIcc)(liVNozJ-T+!1G~Py+;RNl z3-_mE6}QaW{`lI17X)J?LWu3WyN};KG!0ceJ+JO@Wz{x?Mi=bnwQE!-?zpf$YOCL- z;z^+~RS;Xug_!BFigELbVy?w{oI|RHQq2#jUf*H6bJ`xiW2!H9pq*lUP7=+YO;5Sh zHt$XBX1po6Wi;j{P=7u>?b2^Li=K>$GtE$(7;9*>GcCD7KGCyJalXrHNjIQtUrzsc zX_L;Hr<)T@KfQB#>Z6jVIMr$uRxVN1AY;@~;zfdDB3d={)egC8#o%H3&mx;uUsk-? zt3XXwv}l@l=9Gf@kX^|9ZpENk*KfTfP)AxTjKN0&Gr2CsVCz*>^R^w&D`KmB@k8Ds z^Jkv%Rxrm$(q{|jeHQL;8nR1%*!&}62lHL9K5HQdD^x$whmV8>4}1Q+Kg@mwYPzDu z7@ad0pfI<0L1D@q55)P<5BN2eGk&9zmM%-|(m6t$ra7lCX_70Z+Cs|LsR09$Ph!aJ zCpN~r(?%Xrxb`B_Oil+Dyp;?|Kcqm-QMAz4Idfagxx1e;-iLlzK|53x@xf=deB1mZ z9tsYI%-w11cpsH~#i>Ui((`Htq#>t|)J4(hE~b0rXD&E0?$t-1hmdxaXw~DEI!3FW z%H35n=obXcitifb)iM+tUCup$!hGJ-7p5k`hipli9Tmn9An7Mz{a)^TUh%ZbmoVh5733vJ!F-x$nPUyXhmKaz zex-`2f;dO%_HlxRd(&K!d{k-_r=EaF)zte)jh@+f?oAu%tS}}SBE|NT&PP7q_pHh{ zdC1$n5NR$nK(9dhP@#@y8{K8dK6zhVsGm*!{VY^H9<5^(^-ON7y1_`B)2j+0>o2w{ zCYGp%+92iPpmEbD_KzEIQJ~TlEs}K3l#6@6ufO+1AG)i8c9klkL#$M3||lK=ltNAp6k06tufl5nL$Llm1XAP-$$kj|yLLDh3MG(C^7-ybTeRHWDaYqd=bU z`aR(zp9}C+!(I*f%?9#Bet2Kf4xj&7X_u3!jr>e~$dV!F`i6dl+HvLiMysC7O`ibi z^y$~B00W6IwNU+CauY{ELenCfDQ~VMhPXYq;n}2(^i>%18VWUIRNt6x+Vwo_d6n<$ zAshBXp;Y@r>CXRbz01jGzx*3%D+F9GAvO`E%eMTxm8dk z{WtxVTu=biu`r7b^Uus24$aO3kSBb@1vQjo1(lB4bJP&y&&`(Kw6M?qxC3b}89pj% zu&I|JPZB5e<%wII4evnO$SVqCG9jS{`-Q%TgvM3*W)9hK5E8m%xVcC*9GXp66{wS7 z7Ebfe7(t-ZT+E`2h-SDJ}OgTQ?EgxhEMDZHQ|Mgv2EJO z>k8N2LZL4Ahnl?m`N|hn!?K55u!TaI^?Pz_{%76YPOoj{Z5KhF%1d;X9_zBX1CLj!}7xxL$UPxV;xjGo;BZSTD;s1ajZN2{x60l+Np&` z?v^VM`=8q%DH!s93C!a2{4>X)P@fO?g{sN*p@%4FJE!*O0twyRFZ7<;@%50-1i2exosats z-3CjHh78>!w?(Y8xL;?C0(Hk_3*-Wy*x}~WlaA%p>33o+i)524{p0Q~E$x z!cJ{g#}3Pwh@F#eE9QZIo}pflTru`5n>-etF?qyGx}tOOoDW#7*GAaqX|u>s0u&&--IYdOLAwF}-nfX;`u^a~>{b**4Lb8fT zIi}@Q@jNI&?n9YE@08CNiY0uAz{Y==gDoizov_4+VEpQ9K|2a3V-FP``cn{WN8#)V z>l$6we`X|P$AzI*QAhVxdVO^Yw)!I^Rp%{~XR+AGVneKc@$9jZY6co(Ni_qZ_CUci zv6jWK_(O$|ROkmNSjN~-q0jaVMUDY7YgpDhmJfh4+0ZMB-tW&x5n0}YVgD)#$1CG+yS67_W@&u8hETRL;e!HiS%IRh2X3zEXH$ zovJ2d@75mrb1bK-+47p$w0sS$ZNtP7&|-wVTGNJIRl9F&zoq8TSoam*gh|-_4OUQ; z3~WaYEdFjSWES)T#F{a-%qivAP$&TxL7C{%d#2_rR;4iPh{MlMZk2B> zFBU8zFQo9~bC$G*M$|AgCY*XQ;>k%Qr=(|{fH7iz$&+}@C8E8}=Qz#hANEzCYA11s zTF>{!f8fn!JUe?fYk;_}pSTMm2E)&54lu{{Gne-{wA9hkIs zSw_W@9_uqjOZ)obMuVLxU$@2LgzIRs`c%^BzHV1?hD=S`5(KfHYggECBE8Qc4oPl< zZ+aP7d7s^yH$mu@7iHPJ3v6n=Olo$z-)(nys ziAh~cjH(Hc2bAGWgl#J*=Q|7~hKc3NtsjuDpfo5yZ7|fiflzW6fYX5ft<{727fl|p zYV63;!1F=5V55$stJt#kD*`R!bLHQ0}-x5=DEpc@~5Gnl4E)Pn;8E0@61w`bbs|>&gzdI<2dJ8XHGL(gwrnb zrX3w`b~3$hK8das3$DF9?VGTFrA-(y1-xy*YzWqW@mUhg))hW;fyx&9{u!d>oTPPQ zxc|&0ePLaddxr%-WZ%Mece(&AcpaXBdBZMnu%~%-np#@T*UWtse4*jo*xZT>4K8aw@4i|WHI^`D>Wew@)4QK! zy(qa7bGB(8w+u{A@rx7;-O^??cG~0PyW-Z@jcXZO8sxEN*Rx4`Dl~SeY|)>vrE1;y z&tvzSD403VaGZv)-7;j&qr9}OwO2sQ#W|C&%sDkfk$yoR=lkZ(K!l=0`>&r`SG>kx z%9SH6>kJZRyk4PkW7Ya{(=%`H4!f{9(7(Q7;trL9zptvN&e;T{r!^pTX5Ri8Ef8FC3RU zNbPzxLk?11pzkVh_O5Z<&@GxgzfSq75f~M1|2V1b~$b0g!<0fIY zduYwnIgMNuOt)^{XX{au;mp@5v+Xk9aM$eiETL@!ns3HlQ*hfb<7@6ES)o-;*gO~kUlxWI{gd;iy`v141 zpXbUGQLhT$vMGz=ERF|9}K%PS?p{a$uThHvM8A zll1g%GbXO-ukx7$rq3{E65xipw1M&X^nE!@R?POcOw;9Uf$kN^Wzu5W;0n>T$`z)o zFqcV+>Ao98cttLg3DX^S2}A&*IB`fLzeW4dBKko^Y8W>WQm zD3bSqnHruCG*!tLBHWkHWXh!A2jM)(2WpV@pDtFwWX<#_V0vH?lgjk+0$?ER3W9LX zfH+Yh5RPIY(96H32Np7gFwF{wh@A$CF#D zVzOa+9S;$F7th2tU9|`pP!9@$j(e5}5#C%3)ZaG!aWRt}lX2>FEv2aR8)|ts2~whK#`&d zNJ~USL_~U#0HH`H2_dAn?}a9ramM-0SpNCedWhV&v(G;Jo?DXlvflePS-&>fc&dsV zv-yO}PB|&5n6+3Ob23<>9@DtIl8IV zJG5Zzs{3}1X!RZCMn_%Rr%zUEoAaTjrY9DgzjMl%Jaxl&(P$Hr&xLWepAIACvF z-AH@Q49v`WYeaY7d?IHDr3zP*lkjY7@aUw=!Ry;9d_N0k-2G_r)_%=(I{Zjf#l6vE zxvnL1t~unyl1O*2j)w3>CkVUqvO?JhT$}&IWwHxe2 z>lY^$7#Vrn`*d!ZFYK5GA8W0Xm5nujGHsf>rAr}sDT%cz$M?uIxHaG{G*78nu=k67 zpw~i5kEJ1AKDV=S?3Ki#E7icCS$aKZQ|*&eeq7X%XK8J))u%tSlNSimEf#@@n6POZ z6dL)K-Q+j#Tv;i@!YvA}s5vg;7bSdt9ka#MDL8u8>42*V-K6q4T8=5pl)Molk+K4v zH{0LWKK$nBt9viJfM**3>nhutDovz6{Y7it%sgyvBxge9ESKr;RnDf59bFZuVLu7% z4c+(U@+#v?_f_YqoP9C2G&TPbd`+D9iE)wbE3&J$@*3_6#wfdIo;3JkG%QNxZ0gv< zGb>-?4&T{u(d=mJ{kbzOhF{FH>RzsMy}C;KPRy{osD+0K*Kc}6p0!RK1B8da6dZc0 z^bsn7CrL*!^Fnppp6!2QmvYf>x!&w2`!noPyiy3~N9b`ep;#E%fBt*7WO%r1=MmDQ+{Wa5# zucSTajWllS0j@vfJD;XOR#WL8YHgi#pZNZZ@ zqf%0@yPnwST|CO?hH>Jfr6kAOp5<0IH|6-+bjRGfed};Sl-rGWV~3|&pJpguNCO+% z+%;-u-{sL7pMy$w&2E(qMrUT(WxStn;uELRCcWXU$Bm^AE;+mn8Fr-9<3^DFjRP6) zWpvzWH$d$xnKIZ5gV7O@>MlUTRCd`Hpi-@o|9YF+^)KywIM6a>wm@do zA&hmD2!>U)uDKAhgs?ckGTMmM^sQ~V@`eYQZBvTCI# z>$#`9^N2^Fd5?DE!^NUV`pZN9DaF!J7xr{;YXs{1D~F9dvNXXnS0}Cd-1vf|?fjRc zI-iBkNwUv3EW0Os$+$DeMs-8hySs<-q7ZvPvAXA!{G*L-icQb$Lw35UJ$>Jb8%Aed zguB;^-4F*z%zq#XvUDt`E_X2k_2Vdv)VmNJl8u)EMlPCrWj zg4{h5=}}5iCR-*fkPaKKw7|wdZN~1025N>;!+o~yj(Q|}>x{`|LOb-ku*0bDZ(wp+ z?v{4gc*O-f!UE^+h8d_W*j? z!F(3%j(RA&=Zwh(xjoupa*9v8k}pE1dzuv&>@Mv3Z)cP z9~cb$1_S^3_U}3>sn};k;p$gWg=gk!OZklW({B^Q2~VZNRF$6E7%ZEyJKSKIVblnp z6T71#WXsN&T$3x)4pUWpx+Cn~+}*K*cMm~2^Ljwa;)A6`DjFiP4- zb$3*l?6NZ^=jE1ZhmBA?VG?$6ZmE>Pm<6SUKC1drYcHtYGZ{8&S(M4T2`8k&Mkt*) zZZKv>X&ug|*hjTB!DR5&V{8o8=^wu&_cy=6_W}#?_3i23Gqk^-IDt( zy{Y`OePB!Oqf}RPc$lG|=h%XITR?aFr&+9weE88AGwp;n9-EZJf4DjDorB^B)M&>q z`GMNT;VWDj({sQ$NA^W2n*SRrn{EnziGc z7hO(xt}))2+FTI?&yN$dY2Pb-0*c*#DDcYO*0?&!nyNv-)$s#su-(% zS|+@)_f$CXHePA+-Gn778imiepc^Tqml)S_8aohpKAT>E;X1fe%hIUn!p^J$B$u54 zL~UuJo(>ehwXMb#&u>3ppvSGl79c$Zpu|GeVc;~Jh?xur>?)+FO96)$c1EK?kQa@M6*DjZC(R#- znpi|VK^MY|)ix5v&w*n^;R;Am4-37QPt3u2@ymdBLQjn=1jzvmD$ZM&JyOKX4Q>$@ z6eHPP_eK1Md{GQqh~f!Lyc&texIK*u1U5gf0PxB@(aQmA8P#9J7t(ofFSbjGkPkr7 zyLa>WO%d-I4vSx4NmYS+@Iv-OAyw%;@t#99A2X-r}^#llkbb^pwWd*u(gO8v? zDS09)F@w=@fl8?9&JytjOq3UW0-c3Qrx$gQFkQaJ_!k98PJRyDn~{dYo5!)Xf@K|U zEMA3>Mn?(?D&Qg>6>{0ZBb>zd;RF?~(R%oFaXK^Z}~3NKhrnC5}O7x1SZ-8!EY zhT?aXTy+ApAfy)!oM5SlTFCMt3OIh8L!6ZY3R8{pX5SN52+BMFBaRxU=d}mWvrKXD z&=xcrzG^xqTSQ^D)3a0bR#Oo|F0&!4gkK`)@Md2T za)j+!#{~I;GGAjJf@KQVqY`RS_gSVDWXp!GJgl+KGYNRj9FoS?-&N9Z;)~fi6 zdP;c2hBlhH6EI(+3n+NP!uCc)3)!B=4!$bP5_rf(;^Lm~+<8^pm4E~xb##7zxqI1*3kbHe%H?vIF>Jq}c4KRxR zXvKb_j>jq@F7(sP)DXDs5|NYRghZVzB4Nl>gy!tYTi;IRd zrMRlmvbzJ0scam_G6uOp`~ns(!&iyGVJ{S^m=@;aT-^YnutgYQ<_>I%(a*}-Q?!p<6;h<4ss*p+D- zhxA~nQ$@@U1x`+dh{jDYMv7V)>i!i?ezQ&Q4sD(rP4EyQWI{i} zhsmS^q0!o&PQ-*=TTmft$x&r85z5#qY1p#O|*)3I0aSK7}w)R$ZCzV@?umV z@Z1LT4I)mqDWEq*Bk+5AvXigi*?Ske)Ao4N%2Y^Wnpx;9oapYUG&S?~+jb;TflKVg(hZNK z=eop7$#y4?q`MwTDZ0dJ$#!>-Bn^gL1yg(wKUS@0Zg{x*vk`umMdUHht-8b#z_c?a zCfF~vo$y)ZGUf1UjuGvYM^Z~^f-bs%nXXG*p0NCe_l;JgBrJ7eLF6$Wmy~%#xTiRp z+=h>Yuc_5bbQ!;IuH4ZBW0iH%kVm>vcWw#h=Lv5TORKgVxI6F8*_HdMJB2q>L6<aTGc}Zk6kgwK(lfJ^%v4YRjxLK|CMPP;U4nJnyD!f!jmZOrRw32Flhi%M_@B9L#q(!DoQn3H+rrIIz` z^yAu(QkZukeu;tnT8);-BfU)nw;PlC>}4{aAp# zFI=!%VCllm>+DJX+!mJr$A&Fh3L>k?^p_+(V@awK8q*z$0kw)x^1e>RYo?)bKk_9< zet4MS%i!-h`USq{^mg}zJyKZaJ+Ivvi(5PutaRnAp(Yp5F}%*ND+moL(@^K$R`JOp zlXXE3v1RB>2-P;w#Ej~lm_z+r3Y{raJ!%WWS_D(8BFl|ndFwMrf6Q#)xQ*3q`6O-? zpByrmhk}3cnt%V2iyvp68awmU=Ev){PTScLaL9KzSnV~_^-h{E{yqWOVxFd?FyH-B z{<^Ev4>1%Id@FU=_$r{9w8GIXaGXk&FRvOlVgJ0X$9;?@xq7+PR$uEO^Bd^~GEO-2 z0|xRNWUm#QZ2;7-uGU+FrdV)Ygf#cC8?Oo$E(*Kw&}U<-k5i6sOa3$?(kG-I6|*W+GWOwR zG^N86P~=S$KT5p7kU0UhR-aaeG5X^Rw$PLwO+=A>l_XvO3Qu<9A3o4ldOUyrHA9=L zTdnt~CKOlR!pzsapB^Xo`0e5LM-;HO;&ul^z4jW4T= zdbfGm-lvOqUAy0|Vo>&Z=d#C7pX@Pwe&X34>sVFKvPsY09b&9BvkV0J?XaSDSlNj? zgIyRN(paWu7N->ur&SntF-2zXOOu<0qwX*Iu+rKxa9Rg!W(Q2ob}0OOxtiHat$>$W zg)b%G_LhP69WbX3*l7v)>?1jBRqu1;D$qwZdnxq`r8|WOl%^CGc4g9+^-Qq3$&D|cL=1ahFmVts!m^2wCV?R{Qx+*oZVy%E; zt-@jn_!G-OLo#d!86>-STD^*kiZ+ZIsCmO$(|`)C!U_p|f@R=+GAxV?dn`eJo~UM4 ztrbwMRah+n&#(+6lVLP6j3EJ^U9DzTuN6?QRaoB-2Yo%8V;QJRfvu&$HaHAbp9SHY zwE~*83Y#V9i!1}LQD8SHuv-#v>l!t)4y}L=t-=loIMFh&h61anz?vlB!)n#cK$liP zmsVky1ir~Ka3K}8m*R*u?o!Sf@OBWvL*0Y5PmEoU@W3= z>`?s5nO1>gXs~fKn3B^_HP#JkW)lzr6A*c%ungkqa6)2#=q`P4yQ~85cf-QEVUGv!z3}r*YG#^<08K=p zrUcy5Dv;a_qjkd=67bp0YG#WO0gDlZizVR4tO9ib9k!MZ+u%G@eHMgYjtE$eC|oXq zcd`n+Mu**?!){5yty|R0RwDvdBMMhbz&))3Yv{0gI;=?oKCD&EOdk=Tk0{iK;Na`* zdci7iVGnF^4{X_~p=!b*{02n821MZo33`94K<^&d*&f&h3HZ7;HM6aVfUStatrGCN zR)P6Fu%aGVnFJg(LU?0DfH9)bSOOmoRXqbXlL1pZJygy4b~Urzh=ARQ!rc<^7^vzQ zFee7=v;=&1hnkrgBESq$XeI$qhKim6%VfYnwgf&4!doE%tPq7(68KE0>Y1={Oqi0( zP&IiSYInt0gd%DvJOzS_u}s)x3Ak;i+Fffzp*2GB@Bkc$7q*le~16k@Du&}w(veVKbn3Bdm`4zYziO=%N+{jSxO8dbj=bnlfv#w&*jN+ z$x2-EqU6^;%2&p#hE3S0%OcvoAX$ZpkFj4jk)_UrjJZ59_Fuxm&!zt^yUxE-k0xUs zcPfD&?8SG*{y{7FsXg-tHQy)`@C)nY4{Cay2ZQfr{&N|Ai~hUp;Aahrf2aoh%Q5#4 z>A%Yk#BU>ikM+IIgP$-T{V(2tpFTVOy&CXeC*QxP|1LZDu}i`KtOoqdh3Oxt0smp$ z`v>&jWd{Qv<@~rYX#Rp!`#^jQS>sqbZXX&IKK)I%gyk=^U@4>7C5#*6Fk-86Wpjyq~CA!99JTC?(Ox6QJ}k!m2srq;^xtI%|1?=aOiic?MWa1=!h(TM z9(%p~kKTYE=;e2l{YI|ff9lyGh4X{z2K=Hr`zvq2k16B7qW>;C=>5R@2M?mfL}e~{6P(Lso;msXMdpoE<55D3ukHLi`tP!Xze|>oNTk2<-XL%>=jad}By}Uo;(k!}?w|KzwJ%?-`-J&V!#Z_x@F~-^djV z+K@CiQsKsUNcf#d<^l%$srZ|kN6)AAG*VZvjPAVL$ML7T~|wZTy)X`h@*&b|-(v|7bY))?R7*@)~t*@iC;%+#OO2 zpskgG_!v@_cc87+e}NBt!v2%!`#-<`Yj*IL>i^~=rU>3&YzK@bwo3HsQ#fee^${OK zcUA?4th;up5EeQ>R`t-q?@mcCfCj&T_>ge<6K}gqt@Z{!Mo zQJwvj8X*3B`4?ILtuY0D#-RGEWWSLs7`UGQtH$6TCJ^wSXORCP*>B_ue(-$Or{c-S`Js2Hy*o`wM&)97G6Lo=Y6pEc@BhNU{a>CV^%V?u`(|12-A?|8mi|i~ z(EoMUAw?uKKEf(l*I_@^g&jSqR-So7?S}R}sdd-wZt53pC>-Ou;Ne??>i4g16oFRP zI6G8FYm@h@IwOxo$G)U286Jy4aLISf;|t%kzL~kbUK6^ApVFDE(nEy$^jpp18u(WG zuWAP0m-thqKsRar&h+cScYElkrq2bxH+@a;y#oEY>65{4?S0J_=o0$hj?bT*{vGho zr;48<1^kZmGdhz^dx#q1p$`UT%4bj^4r%{Y&EPl0^tQyuS-GJyy71gnz#-E>K3v{+ z(FOWtVnz^EqlXAy}Qhbcley zSofcs!4F~i_axvyCWPyBLe!eJC(EaaZNa^&@#NqQ2pJDa*HdKV7hctx`ex!M4@z=H z9>#OSx0<2x|IPMa)eOEb@o%IE)IF>HShu+3H@%6fJFu+q;;)3ZCR-2IRRZ))Q3mwg z%%X2LgTHA1Rn0(L`hPF+r%D0ew&kbB92ye*-t>#WH>={8rSA)VYws69-{=14W94VK z0)A)lLxl)_Z~7O&ZyfvLaR~nLO!*^wpTdflkr%9cDBoBA!4&d~w%Wo|=;NtF=I#5x zq8a>#m_tKC^)JrzGzafd@6C9_YcKh_Zuc)|r$o8aSqrre`^ee+-k z-Gi29m#Xu>HiI9+^6yDN{l8WI7cu*Hk8KjWM?==dh#|X2VoF=BRxo7@)C~Tj{Z}=E zAzFS!%)b5LiByUGU`a+;+mQXBi$iL1UW6xf6J}T&_>1;m)eMGc`BgE8h6G=S+fZA6 znH2D~D}Gtbp&`NGyM*78ekvHO#NU#>3>bKm^n21z1_J}}_oOcm`rjk|ru6eb|2*`Y z(qGvB{_}UG9|8KN;i2hMk_i~^$&MoS`Ic-n|8wp=i#K(}t)*)X+pA9z_>?CM+DM#* zu+4fKcH1Ni)%&ZWwm>_GAd+sXp0Ey^hxB%_IVpR7TllV#%bqxw2)ff+DstdnCpnP{ ziww06I@+9Se=t{kZ4Sdy@5OU$MB5s&Zb zCr-{otm}b;c+~ZY8DbB?R}&%8Tl%TDNl>p6^oiEj?HAoky^7ICy^|rKxx8O@%z1 zXVw5S$P*h~*H2w1L4Bd0+IN7Oz=yhUfw0ewMS4Ts;IjLT<_s|R5;ye|H%SoR?I#X4 zCW9fx;nB~0MuK@2x8G-OKQq*Y=efOI_y{jK44@v2>8Fm9(EOpFxln@HgNOD4;<5|m zNr6A5&g^H-l3-R9^|>(vIn<3*e!tIr3F4l9;-0U>0McC6&s;IU40T~uUl%g} z)P-C@ztKVoYEC~j=PNbD+}6+BF~Hp0gQ@+RUqhNf|11R+$0||3(IN?EVL!7_f?3ed zERtZ3lS=QM3%H6>L+!wi?CPBi+OSgn)KDKHM|VM03Yys3Xa3lW^;4w#r07l1A3A%) z$@J47kffi|PhTQER5|%PA6A7wIRW~w)b^^>bZ@#7`viNpKpjR)v^ zyEJZMzjhO83FA!%jF*uhKRH0I0wma{jOb^#mSmsY&;CG?-FCqD5&i7qZgmuQYZLqJ z*+2EF`N)2D*RSkAZ2a_o`YuU&Fkt*B3Gw6K^gA|*lS{p9*E31_f< z_pjqcH96v)UC7S`nq-ApSl$vDW=D57h1jY$pkz5*L;yvwO(ge z$^{)g(Tbk<5*3xG`(R-E0G+6ZP9Q3>j7U}wTn07?y!b5jNww^B3x(C*7tU4<>;%YJ zW8v0rjr5bcojtlEP7JK~$qK8zZr5Kc$YaEp1Wk?_+?zn;XD{}XAD18pk*fXdF_P@| z{p>XZ>;-wKWH{Qbt#J&(J6(FnrhyWK((AP1x@B#HqhCvGKZ4M&g7j*;igTtclGw*I zO`$yY*Gcc8jo1z(wnQ9gm+>ureEq-W{~o=Cc<+Y#CO@(1uks~9c#o`%o{PLwe;Cgp zP`VSKPMmTbjt+3aaso`Ty8?`{BLAsaw*W%`5YS!{Ilq73CR*VDci}$FGG_A|3$w1H zP6Z6<_7at8R@{ff{xFCt+x*5Nyo)65C+T(eZ14@jx1Px*$p{10}y|_l%cSqVlHR`}ko&p%qbI+JWcy}-y&&d(> z-W*4X2!u{ymPlBV3>UI%2qGfjcgBg5dZ4F=p>9D`d8rB_4i4bcy##6Ob{ztTn-GU; zgeZ?Q{0g`&sFq_WZ(%UWTX=#I9oOjB(oI;%MePJuFpvj@ZAp7s)$X`r%?p>84w z+-FClao(J471ZsWz#PHIBWUr<3Pdf!wqPJ&61?d3C<2cqVrRn%f*^u0O+XW{>eGNA zizMRuQbj!#1TIaqSR~-VF>E2=3#h^_vW|#P_9gIZ{6*57ASyvrPX+X70$C7=25dSG zLyJUX_>ltWIdv^Wz;5!VUZx_wSdF6d`gl%#8dW5KV>ppiB)_XZn2KS4-T(x&IAIG5 zE+of^B7`*2Fv-X7y?A3ZV(cL)sdV#U*ayr>*qJvcfbtD#(=x-D?r7E4c}5@Xt+x{^ ztti2lU(B-`W01XhNs{#;JTHCQ)9rikJTn7%)>Lkn$p;V99mU8abvZl{jYXYK;?;B8 z5;o}=>%D^4rn~}FZf;g^99)+}AISo&vSVl#j}@v*@aSfgH3q9!z^g?$j0EVV^&&o; z3Pj}Qw&VigNpAtamMLc(>g^~(6ySiUHjS$1$qwqE#)f7%(*+DH+26~V-jf#HLKJa( z3aENKVH=~Y0)Z)X^(S3ZQDM|jW2ft;h`N<(J-Kt^oap&F{?o0%NqSct$r9- z{E}#-ZY@RV`QB84DiE|WMGZ_*39Tf(HSZXgQj`^`Q{aGi7k*-XNG?bZMb=KETIsII zDE1Id$=Lp$s8Z*^oDG-_KrA@_U z`{xm9FHq_C5qfEEyzX%S4k8XyZ7x{h-P0DCA|mkMco8F<7~&vekrI(nQ%6|`|b(+j5S z0$P}Yh}+4@cTF~);DBN_$-g72T#Zy2T_2fll_l>0g;AVJIPU|7H%ss-h=0FWtJrwWNl=Zibz^vN z?kj6;}gZq z)!@d9K5l#0$Z|}l>xKxy6V$c_n&*m(t4RxyZMTdFt$6Mpoeg|vUaILzVc-;$U}7sf zpO>n#QaJIFD@KkDw)o{xdF*zco{R7#fpX+JspomE``rYTqZJE!{9@hndVk@K0&hP4 zx`03q?qYjSLwTbj{Q&f6&Qts(qd0s_36f=h|2nC>jgrjh1{!U_qW!q*q?)!*uCA_N z+vCP$R}Tg`hV%%{hWIMlJ|W)Lq$sqp$#>fQt-2#nA&so#sdQZ~cEqe{1k|o%ydIjn zH3wayEex)m+tiqBgJ-u9w3`}rj^gRpY|^KB)#ObjOmj}>-S09UZDSN^VpF%s3jiBSEX?7xQC ztpK-57f}?r++=w7IIqnsL;?Qw>l!$|UNCszC*+r1?0_WCy9IUD-5lRFXB6hQz)zl0 zc+vu2(smF!tAaqT2bd7OM>y~vfkr*95rC>&HHNsxk!1V|!W)IE`$8GPlPW=!@&)%`fH!X51X>mQE24k{{_n;<-+dUq2f|ZSbi;IHVzcl6aTmz?`^EReF z!o|+KTC=G}c(qJtuPc6}3o1H~h)dvhG~D;E@=nx(7dkfyu8rhJ39d~*o)B3O9w0BL z;a&$39wFWe&+A1hRDtIh8Tdq3(%7J$r7I|eQ9Zj>P(-7P3+mbJ@(fCna%AqM*xXCU zP}MEQ$;hsJU&395u_ytt05oIa4zajeXltA2i9(0>BmmAlJcAR7s)MsRL{1Bpz`Leb zNw=SqCH&-XgxieF6mfZt=+Ja}HWkAtiX?CZO)Ui8L@WA9&my8}wO}H568v6hJ2MB} zX7BaXxVGn(5rXlhy?wRU6Jr$qrm(gy5pmkxowt)(YiZyGu5%`PKT&WpkB;V{Gx!LU z1&V~k+)Wa>Cx@avYJ+m<%~($oGK0g7Q@|msGRiY)mz+J{l0V~9-Ms{-cm!h#;R5`W z7o{YJyq9N4c!+y#+M|-}PbBB??fDNL`<3FJD)J!mmTMi z6Y&T_z~K-`k#TQl8hAN!tKk>?JPE0(9>UIgR4iU3CP;yH?0}h*{w+Rb~GkD#=D&oczLP_$qZ~9 zmB5JR$bv5OrkH&g4A;hq&IqqjX#*A43k048EkXgs8SdcVg5z$>DF{cc&|@$&TX0-f zg)t6FzJp}tmD5>ES@}sc2`7#0V&8>VTYwBtYAf3ZxRn^QeM{8&-E9mVZbuHiCU{2< z^vKO%OgSk;uY-!74m~#3yONQCo{ka;ig2-TYHiCZJ%K5j+eTOgyt$&(TH$+o2RzgY zWQcCjU9VH07u0qwMZ?|rA33!xrltVTLL0|A5Yj?xcoc41GJqW7(re6nbf!IMoCobY z%5ZphZYwcbfXhR^Wmwh;Sym^RwnDDi_0_b4pO%%mp0o}3DNPn7cMW2+i!W zIAjXHk#Ha1u#5sKPUfO(^h7;NJzAJE@V6+VKL~ZET&I>AC!yHD^E9LLTJ0GNe4BY< z9w~FGCFUf{u(5lCC&w4wW=hT~;Ux&DNqW$Zi6sr61}=MY9HfwSZuNc&pGCJ@4GFnz z-XEII!#NQGZ9I2+6yyHAj_gTliGEGVM@RBwn~;74FV0x9pMRIiT~sJV&=4-5zN_tu zeD}EUIl(OpQ5I)J-Gmgj*EVfp=?TBonx^U093!-B2_@0J?p>#9kaY1S7FQlMg1b50 zIgc&$_LUqoq&uwgZ141FPhZ2>IR{~!aWikE?W$)sM?0SQbv()IF5PzH1$0IW{;VxN z!t7aOY;0s~-Y&$3cIZrsSA0a)y`zbtsVSkU3ym`-+CXQTx-;*MwY`_`(N6Jb$3riD z^p(`5oB^dwn@b%fh%E*dILsDeNgP?qJ~WXY$+~uQpQ*Fq`0^Ty z5@t_)Na4{%$xGSqmOkr9nJe1?FtIkC9{Ws=FTFEG)oZqDxsoN;=k%tEm%6%|^IuN> zxNr=9@tBT#v0L7xM%q?|PFGf{8r>Xm(KLL2?Y3r;nY#z~^qz`22W!ff*&8e5$=Q!^ zHebim6{N=62F6o5?j=R~UbwLQe9H5K>`;v5=Ve>=xw;ql`jl~#mP1k{l?8j52bV+2 z9zj7*aiprGtF||n&n1;dh(*{8ikRzci$&X(h(){{6oE}i%uQBKlogg7VEdo1M=C^! z^zR5K9OU}@1M2Gdk_w)I2X=7}S|7>wwbl9-*GC7IajJPV=ArXC5@vOOgFR8p-tn~Dt(!I{M z(P-NavC`B5rP%t1i`6d>N+t>STC+W`wV{_s5Quk#F4o`1YAMp~?aiT#iXmr8nuZ-8 ziJksv+AMMKs=?rP&E?BUTLUU@A@J<*Gji+&j@JdQ_$lgOnc9LR?d$nc#`bAp$nfrpL5;;o4 z59+Tm32AY1y)Yr`mDCt(`;&Gpriz!O`x%TTHo|saP0WR8*Df0Ic38Iibk{(vbJ~`N zv#wmx1Pk6qnS>i$Z$CZ#mUG&UN3#O2Y+tbciOFMw+aGEvb=3}mPcB8w3YK$PQ2Hbw z(&L)3QV7w|Kxy2b!@Fnhys%Yi$(}f;r{^HHr?Z}3dA8u~QsdmJ5fF7H{t z_lDVp<4RBWyx*JgE=KMXd2Y&;)&(!372^ytsmwm}pQYXQn|+lrySaaK=u?5LUUjaN^QOFbK%~FQK9D>3ePrIBgm*l7P&^c|k9jod~<)^p@@Khh@}nD(QcK1U=I{zLit#G9gIG4xzCk^O@8kG38Ov$C)pe6s#2D zxL0bW{Jr7v2@l$oUe>%lCPOTcGt$>M^FU0xA0jOcyWyLZOq8{e33&Nni_|vxdsb_S zh9P_7bz|y=C&Di6FYFUEcaLAg8vitn5aO5@;xM*-Q7Yt;;*>&O@0B_TMOl<2jsj}l z+Q|?<%NZTlIP($;v(LO&dqdJWvb~J@D<%DEv6&0|%rwt*Nj^8BR&EAbY^JjIg-)Ba z%OtrZf>MZ-snl69>8n2Jjxxl0IipJ&XWoiAE&4cLog+KRsJ~az{{nGt*Xrd2&n+@t zQqE0imYd-WkuF!!&VY%=D6r$);-NfG$tb2md4~7px!pAWer%mjDr~c5;hZyNGKP@# zb7M6stxO%ySxUdvg8HqquiuDDcF7tm>SLu&szBMh_GM3%OCl+SoH3Qk6bnu76AIj9 zh%`B)_Znxuh?QCu_MjRKAWKc-b>r%WXTUC5L803ELX~S&#+o|5v64QFfI>0*LQ$3M zNE$27#!0;qhw|zR#gR**D}@xAO0|i*6W{ERb@CnF~HR_&zt1FH{Y%AS=8cnw)1Kf1T;r|yf_jjtP? z3A=Ov(kVNl*AivTO09j4Zw^QwUIghJaXvycerc6ML*_a1c^P#@W&Kg-=Sp8))Ej9r zIGE}3=G+81`570WP<#7Ay^25Jc`#GP6)rFjq92(Rx!6V!_Xyd#;icVrYlLb0;?VqGGj%>&h>pd z{gmxgz`_*=UrH@eg@lIp3Ed>02y);oA6&A;Mru#M#(6aLOR#=>AVW*Osre! zQwrOB0uuVT&rqXR3bU z#aIul?8}fzqd$?y#@EdyIag7<+`p{(@^nY&i*sin{*n;%AioN=SEtXvoZ*8EZJt0303LKCupJ1AH45rw5s%*(C z$gT6yBgoy-*~74t35M7#ya#sU+YsrMSp7m(?4dW4u`1a|{=|euy_qY$39C?~ilt^j zfi>Rm315+b&3t32DBi~ux?rO>_U?o}krO(yprEMi(FrbDM`7dkhQ#idHXU08)^pQm6GKTaB1 ztsH(Qj5K(ze;U7pwX*Z{$P;?A`*yzyeRKPie4BSzR?n3cL8T3iej%B<$|8BY!5mQ94&z6}!D& z6`js(R*&R?&HNHF z{JeA8FswtH3ifgRWUO(+;a;N0^|K~7tP! z)}qA&OX>`nw9(PQ^~|>d?Px#yXYw8GQ?Wvd+@wpN$k)F)1ZPqqd8PqSd5y&ylP$k7 z6lYS^usF)H1X3qt6OJ-EL80>~3@o%*aWIhxcf^d`urfO>$RyqC4CBmcO1n^%bcrN9 z({%6jJ7Xu;F{4+O4=YcJ=Gl5#V|c@E+m^3HX4foR)Vfb$UrQ_{j&_YpR@fJ7c1$qQ zwc>TWweErYX{m&FI+SGOfs~{7S-Yw&hYj-|Ab#IR+zb(eF{5<{nL#geN-r~ekXhKr zED|$!4KfS+m_=e{v{d}`_)nHKs7B8Tj=dbo(vh#yM%0cv&?_YEoor%_@Qh%Ye&YQG z&N83GgZ=t=RzT|Q@+&8=q}g_5NIkLd%LT6@m2&N2NCQRH3!(2Zr8n(#s*Jv4_&hNy zNj{+AVy{C~Pw9Z_c^~FeVl!E=M=1w;bLLq|t=Ac(bQq-kJV=@PmC_9ia&`}Jp4F$y zl=nx{9~3I}_A4n`Od9L-1`T-t#uTC>-7sV7(F2A-@};j2$d{WsDF57Gs1d#c@)be- zp#C?5`VX%d(BI}bsDI`l=hm;BR|Ywizj8JXQob9YoKfs~c3HnsCjBJ?>0Emq3<$j< zCQY*q38*G3RHBC6tezi=T6bq^e{G2Ezd0Cd!Cs2qW>9|B zSNS2~V4zD^edTN!X@fRD8kD(JOi59NZ&!!5l3$!8&Euy5f0(%@_N|)68hzOq-5TwpkvpSGk@R1>_3?v=1JB-XH8e1+#ho+=u0T zBLw-y5jkhf*exge!cOlVem#PD5oZ#DyUf&<_$$Ir$uV26hsl5hA4_Bfb}oS>cRw~ zt)C)u3@2S`K&gGu&BIA;E1J8r-0)G<)0aE7JWpp1k3O@o@uf{PhIP-Zc<-lIS~`<1 zyDvpDK)Gtd{5%I4GH5mVAkK5G7dEF3YeqQpaNSzR!!fBJ5_Vpr>9ky8a`h-gnU?e zZIttt&D*EaP@&5Ae9tR7%qwOw<20mNMV`O;40!_sS`=#iT}rqkp>AGKiN?NTxI zXS?-x;X9f)`lMLTx#!QmAa!G%tYR2N)ld56)8!A{DcsW@4Gvp+L{l}tD8x*Ud8+$m z4dAWPb<+8gfgFAD;$lJV43b`vpYq7s6V79=lpTE}dD4za80xQ*tPwdYlQbgAAHSrZ{zK#F9thv zOD_YRhIbF;vh$x5JB8=(yeBiUsj5|}ab}dYQszd6RxIp~Gwfm1QMUm*^+jN>uN-t!yoY)4AlGjZMk-oakL2OPYm{>$ zR>?(~OEH-z#S5LwFKIR4ZFru5vaWgbB~wkSrb_KOILI{M634V{AdZb`+A+{cwX4>d zTjPuciQ5^*l|*uuQL|>@dybKmIGAT#N(>|z)ddSrN%9vEz*$PhF4~jvMCHYY1req; X6N4zHY0*SLIOxDcmo2mm>pp$}33#M; diff --git a/tests/fixtures/cmdb/topdesk-missing-middel-id.xlsx b/tests/fixtures/cmdb/topdesk-missing-appid.xlsx similarity index 67% rename from tests/fixtures/cmdb/topdesk-missing-middel-id.xlsx rename to tests/fixtures/cmdb/topdesk-missing-appid.xlsx index ab6391f23ef0c9aa66cb8d397d3692dfd8300abe..a88486e5c9d6d904e1a3c8b4bfff281d1bbf3567 100644 GIT binary patch delta 33821 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 delta 38436 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 ILV)7-Ka{nXN&o-= delta 60684 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 From 71252557a60df50a8826be6b1e0796f7e9f5ed31 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 1 Oct 2026 22:18:54 +0200 Subject: [PATCH 4/4] fix(cmdb-import): Dutch for the module source fields, declared admin-only auth, spec anchors that exist check:schema-l10n: the five external-id properties had no catalogue key. format: prettier on the e2e spec. Hydra gate 5: import() and cancel() are admin-only by having no NoAdminRequired; they now say so with an @auth admin-only declaration. Hydra gate 46: the two test copies of OpenRegister classes pointed at a spec that lives in the openregister repository; they now carry a reason-bearing @spec exclude naming it. --- l10n/en.js | 12 +++++++++- l10n/en.json | 12 +++++++++- l10n/nl.js | 12 +++++++++- l10n/nl.json | 12 +++++++++- lib/Controller/CmdbImportController.php | 4 ++++ .../Support/OpenRegister/MappingEngine.php | 8 +++---- .../OpenRegister/PackDefinitionValidator.php | 8 +++---- tests/e2e/spec-coverage/cmdb-import.spec.ts | 23 +++++++++++-------- 8 files changed, 70 insertions(+), 21 deletions(-) diff --git a/l10n/en.js b/l10n/en.js index dcc68178..bc8c34a5 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1005,7 +1005,17 @@ OC.L10N.register( "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s", "step \"%s\" failed": "step \"%s\" failed", "Column \"%s\": formula without a cached value, read as empty": "Column \"%s\": formula without a cached value, read as empty", - "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it." + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.", + "Source id": "Source id", + "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.", + "Source number": "Source number", + "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.", + "Import key": "Import key", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.", + "Created in source": "Created in source", + "The date the application was registered in the source system.": "The date the application was registered in the source system.", + "Changed in source": "Changed in source", + "The date the application was last changed in the source system.": "The date the application was last changed in the source system." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 57d77e96..e0c79ed2 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1004,6 +1004,16 @@ "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s", "step \"%s\" failed": "step \"%s\" failed", "Column \"%s\": formula without a cached value, read as empty": "Column \"%s\": formula without a cached value, read as empty", - "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it." + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.", + "Source id": "Source id", + "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.", + "Source number": "Source number", + "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.", + "Import key": "Import key", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.", + "Created in source": "Created in source", + "The date the application was registered in the source system.": "The date the application was registered in the source system.", + "Changed in source": "Changed in source", + "The date the application was last changed in the source system.": "The date the application was last changed in the source system." } } diff --git a/l10n/nl.js b/l10n/nl.js index 6bfeb530..bf4cfa05 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1075,7 +1075,17 @@ OC.L10N.register( "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s", "step \"%s\" failed": "stap \"%s\" mislukt", "Column \"%s\": formula without a cached value, read as empty": "Kolom \"%s\": formule zonder opgeslagen waarde, gelezen als leeg", - "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert." + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert.", + "Source id": "Bron-id", + "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "De code van de applicatie in het bronsysteem waaruit ze is geïmporteerd, zoals de TOPdesk Applicatie Code (het Middel-ID). Ter informatie; ze kan in de bron veranderen, dus records worden er niet op gekoppeld.", + "Source number": "Bronnummer", + "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "Het applicatienummer in het bronsysteem, zoals het APPID van TOPdesk (ICT Applicatienummer). Een herhaalde CMDB-import koppelt erop, via de importsleutel.", + "Import key": "Importsleutel", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; niet aanpassen.", + "Created in source": "Aangemaakt in de bron", + "The date the application was registered in the source system.": "De datum waarop de applicatie in het bronsysteem is geregistreerd.", + "Changed in source": "Gewijzigd in de bron", + "The date the application was last changed in the source system.": "De datum waarop de applicatie in het bronsysteem het laatst is gewijzigd." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index e69bcc95..a0f4e4fa 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1074,6 +1074,16 @@ "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s", "step \"%s\" failed": "stap \"%s\" mislukt", "Column \"%s\": formula without a cached value, read as empty": "Kolom \"%s\": formule zonder opgeslagen waarde, gelezen als leeg", - "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert." + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert.", + "Source id": "Bron-id", + "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.": "De code van de applicatie in het bronsysteem waaruit ze is geïmporteerd, zoals de TOPdesk Applicatie Code (het Middel-ID). Ter informatie; ze kan in de bron veranderen, dus records worden er niet op gekoppeld.", + "Source number": "Bronnummer", + "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.": "Het applicatienummer in het bronsysteem, zoals het APPID van TOPdesk (ICT Applicatienummer). Een herhaalde CMDB-import koppelt erop, via de importsleutel.", + "Import key": "Importsleutel", + "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.": "De sleutel waarop een herhaalde import deze applicatie koppelt: topdesk::. Gezet door de CMDB-import; niet aanpassen.", + "Created in source": "Aangemaakt in de bron", + "The date the application was registered in the source system.": "De datum waarop de applicatie in het bronsysteem is geregistreerd.", + "Changed in source": "Gewijzigd in de bron", + "The date the application was last changed in the source system.": "De datum waarop de applicatie in het bronsysteem het laatst is gewijzigd." } } diff --git a/lib/Controller/CmdbImportController.php b/lib/Controller/CmdbImportController.php index 337b4a81..821deaec 100644 --- a/lib/Controller/CmdbImportController.php +++ b/lib/Controller/CmdbImportController.php @@ -83,6 +83,8 @@ public function __construct( * * @return JSONResponse The report (200), or an error envelope with the contract code. * + * @auth admin-only importing a CMDB export rewrites the catalogue of a whole municipality, so only a Nextcloud admin runs it. + * * @spec openspec/changes/cmdb-export-import/tasks.md#task-8 */ public function import(): JSONResponse { @@ -162,6 +164,8 @@ private function validateRequest(): array|JSONResponse { * * @return JSONResponse `{success, cancelRequested}`, or 404 OPERATION_NOT_FOUND. * + * @auth admin-only cancelling an import is part of running it, so only a Nextcloud admin may do it (no NoAdminRequired, CSRF checked). + * * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 */ public function cancel(string $operationId): JSONResponse { diff --git a/tests/Unit/Support/OpenRegister/MappingEngine.php b/tests/Unit/Support/OpenRegister/MappingEngine.php index 475b263f..9e06f56c 100644 --- a/tests/Unit/Support/OpenRegister/MappingEngine.php +++ b/tests/Unit/Support/OpenRegister/MappingEngine.php @@ -32,7 +32,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/specs/migration-mapping-packs/spec.md + * @spec exclude test copy of an OpenRegister class; its spec is migration-mapping-packs in the openregister repository */ declare(strict_types=1); @@ -54,7 +54,7 @@ * Maps one source row (CSV row / Excel row / decoded JSON object) onto a set * of target schema-property values, per a migration-pack definition. * - * @spec openspec/specs/migration-mapping-packs/spec.md + * @spec exclude test copy of an OpenRegister class; its spec is migration-mapping-packs in the openregister repository */ class MappingEngine { /** @@ -66,7 +66,7 @@ class MappingEngine { * * @return array{data: array, errors: list} * - * @spec openspec/specs/migration-mapping-packs/spec.md#the-mapping-engine-must-apply-pack-transforms-per-row-and-never-leak-unresolved-references + * @spec exclude test copy of an OpenRegister class; its spec is migration-mapping-packs in the openregister repository */ public function mapRow(array $pack, array $sourceRow, int $rowNumber): array { $data = $pack['defaults'] ?? []; @@ -140,7 +140,7 @@ public function mapRow(array $pack, array $sourceRow, int $rowNumber): array { * * @return bool * - * @spec openspec/specs/migration-mapping-packs/spec.md + * @spec exclude test copy of an OpenRegister class; its spec is migration-mapping-packs in the openregister repository */ public function isRowSkipped(array $pack, int $rowNumber): bool { $skipRows = $pack['skipRows'] ?? []; diff --git a/tests/Unit/Support/OpenRegister/PackDefinitionValidator.php b/tests/Unit/Support/OpenRegister/PackDefinitionValidator.php index 850fd8e1..6d4afb1a 100644 --- a/tests/Unit/Support/OpenRegister/PackDefinitionValidator.php +++ b/tests/Unit/Support/OpenRegister/PackDefinitionValidator.php @@ -26,7 +26,7 @@ * * @link https://OpenRegister.app * - * @spec openspec/specs/migration-mapping-packs/spec.md + * @spec exclude test copy of an OpenRegister class; its spec is migration-mapping-packs in the openregister repository */ declare(strict_types=1); @@ -47,7 +47,7 @@ /** * Structural + business-rule validator for a migration-pack JSON document. * - * @spec openspec/specs/migration-mapping-packs/spec.md + * @spec exclude test copy of an OpenRegister class; its spec is migration-mapping-packs in the openregister repository * * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) One small, independently-testable validate*() method * per pack-document field keeps each check simple; the class total sums them, not any single method. @@ -81,7 +81,7 @@ class PackDefinitionValidator { * * @return string[] List of validation error messages. Empty when valid. * - * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + * @spec exclude test copy of an OpenRegister class; its spec is migration-mapping-packs in the openregister repository */ public function validate(array $definition): array { $errors = []; @@ -107,7 +107,7 @@ public function validate(array $definition): array { * * @throws InvalidArgumentException When the definition is invalid. The message joins every error found. * - * @spec openspec/specs/migration-mapping-packs/spec.md#the-system-must-validate-migration-pack-definitions-structurally-before-storing-them + * @spec exclude test copy of an OpenRegister class; its spec is migration-mapping-packs in the openregister repository */ public function assertValid(array $definition): void { $errors = $this->validate(definition: $definition); diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts index c9129ffd..29927a1a 100644 --- a/tests/e2e/spec-coverage/cmdb-import.spec.ts +++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -45,10 +45,7 @@ import { const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/cmdb') const EXPORT_FIXTURE = path.join(FIXTURES_DIR, 'topdesk-export-anonymised.xlsx') -const MISSING_COLUMN_FIXTURE = path.join( - FIXTURES_DIR, - 'topdesk-missing-appid.xlsx', -) +const MISSING_COLUMN_FIXTURE = path.join(FIXTURES_DIR, 'topdesk-missing-appid.xlsx') // The owner values the anonymised export holds (tests/fixtures/cmdb/README.md). const OWNER_VALUES = ['Achternaam', 'Voornaam', 'Teamleider Applicatiebeheer'] @@ -505,9 +502,12 @@ test.describe.serial('CMDB import section', () => { }) try { // Prove the context is anonymous before trusting an empty answer. - const whoami = await anonymous.get('/ocs/v2.php/cloud/user?format=json', { - headers: { 'OCS-APIRequest': 'true' }, - }) + const whoami = await anonymous.get( + '/ocs/v2.php/cloud/user?format=json', + { + headers: { 'OCS-APIRequest': 'true' }, + }, + ) expect(whoami.status(), 'the context must not be signed in').toBe(401) // OpenRegister: no contact person and no usage for an anonymous caller. @@ -520,7 +520,10 @@ test.describe.serial('CMDB import section', () => { ) if (res.ok()) { const body = await res.json() - expect(body.total ?? (body.results ?? []).length, `schema ${schema}`).toBe(0) + expect( + body.total ?? (body.results ?? []).length, + `schema ${schema}`, + ).toBe(0) } else { expect([401, 403], `schema ${schema}`).toContain(res.status()) } @@ -548,7 +551,9 @@ test.describe.serial('CMDB import section', () => { const ids = Array.isArray(value) ? value : [value] for (const id of ids) { expect( - id === null || id === undefined || typeof id === 'string', + id === null + || id === undefined + || typeof id === 'string', `${field} of search hit ${String(hit.id)} is an id or empty`, ).toBe(true) }

    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 JfHvn4{|BWKlYsyL literal 0 HcmV?d00001 diff --git a/tests/fixtures/cmdb/topdesk-missing-middel-id.xlsx b/tests/fixtures/cmdb/topdesk-missing-middel-id.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ab6391f23ef0c9aa66cb8d397d3692dfd8300abe GIT binary patch literal 160513 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 )217pmQCES-3w;*zM6MpId6zI;X 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/cmdb/topdesk-no-source-sheet.xlsx b/tests/fixtures/cmdb/topdesk-no-source-sheet.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fb7b2fe9971785d031121f15ec75a6ccbc68f4ba GIT binary patch literal 1589 zcmWIWW@Zs#U|`??V#S!F`m(hQj0_BVObiUdK#^$Y{JfIXyps5k%7WD5SiOqeoV63r z`W-e9XnAkb`ZQbA`e}oJ*Cpphiz4RMdrf>t{988Z)V=iPYIuW zQmX8L$F1G7*1MjRw0au-_SdZa@8r6p&uKJfAFOCMwO9J+TO9hfkFoRVhQ+h^HCwGU zJ$W-4TlimWy7V(c@oDm#R0dP0{!hDf@|S$lTj=dNtNg>VUB~&pscmf6jGy1-Vshl> zx0E@NrtJbfVG}m#@vr;qrg@HK;^gjjHrb4+yfJYqXHK1OD1K+T-{*?%Ch?A!oH=DD zPo5~3KW_fs{p0UR+`6^bGh5e&SI<^%a&pPYbnv;>k;=t=Ywf>h`e~C+NBl3Ixc={a z+b5syo@Ea}2{5+E<4qfZ0i^*9G;SarUzD0ttgivsq$pfBwdx zWLEzeP{Rm(Mgut`_OMy zpwyGyHusDF=Q(^$+~mgnp;xbA>*~YxIj?p(KJ2_w>n2?vQlmOaXYWCcgqc4k|EW-x z+T6ml-s|7Tgs)7fK3k=>_|P7pkEDS<;|J0eIr`=KMcGOD`Prak(CdFT?~nn{zW16+ z_Z3?Yn}p_06mWWECwCw_Mrz`X&^doUeEWQC$NQAuoA>R{`#yE@?*)qjceJ0~#prJ1 z)lu{E;h(CQy$zSUWDGci`OSlF@cQ3fKIQf76IUhYZ*AXsZRyvz<1>w3MTsT-_6afy zvp#+Fbr47MITus){kgN!=NlT`5xwzZ&(=3?EaDLdq?{wMr&-}q1()uXfWZECgxJ*Wxvs1(GbkXS_a zE;L5ZasmC!{$aO&U{U!^n~oE^VqI>8K4YGu_d6jvde*Ov+qYK;u*=!BKJl+pto%7g zy-@vpv#HP~6;?;L+i3=KTe55K$vG4REDM}$s*(^#E~Ui>%tVb?Q>rKWrfwD*jeu`)=LMh@d-2)s=1n?@apJA`)#?m9WS4J zb=ybnk3}Q@Yxj4ZPA*g5Yk2(Vk+`qwy@)H~$&OzeDi~2ct2X=czr#RJ>H|G1fr!=O zjMUVUVtp`W2#Vg)enWp|L!Q?1I@c$2e^oKB3Yf&jX}IaarrFk)yJuM}KeFiU{`^}H zk1#Z=?oZ$Af4;2R@8X|=m5SZXUso|7HhN-s;^4u$@85PX22V{{GNV;CX2+px_BVp{ zj$0Y7&SY50r*`$HOB@CiOjz z7x@#)IX(WbXn;2(lL#~JvJL2aFlYb<1=f-d-2n8w4$;oQ(7InBQVlIfHx}}NF^%}x&nQ9iv`330Pv1) Am;e9( literal 0 HcmV?d00001 diff --git a/tests/fixtures/cmdb/topdesk-shuffled-columns.xlsx b/tests/fixtures/cmdb/topdesk-shuffled-columns.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f31cc7bb6f97961aef792b8f66c2f451b4454d6d GIT binary patch literal 157262 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 Date: Thu, 1 Oct 2026 18:35:19 +0200 Subject: [PATCH 3/4] feat(cmdb-import): read the CMDB sheets, key on APPID, owner from Applicatie Eigenaar (Persoon) After the review with Gemeente Rotterdam the import reads the derived sheets "Onbeh Applicaties CMDB" (applications without arranged maintenance) and "Beheerde Applicaties CMDB" (with maintenance) instead of the raw TOPdesk input sheets. Modules are matched on topdesk::; Applicatie Code becomes externalId. Formula cells contribute their cached value; a missing cached value gives an empty field and a row warning. The owner comes from "Applicatie Eigenaar (Persoon)" with its function as role; the technical-owner pack is removed. Applicatiesoort, BNN Classificatie, Classificatie (TIME) and End-of-Life Functioneel are mapped with configurable placeholder values. A test asserts that person data is never anonymously readable. Jira: https://conduction.atlassian.net/browse/WOO-586 Co-Authored-By: Claude Fable 5.1 --- docs/features/README.md | 2 +- docs/features/cmdb-import.md | 193 +++++----- l10n/en.js | 17 +- l10n/en.json | 17 +- l10n/nl.js | 17 +- l10n/nl.json | 17 +- lib/Service/Cmdb/CmdbImportProfile.php | 136 ++++++-- lib/Service/Cmdb/CmdbImportReport.php | 6 +- lib/Service/Cmdb/CmdbRowNormaliser.php | 34 +- lib/Service/Cmdb/CmdbWorkbookReader.php | 126 +++++-- lib/Service/CmdbExportImportService.php | 98 +++--- .../cmdb-import/topdesk-business-owner.json | 9 +- .../cmdb-import/topdesk-manufacturer.json | 6 +- lib/Settings/cmdb-import/topdesk-module.json | 39 ++- lib/Settings/cmdb-import/topdesk-profile.json | 33 +- .../cmdb-import/topdesk-technical-owner.json | 11 - lib/Settings/cmdb-import/topdesk-usage.json | 12 +- .../register.d/topdesk-cmdb-import.json | 6 +- openapi.json | 6 +- .../changes/cmdb-export-import/contract.md | 24 +- openspec/changes/cmdb-export-import/design.md | 143 ++++---- .../changes/cmdb-export-import/migration.md | 6 +- .../changes/cmdb-export-import/proposal.md | 24 +- .../specs/cmdb-export-import/spec.md | 170 +++++---- openspec/changes/cmdb-export-import/tasks.md | 56 +-- .../changes/cmdb-export-import/test-plan.md | 28 +- openspec/specs/cmdb-export-import/spec.md | 4 +- postman/stackiq-tests.json | 3 +- src/utils/cmdbImport.js | 6 +- src/views/settings/sections/CmdbImport.vue | 24 +- .../Controller/CmdbImportControllerTest.php | 12 +- .../Unit/Fixtures/CmdbFixtureHygieneTest.php | 16 +- .../Service/Cmdb/CmdbImportProfileTest.php | 91 +++-- .../Service/Cmdb/CmdbRowNormaliserTest.php | 49 ++- .../Service/Cmdb/CmdbWorkbookReaderTest.php | 61 ++-- .../Service/CmdbExportImportServiceTest.php | 330 ++++++++++++------ .../Settings/CmdbPersonDataVisibilityTest.php | 134 +++++++ tests/e2e/spec-coverage/cmdb-import.spec.ts | 105 +++++- tests/fixtures/cmdb/README.md | 29 +- tests/fixtures/cmdb/build-fixtures.py | 131 +++++-- .../cmdb/topdesk-export-anonymised.xlsx | Bin 160471 -> 160560 bytes .../cmdb/topdesk-formula-and-connection.xlsx | Bin 160907 -> 160968 bytes ...del-id.xlsx => topdesk-missing-appid.xlsx} | Bin 160513 -> 160578 bytes .../cmdb/topdesk-shuffled-columns.xlsx | Bin 157262 -> 156723 bytes 44 files changed, 1509 insertions(+), 722 deletions(-) delete mode 100644 lib/Settings/cmdb-import/topdesk-technical-owner.json create mode 100644 tests/Unit/Settings/CmdbPersonDataVisibilityTest.php rename tests/fixtures/cmdb/{topdesk-missing-middel-id.xlsx => topdesk-missing-appid.xlsx} (67%) diff --git a/docs/features/README.md b/docs/features/README.md index b14e666e..7fa964f0 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -158,7 +158,7 @@ The ArchiMate integration maps GEMMA Softwarecatalogus objects to ArchiMate appl ## CMDB Import -Import a TOPdesk CMDB export (`.xlsx`) for one municipality from the **CMDB import** section of the admin settings. Every application row becomes or updates a module, its manufacturer as a Supplier organisation, a usage that links it to the municipality, and contact persons for its owners (identity in Nextcloud Contacts). A repeat import matches on Middel-ID per municipality, so it updates instead of duplicating, and leaves applications missing from the newer export as they are. The column mapping is declarative JSON executed by OpenRegister's mapping engine. +Import a TOPdesk CMDB export (`.xlsx`) for one municipality from the **CMDB import** section of the admin settings. Every application row of the two CMDB sheets ("Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB") becomes or updates a module, its vendor as a Supplier organisation, a usage that links it to the municipality and records whether maintenance is arranged, and a contact person for its owner (identity in Nextcloud Contacts, never public). A repeat import matches on APPID per municipality, so it updates instead of duplicating, and leaves applications missing from the newer export as they are. The column mapping is declarative JSON executed by OpenRegister's mapping engine. See [CMDB import](cmdb-import.md) for the steps, the expected file structure, the error codes and how to adjust the mapping. diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index 39d48574..5167ccda 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -6,13 +6,15 @@ # CMDB import Imports a TOPdesk CMDB export (an Excel workbook, `.xlsx`) for one -municipality. Every application row of the export becomes, or updates: +municipality. Every application row of the two CMDB sheets becomes, or +updates: - a **module** (the application, `schema:SoftwareApplication`); -- its manufacturer as an **organisation** of type Supplier; +- its vendor (the maker of the software) as an **organisation** of type + Supplier; - a **usage** that links the application to the municipality; -- **contact persons** of the municipality for its owners, with their identity in - Nextcloud Contacts. +- a **contact person** of the municipality for its owner, with the identity + in Nextcloud Contacts. Owners are never readable by the public. All of it is stored as OpenRegister objects in the stackiq register. Import a newer export later and the same applications are updated, not duplicated. @@ -73,7 +75,7 @@ When the import finishes, the section shows: and warnings; - **warnings for the whole file**, for example an optional column that is missing; -- the **rows** table: sheet, row number, Middel-ID, application, outcome, +- the **rows** table: sheet, row number, APPID, application, outcome, and the reasons and warnings for that row. Filter it with **Show rows with outcome**. The application name links to the module in stackiq. @@ -84,83 +86,104 @@ the same organisation. ## The file -The import reads two sheets and ignores all others: +The import reads the two CMDB sheets of the export and ignores all others, +including the `Invoer` sheets they are derived from: -| Sheet | Rows it accepts (column "Soort") | -|---|---| -| `Invoer AIA data` | `Application Inventory` | -| `Invoer APP data` | `Applicatie` | +| Sheet | What it holds | Recorded on the usage | +|---|---|---| +| `Onbeh Applicaties CMDB` | applications **without** arranged maintenance (from the AIA export) | `Beheer geregeld: nee` | +| `Beheerde Applicaties CMDB` | applications **with** arranged maintenance (from the APP export) | `Beheer geregeld: ja` | At least one of the two must be present. Row 1 of each sheet holds the -column names. Columns are found by name, not by position: case, surrounding -spaces and a trailing `:` or `⚡` do not matter, and the order of the columns -does not matter. Empty rows, including formatted rows below the data, are -ignored and not counted. A sheet may hold at most 10,000 rows with data. - -Two columns are **required** on every source sheet that is present: -`Middel-ID` and `Naam`. Every other column is optional; when one is missing, +column names. Columns are found by name per sheet, not by position: case, +surrounding spaces and a trailing `:` or `⚡` do not matter, and the order of +the columns does not matter. A column that one sheet has and the other has +not (such as `Nickname`, only on `Beheerde Applicaties CMDB`) is optional on +the sheet that lacks it. Empty rows, including formatted rows below the data, +are ignored and not counted. A sheet may hold at most 10,000 rows with data. + +Two columns are **required** on every CMDB sheet that is present: `APPID` +and `Applicatie Naam`. Every other column is optional; when one is missing, the import names it once in the warnings for the whole file. -Formula cells are read as the value Excel stored with them; formulas are -never recalculated. External data connections, Power Query queries and links -in the workbook are never opened. Macro-enabled workbooks (`.xlsm`), old -Excel files (`.xls`) and CSV files are not accepted. +**Formulas.** The CMDB sheets are formulas that read the `Invoer` sheets. +The import reads the value Excel stored with each formula cell; formulas are +never calculated. Save the workbook in Excel before importing it, so every +formula has a stored value. A formula without a stored value is read as an +empty cell and the row carries the warning `Column "…": formula without a +cached value, read as empty`; the row is still imported. A stored `0` is what +Excel shows for a reference to an empty cell, and is read as empty too. +External data connections, Power Query queries and links in the workbook are +never opened. Macro-enabled workbooks (`.xlsm`), old Excel files (`.xls`) and +CSV files are not accepted. + +**Placeholder values.** The CMDB sheets fill some empty cells with a +placeholder. These are read as empty: `NB` in `BNN Classificatie`, and the +date 2036-01-01 (Excel serial 49675) in `End-of-Life Functioneel`. ### Columns and where they go | Column | Goes to | Rule | |---|---|---| -| Naam | module name | required | -| Middel-ID | module external id, and the match key | required, see [Repeat imports](#repeat-imports) | -| ICT Applicatienummer | module external number | number stored as text | -| Functionele omschrijving | module long description | | -| ICT BBN Classificatie | module BBN level | `BBN1`/`BBN 1` etc. become `BBN1`, `BBN2`, `BBN3`; another value is dropped with a warning | -| Aanmaakdatum | module external creation date | Excel date | -| Wijzigingsdatum | module external modification date | Excel date | -| Fabrikant | Supplier organisation, set as provider on the module and the usage | one organisation per name, see below | -| Status | usage status | In productie → In production, In voorraad → Planned, In ontwikkeling → Acquisition, Uit te faseren → To be phased out, Uitgefaseerd → Phased out; another value is dropped with a warning | -| ICT TIME Classificatie | usage TIME classification | Tolerate/Tolereren, Invest/Investeren, Migrate/Migreren, Eliminate/Elimineren | -| End of Life Business | usage phase-out date | Excel date | -| Eigenaar afdeling, Eigenaar cluster | usage internal annotation | joined with ` / `, written only when the usage is new or the note is empty | -| Eigenaar, Eigenaar e-mail, Eigenaar functie | usage business owner (contact person) | see [Owners](#owners) | -| FB contactpersoon 1 | usage technical owner (contact person) | see [Owners](#owners) | -| Soort | not stored | decides whether the row is imported | - -Columns not in this table are not read at all. That includes Personeelsnummer, -the phone number columns, the group owner and group mailbox columns, -Configuratie coördinator, FB contactpersoon 2 and Opmerkingen. - -**Manufacturers.** Names are compared after trimming, collapsing spaces and +| APPID | module external number, and the match key | required, see [Repeat imports](#repeat-imports) | +| Applicatie Naam | module name | required | +| Applicatie Code | module external id | reference only; it can change in TOPdesk, so it is not the match key | +| Roepnaam, Nickname | module short description | Roepnaam when filled, otherwise Nickname (only on `Beheerde Applicaties CMDB`) | +| Functionele Omschrijving | module long description | | +| Applicatiesoort | module hosting model (`cloudDienstverleningsmodel`) | `Saas` → SaaS, `PaaS` → PaaS, `IaaS` → IaaS, `On-premise(s)` → On-premises (self-managed); another value is dropped with a warning | +| BNN Classificatie | module BBN level | `BBN1`/`BBN 1`/`BNN1` etc. become `BBN1`, `BBN2`, `BBN3`; `NB` is empty; another value is dropped with a warning | +| Datum | module external creation date | Excel date | +| Referentie datum wijziging | module external modification date | Excel date | +| Vendor | Supplier organisation, set as provider on the module and the usage | one organisation per name, see below | +| Applicatie Status | usage status | In productie → In production, In voorraad → Planned, In ontwikkeling → Acquisition, Uit te faseren → To be phased out, Uitgefaseerd → Phased out; another value is dropped with a warning | +| Classificatie | usage TIME classification | Tolereren/Tolerate, Investeren/Invest, Migreren/Migrate, Elimineren/Eliminate | +| End-of-Life Functioneel | usage phase-out date | Excel date; 2036-01-01 is empty | +| (the sheet), Cluster, Applicatie Eigenaar (Afdeling) | usage internal annotation | `Beheer geregeld: ja` or `nee`, the cluster and the department, joined with ` / `; written only when the usage is new or the note is empty | +| Applicatie Eigenaar (Persoon), Applicatie Eigenaar (Functie) | usage business owner (contact person) | see [Owners](#owners) | + +Columns not in this table are not read at all. That includes Hostingpartij +and Leverancier (not mapped yet), the BIV and value columns (Beschikbaarheid, +Integriteit, Vertrouwelijkheid, Applicatienut and the like), Behandelgroep, +Cloud, Rappeldatum, Rappelreden, Locatie BIOToets, Software Suite, Standaard, +Top5, COTS and Applicatie Nummer. + +**Vendors.** Names are compared after trimming, collapsing spaces and ignoring case, so `Fabfrikant`, `Fabfrikant ` and `FABFRIKANT` are one Supplier. An existing organisation of type Supplier with the same name is -reused. A row without a manufacturer is imported without a provider. +reused. A row without a vendor is imported without a provider. ## Repeat imports -An application is recognised by its **Middel-ID within the municipality**. -Two municipalities can each have an `APP-00001` without colliding. +An application is recognised by its **APPID within the municipality**: the +match key is `topdesk::`. The APPID (TOPdesk's ICT +Applicatienummer) stays the same when TOPdesk changes the Applicatie Code +(Middel-ID). Two municipalities can each have an APPID `101` without +colliding. -- **New Middel-ID**: a module and a usage are created. The module gets a +- **New APPID**: a module and a usage are created. The module gets a publication date (the moment the import started), so OpenCatalogi lists it. -- **Known Middel-ID, values changed**: only the fields in the column table +- **Known APPID, values changed**: only the fields in the column table are updated. Everything else on the module stays as it is, for example a website an administrator added. The publication date and the depublication date are never changed: a module an administrator depublished stays depublished. The row is reported as *updated*. -- **Known Middel-ID, nothing changed**: nothing is saved; the row is reported +- **Known APPID, nothing changed**: nothing is saved; the row is reported as *unchanged*. Importing the same export twice creates nothing the second time. -- **Middel-ID missing from a newer export**: the application, its usage and +- **APPID missing from a newer export**: the application, its usage and its contact persons are left as they are. They are not changed, depublished or deleted. - Each application keeps exactly one usage for the municipality. -Rows are **skipped** when the Middel-ID is empty (`missing Middel-ID`), when -a Middel-ID appears a second time in the same upload, also across the two -sheets (`duplicate Middel-ID in file`; the first occurrence is imported), when -"Soort" is not accepted for the sheet (`unsupported Soort "…"`), or, with -**Update existing records** off, when the application already exists -(`exists`). +Rows are **skipped** when the APPID is empty (`missing APPID`), when the +Applicatie Naam is empty (`missing Applicatie Naam`), when an APPID appears a +second time in the same upload, also across the two sheets (`duplicate APPID +in file`; the first occurrence is imported), or, with **Update existing +records** off, when the application already exists (`exists`). + +An application that moves from `Onbeh Applicaties CMDB` to `Beheerde +Applicaties CMDB` keeps its module and usage (same APPID); its internal note +is not rewritten when it already has one. Every row is processed on its own. When one row fails, for example because OpenRegister refuses to save it, that row is reported as *failed* with the @@ -169,26 +192,29 @@ the failed row. ## Owners -Owners become **contact persons of the municipality**, never Nextcloud user -accounts. - -- The business owner comes from "Eigenaar", "Eigenaar e-mail" and "Eigenaar - functie" (the function is stored as the contact person's role). -- The technical owner comes from "FB contactpersoon 1". - -The person's identity (name, e-mail address) is kept in **Nextcloud -Contacts**, in the first writable address book of the administrator who runs -the import, the same as every other stackiq contact. A contact is found by -e-mail address when the export has one, otherwise by an exact match on the -name, and created when neither finds one. The stackiq contact person object -only holds the link to that contact, the role and the municipality. The same -owner on several rows is one contact person. +The owner becomes a **contact person of the municipality**, never a +Nextcloud user account. It comes from `Applicatie Eigenaar (Persoon)`; its +function (`Applicatie Eigenaar (Functie)`) is stored as the contact person's +role, and the department (`Applicatie Eigenaar (Afdeling)`) goes into the +usage's internal note. When TOPdesk has no owner, the CMDB sheet shows the +owner's function in the person column; the import then uses that function as +the contact's name. No technical owner is imported: the functional +administrator (FB contactpersoon) is not read. + +The identity is kept in **Nextcloud Contacts**, in the first writable +address book of the administrator who runs the import, the same as every +other stackiq contact. The CMDB sheets have no e-mail address, so a contact +is found by an exact match on the name, and created when there is none. The +stackiq contact person object only holds the link to that contact, the role +and the municipality. The same owner on several rows is one contact person. When the Contacts app is disabled, applications and usages are still imported; the owners are skipped and each affected row carries a warning. -The import report and the Nextcloud log never contain owner names or e-mail -addresses. +**Never public.** Contact persons and usages have no public read rule, so an +anonymous visitor cannot read them through OpenRegister, and a published +module in an OpenCatalogi search result refers to them by id at most. The +import report and the Nextcloud log never contain owner names. ## Errors and what to do @@ -202,9 +228,9 @@ and the section shows the reason and the error code. | `NO_FILE_UPLOADED` | No file arrived. | Choose the file again. | | `MUNICIPALITY_REQUIRED` | No municipality was chosen. | Pick or type a municipality. | | `MUNICIPALITY_INVALID` | The chosen organisation does not exist or is not of type Municipality. | Pick an organisation of type Municipality, or type a new name. | -| `NO_SOURCE_SHEET` | Neither `Invoer AIA data` nor `Invoer APP data` is in the workbook. | Check the sheet names; they must match exactly. | -| `MISSING_COLUMN` | A present source sheet has no `Middel-ID` or `Naam` column. The message names the sheet and the column. | Add the column to that sheet. | -| `TOO_MANY_ROWS` | A source sheet has more than 10,000 rows with data. | Split the export and import the parts one after the other. | +| `NO_SOURCE_SHEET` | Neither `Onbeh Applicaties CMDB` nor `Beheerde Applicaties CMDB` is in the workbook. | Check the sheet names; they must match exactly. | +| `MISSING_COLUMN` | A present CMDB sheet has no `APPID` or `Applicatie Naam` column. The message names the sheet and the column. | Add the column to that sheet. | +| `TOO_MANY_ROWS` | A CMDB sheet has more than 10,000 rows with data. | Split the export and import the parts one after the other. | | `MISSING_RECORDS_UNSUPPORTED` | The request asked to mark or remove records missing from the export. Only keeping them is supported. | Not reachable from the section; reported for API callers. | | `MAPPING_UNAVAILABLE` | OpenRegister's mapping engine is missing, or one of the mapping files is invalid. | Update OpenRegister. If you changed a mapping file, check it against the Nextcloud log. | | `READER_UNAVAILABLE` | The Excel reader that ships with OpenRegister cannot be loaded. | Make sure OpenRegister is installed and enabled. | @@ -222,26 +248,25 @@ in `lib/Settings/cmdb-import/`, executed by OpenRegister's mapping engine: | File | What it maps | |---|---| -| `topdesk-profile.json` | the sheets and accepted "Soort" values, the match column, the required, date and id columns, the limits, and which pack is used for which target | -| `topdesk-module.json` | a row to the module | -| `topdesk-manufacturer.json` | "Fabrikant" to the Supplier organisation | +| `topdesk-profile.json` | the sheets, the constant each sheet adds to its rows (`Beheer`) and the columns it is known to lack, the match column, the required, date and id columns, the placeholder values that mean empty, the limits, and which pack is used for which target | +| `topdesk-module.json` | a row to the module (hosting model and BBN lookups) | +| `topdesk-manufacturer.json` | "Vendor" to the Supplier organisation | | `topdesk-municipality.json` | a typed municipality name to a new organisation | | `topdesk-usage.json` | a row to the usage (status and TIME lookups, dates, annotation) | -| `topdesk-business-owner.json` | the business owner columns | -| `topdesk-technical-owner.json` | the technical owner column | +| `topdesk-business-owner.json` | the owner columns | Each pack has a list of `fieldMappings`, one per column: `source` (the column name in the export), `target` (the field), optionally `required`, and a `transform` such as `trim`, `date` or a `lookup` with a `map` of export values -to stored values. For example, to also store "Roepnaam" as the module's short -description, add to `topdesk-module.json`: +to stored values. For example, to accept a new "Applicatiesoort" value, add +it to the `map` of the hosting-model lookup in `topdesk-module.json`: ```json -{ "source": "Roepnaam", "target": "shortDescription", "transform": { "type": "trim" } } +"Cloud": ["SaaS"] ``` -To accept a new "Status" value, add it to the `map` of the status lookup in -`topdesk-usage.json`. The packs are checked by OpenRegister when an import +To accept a new "Applicatie Status" value, add it to the `map` of the status +lookup in `topdesk-usage.json`. The packs are checked by OpenRegister when an import starts; an invalid pack stops the import with `MAPPING_UNAVAILABLE` before any row is read. A mapping file changed on the server is overwritten by the next app update, so propose lasting changes to the app itself. diff --git a/l10n/en.js b/l10n/en.js index 9109bcc2..dcc68178 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -916,9 +916,9 @@ OC.L10N.register( "Choose the TOPdesk export and try again.": "Choose the TOPdesk export and try again.", "CMDB import": "CMDB import", "Created": "Created", - "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.": "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.", + "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.", "Error code: {code}": "Error code: {code}", - "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".", "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.", "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.", "Failed": "Failed", @@ -928,10 +928,10 @@ OC.L10N.register( "Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.", "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", "Import progress": "Import progress", - "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.", "Importing the export…": "Importing the export…", "Importing…": "Importing…", - "Middel-ID": "Middel-ID", + "APPID": "APPID", "Municipality": "Municipality", "No file was uploaded.": "No file was uploaded.", "No rows with this outcome": "No rows with this outcome", @@ -947,7 +947,7 @@ OC.L10N.register( "Reload the page and try again.": "Reload the page and try again.", "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.", "Row": "Row", - "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.", + "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.", "Rows": "Rows", "Rows read": "Rows read", "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.", @@ -956,7 +956,7 @@ OC.L10N.register( "Sign in again and retry the import.": "Sign in again and retry the import.", "Skipped": "Skipped", "The chosen organisation is not a municipality.": "The chosen organisation is not a municipality.", - "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.", + "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.", "The Excel reader is not available.": "The Excel reader is not available.", "The file": "The file", "The file is larger than 10 MB.": "The file is larger than 10 MB.", @@ -967,7 +967,7 @@ OC.L10N.register( "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.", "The server could not be reached.": "The server could not be reached.", "The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".", - "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.": "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.", + "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.", "The workbook has none of the sheets the import reads.": "The workbook has none of the sheets the import reads.", "This file is not an Excel workbook (.xlsx).": "This file is not an Excel workbook (.xlsx).", "This import is no longer running.": "This import is no longer running.", @@ -1004,7 +1004,8 @@ OC.L10N.register( "missing %s": "missing %s", "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s", "step \"%s\" failed": "step \"%s\" failed", - "unsupported %1$s \"%2$s\"": "unsupported %1$s \"%2$s\"" + "Column \"%s\": formula without a cached value, read as empty": "Column \"%s\": formula without a cached value, read as empty", + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 3975c5ab..57d77e96 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -915,9 +915,9 @@ "Choose the TOPdesk export and try again.": "Choose the TOPdesk export and try again.", "CMDB import": "CMDB import", "Created": "Created", - "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.": "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.", + "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.", "Error code: {code}": "Error code: {code}", - "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".", "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.", "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.", "Failed": "Failed", @@ -927,10 +927,10 @@ "Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.", "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.", "Import progress": "Import progress", - "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.", "Importing the export…": "Importing the export…", "Importing…": "Importing…", - "Middel-ID": "Middel-ID", + "APPID": "APPID", "Municipality": "Municipality", "No file was uploaded.": "No file was uploaded.", "No rows with this outcome": "No rows with this outcome", @@ -946,7 +946,7 @@ "Reload the page and try again.": "Reload the page and try again.", "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.", "Row": "Row", - "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.", + "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.", "Rows": "Rows", "Rows read": "Rows read", "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.", @@ -955,7 +955,7 @@ "Sign in again and retry the import.": "Sign in again and retry the import.", "Skipped": "Skipped", "The chosen organisation is not a municipality.": "The chosen organisation is not a municipality.", - "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.", + "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.", "The Excel reader is not available.": "The Excel reader is not available.", "The file": "The file", "The file is larger than 10 MB.": "The file is larger than 10 MB.", @@ -966,7 +966,7 @@ "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.", "The server could not be reached.": "The server could not be reached.", "The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".", - "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.": "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.", + "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.", "The workbook has none of the sheets the import reads.": "The workbook has none of the sheets the import reads.", "This file is not an Excel workbook (.xlsx).": "This file is not an Excel workbook (.xlsx).", "This import is no longer running.": "This import is no longer running.", @@ -1003,6 +1003,7 @@ "missing %s": "missing %s", "step \"%1$s\" failed: %2$s": "step \"%1$s\" failed: %2$s", "step \"%s\" failed": "step \"%s\" failed", - "unsupported %1$s \"%2$s\"": "unsupported %1$s \"%2$s\"" + "Column \"%s\": formula without a cached value, read as empty": "Column \"%s\": formula without a cached value, read as empty", + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it." } } diff --git a/l10n/nl.js b/l10n/nl.js index 04278215..6bfeb530 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -986,9 +986,9 @@ OC.L10N.register( "Choose the TOPdesk export and try again.": "Kies de TOPdesk-export en probeer het opnieuw.", "CMDB import": "CMDB-import", "Created": "Aangemaakt", - "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de fabrikant en een gebruik dat de applicatie aan de gekozen gemeente koppelt. Eigenaren worden contactpersonen van de gemeente in Nextcloud Contacten.", + "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de leverancier van de software (Vendor) en een gebruik dat de applicatie aan de gekozen gemeente koppelt. De applicatie-eigenaar wordt een contactpersoon van de gemeente in Nextcloud Contacten; eigenaren worden nooit openbaar getoond.", "Error code: {code}": "Foutcode: {code}", - "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Invoer AIA data\" of \"Invoer APP data\".", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".", "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.", "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.", "Failed": "Mislukt", @@ -998,10 +998,10 @@ OC.L10N.register( "Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.", "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", "Import progress": "Voortgang van de import", - "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het Middel-ID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.", "Importing the export…": "De export wordt geïmporteerd…", "Importing…": "Importeren…", - "Middel-ID": "Middel-ID", + "APPID": "APPID", "Municipality": "Gemeente", "No file was uploaded.": "Er is geen bestand geüpload.", "No rows with this outcome": "Geen rijen met dit resultaat", @@ -1017,7 +1017,7 @@ OC.L10N.register( "Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.", "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.", "Row": "Rij", - "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"Middel-ID\" en \"Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.", + "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.", "Rows": "Rijen", "Rows read": "Rijen gelezen", "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Sla de TOPdesk-export op als Excel-werkmap (.xlsx). CSV-, .xls- en .xlsm-bestanden met macro's worden niet geaccepteerd.", @@ -1026,7 +1026,7 @@ OC.L10N.register( "Sign in again and retry the import.": "Meld u opnieuw aan en probeer de import nog eens.", "Skipped": "Overgeslagen", "The chosen organisation is not a municipality.": "De gekozen organisatie is geen gemeente.", - "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"Middel-ID\" en \"Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.", + "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"APPID\" en \"Applicatie Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.", "The Excel reader is not available.": "De Excel-lezer is niet beschikbaar.", "The file": "Het bestand", "The file is larger than 10 MB.": "Het bestand is groter dan 10 MB.", @@ -1037,7 +1037,7 @@ OC.L10N.register( "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.", "The server could not be reached.": "De server is niet bereikbaar.", "The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".", - "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.": "De tabbladen \"Invoer AIA data\" en \"Invoer APP data\" worden gelezen; andere tabbladen worden genegeerd.", + "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"Onbeh Applicaties CMDB\" (applicaties zonder geregeld beheer) en \"Beheerde Applicaties CMDB\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.", "The workbook has none of the sheets the import reads.": "De werkmap bevat geen van de tabbladen die de import leest.", "This file is not an Excel workbook (.xlsx).": "Dit bestand is geen Excel-werkmap (.xlsx).", "This import is no longer running.": "Deze import loopt niet meer.", @@ -1074,7 +1074,8 @@ OC.L10N.register( "missing %s": "%s ontbreekt", "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s", "step \"%s\" failed": "stap \"%s\" mislukt", - "unsupported %1$s \"%2$s\"": "niet-ondersteunde %1$s \"%2$s\"" + "Column \"%s\": formula without a cached value, read as empty": "Kolom \"%s\": formule zonder opgeslagen waarde, gelezen als leeg", + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index f9de4372..e69bcc95 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -985,9 +985,9 @@ "Choose the TOPdesk export and try again.": "Kies de TOPdesk-export en probeer het opnieuw.", "CMDB import": "CMDB-import", "Created": "Aangemaakt", - "Each application row of the export becomes or updates an application, its manufacturer, and a usage that links it to the chosen municipality. Owners become contact persons of the municipality in Nextcloud Contacts.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de fabrikant en een gebruik dat de applicatie aan de gekozen gemeente koppelt. Eigenaren worden contactpersonen van de gemeente in Nextcloud Contacten.", + "Each application row of the export becomes or updates an application, its vendor, and a usage that links it to the chosen municipality. The application owner becomes a contact person of the municipality in Nextcloud Contacts; owners are never shown to the public.": "Elke applicatierij uit de export wordt een applicatie (of werkt die bij), met de leverancier van de software (Vendor) en een gebruik dat de applicatie aan de gekozen gemeente koppelt. De applicatie-eigenaar wordt een contactpersoon van de gemeente in Nextcloud Contacten; eigenaren worden nooit openbaar getoond.", "Error code: {code}": "Foutcode: {code}", - "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Invoer AIA data\" or \"Invoer APP data\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Invoer AIA data\" of \"Invoer APP data\".", + "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".", "Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.", "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.", "Failed": "Mislukt", @@ -997,10 +997,10 @@ "Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.", "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.", "Import progress": "Voortgang van de import", - "Importing a newer export again updates the same applications, matched on Middel-ID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het Middel-ID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.", + "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.", "Importing the export…": "De export wordt geïmporteerd…", "Importing…": "Importeren…", - "Middel-ID": "Middel-ID", + "APPID": "APPID", "Municipality": "Gemeente", "No file was uploaded.": "Er is geen bestand geüpload.", "No rows with this outcome": "Geen rijen met dit resultaat", @@ -1016,7 +1016,7 @@ "Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.", "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.", "Row": "Rij", - "Row 1 holds the column names. \"Middel-ID\" and \"Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"Middel-ID\" en \"Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.", + "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.", "Rows": "Rijen", "Rows read": "Rijen gelezen", "Save the TOPdesk export as an Excel workbook (.xlsx). CSV, .xls and macro-enabled .xlsm files are not accepted.": "Sla de TOPdesk-export op als Excel-werkmap (.xlsx). CSV-, .xls- en .xlsm-bestanden met macro's worden niet geaccepteerd.", @@ -1025,7 +1025,7 @@ "Sign in again and retry the import.": "Meld u opnieuw aan en probeer de import nog eens.", "Skipped": "Overgeslagen", "The chosen organisation is not a municipality.": "De gekozen organisatie is geen gemeente.", - "The columns \"Middel-ID\" and \"Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"Middel-ID\" en \"Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.", + "The columns \"APPID\" and \"Applicatie Naam\" are required on every source sheet. Add the column to the export and try again. Nothing was imported.": "De kolommen \"APPID\" en \"Applicatie Naam\" zijn op elk brontabblad verplicht. Voeg de kolom toe aan de export en probeer het opnieuw. Er is niets geïmporteerd.", "The Excel reader is not available.": "De Excel-lezer is niet beschikbaar.", "The file": "Het bestand", "The file is larger than 10 MB.": "Het bestand is groter dan 10 MB.", @@ -1036,7 +1036,7 @@ "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.", "The server could not be reached.": "De server is niet bereikbaar.", "The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".", - "The sheets \"Invoer AIA data\" and \"Invoer APP data\" are read; other sheets are ignored.": "De tabbladen \"Invoer AIA data\" en \"Invoer APP data\" worden gelezen; andere tabbladen worden genegeerd.", + "The sheets \"Onbeh Applicaties CMDB\" (applications without arranged maintenance) and \"Beheerde Applicaties CMDB\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"Onbeh Applicaties CMDB\" (applicaties zonder geregeld beheer) en \"Beheerde Applicaties CMDB\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.", "The workbook has none of the sheets the import reads.": "De werkmap bevat geen van de tabbladen die de import leest.", "This file is not an Excel workbook (.xlsx).": "Dit bestand is geen Excel-werkmap (.xlsx).", "This import is no longer running.": "Deze import loopt niet meer.", @@ -1073,6 +1073,7 @@ "missing %s": "%s ontbreekt", "step \"%1$s\" failed: %2$s": "stap \"%1$s\" mislukt: %2$s", "step \"%s\" failed": "stap \"%s\" mislukt", - "unsupported %1$s \"%2$s\"": "niet-ondersteunde %1$s \"%2$s\"" + "Column \"%s\": formula without a cached value, read as empty": "Kolom \"%s\": formule zonder opgeslagen waarde, gelezen als leeg", + "Formula cells are read as the value Excel saved with the workbook; formulas are never calculated. Save the workbook in Excel before importing it.": "Formulecellen worden gelezen als de waarde die Excel bij de werkmap heeft opgeslagen; formules worden nooit berekend. Sla de werkmap op in Excel voordat u hem importeert." } } diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php index ce6d5d92..ff67accc 100644 --- a/lib/Service/Cmdb/CmdbImportProfile.php +++ b/lib/Service/Cmdb/CmdbImportProfile.php @@ -3,12 +3,13 @@ /** * CMDB import profile. * - * Loads `lib/Settings/cmdb-import/topdesk-profile.json` and the six + * Loads `lib/Settings/cmdb-import/topdesk-profile.json` and the five * migration packs it names, validates every pack with OpenRegister's * `MigrationPack\PackDefinitionValidator`, and answers the questions the * reader, the normaliser and the import service ask about the export: which - * sheets, which columns are required, which are dates or ids, and which - * columns may be read at all (the allowlist, design D3). + * sheets, which columns are required, which are dates or ids, which values + * mean empty, which constants a sheet adds to its rows, and which columns + * may be read at all (the allowlist, design D3). * * `PackDefinitionValidator` is not part of OpenRegister's `Contract` * namespace, so it is resolved defensively. When it is missing, or a shipped @@ -43,6 +44,7 @@ * * @SuppressWarnings(PHPMD.TooManyPublicMethods) One small accessor per profile setting, so * callers never read the raw JSON. + * @SuppressWarnings(PHPMD.TooManyMethods) The same accessors, plus the loader's small private helpers. * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) The accessors each guard against a * malformed profile value; the sum passes the threshold, no single method is complex. */ @@ -57,7 +59,7 @@ class CmdbImportProfile { * * @var array */ - public const TARGETS = ['module', 'manufacturer', 'municipality', 'usage', 'businessOwner', 'technicalOwner']; + public const TARGETS = ['module', 'manufacturer', 'municipality', 'usage', 'businessOwner']; /** * Default upload limit when the profile file cannot be read (10 MB). @@ -186,9 +188,10 @@ public function maxRowsPerSheet(): int { }//end maxRowsPerSheet() /** - * The source sheets with the Soort values each accepts. + * The source sheets, each with the constants it adds to its rows and the + * pack columns it is known not to have. * - * @return array}> + * @return array, absentColumns: array}> * * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 */ @@ -199,11 +202,22 @@ public function sheets(): array { continue; } - $sheets[] = [ - 'name' => $sheet['name'], - 'acceptedKinds' => array_values(array_map('strval', ($sheet['acceptedKinds'] ?? []))), - ]; - } + $constants = []; + if (is_array($sheet['constants'] ?? null) === true) { + foreach ($sheet['constants'] as $column => $value) { + if (is_scalar($value) === true) { + $constants[(string)$column] = (string)$value; + } + } + } + + $absent = []; + if (is_array($sheet['absentColumns'] ?? null) === true) { + $absent = array_values(array_map('strval', $sheet['absentColumns'])); + } + + $sheets[] = ['name' => $sheet['name'], 'constants' => $constants, 'absentColumns' => $absent]; + }//end foreach return $sheets; }//end sheets() @@ -220,56 +234,107 @@ public function sheetNames(): array { }//end sheetNames() /** - * The Soort values a sheet accepts. + * The constants a sheet adds to each of its rows, as column => value. + * + * A constant is mapped like a column (the usage pack reads "Beheer"), but + * it is never looked up in the sheet. + * + * @param string $sheetName The sheet name. + * + * @return array + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + */ + public function sheetConstants(string $sheetName): array { + foreach ($this->sheets() as $sheet) { + if ($sheet['name'] === $sheetName) { + return $sheet['constants']; + } + } + + return []; + }//end sheetConstants() + + /** + * The pack columns a sheet is known not to have; their absence is no warning. * * @param string $sheetName The sheet name. * * @return array * - * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 */ - public function acceptedKinds(string $sheetName): array { + public function absentColumns(string $sheetName): array { foreach ($this->sheets() as $sheet) { if ($sheet['name'] === $sheetName) { - return $sheet['acceptedKinds']; + return $sheet['absentColumns']; } } return []; - }//end acceptedKinds() + }//end absentColumns() /** - * The match key column ("Middel-ID"). + * The names of every sheet constant; these are never read from a sheet. * - * @return string + * @return array * - * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 */ - public function keyColumn(): string { - return (string)($this->profile()['keyColumn'] ?? 'Middel-ID'); - }//end keyColumn() + public function constantColumns(): array { + $columns = []; + foreach ($this->sheets() as $sheet) { + $columns = array_merge($columns, array_keys($sheet['constants'])); + } + + return array_values(array_unique(array_map('strval', $columns))); + }//end constantColumns() + + /** + * Values that mean "empty" per column, such as the "NB" a CMDB sheet + * writes for an unknown BNN classification. + * + * @return array> + * + * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 + */ + public function emptyValues(): array { + $value = $this->profile()['emptyValues'] ?? []; + if (is_array($value) === false) { + return []; + } + + $empty = []; + foreach ($value as $column => $values) { + if (is_array($values) === true) { + $empty[(string)$column] = array_values(array_map('strval', $values)); + } + } + + return $empty; + }//end emptyValues() /** - * The application name column ("Naam"). + * The match key column ("APPID"). * * @return string * - * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 + * @spec openspec/changes/cmdb-export-import/tasks.md#task-5 */ - public function nameColumn(): string { - return (string)($this->profile()['nameColumn'] ?? 'Naam'); - }//end nameColumn() + public function keyColumn(): string { + return (string)($this->profile()['keyColumn'] ?? 'APPID'); + }//end keyColumn() /** - * The row-kind column ("Soort"). + * The application name column ("Applicatie Naam"). * * @return string * * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 */ - public function kindColumn(): string { - return (string)($this->profile()['kindColumn'] ?? 'Soort'); - }//end kindColumn() + public function nameColumn(): string { + return (string)($this->profile()['nameColumn'] ?? 'Applicatie Naam'); + }//end nameColumn() /** * Columns whose absence stops the import with MISSING_COLUMN. @@ -406,8 +471,8 @@ public function missingRecordsModes(): array { /** * Every column the profile or a pack references: the read allowlist. * - * The municipality pack maps the request options, not a sheet, so its - * sources are left out. + * The municipality pack maps the request options, not a sheet, and the + * sheet constants are added by the import, so both are left out. * * @return array * @@ -415,7 +480,7 @@ public function missingRecordsModes(): array { */ public function referencedColumns(): array { $columns = array_merge( - [$this->keyColumn(), $this->nameColumn(), $this->kindColumn()], + [$this->keyColumn(), $this->nameColumn()], $this->requiredColumns(), $this->dateColumns(), $this->idColumns() @@ -430,9 +495,10 @@ public function referencedColumns(): array { } } + $excluded = array_merge(self::OPTION_SOURCES, $this->constantColumns()); $columns = array_filter( $columns, - fn (string $column): bool => $column !== '' && $column[0] !== '/' && in_array($column, self::OPTION_SOURCES, true) === false + fn (string $column): bool => $column !== '' && $column[0] !== '/' && in_array($column, $excluded, true) === false ); return array_values(array_unique($columns)); diff --git a/lib/Service/Cmdb/CmdbImportReport.php b/lib/Service/Cmdb/CmdbImportReport.php index ca5856bb..091b0a90 100644 --- a/lib/Service/Cmdb/CmdbImportReport.php +++ b/lib/Service/Cmdb/CmdbImportReport.php @@ -84,7 +84,7 @@ public function __construct( * * @param string $sheet The sheet name. * @param int $row The 1-based sheet row number. - * @param string $middelId The Middel-ID ('' when missing). + * @param string $appId The APPID ('' when missing). * @param string $name The application name ('' when missing). * @param string $outcome One of the outcome constants. * @param array $reasons Why the row was skipped or failed. @@ -99,7 +99,7 @@ public function __construct( public function addRow( string $sheet, int $row, - string $middelId, + string $appId, string $name, string $outcome, array $reasons = [], @@ -110,7 +110,7 @@ public function addRow( $this->rows[] = [ 'sheet' => $sheet, 'row' => $row, - 'middelId' => $middelId, + 'appId' => $appId, 'name' => $name, 'outcome' => $outcome, 'reasons' => array_values($reasons), diff --git a/lib/Service/Cmdb/CmdbRowNormaliser.php b/lib/Service/Cmdb/CmdbRowNormaliser.php index 31a713a4..80c71f08 100644 --- a/lib/Service/Cmdb/CmdbRowNormaliser.php +++ b/lib/Service/Cmdb/CmdbRowNormaliser.php @@ -12,6 +12,9 @@ * - Id columns: a whole number becomes a string without a decimal part * (`1234.0` becomes `"1234"`). * - Every value is trimmed; an empty value becomes the empty string. + * - A value the profile lists as "empty" for its column (such as the "NB" a + * CMDB sheet writes for an unknown BNN classification) becomes the empty + * string, compared case-insensitively before any conversion. * * The conversion lives here and not in the packs, so every pack stays a plain * OpenRegister migration pack. @@ -58,15 +61,21 @@ class CmdbRowNormaliser { * @param array $dateColumns Columns holding Excel serial dates. * @param array $idColumns Columns holding identifiers. * @param bool $date1904 Whether the workbook uses the 1904 date system. + * @param array> $emptyValues Values that mean empty, per column. * * @return array Column name => normalised value. * * @spec openspec/changes/cmdb-export-import/tasks.md#task-4 */ - public function normalise(array $cells, array $dateColumns, array $idColumns, bool $date1904 = false): array { + public function normalise(array $cells, array $dateColumns, array $idColumns, bool $date1904 = false, array $emptyValues = []): array { $row = []; foreach ($cells as $column => $value) { $column = (string)$column; + if (self::meansEmpty(text: $this->toText(value: $value), empty: ($emptyValues[$column] ?? [])) === true) { + $row[$column] = ''; + continue; + } + if (in_array($column, $dateColumns, true) === true) { $row[$column] = $this->normaliseDate(value: $value, date1904: $date1904); continue; @@ -144,6 +153,29 @@ public function normaliseId(mixed $value): string { return $text; }//end normaliseId() + /** + * Whether a value is one of the column's "empty" values. + * + * @param string $text The trimmed value. + * @param array $empty The column's empty values. + * + * @return bool + */ + private static function meansEmpty(string $text, array $empty): bool { + if ($text === '' || $empty === []) { + return false; + } + + $needle = mb_strtolower($text); + foreach ($empty as $candidate) { + if (mb_strtolower(trim($candidate)) === $needle) { + return true; + } + } + + return false; + }//end meansEmpty() + /** * Any scalar as trimmed text; null as the empty string. * diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php index a822de15..1ec5cac0 100644 --- a/lib/Service/Cmdb/CmdbWorkbookReader.php +++ b/lib/Service/Cmdb/CmdbWorkbookReader.php @@ -3,7 +3,7 @@ /** * CMDB workbook reader. * - * Reads the source sheets of a TOPdesk CMDB export (design D3): + * Reads the CMDB sheets of a TOPdesk CMDB export (design D3): * * 1. Before PhpSpreadsheet is touched: the name ends in `.xlsx`, the file * starts with the ZIP signature and the package holds `xl/workbook.xml`. @@ -20,7 +20,10 @@ * 4. A cell yields its stored value; a formula cell yields the value Excel * cached (`getOldCalculatedValue()`). Formulas are never evaluated, and no * HTTP client is involved, so external connections, Power Query packages - * and hyperlinks stay inert. + * and hyperlinks stay inert. A formula without a cached value yields an + * empty cell and its column is listed in the row's `uncached`, so the + * import can warn; a cached number 0 is what Excel stores for a reference + * to an empty cell, so it yields an empty cell too. * 5. Rows whose kept cells are all empty are dropped; more non-empty rows than * the profile allows stops the import with `TOO_MANY_ROWS` (422). * @@ -120,7 +123,7 @@ public function isAvailable(): bool { * @param string $path The xlsx file, already checked by assertXlsx(). * @param CmdbImportProfile $profile The import profile. * - * @return array `rows` (list of {sheet, row, cells}), `importWarnings` + * @return array `rows` (list of {sheet, row, cells, uncached}), `importWarnings` * (list of {sheet, message}) and `date1904` (bool). * * @throws CmdbImportException READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS. @@ -204,7 +207,7 @@ public static function normaliseHeader(string $header): string { * @param array $sheetNames The present source sheets, in profile order. * @param CmdbImportProfile $profile The import profile. * - * @return array `rows` (list of {sheet, row, cells}), `importWarnings` + * @return array `rows` (list of {sheet, row, cells, uncached}), `importWarnings` * (list of {sheet, message}) and `date1904` (bool). * * @throws CmdbImportException MISSING_COLUMN or TOO_MANY_ROWS. @@ -232,12 +235,8 @@ private function readSheets(object $spreadsheet, array $sheetNames, CmdbImportPr } } - foreach ($mapped as $column) { - if (in_array($column, $columns, true) === false && in_array($column, $required, true) === false) { - $warnings[] = ['sheet' => $sheetName, 'column' => $column, 'message' => sprintf('Optional column "%s" not found', $column)]; - } - } - + $skip = array_merge($required, $profile->absentColumns(sheetName: $sheetName)); + array_push($warnings, ...self::missingOptionalColumns(sheetName: $sheetName, mapped: $mapped, columns: $columns, skip: $skip)); $columnsPerSheet[$sheetName] = $columns; } @@ -303,7 +302,7 @@ private function resolveColumns(object $worksheet, array $referenced): array { * @param string $sheetName The sheet name. * @param int $limit The maximum number of non-empty rows. * - * @return array}> + * @return array, uncached: array}> * * @throws CmdbImportException TOO_MANY_ROWS. */ @@ -311,21 +310,11 @@ private function readRows(object $worksheet, array $columns, string $sheetName, $rows = []; $lastRow = (int)$worksheet->getHighestDataRow(); for ($rowNumber = 2; $rowNumber <= $lastRow; $rowNumber++) { - $cells = []; - $empty = true; - foreach ($columns as $letters => $name) { - $value = null; - $coordinate = $letters . $rowNumber; - if ($worksheet->cellExists($coordinate) === true) { - $value = $this->cellValue(cell: $worksheet->getCell($coordinate)); - } - - $cells[$name] = $value; - if ($value !== null && (is_string($value) === false || trim($value) !== '')) { - $empty = false; - } - } - + ['cells' => $cells, 'uncached' => $uncached, 'empty' => $empty] = $this->readRow( + worksheet: $worksheet, + columns: $columns, + rowNumber: $rowNumber + ); if ($empty === true) { continue; } @@ -338,15 +327,85 @@ private function readRows(object $worksheet, array $columns, string $sheetName, ); } - $rows[] = ['sheet' => $sheetName, 'row' => $rowNumber, 'cells' => $cells]; + $rows[] = ['sheet' => $sheetName, 'row' => $rowNumber, 'cells' => $cells, 'uncached' => $uncached]; }//end for return $rows; }//end readRows() + /** + * The kept cells of one row, the columns whose formula has no cached value, + * and whether every kept cell is empty. + * + * @param object $worksheet The worksheet. + * @param array $columns Column letter => column name. + * @param int $rowNumber The 1-based row number. + * + * @return array{cells: array, uncached: array, empty: bool} + */ + private function readRow(object $worksheet, array $columns, int $rowNumber): array { + $cells = []; + $uncached = []; + $empty = true; + foreach ($columns as $letters => $name) { + $value = null; + $coordinate = $letters . $rowNumber; + if ($worksheet->cellExists($coordinate) === true) { + $cell = $worksheet->getCell($coordinate); + $value = $this->cellValue(cell: $cell); + if (self::isUncachedFormula(cell: $cell) === true) { + $uncached[] = $name; + } + } + + $cells[$name] = $value; + if ($value !== null && (is_string($value) === false || trim($value) !== '')) { + $empty = false; + } + } + + return ['cells' => $cells, 'uncached' => $uncached, 'empty' => $empty]; + }//end readRow() + + /** + * One import warning per pack column a sheet lacks, except the ones to skip. + * + * @param string $sheetName The sheet name. + * @param array $mapped The sheet-mapped pack sources. + * @param array $columns The sheet's resolved columns. + * @param array $skip Required columns and the columns the sheet is known to lack. + * + * @return array + */ + private static function missingOptionalColumns(string $sheetName, array $mapped, array $columns, array $skip): array { + $warnings = []; + foreach ($mapped as $column) { + if (in_array($column, $columns, true) === false && in_array($column, $skip, true) === false) { + $warnings[] = ['sheet' => $sheetName, 'column' => $column, 'message' => sprintf('Optional column "%s" not found', $column)]; + } + } + + return $warnings; + }//end missingOptionalColumns() + + /** + * Whether a cell holds a formula without a cached value. + * + * @param object $cell The PhpSpreadsheet cell. + * + * @return bool + */ + private static function isUncachedFormula(object $cell): bool { + return $cell->getDataType() === 'f' && $cell->getOldCalculatedValue() === null; + }//end isUncachedFormula() + /** * The stored value of a cell; for a formula, the value Excel cached. * + * A formula without a cached value, and a formula whose cached value is + * the number 0 (Excel's result for a reference to an empty cell), yield + * null. + * * @param object $cell The PhpSpreadsheet cell. * * @return mixed A scalar or null. @@ -356,6 +415,9 @@ private function cellValue(object $cell): mixed { if ($cell->getDataType() === 'f') { // The value Excel cached; the formula itself is never evaluated. $value = $cell->getOldCalculatedValue(); + if ((is_int($value) === true || is_float($value) === true) && (float)$value === 0.0) { + return null; + } } if (is_object($value) === true && method_exists($value, 'getPlainText') === true) { @@ -377,6 +439,7 @@ private function cellValue(object $cell): mixed { * @return array */ private function packSources(CmdbImportProfile $profile): array { + $constants = $profile->constantColumns(); $sources = []; foreach (CmdbImportProfile::TARGETS as $target) { if ($target === 'municipality') { @@ -388,7 +451,14 @@ private function packSources(CmdbImportProfile $profile): array { } } - return array_values(array_unique(array_filter($sources, fn (string $source): bool => $source !== ''))); + return array_values( + array_unique( + array_filter( + $sources, + fn (string $source): bool => $source !== '' && in_array($source, $constants, true) === false + ) + ) + ); }//end packSources() /** diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php index bdfd081a..793e9b83 100644 --- a/lib/Service/CmdbExportImportService.php +++ b/lib/Service/CmdbExportImportService.php @@ -5,12 +5,12 @@ * * Turns a TOPdesk CMDB export (xlsx) into stackiq objects for one * municipality (openspec/changes/cmdb-export-import). Every application row - * becomes, or updates, a `module` with its manufacturer `organization` - * (type Supplier), a `usage` that links it to the municipality, and - * `contactPerson` objects for its owners, resolved through Nextcloud - * Contacts. Rows are matched on `externalKey` = - * `topdesk::`, so a second import of a newer - * export updates the same records. + * of the CMDB sheets ("Onbeh Applicaties CMDB", "Beheerde Applicaties + * CMDB") becomes, or updates, a `module` with its vendor `organization` + * (type Supplier), a `usage` that links it to the municipality, and a + * `contactPerson` for its owner, resolved through Nextcloud Contacts. Rows + * are matched on `externalKey` = `topdesk::`, so a + * second import of a newer export updates the same records. * * What each column becomes is declarative: the migration packs under * `lib/Settings/cmdb-import/`, executed by OpenRegister's @@ -124,7 +124,7 @@ class CmdbExportImportService { private ?array $suppliers = null; /** - * Middel-IDs seen in this upload. + * APPIDs seen in this upload. * * @var array */ @@ -313,7 +313,7 @@ public function import(string $path, array $options): array { /** * Process one row in its own error boundary and add its outcome. * - * @param array{sheet: string, row: int, cells: array} $row The reader row. + * @param array{sheet: string, row: int, cells: array, uncached?: array} $row The reader row. * @param string $municipalityUuid The consumer. * @param bool $updateExisting Whether matched rows are updated. * @param string $startedAt ISO start time of the import. @@ -332,29 +332,34 @@ private function processRow( bool $date1904, CmdbImportReport $report, ): void { + $sheet = $row['sheet']; $values = $this->normaliser->normalise( - cells: $row['cells'], + cells: array_merge($row['cells'], $this->profile->sheetConstants(sheetName: $sheet)), dateColumns: $this->profile->dateColumns(), idColumns: $this->profile->idColumns(), - date1904: $date1904 + date1904: $date1904, + emptyValues: $this->profile->emptyValues() ); - $sheet = $row['sheet']; $rowNumber = $row['row']; - $middelId = ($values[$this->profile->keyColumn()] ?? ''); + $appId = ($values[$this->profile->keyColumn()] ?? ''); $name = ($values[$this->profile->nameColumn()] ?? ''); - $entry = ['sheet' => $sheet, 'row' => $rowNumber, 'middelId' => $middelId, 'name' => $name]; + $entry = ['sheet' => $sheet, 'row' => $rowNumber, 'appId' => $appId, 'name' => $name]; - $skipReason = $this->skipReason(sheet: $sheet, values: $values, middelId: $middelId); + $warnings = []; + foreach (($row['uncached'] ?? []) as $column) { + $warnings[] = $this->l10n->t('Column "%s": formula without a cached value, read as empty', [(string)$column]); + } + + $skipReason = $this->skipReason(appId: $appId); if ($skipReason !== null) { - $this->addRow(report: $report, entry: $entry, outcome: CmdbImportReport::SKIPPED, reasons: [$skipReason]); + $this->addRow(report: $report, entry: $entry, outcome: CmdbImportReport::SKIPPED, reasons: [$skipReason], warnings: $warnings); return; } $step = 'mapping'; $moduleUuid = null; $usageUuid = null; - $warnings = []; try { $module = $this->map(target: 'module', values: $values, rowNumber: $rowNumber, warnings: $warnings); @@ -368,7 +373,7 @@ private function processRow( $providerUuid = $this->resolveManufacturer(values: $values, rowNumber: $rowNumber); $step = 'module'; - $externalKey = $this->profile->externalKeyPrefix() . ':' . $municipalityUuid . ':' . $middelId; + $externalKey = $this->profile->externalKeyPrefix() . ':' . $municipalityUuid . ':' . $appId; $moduleResult = $this->upsertModule( data: $module['data'], externalKey: $externalKey, @@ -415,7 +420,7 @@ private function processRow( * Report a row as failed at a step, and log it without person data. * * @param CmdbImportReport $report The report. - * @param array{sheet: string, row: int, middelId: string, name: string} $entry Where the row is. + * @param array{sheet: string, row: int, appId: string, name: string} $entry Where the row is. * @param string $step The step that failed. * @param Throwable $e The cause. * @param array $warnings Row warnings so far. @@ -430,7 +435,7 @@ private function failRow(CmdbImportReport $report, array $entry, string $step, T $this->logger->warning( 'CmdbExportImportService: row failed', array_merge( - ['sheet' => $entry['sheet'], 'row' => $entry['row'], 'middelId' => $entry['middelId']], + ['sheet' => $entry['sheet'], 'row' => $entry['row'], 'appId' => $entry['appId']], ['step' => $step, 'exception' => get_class($e), 'error' => $detail] ) ); @@ -519,7 +524,7 @@ private function finishOperation(array $report): void { * Add a row outcome to the report. * * @param CmdbImportReport $report The report. - * @param array{sheet: string, row: int, middelId: string, name: string} $entry Where the row is. + * @param array{sheet: string, row: int, appId: string, name: string} $entry Where the row is. * @param string $outcome The outcome. * @param array $reasons Reasons. * @param array $warnings Warnings. @@ -542,7 +547,7 @@ private function addRow( $report->addRow( sheet: $entry['sheet'], row: $entry['row'], - middelId: $entry['middelId'], + appId: $entry['appId'], name: $entry['name'], outcome: $outcome, reasons: $reasons, @@ -555,32 +560,24 @@ private function addRow( /** * Why a row is skipped before mapping, or null when it is imported. * - * @param string $sheet The sheet name. - * @param array $values The normalised row. - * @param string $middelId The Middel-ID. + * An APPID seen earlier in the same upload, on either sheet, is a duplicate. + * + * @param string $appId The APPID. * * @return string|null * * @spec openspec/changes/cmdb-export-import/tasks.md#task-7 */ - private function skipReason(string $sheet, array $values, string $middelId): ?string { - if ($middelId === '') { + private function skipReason(string $appId): ?string { + if ($appId === '') { return $this->l10n->t('missing %s', [$this->profile->keyColumn()]); } - if (isset($this->seenKeys[$middelId]) === true) { + if (isset($this->seenKeys[$appId]) === true) { return $this->l10n->t('duplicate %s in file', [$this->profile->keyColumn()]); } - $this->seenKeys[$middelId] = true; - - $kindColumn = $this->profile->kindColumn(); - $accepted = $this->profile->acceptedKinds(sheetName: $sheet); - if ($accepted !== [] && array_key_exists($kindColumn, $values) === true - && in_array($values[$kindColumn], $accepted, true) === false - ) { - return $this->l10n->t('unsupported %1$s "%2$s"', [$kindColumn, $values[$kindColumn]]); - } + $this->seenKeys[$appId] = true; return null; }//end skipReason() @@ -613,7 +610,7 @@ private function map(string $target, array $values, int $rowNumber, array &$warn } } - $silent = in_array($target, ['manufacturer', 'businessOwner', 'technicalOwner', 'municipality'], true); + $silent = in_array($target, ['manufacturer', 'businessOwner', 'municipality'], true); $missing = []; foreach (($result['errors'] ?? []) as $error) { $source = (string)($error['source'] ?? ''); @@ -712,7 +709,7 @@ private function upsertModule(array $data, string $externalKey, ?string $provide * @param string $municipalityUuid The consumer. * @param string $moduleUuid The module. * @param string|null $providerUuid The supplier, when there is one. - * @param array{businessOwner: string|null, technicalOwner: string|null} $owners The owner contact persons. + * @param array{businessOwner: string|null} $owners The owner contact person. * * @return array{uuid: string, outcome: string} * @@ -720,12 +717,12 @@ private function upsertModule(array $data, string $externalKey, ?string $provide */ private function upsertUsage(array $data, string $municipalityUuid, string $moduleUuid, ?string $providerUuid, array $owners): array { if (isset($data['interneAnnotation']) === true && is_string($data['interneAnnotation']) === true) { - $note = $data['interneAnnotation']; - if (str_ends_with($note, self::NOTE_SEPARATOR) === true) { - $note = substr($note, 0, -strlen(self::NOTE_SEPARATOR)); - } - - $data['interneAnnotation'] = trim($note); + // The concat mapping leaves an empty part for every empty column; drop those. + $parts = array_filter( + array_map('trim', explode(self::NOTE_SEPARATOR, $data['interneAnnotation'])), + fn (string $part): bool => $part !== '' + ); + $data['interneAnnotation'] = implode(self::NOTE_SEPARATOR, $parts); } $data['consumer'] = $municipalityUuid; @@ -803,19 +800,22 @@ private function merge(string $target, array $stored, array $mapped): ?array { }//end merge() /** - * Resolve the business and technical owner of a row as contact persons. + * Resolve the owner of a row (Applicatie Eigenaar) as the usage's business owner. + * + * No technical owner is read: the functional administrator columns are + * not part of the mapping. * * @param array $values The normalised row. * @param int $rowNumber The sheet row number. - * @param string $municipalityUuid The municipality the contact persons belong to. + * @param string $municipalityUuid The municipality the contact person belongs to. * @param array $warnings Row warnings, appended to. * - * @return array{businessOwner: string|null, technicalOwner: string|null} + * @return array{businessOwner: string|null} * * @spec openspec/changes/cmdb-export-import/tasks.md#task-6 */ private function resolveOwners(array $values, int $rowNumber, string $municipalityUuid, array &$warnings): array { - $owners = ['businessOwner' => null, 'technicalOwner' => null]; + $owners = ['businessOwner' => null]; $identities = []; foreach (array_keys($owners) as $target) { $silent = []; @@ -1296,7 +1296,7 @@ private function resetRun(): void { /** * The source column of an owner target, for warnings. * - * @param string $target businessOwner or technicalOwner. + * @param string $target businessOwner. * * @return string */ diff --git a/lib/Settings/cmdb-import/topdesk-business-owner.json b/lib/Settings/cmdb-import/topdesk-business-owner.json index 6f90d2ac..f987d51e 100644 --- a/lib/Settings/cmdb-import/topdesk-business-owner.json +++ b/lib/Settings/cmdb-import/topdesk-business-owner.json @@ -1,13 +1,12 @@ { "id": "stackiq-topdesk-business-owner", "name": "TOPdesk CMDB export to business owner identity", - "description": "The business owner of the application. The import resolves the identity in Nextcloud Contacts and links a contactPerson of the municipality as usage.businessOwner. No other person column is read.", + "description": "The application owner (Applicatie Eigenaar (Persoon)); the value may be a function instead of a name and is used as the display name either way. The import resolves it in Nextcloud Contacts by exact display name and links a contactPerson of the municipality as usage.businessOwner, with the owner's function as its role. No other person column is read.", "sourceFormat": "excel", - "version": "1.0.0", + "version": "2.0.0", "fieldMappings": [ - { "source": "Eigenaar", "target": "name", "required": true, "transform": { "type": "trim" } }, - { "source": "Eigenaar e-mail", "target": "email", "transform": { "type": "trim" } }, - { "source": "Eigenaar functie", "target": "role", "transform": { "type": "trim" } } + { "source": "Applicatie Eigenaar (Persoon)", "target": "name", "required": true, "transform": { "type": "trim" } }, + { "source": "Applicatie Eigenaar (Functie)", "target": "role", "transform": { "type": "trim" } } ], "idStrategy": { "type": "generate" } } diff --git a/lib/Settings/cmdb-import/topdesk-manufacturer.json b/lib/Settings/cmdb-import/topdesk-manufacturer.json index ae720f77..49fd3ad0 100644 --- a/lib/Settings/cmdb-import/topdesk-manufacturer.json +++ b/lib/Settings/cmdb-import/topdesk-manufacturer.json @@ -1,11 +1,11 @@ { "id": "stackiq-topdesk-manufacturer", "name": "TOPdesk CMDB export to stackiq supplier organisation", - "description": "The Fabrikant column becomes one organisation of type Supplier per distinct name. An empty Fabrikant means the row has no provider.", + "description": "The Vendor column (the maker of the software) becomes one organisation of type Supplier per distinct name. An empty Vendor means the row has no provider. Leverancier and Hostingpartij are not read.", "sourceFormat": "excel", - "version": "1.0.0", + "version": "2.0.0", "fieldMappings": [ - { "source": "Fabrikant", "target": "name", "required": true, "transform": { "type": "trim" } } + { "source": "Vendor", "target": "name", "required": true, "transform": { "type": "trim" } } ], "defaults": { "type": "Supplier", "status": "Active" }, "idStrategy": { "type": "generate" } diff --git a/lib/Settings/cmdb-import/topdesk-module.json b/lib/Settings/cmdb-import/topdesk-module.json index 81a057e8..e5580d97 100644 --- a/lib/Settings/cmdb-import/topdesk-module.json +++ b/lib/Settings/cmdb-import/topdesk-module.json @@ -1,28 +1,43 @@ { "id": "stackiq-topdesk-module", "name": "TOPdesk CMDB export to stackiq module", - "description": "One application row of a TOPdesk CMDB export becomes a stackiq module. A mapping marked required that fails skips the row; any other failing mapping drops that field with a warning.", + "description": "One application row of a CMDB sheet becomes a stackiq module. A mapping marked required that fails skips the row; any other failing mapping drops that field with a warning. Nickname and Roepnaam both feed shortDescription; Roepnaam wins when both are filled.", "sourceFormat": "excel", - "version": "1.0.0", + "version": "2.0.0", "fieldMappings": [ - { "source": "Naam", "target": "name", "required": true, "transform": { "type": "trim" } }, - { "source": "Middel-ID", "target": "externalId", "required": true, "transform": { "type": "trim" } }, - { "source": "ICT Applicatienummer", "target": "externalNumber", "transform": { "type": "trim" } }, - { "source": "Functionele omschrijving", "target": "longDescription", "transform": { "type": "trim" } }, + { "source": "Applicatie Naam", "target": "name", "required": true, "transform": { "type": "trim" } }, + { "source": "APPID", "target": "externalNumber", "required": true, "transform": { "type": "trim" } }, + { "source": "Applicatie Code", "target": "externalId", "transform": { "type": "trim" } }, + { "source": "Nickname", "target": "shortDescription", "transform": { "type": "trim" } }, + { "source": "Roepnaam", "target": "shortDescription", "transform": { "type": "trim" } }, + { "source": "Functionele Omschrijving", "target": "longDescription", "transform": { "type": "trim" } }, { - "source": "ICT BBN Classificatie", + "source": "Applicatiesoort", + "target": "cloudDienstverleningsmodel", + "transform": { + "type": "lookup", + "map": { + "Saas": ["SaaS"], "SaaS": ["SaaS"], "SAAS": ["SaaS"], "saas": ["SaaS"], + "Paas": ["PaaS"], "PaaS": ["PaaS"], "PAAS": ["PaaS"], + "Iaas": ["IaaS"], "IaaS": ["IaaS"], "IAAS": ["IaaS"], + "On-premise": ["On-premises (self-managed)"], "On-premises": ["On-premises (self-managed)"], "On premise": ["On-premises (self-managed)"] + } + } + }, + { + "source": "BNN Classificatie", "target": "bbnLevel", "transform": { "type": "lookup", "map": { - "BBN1": "BBN1", "BBN 1": "BBN1", "bbn1": "BBN1", "bbn 1": "BBN1", - "BBN2": "BBN2", "BBN 2": "BBN2", "bbn2": "BBN2", "bbn 2": "BBN2", - "BBN3": "BBN3", "BBN 3": "BBN3", "bbn3": "BBN3", "bbn 3": "BBN3" + "BBN1": "BBN1", "BBN 1": "BBN1", "bbn1": "BBN1", "bbn 1": "BBN1", "BNN1": "BBN1", "BNN 1": "BBN1", + "BBN2": "BBN2", "BBN 2": "BBN2", "bbn2": "BBN2", "bbn 2": "BBN2", "BNN2": "BBN2", "BNN 2": "BBN2", + "BBN3": "BBN3", "BBN 3": "BBN3", "bbn3": "BBN3", "bbn 3": "BBN3", "BNN3": "BBN3", "BNN 3": "BBN3" } } }, - { "source": "Aanmaakdatum", "target": "externalCreatedAt", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } }, - { "source": "Wijzigingsdatum", "target": "externalModifiedAt", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } } + { "source": "Datum", "target": "externalCreatedAt", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } }, + { "source": "Referentie datum wijziging", "target": "externalModifiedAt", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } } ], "idStrategy": { "type": "generate" } } diff --git a/lib/Settings/cmdb-import/topdesk-profile.json b/lib/Settings/cmdb-import/topdesk-profile.json index 211e9587..88ddf79d 100644 --- a/lib/Settings/cmdb-import/topdesk-profile.json +++ b/lib/Settings/cmdb-import/topdesk-profile.json @@ -1,28 +1,37 @@ { "id": "topdesk-cmdb", "name": "TOPdesk CMDB export", - "version": "1.0.0", - "description": "How stackiq reads a TOPdesk CMDB export (xlsx). Sheets, key and required columns, date and id columns, the pack per target and the limits. Columns that neither this profile nor a pack names are never read.", + "version": "2.0.0", + "description": "How stackiq reads a TOPdesk CMDB export (xlsx): the two CMDB sheets, the key and required columns, date and id columns, values that mean empty, the pack per target and the limits. Columns that neither this profile nor a pack names are never read.", "maxFileBytes": 10485760, "maxRowsPerSheet": 10000, "sheets": [ - { "name": "Invoer AIA data", "acceptedKinds": ["Application Inventory"] }, - { "name": "Invoer APP data", "acceptedKinds": ["Applicatie"] } + { + "name": "Onbeh Applicaties CMDB", + "constants": { "Beheer": "Beheer geregeld: nee" }, + "absentColumns": ["Nickname"] + }, + { + "name": "Beheerde Applicaties CMDB", + "constants": { "Beheer": "Beheer geregeld: ja" } + } ], - "keyColumn": "Middel-ID", - "nameColumn": "Naam", - "kindColumn": "Soort", - "requiredColumns": ["Middel-ID", "Naam"], - "dateColumns": ["Aanmaakdatum", "Wijzigingsdatum", "End of Life Business", "Einddatum"], - "idColumns": ["Middel-ID", "ICT Applicatienummer"], + "keyColumn": "APPID", + "nameColumn": "Applicatie Naam", + "requiredColumns": ["APPID", "Applicatie Naam"], + "dateColumns": ["Datum", "Referentie datum wijziging", "End-of-Life Functioneel"], + "idColumns": ["APPID"], + "emptyValues": { + "BNN Classificatie": ["NB"], + "End-of-Life Functioneel": ["49675"] + }, "externalKeyPrefix": "topdesk", "packs": { "module": "topdesk-module.json", "manufacturer": "topdesk-manufacturer.json", "municipality": "topdesk-municipality.json", "usage": "topdesk-usage.json", - "businessOwner": "topdesk-business-owner.json", - "technicalOwner": "topdesk-technical-owner.json" + "businessOwner": "topdesk-business-owner.json" }, "createOnly": { "module": { "type": "Application" }, diff --git a/lib/Settings/cmdb-import/topdesk-technical-owner.json b/lib/Settings/cmdb-import/topdesk-technical-owner.json deleted file mode 100644 index 317a062e..00000000 --- a/lib/Settings/cmdb-import/topdesk-technical-owner.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "id": "stackiq-topdesk-technical-owner", - "name": "TOPdesk CMDB export to technical owner identity", - "description": "The functional administrator (FB contactpersoon 1). Without an e-mail address the contact is matched on its exact display name, and linked as usage.technicalOwner.", - "sourceFormat": "excel", - "version": "1.0.0", - "fieldMappings": [ - { "source": "FB contactpersoon 1", "target": "name", "required": true, "transform": { "type": "trim" } } - ], - "idStrategy": { "type": "generate" } -} diff --git a/lib/Settings/cmdb-import/topdesk-usage.json b/lib/Settings/cmdb-import/topdesk-usage.json index 3e9f9d79..12ae459f 100644 --- a/lib/Settings/cmdb-import/topdesk-usage.json +++ b/lib/Settings/cmdb-import/topdesk-usage.json @@ -1,12 +1,12 @@ { "id": "stackiq-topdesk-usage", "name": "TOPdesk CMDB export to stackiq usage", - "description": "The usage that links the application to the municipality. The Status lookup is provisional until the municipality confirms its values; an unknown value drops the field with a warning.", + "description": "The usage that links the application to the municipality. The internal note records whether maintenance is arranged (the sheet the row came from), the cluster and the owner's department. An unknown status or TIME value drops the field with a warning.", "sourceFormat": "excel", - "version": "1.0.0", + "version": "2.0.0", "fieldMappings": [ { - "source": "Status", + "source": "Applicatie Status", "target": "status", "transform": { "type": "lookup", @@ -20,7 +20,7 @@ } }, { - "source": "ICT TIME Classificatie", + "source": "Classificatie", "target": "timeClassification", "transform": { "type": "lookup", @@ -32,8 +32,8 @@ } } }, - { "source": "End of Life Business", "target": "startDateOutPhased", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } }, - { "source": "Eigenaar afdeling", "target": "interneAnnotation", "transform": { "type": "concat", "fields": ["Eigenaar cluster"], "separator": " / " } } + { "source": "End-of-Life Functioneel", "target": "startDateOutPhased", "transform": { "type": "date", "sourceFormat": "!Y-m-d", "targetFormat": "Y-m-d" } }, + { "source": "Beheer", "target": "interneAnnotation", "transform": { "type": "concat", "fields": ["Cluster", "Applicatie Eigenaar (Afdeling)"], "separator": " / " } } ], "idStrategy": { "type": "generate" } } diff --git a/lib/Settings/register.d/topdesk-cmdb-import.json b/lib/Settings/register.d/topdesk-cmdb-import.json index d2ff7205..1f226ed5 100644 --- a/lib/Settings/register.d/topdesk-cmdb-import.json +++ b/lib/Settings/register.d/topdesk-cmdb-import.json @@ -7,7 +7,7 @@ "externalId": { "type": "string", "title": "Source id", - "description": "The identifier of the application in the source system it was imported from, such as the TOPdesk Middel-ID.", + "description": "The code of the application in the source system it was imported from, such as the TOPdesk Applicatie Code (the Middel-ID). Shown for reference; it can change in the source, so it is not used to match records.", "maxLength": 100, "visible": true, "facetable": false, @@ -16,7 +16,7 @@ "externalNumber": { "type": "string", "title": "Source number", - "description": "The application number in the source system, such as TOPdesk's ICT Applicatienummer. Shown for reference; not used to match records.", + "description": "The application number in the source system, such as TOPdesk's APPID (ICT Applicatienummer). A repeated CMDB import matches on it, through the import key.", "maxLength": 50, "visible": true, "facetable": false, @@ -25,7 +25,7 @@ "externalKey": { "type": "string", "title": "Import key", - "description": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.", + "description": "The key a repeated import matches this application on: topdesk::. Set by the CMDB import; do not edit.", "maxLength": 200, "visible": true, "facetable": false, diff --git a/openapi.json b/openapi.json index b2932900..f0d63edd 100644 --- a/openapi.json +++ b/openapi.json @@ -13,7 +13,7 @@ "post": { "operationId": "cmdbImport-import", "summary": "Import a TOPdesk CMDB export (xlsx) for one municipality", - "description": "Admin-only, CSRF-protected (requesttoken header or OCS-APIRequest: true). Creates or updates modules, supplier organisations, usages and owner contact persons, matched on topdesk::. See openspec/changes/cmdb-export-import/contract.md.", + "description": "Admin-only, CSRF-protected (requesttoken header or OCS-APIRequest: true). Creates or updates modules, supplier organisations, usages and owner contact persons, matched on topdesk::. See openspec/changes/cmdb-export-import/contract.md.", "tags": [ "cmdb-import" ], @@ -308,7 +308,7 @@ "required": [ "sheet", "row", - "middelId", + "appId", "name", "outcome", "reasons", @@ -323,7 +323,7 @@ "row": { "type": "integer" }, - "middelId": { + "appId": { "type": "string" }, "name": { diff --git a/openspec/changes/cmdb-export-import/contract.md b/openspec/changes/cmdb-export-import/contract.md index 6760d2eb..628cc39a 100644 --- a/openspec/changes/cmdb-export-import/contract.md +++ b/openspec/changes/cmdb-export-import/contract.md @@ -3,7 +3,7 @@ ## Consumers - `stackiq` frontend: the "CMDB import" admin-settings section (`src/views/settings/sections/CmdbImport.vue`) is the only caller of the two new endpoints. -- `opencatalogi` and `portaliq` call no new endpoint. They read the objects the import writes through their existing OpenRegister paths. Their interface is the data shape below: `module.publicationDate` for OpenCatalogi, and `usage.consumer` / `usage.module` for Portaliq. +- `opencatalogi` and `portaliq` call no new endpoint. They read the objects the import writes through their existing OpenRegister paths. Their interface is the data shape below: `module.publicationDate` for OpenCatalogi, and `usage.consumer` / `usage.module` for Portaliq. Neither gets owner data anonymously: `usage` and `contactPerson` have no public read rule, and a public `module` refers to them by id only. Paths are relative to `/index.php/apps/stackiq`. @@ -30,19 +30,17 @@ Paths are relative to `/index.php/apps/stackiq`. "operationId": "cmdb-00000000-0000-0000-0000-000000000000", "cancelled": false, "municipality": { "uuid": "00000000-0000-0000-0000-000000000001", "name": "Gemeente Voorbeeldstad", "created": false }, - "summary": { "rowsRead": 2, "processed": 2, "created": 2, "updated": 0, "unchanged": 0, "skipped": 0, "failed": 0, "warnings": 0 }, - "importWarnings": [ - { "sheet": "Invoer AIA data", "message": "Optional column \"ICT TIME Classificatie\" not found" } - ], + "summary": { "rowsRead": 2, "processed": 2, "created": 2, "updated": 0, "unchanged": 0, "skipped": 0, "failed": 0, "warnings": 1 }, + "importWarnings": [], "rows": [ { - "sheet": "Invoer AIA data", + "sheet": "Onbeh Applicaties CMDB", "row": 2, - "middelId": "AIA-AangetekendMailen", + "appId": "1234", "name": "Aangetekend Mailen", "outcome": "created", "reasons": [], - "warnings": [], + "warnings": ["Column \"Applicatiesoort\": Value \"Webapplicatie\" has no mapping and no default is configured"], "moduleUuid": "00000000-0000-0000-0000-000000000004", "usageUuid": "00000000-0000-0000-0000-000000000005" } @@ -50,7 +48,7 @@ Paths are relative to `/index.php/apps/stackiq`. } ``` -`outcome` is one of `created`, `updated`, `unchanged`, `skipped`, `failed`. `reasons` and `warnings` are translated strings that name columns and values. They never contain owner names, e-mail addresses or other person data. `summary.rowsRead` counts the non-empty rows in the workbook; `summary.processed` counts the rows in `rows`, which is lower than `rowsRead` only after a cancel. `summary.warnings` counts row warnings; `importWarnings` are not included. +`appId` is the row's APPID, the match key (`''` when the row has none). `outcome` is one of `created`, `updated`, `unchanged`, `skipped`, `failed`. `reasons` and `warnings` are translated strings that name columns and values; a formula cell without a cached value gives the warning `Column "": formula without a cached value, read as empty`. They never contain owner names, e-mail addresses or other person data. `summary.rowsRead` counts the non-empty rows in the workbook; `summary.processed` counts the rows in `rows`, which is lower than `rowsRead` only after a cancel. `summary.warnings` counts row warnings; `importWarnings` are not included. **Errors:** | Code | Condition | @@ -64,7 +62,7 @@ Paths are relative to `/index.php/apps/stackiq`. | 500 | `IMPORT_FAILED` (unexpected; generic message, details only in the log) | | 503 | `MAPPING_UNAVAILABLE`, `READER_UNAVAILABLE`, `NOT_CONFIGURED` | -Error body: `{"success": false, "error": "", "message": "", "details": {...}}`. `details` is always an object, empty when the code has none. For `MISSING_COLUMN`, `details` is `{"sheet": "...", "column": "..."}`. For `NO_SOURCE_SHEET`, it is `{"expected": ["Invoer AIA data", "Invoer APP data"]}`. For `TOO_MANY_ROWS`, it is `{"sheet": "...", "limit": 10000}`. For `FILE_TOO_LARGE`, it is `{"maxBytes": 10485760}`. For `MISSING_RECORDS_UNSUPPORTED`, it is `{"accepted": ["keep"]}`. +Error body: `{"success": false, "error": "", "message": "", "details": {...}}`. `details` is always an object, empty when the code has none. For `MISSING_COLUMN`, `details` is `{"sheet": "...", "column": "..."}`. For `NO_SOURCE_SHEET`, it is `{"expected": ["Onbeh Applicaties CMDB", "Beheerde Applicaties CMDB"]}`. For `TOO_MANY_ROWS`, it is `{"sheet": "...", "limit": 10000}`. For `FILE_TOO_LARGE`, it is `{"maxBytes": 10485760}`. For `MISSING_RECORDS_UNSUPPORTED`, it is `{"accepted": ["keep"]}`. ### `POST /api/cmdb-import/{operationId}/cancel` **Auth**: Nextcloud admin session plus CSRF token. @@ -96,8 +94,8 @@ Returns the `ProgressTracker` snapshot for the `cmdb_import` operation: `progres | `MISSING_RECORDS_UNSUPPORTED` | option not supported | `missingRecords` is not `keep` | | `MUNICIPALITY_REQUIRED` | no consumer | neither `municipalityUuid` nor `municipalityName` given | | `MUNICIPALITY_INVALID` | wrong consumer | uuid unknown, or the organisation is not of type Municipality | -| `NO_SOURCE_SHEET` | nothing to read | neither "Invoer AIA data" nor "Invoer APP data" present | -| `MISSING_COLUMN` | required column absent | a present source sheet lacks "Middel-ID" or "Naam" | +| `NO_SOURCE_SHEET` | nothing to read | neither "Onbeh Applicaties CMDB" nor "Beheerde Applicaties CMDB" present | +| `MISSING_COLUMN` | required column absent | a present source sheet lacks "APPID" or "Applicatie Naam" | | `TOO_MANY_ROWS` | file too large to process | a source sheet has more non-empty rows than `maxRowsPerSheet` (10,000) | | `MAPPING_UNAVAILABLE` | mapping cannot run | OpenRegister's `MappingEngine`/`PackDefinitionValidator` missing, or a shipped pack is invalid | | `READER_UNAVAILABLE` | xlsx reader missing | PhpSpreadsheet's Xlsx reader cannot be loaded | @@ -107,7 +105,7 @@ Returns the `ProgressTracker` snapshot for the `cmdb_import` operation: `progres ## Versioning -Internal app API, unversioned like the other stackiq settings endpoints. The report fields above are additive-only: new fields MAY be added, and existing fields keep their meaning. The `module` properties `externalId`, `externalNumber`, `externalKey`, `externalCreatedAt` and `externalModifiedAt` are part of the register schema and follow the register's versioning (`module` 0.3.5). +Internal app API, unversioned like the other stackiq settings endpoints. The report fields above are additive-only: new fields MAY be added, and existing fields keep their meaning. (Before the first release the row field `middelId` was renamed to `appId`, together with the switch of the match key to the APPID.) The `module` properties `externalId`, `externalNumber`, `externalKey`, `externalCreatedAt` and `externalModifiedAt` are part of the register schema and follow the register's versioning (`module` 0.3.5). ## Breaking Change Policy diff --git a/openspec/changes/cmdb-export-import/design.md b/openspec/changes/cmdb-export-import/design.md index a7418f3a..5d620080 100644 --- a/openspec/changes/cmdb-export-import/design.md +++ b/openspec/changes/cmdb-export-import/design.md @@ -2,7 +2,7 @@ ## Context -A municipality delivers its application landscape as a TOPdesk export (xlsx). The anonymised test export has ten sheets. Two of them are the raw TOPdesk exports this change reads: "Invoer AIA data" (52 columns, Soort = Application Inventory) and "Invoer APP data" (82 columns, Soort = Applicatie). The "*CMDB" sheets are derived from them with formulas, and "Invoer gearchiveerde appl" is the archive; neither is read. Both source sheets carry hundreds of formatted but empty rows below the data. Dates are Excel serial numbers. The file also contains document metadata, a SharePoint sensitivity label, an embedded Power Query package and an external data connection (`xl/connections.xml`). +A municipality delivers its application landscape as a TOPdesk export (xlsx). The anonymised test export has ten sheets. The municipality's application manager exports AIA and APP from TOPdesk into the raw "Invoer AIA data" and "Invoer APP data" sheets; the CMDB sheets next to them are the overviews the municipality itself uses as "the CMDB", built from the raw sheets with formulas. Decided with the municipality on 2026-10-01: the import reads the two CMDB sheets, "Onbeh Applicaties CMDB" (35 columns, from AIA: applications **without** arranged maintenance) and "Beheerde Applicaties CMDB" (42 columns, from APP: **with** arranged maintenance). The "Invoer" sheets are not read; "Gearchiveerde Applicaties" is a follow-up (missing records). Both CMDB sheets carry formatted but empty rows below the data, and "Beheerde" also formula rows that reference empty "Invoer" rows. Every cell is a formula; the reader uses the cached values. Dates are Excel serial numbers. The file also contains document metadata, a SharePoint sensitivity label, an embedded Power Query package and an external data connection (`xl/connections.xml`). The chain baseline on the local rig (OpenRegister 2.1.34-unstable, OpenCatalogi 2.1.17-unstable, Portaliq 0.2.8-unstable, stackiq 0.2.4-unstable) fixed what the import has to produce: @@ -16,14 +16,15 @@ Stackiq already has two upload imports: `SbomController` with `SbomImportService **Goals** -- One admin action turns a TOPdesk export into modules, manufacturers, usages and owners for one municipality. +- One admin action turns a TOPdesk export into modules, vendors, usages and owners for one municipality. +- Owner data is kept in stackiq and Nextcloud Contacts but is never publicly readable. - Re-importing a newer export updates the same records and creates no duplicates. - The mapping is data (JSON), not code, and runs through OpenRegister's mapping engine. - An untrusted spreadsheet is read safely, and one bad row never breaks the import. **Non-Goals** -- Connections from "Ouders" / "Kind-middelen", suites, hosting parties, the archive sheet. +- Connections, suites, hosting parties ("Hostingpartij"), "Leverancier", the archive sheet, the functional administrator as technical owner. - Marking or removing records that disappeared from the export. - A background job, a dry run, an `occ` command, a live TOPdesk connection. - Writing OpenCatalogi catalogues or Portaliq accounts. @@ -38,11 +39,11 @@ Admin settings, "CMDB import" section (CmdbImport.vue) CmdbImportController::import() admin-only, CSRF, size/type checks ▼ CmdbExportImportService::import() - ├─ CmdbImportProfile lib/Settings/cmdb-import/topdesk-profile.json + 6 packs + ├─ CmdbImportProfile lib/Settings/cmdb-import/topdesk-profile.json + 5 packs │ (packs checked with OR PackDefinitionValidator) ├─ CmdbWorkbookReader PhpSpreadsheet Xlsx, read-data-only, profile sheets only, │ header-name columns, allowlisted columns, empty rows dropped - ├─ CmdbRowNormaliser trim, Excel serial → Y-m-d, numeric ids → string + ├─ CmdbRowNormaliser trim, placeholder → empty, Excel serial → Y-m-d, numeric ids → string ├─ OR MappingEngine::mapRow() once per pack per row ├─ resolve per row, in order: │ municipality (once) → manufacturer → module → owners → usage @@ -73,13 +74,14 @@ Files, all in `lib/Settings/cmdb-import/`: | File | Target | Notes | |---|---|---| -| `topdesk-profile.json` | none | Sheets with their accepted Soort values, key column, required columns, date and id columns, the pack per target, create-only fields, size and row limits | -| `topdesk-module.json` | `module` | Mapping errors on `required` mappings skip the row | -| `topdesk-manufacturer.json` | `organization` (Supplier) | Empty "Fabrikant" means no provider | +| `topdesk-profile.json` | none | The two sheets, each with its `constants` (the `Beheer` value added to every row) and `absentColumns` (pack columns the sheet is known not to have), key column, required columns, date and id columns, `emptyValues` (placeholders that mean empty), the pack per target, create-only fields, size and row limits | +| `topdesk-module.json` | `module` | Mapping errors on `required` mappings skip the row; lookups for hosting model and BBN level | +| `topdesk-manufacturer.json` | `organization` (Supplier) | Empty "Vendor" means no provider | | `topdesk-municipality.json` | `organization` (Municipality) | Maps the options row `{municipalityName}`, not a sheet row | -| `topdesk-usage.json` | `usage` | Lookups for status and TIME class | -| `topdesk-business-owner.json` | owner identity | `name`, `email`, `role`; the service turns it into a contact and a `contactPerson` | -| `topdesk-technical-owner.json` | owner identity | `name` | +| `topdesk-usage.json` | `usage` | Lookups for status and TIME class; the maintenance note | +| `topdesk-business-owner.json` | owner identity | `name`, `role`; the service turns it into a contact and a `contactPerson` | + +There is no technical-owner pack: the functional administrator (FB contactpersoon) is not imported (decided 2026-10-01). Stackiq-specific settings live in the profile, not in the packs, so every pack stays a valid OpenRegister pack. @@ -95,8 +97,9 @@ Stackiq-specific settings live in the profile, not in the packs, so every pack s 1. Before PhpSpreadsheet: the name ends in `.xlsx`, the first bytes are the ZIP signature `PK\x03\x04`, and `ZipArchive` lists `xl/workbook.xml`. Otherwise 400 `NOT_XLSX`. 2. `new \PhpOffice\PhpSpreadsheet\Reader\Xlsx()`, then `setReadDataOnly(true)` and `setLoadSheetsOnly([...profile sheet names that exist])`. The sheet names come from `listWorksheetNames()`. The class comes from OpenRegister's vendor directory, which is loaded whenever OpenRegister is enabled. It is checked with `class_exists`; if absent, 503 `READER_UNAVAILABLE`. 3. Row 1 holds the headers. Each header is normalised (trim, collapse whitespace, drop a trailing `:` or `⚡`, lower case) and matched to the column names the profile and the packs reference. Only those columns are kept. Every other cell, such as Personeelsnummer, phone numbers and group mailboxes, is never copied out of the reader. -4. For each cell the reader takes `getValue()`. For a formula cell (data type `f`) it takes `getOldCalculatedValue()`, the value Excel cached. It never calls `getCalculatedValue()` or `toArray()` with formula calculation. The source sheets have no formulas today; the rule covers exports that do. -5. A row whose kept cells are all empty is dropped and not counted. A source sheet with more than `maxRowsPerSheet` (10,000) non-empty rows stops the import with 422 `TOO_MANY_ROWS`. +4. For each cell the reader takes `getValue()`. For a formula cell (data type `f`) it takes `getOldCalculatedValue()`, the value Excel cached. It never calls `getCalculatedValue()` or `toArray()` with formula calculation. Every cell of the CMDB sheets is a formula, so this is the normal path. A formula without a cached value (no `` in the file, for example a workbook written by a tool that does not calculate) is read as empty and its column is listed in the row's `uncached`; the service turns that into the row warning `Column "…": formula without a cached value, read as empty`. It never fails the row. A cached number `0` is what Excel stores for a reference to an empty cell, and is read as empty. +5. A row whose kept cells are all empty is dropped and not counted. With rule 4 this also drops the formula rows that reference empty "Invoer" rows. +6. Columns are resolved per sheet. A required column missing on a present sheet stops the import; an optional one gives one import-level warning, unless the profile lists it in that sheet's `absentColumns` (`Nickname` exists only on "Beheerde"). A source sheet with more than `maxRowsPerSheet` (10,000) non-empty rows stops the import with 422 `TOO_MANY_ROWS`. External connections, the Power Query package and hyperlinks are never resolved: PhpSpreadsheet does not follow them, and the reader gets no HTTP client. @@ -104,27 +107,30 @@ External connections, the Power Query package and hyperlinks are never resolved: `CmdbRowNormaliser` turns reader output into the flat `column => string` row the engine expects: -- Columns listed in the profile's `dateColumns` ("Aanmaakdatum", "Wijzigingsdatum", "End of Life Business", "Einddatum"): a numeric value is converted with `PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject()` in UTC and written as `Y-m-d`. For example, `45111.38…` becomes `2023-07-04` and `53359` becomes `2046-02-01`. A non-numeric value stays as it is, so the pack's `date` transform (`sourceFormat: Y-m-d`) either accepts it or reports a warning. -- Columns listed in `idColumns` ("Middel-ID", "ICT Applicatienummer"): a whole number becomes a string without a decimal part (`1234.0` becomes `"1234"`). +- Values listed in the profile's `emptyValues` for their column become empty, compared case-insensitively before any conversion: `NB` in "BNN Classificatie" (the CMDB sheet's "niet bekend") and `49675` (2036-01-01) in "End-of-Life Functioneel" (the CMDB sheet's placeholder for "no end-of-life date"; its formula turns an empty date, or TOPdesk's 2099-12-31, into 49675). +- Columns listed in the profile's `dateColumns` ("Datum", "Referentie datum wijziging", "End-of-Life Functioneel"): a numeric value is converted with `PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject()` in UTC and written as `Y-m-d`. For example, `45111.38…` becomes `2023-07-04` and `53359` becomes `2046-02-01`. A non-numeric value stays as it is, so the pack's `date` transform (`sourceFormat: Y-m-d`) either accepts it or reports a warning. +- Columns listed in `idColumns` ("APPID"): a whole number becomes a string without a decimal part (`1234.0` becomes `"1234"`). +- The constants of the row's sheet are added before mapping (`Beheer` = `Beheer geregeld: nee` on "Onbeh", `ja` on "Beheerde"), so the usage pack can map the sheet like a column. - Every value is trimmed. An empty string counts as empty. The engine's `date` transform only parses formatted strings. Doing the serial conversion in the normaliser keeps the packs plain OpenRegister packs. ### D5. Matching key and upsert -The key is the TOPdesk Middel-ID, scoped to the municipality: `externalKey = "topdesk:" + municipalityUuid + ":" + Middel-ID`. Middel-IDs are unique within one TOPdesk instance, not across municipalities. With the scope, two municipalities can each import an "APP-00001" without colliding. +The key is the TOPdesk APPID (the ICT Applicatienummer), scoped to the municipality: `externalKey = "topdesk:" + municipalityUuid + ":" + APPID`. Decided with the municipality on 2026-10-01: the Middel-ID (CMDB column "Applicatie Code") can be changed in TOPdesk, the APPID cannot; the first real import also showed the Middel-ID prefix in two spellings (`APP-` and `App-`). The Applicatie Code is stored as `externalId` for reference and updated like any mapped field. APPIDs are unique within one TOPdesk instance, not across municipalities; with the scope, two municipalities can each import an APPID `101` without colliding. Per row: -1. A row without a Middel-ID is skipped (`missing Middel-ID`). A Middel-ID already seen in this upload is skipped (`duplicate Middel-ID in file`). A Soort outside the sheet's accepted values is skipped (`unsupported Soort ""`). +1. A row without an APPID is skipped (`missing APPID`). An APPID already seen in this upload, on either sheet, is skipped (`duplicate APPID in file`). The CMDB sheets have no "Soort" column, so there is no row-kind filter. 2. Look up the module with `searchObjects` on the configured register and module schema, filtered on `externalKey`, with `_rbac: false` and `_multitenancy: false` (as `SbomImportService` does; the caller is an admin). The result is cached for the run. 3. No match: create the module from the mapped data, plus `externalKey`, the create-only defaults (`type: Application`), and `publicationDate` (D6). 4. Match and `updateExisting=false`: skip with reason `exists`. 5. Match: merge the mapped fields onto the stored object. Every field the pack does not map stays as it is. Create-only fields stay as they are, unless the stored value is empty. If the merged object equals the stored one, do not save, and report `unchanged`. Otherwise save, and report `updated`. -`ICT Applicatienummer` is stored as `externalNumber` and shown, but is not a match key. If the Middel-ID is missing and a second key is needed, that is a follow-up (open question). +The APPID is also stored as `externalNumber`, so it is visible on the module. -- **Alternative: OpenRegister's `idStrategy: sourceField` (Middel-ID as the object id).** Rejected. Object ids are global uuids, and Middel-ID is neither a uuid nor unique across municipalities. +- **Alternative: the Middel-ID ("Applicatie Code") as the key**, as in the first version of this change. Rejected on 2026-10-01: it can change in the source. +- **Alternative: OpenRegister's `idStrategy: sourceField` (APPID as the object id).** Rejected. Object ids are global uuids, and the APPID is neither a uuid nor unique across municipalities. - **Alternative: put the key on `usage` (per municipality by nature).** Rejected for this change: the key on `module` was decided in the plan (Q3), and the usage is found from the module anyway (D7). ### D6. publicationDate @@ -137,20 +143,22 @@ Per row: Per row, in this order: 1. **Municipality** (once per import): `municipalityUuid` must resolve to an `organization` of type `Municipality`. Otherwise 422 `MUNICIPALITY_INVALID`. With `municipalityName`, the service reuses an existing Municipality with the same normalised name, or creates one through the municipality pack. -2. **Manufacturer**: map "Fabrikant" through the manufacturer pack. The normalised name (trim, collapse whitespace, lower case) is looked up in the run cache, then among `organization` objects of type `Supplier`. A new one is created only when neither matches. +2. **Manufacturer**: map "Vendor" (the maker of the software) through the manufacturer pack. "Leverancier" (where the municipality buys it) and "Hostingpartij" are not read (follow-up). The normalised name (trim, collapse whitespace, lower case) is looked up in the run cache, then among `organization` objects of type `Supplier`. A new one is created only when neither matches. The first real import (1,137 rows, 479 suppliers) showed no two names that differ only in case, spacing or a legal-form suffix (`B.V.`, `BV`, `Inc.` …), so the normalisation is not widened. 3. **Module** (D5), with `provider` = the manufacturer when there is one. 4. **Owners** (D8). -5. **Usage**: `searchObjects` on `consumer` = municipality and `module` = module uuid. Create or merge the usage pack's fields, plus `consumer`, `module`, `provider` = the manufacturer, and `businessOwner` / `technicalOwner`. `interneAnnotation` ("Eigenaar afdeling / Eigenaar cluster") is create-only, because it is a free-text note an admin may edit. +5. **Usage**: `searchObjects` on `consumer` = municipality and `module` = module uuid. Create or merge the usage pack's fields, plus `consumer`, `module`, `provider` = the manufacturer, and `businessOwner`. `interneAnnotation` ("Beheer geregeld: ja|nee / Cluster / Applicatie Eigenaar (Afdeling)", empty parts left out) is create-only, because it is a free-text note an admin may edit. When step 3 succeeds and step 5 fails, the row is `failed` with the step named. The next import completes it, because every step is find-or-create. -### D8. Owners as contact persons +### D8. The owner as contact person + +Stackiq keeps a person's identity in Nextcloud Contacts. A `contactPerson` object holds only `contactsUid`, `role`, `organization` and `roles`. The owner is "Applicatie Eigenaar (Persoon)"; when TOPdesk has no owner the CMDB sheet shows the owner's function there instead, and the import uses that as the display name too. "Applicatie Eigenaar (Functie)" is the role; "Applicatie Eigenaar (Afdeling)" goes into the usage note (D7), because a contact person has no department field. The functional administrator (FB contactpersoon) is not imported, so there is no `technicalOwner`. -Stackiq keeps a person's identity in Nextcloud Contacts. A `contactPerson` object holds only `contactsUid`, `role`, `organization` and `roles`. For each owner identity mapped from the row: +1. The CMDB sheets carry no e-mail address, so the service runs `searchContacts(name)` and accepts only an exact, case-insensitive display-name match; otherwise `StackiqContactSyncService::syncToContacts('contactPerson', ['voornaam' => …, 'achternaam' => …, 'role' => …])` creates the contact. This avoids creating a new contact on every import. +2. Find the `contactPerson` with that `contactsUid` and `organization` = the municipality (run cache, then `searchObjects`). If none exists, create it with `role` = "Applicatie Eigenaar (Functie)" when given. +3. Set `usage.businessOwner` to its uuid. -1. `StackiqContactSyncService::syncToContacts('contactPerson', ['name' => …, 'email' => …])` resolves the contact: by e-mail when one is given, otherwise it creates one. Without an e-mail, the service first runs `searchContacts(name)` and accepts only an exact, case-insensitive display-name match. This avoids creating a new contact for "FB contactpersoon 1" on every import. -2. Find the `contactPerson` with that `contactsUid` and `organization` = the municipality (run cache, then `searchObjects`). If none exists, create it with `role` = "Eigenaar functie" when given. -3. Set `usage.businessOwner` / `usage.technicalOwner` to its uuid. +**Never public.** `contactPerson` and `usage` have read rules for named groups only, none for `public`, and a published `module` refers to them through `contactPerson` / `usages` relations. An anonymous OpenCatalogi search hit therefore carries at most ids, and OpenRegister's objects API returns no contact person or usage to an anonymous caller. `tests/Unit/Settings/CmdbPersonDataVisibilityTest.php` pins the read rules on the merged register; the e2e test checks the running stack anonymously. The import never calls the user-provisioning paths (`ContactpersoonService::processContactpersoon`, `convertToUser`). The scheduled `OrganizationSyncService::performUserSync` provisions users for contact persons. A unit test asserts that a `contactPerson` written by the import does not meet its selection criteria, and the implementation task verifies that before shipping (see Risks). When Contacts is disabled, owners are skipped with a warning and the row is still imported. @@ -158,7 +166,7 @@ The import never calls the user-provisioning paths (`ContactpersoonService::proc The import runs inside the upload request, as the SBOM and ArchiMate imports do. The client sends a fresh `operationId`. The service calls `startOperation('cmdb_import', ['total_items' => rowCount])`, then `updateProgress` after each row and `completeOperation($report)` at the end. The UI polls the existing `GET /api/progress/{operationId}`. `POST /api/cmdb-import/{operationId}/cancel` calls `setCancelRequested()`. The service checks `isCancelRequested()` between rows and returns the partial report with `cancelled: true`. -Report shape (contract.md is authoritative): `summary {rowsRead, created, updated, unchanged, skipped, failed, warnings}`, `importWarnings[]` (for example, a missing optional column), and `rows[] {sheet, row, middelId, name, outcome, reasons[], warnings[], moduleUuid, usageUuid}`. Reasons name columns and values. They never name owners, e-mail addresses or other person data, and neither do log lines. +Report shape (contract.md is authoritative): `summary {rowsRead, created, updated, unchanged, skipped, failed, warnings}`, `importWarnings[]` (for example, a missing optional column), and `rows[] {sheet, row, appId, name, outcome, reasons[], warnings[], moduleUuid, usageUuid}`. Reasons name columns and values. They never name owners, e-mail addresses or other person data, and neither do log lines. ### D10. Controller and validation order @@ -188,35 +196,32 @@ Cell values are shown with text interpolation only, never `v-html`. Requests use ## Column mapping -Source columns of "Invoer AIA data" and "Invoer APP data" and where they go. Columns that are not listed are not read (D3). +Source columns of "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB" and where they go, by header name. A column that one sheet lacks is optional there. Columns that are not listed are not read (D3). -| Column | Target | Rule | -|---|---|---| -| Naam | module.name | trim, required | -| Middel-ID | module.externalId; part of module.externalKey | trim, required, match key (D5) | -| ICT Applicatienummer | module.externalNumber | numeric to string | -| Functionele omschrijving | module.longDescription | trim | -| ICT BBN Classificatie | module.bbnLevel | lookup "BBN1"/"BBN 1" etc. to `BBN1`/`BBN2`/`BBN3`; unknown value: warning | -| Aanmaakdatum | module.externalCreatedAt | Excel serial to date | -| Wijzigingsdatum | module.externalModifiedAt | Excel serial to date | -| Fabrikant | organization (Supplier) via module.provider and usage.provider | dedup on normalised name (D7) | -| Status | usage.status | lookup (provisional): In productie → In production, In voorraad → Planned, In ontwikkeling → Acquisition, Uit te faseren → To be phased out, Uitgefaseerd → Phased out; unknown value: warning | -| ICT TIME Classificatie | usage.timeClassification | lookup of the English and Dutch forms (Tolerate/Tolereren, Invest/Investeren, Migrate/Migreren, Eliminate/Elimineren) | -| End of Life Business | usage.startDateOutPhased | Excel serial to date | -| Eigenaar afdeling, Eigenaar cluster | usage.interneAnnotation | concat with " / ", create-only | -| Eigenaar, Eigenaar e-mail, Eigenaar functie | usage.businessOwner (contactPerson + Nextcloud contact; role = functie) | D8 | -| FB contactpersoon 1 | usage.technicalOwner (contactPerson + Nextcloud contact) | D8, match on name | -| Soort | none (row filter) | accepted values per sheet in the profile | -| Roepnaam | not mapped (no field) | a pack mapping to shortDescription is the documented example of adjusting the mapping | -| ICT Applicatiesoort | not mapped (no field: cloudDienstverleningsmodel uses another vocabulary) | | -| ICT Hostingspartij | not mapped (no field for a hosting party on module) | follow-up | -| Software suite | not mapped in this change (suite schema; needs a second pass) | follow-up | -| Leverancier | not mapped in this change | candidate second supplier source | -| Afdeling, Behandelaarsgroep | not mapped (no field) | | -| ICT Beschikbaarheid / Integriteit / Vertrouwelijkheid, IB-*, ICT Back-up-*, ICT Cryptografie-*, ICT Audit logging and the other ICT assessment columns | not mapped (no field on module or usage) | schema extension is out of scope | -| Ouders, Kind-middelen, APM / afhankelijkheid / gebruik / gedistribueerd / proces koppeling | not mapped in this change | connections follow-up | -| Einddatum, Publiceren op SARA, Toewijzingen-* | not mapped (meaning to be confirmed with the municipality) | | -| Personeelsnummer, Eigenaar mobiel nummer, Groepseigenaar-*, Groepsmail, Groepsnummer, Configuratie coördinator, FB contactpersoon 2, Opmerkingen | never read (privacy, D3) | | +| Column | Sheets | Target | Rule | +|---|---|---|---| +| APPID | both | module.externalNumber; part of module.externalKey | trim, required, numeric to string, match key (D5) | +| Applicatie Naam | both | module.name | trim, required | +| Applicatie Code | both | module.externalId | trim; reference only (the Middel-ID; can change in the source) | +| Roepnaam | both | module.shortDescription | trim; wins over Nickname | +| Nickname | Beheerde | module.shortDescription | trim; used when Roepnaam is empty; listed as absent on Onbeh | +| Functionele Omschrijving | both | module.longDescription | trim | +| Applicatiesoort | both | module.cloudDienstverleningsmodel | lookup: `Saas`/`SaaS` → `["SaaS"]`, `PaaS` → `["PaaS"]`, `IaaS` → `["IaaS"]`, `On-premise(s)` → `["On-premises (self-managed)"]`; another value (such as `Webapplicatie`): warning, field dropped | +| BNN Classificatie | both | module.bbnLevel | `NB` is empty; lookup "BBN1"/"BBN 1"/"BNN1" etc. to `BBN1`/`BBN2`/`BBN3`; unknown value: warning | +| Datum | both | module.externalCreatedAt | Excel serial to date | +| Referentie datum wijziging | both | module.externalModifiedAt | Excel serial to date | +| Vendor | both | organization (Supplier) via module.provider and usage.provider | dedup on normalised name (D7) | +| Applicatie Status | both | usage.status | lookup: In productie → In production, In voorraad → Planned, In ontwikkeling → Acquisition, Uit te faseren → To be phased out, Uitgefaseerd → Phased out; unknown value: warning | +| Classificatie | both | usage.timeClassification | lookup Tolereren/Tolerate, Investeren/Invest, Migreren/Migrate, Elimineren/Eliminate | +| End-of-Life Functioneel | both | usage.startDateOutPhased | `49675` (2036-01-01) is empty; Excel serial to date | +| (sheet constant `Beheer`), Cluster, Applicatie Eigenaar (Afdeling) | both | usage.interneAnnotation | concat with " / ", empty parts dropped, create-only; `Beheer geregeld: nee` (Onbeh) or `ja` (Beheerde) | +| Applicatie Eigenaar (Persoon), Applicatie Eigenaar (Functie) | both | usage.businessOwner (contactPerson + Nextcloud contact; role = Functie) | D8; the person column may hold a function | +| Hostingpartij | both | not mapped | follow-up; "Leverancier" (where the municipality buys the software) is not on the CMDB sheets | +| Software Suite | both | not mapped (suite schema; needs a second pass) | follow-up | +| Applicatiecomponent, Bron, Datum Interface, Referentie element externe ID, Cloud, Rappeldatum, Rappelreden, Locatie BIOToets | both | not mapped | Cloud is derived from Applicatiesoort; Datum Interface is the export date | +| Beschikbaarheid, Integriteit, Vertrouwelijkheid, Applicatienut, Kwaliteit en betrouwbaarheid van leverancier, Flexibiliteit, Gebruikerstevredenheid, Reputatie risico | both | not mapped (no field on module or usage) | schema extension is out of scope | +| Standaard, Behandelgroep, End-of-life Technisch, End-of-support Technisch, Top5, COTS, Applicatie Nummer | Beheerde | not mapped | Applicatie Nummer repeats the APPID | +| every column of the "Invoer" sheets | – | never read | | ## API Design @@ -244,7 +249,7 @@ The fragment bumps `module` to `0.3.5`. Fragments are merged in filename order a - **Imperative, because it is an external integration:** reading an uploaded third-party file, splitting a row into four linked objects, resolving contacts in Nextcloud Contacts, progress and cancel. These are not object lifecycle, aggregation, notification or relation rules that an `x-openregister-*` block can express. This is the external-integration exception: the service is imperative glue around the file. - **Declarative:** what each column becomes (target property, transform, lookup, required) is JSON in OpenRegister's migration-pack format, executed by OpenRegister's `MappingEngine`. Changing the mapping changes no PHP. -- **Matching rule (stated once, enforced in code):** a module matches when its `externalKey` equals `topdesk::`. A usage matches on (`consumer`, `module`). A supplier matches on its normalised name and type `Supplier`. A contact person matches on (`contactsUid`, `organization`). +- **Matching rule (stated once, enforced in code):** a module matches when its `externalKey` equals `topdesk::`. A usage matches on (`consumer`, `module`). A supplier matches on its normalised name and type `Supplier`. A contact person matches on (`contactsUid`, `organization`). - **publicationDate rule (stated once, enforced in code):** set to the import's start time on create; never written on update. - No `x-openregister-*` block is added or changed. The usage name keeps coming from the schema's existing name template. @@ -262,10 +267,10 @@ The fragment bumps `module` to `0.3.5`. Fragments are merged in filename order a - **Auth and CSRF:** both routes are admin-only through Nextcloud's middleware, with CSRF required. This is stricter than `SbomController` and `importArchiMate`, which carry `NoCSRFRequired`. The admin check happens before the body is read. - **File checks before parsing:** size limit (10 MB, profile), `.xlsx` extension, ZIP signature and `xl/workbook.xml`. `.xlsm` and `.xls` are rejected. The upload is read from PHP's temporary upload file and never written into Nextcloud Files. - **No evaluation, no fetching:** read-data-only, profile sheets only, cached values for formula cells, no `getCalculatedValue()`, no HTTP client in the reader. External connections, Power Query packages and hyperlinks are inert. -- **Resource bounds:** row cap per sheet, and only allowlisted columns are kept. Memory is bounded by loading only the two source sheets. +- **Resource bounds:** row cap per sheet, and only allowlisted columns are kept. Memory is bounded by loading only the two CMDB sheets. - **Injection:** every value is a string that goes through OpenRegister's schema validation on save, and is never used in SQL, file paths or templates. The UI renders values as text only. - **Isolation:** every row runs in its own try/catch. Errors are reported per row, and the import continues. -- **Privacy:** the column allowlist keeps personnel numbers, phones and group mailboxes out of memory. Owner identity goes only to Nextcloud Contacts. Reports and logs carry no person data. +- **Privacy:** the column allowlist keeps every person column except the owner out of memory; the "Invoer" sheets, which hold personnel numbers, phones and group mailboxes, are not read at all. Owner identity goes only to Nextcloud Contacts, and `contactPerson` and `usage` are never publicly readable (D8). Reports and logs carry no person data. - **Fixture hygiene:** the test fixture is the anonymised export with document metadata, the custom properties (sensitivity label), `customXml/` (including the Power Query package) and `xl/connections.xml` removed. One small synthetic connection part is added back for the external-connection test. ## NL Design System @@ -297,7 +302,6 @@ lib/ topdesk-municipality.json topdesk-usage.json topdesk-business-owner.json - topdesk-technical-owner.json register.d/ topdesk-cmdb-import.json (module 0.3.5: five properties + seed modules) src/views/settings/ @@ -306,7 +310,7 @@ src/views/settings/ tests/ fixtures/cmdb/ topdesk-export-anonymised.xlsx (sanitised copy of the test export) - topdesk-missing-middel-id.xlsx + topdesk-missing-appid.xlsx topdesk-shuffled-columns.xlsx topdesk-formula-and-connection.xlsx Unit/Service/CmdbExportImportServiceTest.php @@ -315,6 +319,7 @@ tests/ Unit/Service/Cmdb/CmdbImportProfileTest.php Unit/Controller/CmdbImportControllerTest.php Unit/Settings/TopdeskCmdbFragmentTest.php + Unit/Settings/CmdbPersonDataVisibilityTest.php e2e/spec-coverage/cmdb-import.spec.ts docs/features/ cmdb-import.md @@ -372,8 +377,11 @@ The seeds show the new properties in a fresh install. They carry no `publication | name | naamtest123 | | externalId | APP-test123 | | externalNumber | 2 | -| externalKey | topdesk:00000000-0000-0000-0000-000000000001:APP-test123 | +| externalKey | topdesk:00000000-0000-0000-0000-000000000001:2 | +| shortDescription | Naamtest | | longDescription | Accomodatieplanning. | +| cloudDienstverleningsmodel | ["SaaS"] | +| bbnLevel | BBN2 | | provider | 00000000-0000-0000-0000-000000000003 | | publicationDate | 2026-10-01T10:00:00+00:00 | @@ -386,7 +394,9 @@ The seeds show the new properties in a fresh install. They carry no `publication | module | 00000000-0000-0000-0000-000000000004 | | provider | 00000000-0000-0000-0000-000000000003 | | status | In production | +| timeClassification | Tolerate | | startDateOutPhased | 2046-02-01 | +| interneAnnotation | Beheer geregeld: ja / B10 / B10 Maatschappelijke Ontwikkeling | | businessOwner | 00000000-0000-0000-0000-000000000006 | `contactPerson`: @@ -396,7 +406,7 @@ The seeds show the new properties in a fresh install. They carry no `publication | uuid | 00000000-0000-0000-0000-000000000006 | | contactsUid | `` | | organization | 00000000-0000-0000-0000-000000000001 | -| role | Afdelingshoofd | +| role | Teamleider Applicatiebeheer | ## Risks / Trade-offs @@ -404,7 +414,9 @@ The seeds show the new properties in a fresh install. They carry no `publication - [Owner contacts land in the importing admin's address book] → `StackiqContactSyncService` writes to the first writable address book of the acting user, the same as every other stackiq contact path. The docs say so. A dedicated system address book is a follow-up. - [Long synchronous request] → Per-row progress, cancel, and "unchanged" rows skip the save. About 1,100 rows is expected to fit. A background job is a follow-up if it does not. - [OpenRegister internals (`MappingEngine`, `PackDefinitionValidator`, PhpSpreadsheet) change shape] → Guarded resolution with 503, and a contract test that maps the fixture through the real engine in the dev environment. -- [Provisional status lookup] → An unknown value is a warning, never a wrong value. Once the municipality confirms its status values, the map in the JSON is extended, with no code change. +- [Provisional lookups for Applicatiesoort and BNN Classificatie] → The values of the first real import were not kept (the report lives in the progress cache for an hour). The maps hold the values the anonymised export and the CMDB formulas show (`Saas`, `Webapplicatie`, `NB`) plus the usual spellings. An unknown value is a warning, never a wrong value; once the municipality lists its values, the maps in the JSON are extended, with no code change. +- [CMDB placeholders] → "End-of-Life Functioneel" `2036-01-01` and "BNN Classificatie" `NB` are read as empty. A real end-of-life date of exactly 2036-01-01 would be lost; the municipality confirms. "Classificatie" defaults to `Tolereren` on "Beheerde" when TOPdesk has none; that cannot be told apart from a real `Tolereren` and is imported as Tolerate. +- [An application moves between the sheets] → Same APPID, so the same module and usage; the usage note (create-only) keeps its old `Beheer geregeld` line when it is not empty. - [An unknown status on create falls back to the usage schema's default "In production"] → Accepted. The warning in the report makes it visible. - [The fragment version is overwritten by merge order] → Filename ordering plus a unit test on the merged version (Mixed-spec rationale). @@ -414,6 +426,7 @@ No data migration. The register fragment deploys with the existing repair-step r ## Open Questions -- Which "Status" and "ICT TIME Classificatie" values occur in the municipality's real export (lookup maps)? -- Should "ICT Applicatienummer" serve as a fallback key when "Middel-ID" is empty? +- Which "Applicatiesoort" and "BNN Classificatie" values occur in the municipality's real export, and which hosting model does each mean (lookup maps)? +- Is 2036-01-01 in "End-of-Life Functioneel" always the placeholder, and should a "Beheerde" row without a TIME class really be Tolerate? +- Should the maintenance status update the usage note on re-import (it is create-only today), or get a field of its own? - Should OpenRegister promote `MigrationPack\MappingEngine` to its `Contract` namespace? diff --git a/openspec/changes/cmdb-export-import/migration.md b/openspec/changes/cmdb-export-import/migration.md index a1ab7d80..b0b8cd43 100644 --- a/openspec/changes/cmdb-export-import/migration.md +++ b/openspec/changes/cmdb-export-import/migration.md @@ -10,9 +10,9 @@ The `module` schema is at version `0.3.5` with five extra optional properties, a | Property | Type | Notes | |---|---|---| -| `externalId` | string, maxLength 100 | TOPdesk Middel-ID, shown as "Source id" | -| `externalNumber` | string, maxLength 50 | TOPdesk "ICT Applicatienummer" | -| `externalKey` | string, maxLength 200, `table.default: false` | `topdesk::`, the import's match key | +| `externalId` | string, maxLength 100 | TOPdesk Applicatie Code (the Middel-ID), shown as "Source id"; reference only | +| `externalNumber` | string, maxLength 50 | TOPdesk APPID ("ICT Applicatienummer") | +| `externalKey` | string, maxLength 200, `table.default: false` | `topdesk::`, the import's match key | | `externalCreatedAt` | string, format date | creation date in the source system | | `externalModifiedAt` | string, format date | last change in the source system | diff --git a/openspec/changes/cmdb-export-import/proposal.md b/openspec/changes/cmdb-export-import/proposal.md index 3bb2cc08..5c9b4f93 100644 --- a/openspec/changes/cmdb-export-import/proposal.md +++ b/openspec/changes/cmdb-export-import/proposal.md @@ -7,7 +7,7 @@ depends_on: [] ## Summary -A Nextcloud admin uploads a TOPdesk CMDB export (xlsx) in stackiq's admin settings, picks the municipality the export belongs to, and stackiq turns every application row into OpenRegister objects in the `stackiq` register: a `module` (the application), an `organization` for its manufacturer, a `usage` that links the application to the municipality, and `contactPerson` objects for the business and technical owner. Rows are matched on TOPdesk's `Middel-ID`, so a second import of a newer export updates the same records instead of duplicating them. The column-to-field mapping is declarative JSON executed by OpenRegister's migration-pack mapping engine. The admin follows the import live and gets a per-row report: created, updated, unchanged, skipped or failed, with the reason. +A Nextcloud admin uploads a TOPdesk CMDB export (xlsx) in stackiq's admin settings, picks the municipality the export belongs to, and stackiq turns every application row into OpenRegister objects in the `stackiq` register: a `module` (the application), an `organization` for its vendor, a `usage` that links the application to the municipality and records whether maintenance is arranged, and a `contactPerson` for the application owner, which is never publicly readable. The rows come from the two CMDB sheets, "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB". Rows are matched on TOPdesk's APPID (ICT Applicatienummer), so a second import of a newer export updates the same records instead of duplicating them. The column-to-field mapping is declarative JSON executed by OpenRegister's migration-pack mapping engine. The admin follows the import live and gets a per-row report: created, updated, unchanged, skipped or failed, with the reason. ## Motivation @@ -25,7 +25,7 @@ Stackiq already has two upload-and-import flows (SBOM, ArchiMate). This change a ### New Capabilities -- `cmdb-export-import`: an admin uploads a TOPdesk CMDB export (xlsx) and stackiq creates or updates modules, manufacturer organisations, usages and owner contact persons for one municipality, matched on the TOPdesk Middel-ID, with live progress and a per-row report. +- `cmdb-export-import`: an admin uploads a TOPdesk CMDB export (xlsx) and stackiq creates or updates modules, vendor organisations, usages and owner contact persons for one municipality, matched on the TOPdesk APPID, with live progress and a per-row report. ### Modified Capabilities @@ -40,10 +40,10 @@ None. The `module` schema gains five optional properties through a register frag ### In Scope - Upload endpoint for `.xlsx` files only, with a size limit, admin-only and CSRF-protected. -- Reading the two raw TOPdesk sheets, "Invoer AIA data" (Soort = Application Inventory) and "Invoer APP data" (Soort = Applicatie). Columns are found by header name, not position. Values are read as stored; formulas are never evaluated. +- Reading the two CMDB sheets the municipality uses as its CMDB (decided with the municipality on 2026-10-01): "Onbeh Applicaties CMDB" (from the AIA export: applications without arranged maintenance) and "Beheerde Applicaties CMDB" (from the APP export: with arranged maintenance). The raw "Invoer" sheets are not read. Columns are found by header name per sheet, not position. The CMDB sheets are formulas: the value Excel cached is read; formulas are never evaluated, and a formula without a cached value is an empty cell with a row warning. - One municipality per import, chosen by the admin from existing stackiq organisations of type Municipality, or created from a name the admin types. -- Per row: upsert the `module` on Middel-ID, find or create the manufacturer `organization` from the "Fabrikant" column (one organisation per distinct manufacturer), upsert the `usage` (consumer = municipality, module = the application), and find or create `contactPerson` objects for "Eigenaar" (business owner) and "FB contactpersoon 1" (technical owner) through Nextcloud Contacts. -- Declarative mapping: one migration-pack JSON per target (module, manufacturer, municipality, usage, business owner, technical owner) plus one import profile (sheets, required columns, key column, date columns), executed through OpenRegister's `MappingEngine::mapRow()`. +- Per row: upsert the `module` on APPID, find or create the vendor `organization` from the "Vendor" column (one organisation per distinct vendor), upsert the `usage` (consumer = municipality, module = the application, maintenance arranged yes/no in its note), and find or create the `contactPerson` for "Applicatie Eigenaar (Persoon)" (business owner) through Nextcloud Contacts. Contact persons and usages stay out of every public read. +- Declarative mapping: one migration-pack JSON per target (module, manufacturer, municipality, usage, business owner) plus one import profile (sheets, sheet constants, required columns, key column, date columns, placeholder values), executed through OpenRegister's `MappingEngine::mapRow()`. - Excel serial dates converted to ISO dates before mapping. - `publicationDate` set to the import time on newly created modules, never changed on update. - Repeatable import: matched records are updated, records missing from a newer export are left alone (`missingRecords: keep`, the only accepted value for now). @@ -52,9 +52,9 @@ None. The `module` schema gains five optional properties through a register frag ### Out of Scope -- The sheet "Invoer gearchiveerde appl" and the derived "*CMDB" sheets (deferred until the municipality confirms what they hold). +- The "Invoer" sheets, and the archive sheet "Gearchiveerde Applicaties" (follow-up: mark an APPID that left the CMDB sheets as archived). - Connections between applications from the "Ouders" / "Kind-middelen" columns (a second pass after all modules exist, as a follow-up change). -- Suites from "Software suite", hosting parties from "ICT Hostingspartij", and "Leverancier" as a second supplier source. The mapping can take them later without code once a target is agreed. +- Suites from "Software Suite", hosting parties from "Hostingpartij", "Leverancier" as a second supplier source, and the functional administrator (FB contactpersoon) as technical owner. The mapping can take them later without code once a target is agreed. - Marking or removing records that disappeared from a newer export (`missingRecords: mark|remove`, reserved values; belongs with operations-record-reconciliation, stackiq#1127). - A live TOPdesk or ServiceNow connection (stackiq#373, stackiq#1134), a dry-run mode, an `occ` command, and running the import as a background job. - Configuring OpenCatalogi catalogues or Portaliq account claims. The docs describe both prerequisites; the import does not write to those apps. @@ -70,7 +70,7 @@ None for stackiq's `composer.json` or `package.json`. The xlsx reader (`phpoffic ## Impact - **New backend**: `lib/Service/CmdbExportImportService.php` (plus small helpers for sheet reading and row normalisation), `lib/Controller/CmdbImportController.php`, two routes in `appinfo/routes.php`. -- **New configuration**: `lib/Settings/cmdb-import/topdesk-profile.json` and six pack files `lib/Settings/cmdb-import/topdesk-*.json`. +- **New configuration**: `lib/Settings/cmdb-import/topdesk-profile.json` and five pack files `lib/Settings/cmdb-import/topdesk-*.json`. - **Schema**: `lib/Settings/register.d/topdesk-cmdb-import.json` adds `externalId`, `externalNumber`, `externalKey`, `externalCreatedAt` and `externalModifiedAt` to `module` (all optional), with a version bump so the register import deploys them. - **New frontend**: `src/views/settings/sections/CmdbImport.vue`, registered in `src/views/settings/StackiqSettings.vue`. - **Data**: imports write `module`, `organization`, `usage` and `contactPerson` objects, and Nextcloud Contacts cards for owners. No existing object is deleted. @@ -83,7 +83,7 @@ None for stackiq's `composer.json` or `package.json`. The xlsx reader (`phpoffic ## Risks ### Risk 1: Personal data from a third party's export -**Severity:** High — **Mitigation:** only the owner columns the import maps ("Eigenaar", "Eigenaar e-mail", "Eigenaar functie", "FB contactpersoon 1") are read into stackiq, and they go to Nextcloud Contacts as the existing contact model requires. Personnel numbers, phone numbers and group mailboxes are never read. The report and the logs name rows by sheet, row number and Middel-ID only. Tests use the anonymised export. The import never creates Nextcloud user accounts, and a test asserts that the contact-person objects it writes do not qualify for the user sync. +**Severity:** High — **Mitigation:** only the owner columns the import maps ("Applicatie Eigenaar (Persoon)", "Applicatie Eigenaar (Functie)") are read into stackiq, and they go to Nextcloud Contacts as the existing contact model requires. The "Invoer" sheets, with personnel numbers, phone numbers and group mailboxes, are never read. `contactPerson` and `usage` have no public read rule, so owners never reach OpenCatalogi or Portaliq anonymously; a unit test pins the rule and an e2e test checks it anonymously. The report and the logs name rows by sheet, row number and APPID only. Tests use the anonymised export. The import never creates Nextcloud user accounts, and a test asserts that the contact-person objects it writes do not qualify for the user sync. ### Risk 2: Hidden coupling to OpenRegister internals **Severity:** Medium — **Mitigation:** `MappingEngine`, `PackDefinitionValidator` and PhpSpreadsheet are resolved through the container or a `class_exists` check. If any of them is missing, the import endpoint answers 503 with a clear message instead of failing halfway. Promoting `MappingEngine` to an OpenRegister contract is noted as an open question. @@ -92,7 +92,7 @@ None for stackiq's `composer.json` or `package.json`. The xlsx reader (`phpoffic **Severity:** Medium — **Mitigation:** an export with about 1,100 rows needs several saves per row. The service reports progress per row, honours cancel between rows, and skips the save when nothing changed. A background-job variant is a follow-up if real exports time out. ### Risk 4: TOPdesk values that do not match stackiq vocabularies -**Severity:** Low — **Mitigation:** "Status", "ICT TIME Classificatie" and "ICT BBN Classificatie" go through `lookup` maps. An unknown value drops only that field, and the row gets a warning that names the column and the value. Admins can extend the maps in the JSON. +**Severity:** Low — **Mitigation:** "Applicatie Status", "Classificatie", "Applicatiesoort" and "BNN Classificatie" go through `lookup` maps. An unknown value drops only that field, and the row gets a warning that names the column and the value. Admins can extend the maps in the JSON. ## Rollback Strategy @@ -100,6 +100,6 @@ The change is additive. Revert the PR to remove the routes, the settings section ## Open Questions -- Does the municipality confirm that the "Invoer" sheets are the unedited export, what the difference between AIA and APP is, and whether archived applications should be included? Until then the change reads both "Invoer" sheets and skips the archive sheet. -- Which TOPdesk "Status" values occur, and which usage status should each one get? The lookup map in design.md is provisional. +- Answered on 2026-10-01: the CMDB sheets are the source; AIA = without arranged maintenance, APP = with; archived applications are a follow-up; the key is the APPID. +- Which "Applicatiesoort" and "BNN Classificatie" values occur in the real export, and which hosting model does each "Applicatiesoort" mean? The lookup maps are provisional. - Should OpenRegister expose `MappingEngine` as a public contract (as it does for `ObjectServiceInterface`)? diff --git a/openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md index 2583f4ee..92ccf2d7 100644 --- a/openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md +++ b/openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md @@ -7,7 +7,7 @@ ## Purpose -A Nextcloud admin imports a TOPdesk CMDB export (xlsx) into stackiq for one municipality. Every application row becomes, or updates, a `module` (schema:SoftwareApplication) with its manufacturer `organization` (schema:Organization), a `usage` that links the application to the municipality, and `contactPerson` objects (schema:Person) for its owners. All data is stored as OpenRegister objects (ADR-001). The column-to-field mapping is declarative JSON executed by OpenRegister's mapping engine (ADR-011, ADR-031), so the import can be repeated with a newer export without creating duplicates. OpenCatalogi lists the imported applications, and Portaliq shows them to the municipality. +A Nextcloud admin imports a TOPdesk CMDB export (xlsx) into stackiq for one municipality. Every application row of the export's two CMDB sheets ("Onbeh Applicaties CMDB", applications without arranged maintenance, and "Beheerde Applicaties CMDB", with arranged maintenance) becomes, or updates, a `module` (schema:SoftwareApplication) with its vendor `organization` (schema:Organization), a `usage` that links the application to the municipality, and a `contactPerson` (schema:Person) for its owner, which is never publicly readable. All data is stored as OpenRegister objects (ADR-001). The column-to-field mapping is declarative JSON executed by OpenRegister's mapping engine (ADR-011, ADR-031), so the import can be repeated with a newer export without creating duplicates. OpenCatalogi lists the imported applications, and Portaliq shows them to the municipality. Nextcloud OCP interfaces used: `OCP\IRequest` (multipart upload), `OCP\IUserSession` and `OCP\IGroupManager` (admin check), `OCP\Contacts\IManager` (owner identity, through `StackiqContactSyncService`), `OCP\ICacheFactory` (progress, through `ProgressTracker`), `OCP\IL10N` (messages). OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` for every read and write. @@ -51,16 +51,24 @@ Nextcloud OCP interfaces used: `OCP\IRequest` (multipart upload), `OCP\IUserSess ### Requirement: REQ-CMDB-002 The workbook SHALL be read as stored data, without evaluating formulas or following links -The reader SHALL open the workbook with PhpSpreadsheet's Xlsx reader in read-data-only mode, SHALL load only the sheets named in the import profile, and SHALL read each cell's stored value. For a formula cell it SHALL use the value cached in the file and SHALL NOT evaluate the formula. It SHALL NOT contact external data connections, linked workbooks or URLs found in the file. It SHALL stop with 422 `TOO_MANY_ROWS` when a source sheet holds more data rows than the profile's limit (default 10,000). +The reader SHALL open the workbook with PhpSpreadsheet's Xlsx reader in read-data-only mode, SHALL load only the sheets named in the import profile, and SHALL read each cell's stored value. For a formula cell it SHALL use the value cached in the file and SHALL NOT evaluate the formula. It SHALL NOT contact external data connections, linked workbooks or URLs found in the file. A formula cell without a cached value SHALL be read as empty and SHALL add a row warning naming the column; it SHALL NOT fail the row or the import. A formula whose cached value is the number 0 (Excel's result for a reference to an empty cell) SHALL be read as empty. It SHALL stop with 422 `TOO_MANY_ROWS` when a source sheet holds more data rows than the profile's limit (default 10,000). #### Scenario: A formula cell yields its cached value and is not evaluated @e2e exclude Reader behaviour; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php reads a fixture whose source sheet has a formula cell and asserts the cached value is returned and the calculation engine is never invoked. -- **GIVEN** a source sheet where column "Naam" in row 2 holds a formula with a cached value `Rekenmodel` +- **GIVEN** a CMDB sheet where column "Applicatie Naam" in row 2 holds a formula with a cached value `Rekenmodel` - **WHEN** the workbook is read -- **THEN** the row SHALL carry `Naam = Rekenmodel` +- **THEN** the row SHALL carry `Applicatie Naam = Rekenmodel` - **AND** the formula SHALL NOT be evaluated +#### Scenario: A formula without a cached value is read as empty with a warning +@e2e exclude Reader and service behaviour; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php reads a fixture whose "Roepnaam" formula has no cached value, and tests/Unit/Service/CmdbExportImportServiceTest.php asserts the row warning. + +- **GIVEN** a CMDB sheet where column "Roepnaam" in row 2 holds a formula without a cached value +- **WHEN** the workbook is imported +- **THEN** the row SHALL be imported with an empty "Roepnaam" +- **AND** the row's report entry SHALL carry the warning `Column "Roepnaam": formula without a cached value, read as empty` + #### Scenario: An external data connection in the workbook is never contacted @e2e exclude Network isolation; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php reads a fixture that declares an external connection, with a reader that has no HTTP client, and asserts the read succeeds. @@ -71,21 +79,21 @@ The reader SHALL open the workbook with PhpSpreadsheet's Xlsx reader in read-dat ### Requirement: REQ-CMDB-003 Columns SHALL be resolved by header name, and a missing required column SHALL stop the import with 422 -The reader SHALL take the first row of each source sheet as headers and SHALL match them to the profile's column names case-insensitively, after trimming whitespace and dropping a trailing `:` or `⚡`. Column order SHALL NOT matter. When a present source sheet lacks a column the profile marks as required (`Middel-ID`, `Naam`), the endpoint SHALL answer 422 with error `MISSING_COLUMN`, naming the column and the sheet, before any object is written. When neither source sheet ("Invoer AIA data", "Invoer APP data") exists, the endpoint SHALL answer 422 with error `NO_SOURCE_SHEET`, naming both expected sheets. A missing optional column SHALL produce one import-level warning and no row error. +The source sheets SHALL be the CMDB sheets "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB"; the "Invoer" sheets SHALL NOT be read. The reader SHALL take the first row of each source sheet as headers and SHALL match them to the profile's column names per sheet, case-insensitively, after trimming whitespace and dropping a trailing `:` or `⚡`. Column order SHALL NOT matter. When a present source sheet lacks a column the profile marks as required (`APPID`, `Applicatie Naam`), the endpoint SHALL answer 422 with error `MISSING_COLUMN`, naming the column and the sheet, before any object is written. When neither source sheet exists, the endpoint SHALL answer 422 with error `NO_SOURCE_SHEET`, naming both expected sheets. A missing optional column SHALL produce one import-level warning and no row error, except for a column the profile lists as absent on that sheet (`Nickname` on "Onbeh Applicaties CMDB"). #### Scenario: A missing required column is named in the 422 response @e2e tests/e2e/spec-coverage/cmdb-import.spec.ts -- **GIVEN** an export whose sheet "Invoer APP data" has no column "Middel-ID" +- **GIVEN** an export whose sheet "Beheerde Applicaties CMDB" has no column "APPID" - **WHEN** a Nextcloud admin uploads it -- **THEN** the endpoint SHALL answer 422 with error `MISSING_COLUMN`, column `Middel-ID` and sheet `Invoer APP data` +- **THEN** the endpoint SHALL answer 422 with error `MISSING_COLUMN`, column `APPID` and sheet `Beheerde Applicaties CMDB` - **AND** the section SHALL show that column and sheet name to the admin - **AND** no object SHALL be written #### Scenario: Columns in a different order map the same @e2e exclude Reader behaviour; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php reads a fixture with shuffled columns and asserts identical rows. -- **GIVEN** an export where "Naam" comes before "Middel-ID" and the header reads `Groepseigenaar mail⚡` +- **GIVEN** an export where "Applicatie Naam" comes before "APPID" and the header reads `Vendor⚡` - **WHEN** the workbook is read - **THEN** every row SHALL carry the same values under the profile's column names as in the original order @@ -94,7 +102,7 @@ The reader SHALL take the first row of each source sheet as headers and SHALL ma - **GIVEN** an xlsx that contains only a sheet "Blad1" - **WHEN** a Nextcloud admin uploads it -- **THEN** the endpoint SHALL answer 422 with error `NO_SOURCE_SHEET` naming "Invoer AIA data" and "Invoer APP data" +- **THEN** the endpoint SHALL answer 422 with error `NO_SOURCE_SHEET` naming "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB" ### Requirement: REQ-CMDB-004 Every import SHALL have exactly one consuming municipality, chosen by the admin @@ -125,60 +133,77 @@ The request SHALL carry either `municipalityUuid`, the uuid of an existing stack ### Requirement: REQ-CMDB-005 Field mapping SHALL be declarative and executed by OpenRegister's mapping engine -The service SHALL map each normalised row with OpenRegister's `MigrationPack\MappingEngine::mapRow()`, once per target pack: module, manufacturer, municipality, usage, business owner, technical owner. The packs and the import profile SHALL ship as JSON under `lib/Settings/cmdb-import/`. Each pack SHALL pass OpenRegister's `PackDefinitionValidator` when the import starts; an invalid pack, or a missing `MappingEngine`, SHALL stop the import with 503 `MAPPING_UNAVAILABLE` before any row is read. Before mapping, the service SHALL convert the cells of the profile's date columns from Excel serial numbers to `Y-m-d`, and SHALL turn numeric id cells into strings without a decimal part. A mapping error on a mapping marked `required` in the module pack SHALL skip the row. In the manufacturer and owner packs it SHALL mean the row has no manufacturer or no such owner, without a warning. A mapping error on any other mapping SHALL drop only that field and add a row warning naming the column and the value. The reader SHALL keep only the columns that the profile or a pack references, and SHALL discard every other cell when it reads the row. +The service SHALL map each normalised row with OpenRegister's `MigrationPack\MappingEngine::mapRow()`, once per target pack: module, manufacturer, municipality, usage, business owner. The packs and the import profile SHALL ship as JSON under `lib/Settings/cmdb-import/`. Each pack SHALL pass OpenRegister's `PackDefinitionValidator` when the import starts; an invalid pack, or a missing `MappingEngine`, SHALL stop the import with 503 `MAPPING_UNAVAILABLE` before any row is read. Before mapping, the service SHALL convert the cells of the profile's date columns from Excel serial numbers to `Y-m-d`, SHALL turn numeric id cells into strings without a decimal part, SHALL read a value the profile lists as empty for its column (`NB` in "BNN Classificatie", serial `49675` in "End-of-Life Functioneel") as empty, and SHALL add the constants of the row's sheet (`Beheer` = `Beheer geregeld: nee` or `ja`). A mapping error on a mapping marked `required` in the module pack SHALL skip the row. In the manufacturer and owner packs it SHALL mean the row has no manufacturer or no such owner, without a warning. A mapping error on any other mapping SHALL drop only that field and add a row warning naming the column and the value. The reader SHALL keep only the columns that the profile or a pack references, and SHALL discard every other cell when it reads the row. #### Scenario: Excel serial dates are converted before mapping @e2e exclude Pure transformation; tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php asserts the conversions below. -- **GIVEN** the AIA row of the anonymised export with "Aanmaakdatum" = `45111.380322627316` and "Wijzigingsdatum" = `46232.552113113423`, and the APP row with "End of Life Business" = `53359` +- **GIVEN** the "Onbeh" row of the anonymised export with "Datum" = `45111.380322627316` and "Referentie datum wijziging" = `46232.552113113423`, and the "Beheerde" row with "End-of-Life Functioneel" = `53359` - **WHEN** the rows are normalised -- **THEN** "Aanmaakdatum" SHALL be `2023-07-04`, "Wijzigingsdatum" SHALL be `2026-07-29`, and "End of Life Business" SHALL be `2046-02-01` -- **AND** "ICT Applicatienummer" `1234` SHALL be the string `"1234"` +- **THEN** "Datum" SHALL be `2023-07-04`, "Referentie datum wijziging" SHALL be `2026-07-29`, and "End-of-Life Functioneel" SHALL be `2046-02-01` +- **AND** "APPID" `1234` SHALL be the string `"1234"` +- **AND** "End-of-Life Functioneel" `49675` and "BNN Classificatie" `NB` SHALL be empty #### Scenario: Changing a pack changes the mapping without code -@e2e exclude Configuration behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php loads an alternate module pack that maps "Roepnaam" to shortDescription and asserts the mapped module. +@e2e exclude Configuration behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php loads an alternate module pack that maps "Software Suite" to licentietype and asserts the mapped module. -- **GIVEN** the module pack is edited to add a mapping from "Roepnaam" to `shortDescription` -- **WHEN** an export is imported whose row has "Roepnaam" = `Mailen` -- **THEN** the created module SHALL have `shortDescription` = `Mailen` +- **GIVEN** the module pack is edited to add a mapping from "Software Suite" to `licentietype` +- **WHEN** an export is imported whose row has "Software Suite" = `Suite` +- **THEN** the created module SHALL have `licentietype` = `Suite` - **AND** no PHP code SHALL have changed #### Scenario: An unknown status value drops only that field @e2e exclude Mapping behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the row outcome and warning. -- **GIVEN** a row whose "Status" is `Onbekende status`, which the usage pack's lookup does not contain +- **GIVEN** a row whose "Applicatie Status" is `Onbekende status`, which the usage pack's lookup does not contain - **WHEN** the row is imported - **THEN** the module and the usage SHALL be saved without a status from the export -- **AND** the row's report entry SHALL carry a warning naming column "Status" and value `Onbekende status` +- **AND** the row's report entry SHALL carry a warning naming column "Applicatie Status" and value `Onbekende status` -### Requirement: REQ-CMDB-006 A module SHALL be matched on its TOPdesk Middel-ID, so a re-import updates instead of duplicating +#### Scenario: The classifications map to the stackiq fields +@e2e exclude Mapping behaviour; tests/Unit/Service/CmdbExportImportServiceTest.php imports the fixture and asserts the fields. + +- **GIVEN** the "Beheerde" row of the anonymised export with "Applicatiesoort" `Saas`, "BNN Classificatie" `BBN2`, "Classificatie" `Tolereren` and "End-of-Life Functioneel" `53359` +- **WHEN** it is imported +- **THEN** the module SHALL have `cloudDienstverleningsmodel` = `["SaaS"]` and `bbnLevel` = `BBN2` +- **AND** the usage SHALL have `timeClassification` = `Tolerate` and `startDateOutPhased` = `2046-02-01` +- **AND** the "Onbeh" row's "Applicatiesoort" `Webapplicatie`, which is not a hosting model, SHALL be dropped with a warning -For each row the service SHALL compute `externalKey` = `topdesk::` and look up a `module` with that `externalKey`. When none exists it SHALL create one. When one exists it SHALL update only the fields the module pack maps and SHALL leave every other field as it is. When the mapped fields equal the stored values it SHALL NOT save the module and SHALL report the row as `unchanged`. With `updateExisting=false` a matched row SHALL be reported as `skipped` with reason `exists`, without changes. A row without a Middel-ID SHALL be skipped with reason `missing Middel-ID`. When a Middel-ID occurs more than once in one upload, across both sheets, the first occurrence SHALL be imported and every later one SHALL be skipped with reason `duplicate Middel-ID in file`. +### Requirement: REQ-CMDB-006 A module SHALL be matched on its TOPdesk APPID, so a re-import updates instead of duplicating + +For each row the service SHALL compute `externalKey` = `topdesk::` (the APPID is TOPdesk's ICT Applicatienummer; the Applicatie Code, or Middel-ID, can change in TOPdesk and is stored as `externalId` for reference only) and look up a `module` with that `externalKey`. When none exists it SHALL create one. When one exists it SHALL update only the fields the module pack maps and SHALL leave every other field as it is. When the mapped fields equal the stored values it SHALL NOT save the module and SHALL report the row as `unchanged`. With `updateExisting=false` a matched row SHALL be reported as `skipped` with reason `exists`, without changes. A row without an APPID SHALL be skipped with reason `missing APPID`. When an APPID occurs more than once in one upload, across both sheets, the first occurrence SHALL be imported and every later one SHALL be skipped with reason `duplicate APPID in file`. #### Scenario: Re-importing the same export creates no duplicates @e2e tests/e2e/spec-coverage/cmdb-import.spec.ts -- **GIVEN** the anonymised export was imported once for "Gemeente Voorbeeldstad", which created modules `APP-test123` and `AIA-AangetekendMailen` +- **GIVEN** the anonymised export was imported once for "Gemeente Voorbeeldstad", which created the modules with APPID `1234` and `2` - **WHEN** the same export is imported again for the same municipality - **THEN** the report SHALL show 0 created and 2 unchanged rows - **AND** the number of modules, organisations, usages and contact persons in the register SHALL be the same as after the first import #### Scenario: A changed field is updated on re-import -@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php imports the fixture, changes "Naam" of APP-test123 to `naamtest124` in the row data, imports again, and asserts one module with the new name. +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php imports a row, changes "Applicatie Naam" of APPID 2 to `naamtest124` in the row data, imports again, and asserts one module with the new name. -- **GIVEN** module `APP-test123` was imported with name `naamtest123`, and an admin has since set its `website` -- **WHEN** a newer export where "Naam" for `APP-test123` is `naamtest124` is imported +- **GIVEN** the module with APPID `2` was imported with name `naamtest123`, and an admin has since set its `website` +- **WHEN** a newer export where "Applicatie Naam" for APPID `2` is `naamtest124` is imported - **THEN** the same module SHALL now have name `naamtest124` - **AND** its `website` SHALL be unchanged - **AND** the report SHALL show the row as `updated` -#### Scenario: A Middel-ID that occurs twice in one file is imported once -@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php feeds two rows with the same Middel-ID. +#### Scenario: An APPID that occurs twice in one file is imported once +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php feeds two rows with the same APPID, also across both sheets. -- **GIVEN** an upload where `APP-test123` appears in row 2 and row 7 of "Invoer APP data" +- **GIVEN** an upload where APPID `2` appears in row 2 and row 7 of "Beheerde Applicaties CMDB" - **WHEN** it is imported - **THEN** row 2 SHALL be imported -- **AND** row 7 SHALL be reported as `skipped` with reason `duplicate Middel-ID in file` +- **AND** row 7 SHALL be reported as `skipped` with reason `duplicate APPID in file` + +#### Scenario: A changed Applicatie Code keeps the same module +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php imports APPID 42 with two different codes. + +- **GIVEN** the module with APPID `42` was imported with "Applicatie Code" `APP-Oud` +- **WHEN** a newer export has APPID `42` with "Applicatie Code" `App-Nieuw` +- **THEN** the same module SHALL be updated, with `externalId` = `App-Nieuw` and the same `externalKey` ### Requirement: REQ-CMDB-007 A newly created module SHALL get a publicationDate, and an existing one SHALL keep its own @@ -195,19 +220,19 @@ When the service creates a `module` it SHALL set `publicationDate` to the time t #### Scenario: Re-import preserves publicationDate @e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php asserts both cases. -- **GIVEN** module `AIA-AangetekendMailen` was imported with `publicationDate` 2026-10-01T09:00:00+00:00, and module `APP-test123` was later depublished by an admin +- **GIVEN** the module with APPID `1234` was imported with `publicationDate` 2026-10-01T09:00:00+00:00, and the module with APPID `2` was later depublished by an admin - **WHEN** a newer export is imported that changes both modules' names -- **THEN** `AIA-AangetekendMailen` SHALL keep `publicationDate` 2026-10-01T09:00:00+00:00 -- **AND** `APP-test123` SHALL keep its `depublicationDate` and SHALL NOT get a new `publicationDate` +- **THEN** the module with APPID `1234` SHALL keep `publicationDate` 2026-10-01T09:00:00+00:00 +- **AND** the module with APPID `2` SHALL keep its `depublicationDate` and SHALL NOT get a new `publicationDate` ### Requirement: REQ-CMDB-008 A manufacturer SHALL become one supplier organisation, however many rows name it -The service SHALL map "Fabrikant" through the manufacturer pack to an `organization` of type `Supplier`. It SHALL match names after trimming, collapsing whitespace and ignoring case, first against the organisations it has already resolved during this import, then against existing organisations of type `Supplier`, and SHALL create one only when neither matches. The imported module's `provider` and the usage's `provider` SHALL reference that organisation. A row with an empty "Fabrikant" SHALL be imported without a provider. +The service SHALL map "Vendor" (the maker of the software) through the manufacturer pack to an `organization` of type `Supplier`. It SHALL match names after trimming, collapsing whitespace and ignoring case, first against the organisations it has already resolved during this import, then against existing organisations of type `Supplier`, and SHALL create one only when neither matches. The imported module's `provider` and the usage's `provider` SHALL reference that organisation. A row with an empty "Vendor" SHALL be imported without a provider. "Leverancier" and "Hostingpartij" SHALL NOT be read. #### Scenario: Rows with the same manufacturer share one organisation @e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php feeds three rows with "Fabfrikant", "Fabfrikant " and "FABFRIKANT". -- **GIVEN** three rows whose "Fabrikant" is `Fabfrikant`, `Fabfrikant ` and `FABFRIKANT` +- **GIVEN** three rows whose "Vendor" is `Fabfrikant`, `Fabfrikant ` and `FABFRIKANT` - **WHEN** they are imported - **THEN** exactly one organisation `Fabfrikant` of type `Supplier` SHALL exist - **AND** all three modules SHALL have `provider` = its uuid @@ -216,13 +241,21 @@ The service SHALL map "Fabrikant" through the manufacturer pack to an `organizat @e2e exclude Covered by the service test. - **GIVEN** an existing organisation `Aangetekend B.V.` of type `Supplier` -- **WHEN** the AIA row with "Fabrikant" `Aangetekend B.V.` is imported +- **WHEN** the "Onbeh" row with "Vendor" `Aangetekend B.V.` is imported - **THEN** no new organisation SHALL be created -- **AND** module `AIA-AangetekendMailen` SHALL have `provider` = the existing organisation's uuid +- **AND** the module with APPID `1234` SHALL have `provider` = the existing organisation's uuid ### Requirement: REQ-CMDB-009 Each imported application SHALL have one usage that links it to the municipality -For each imported module the service SHALL keep exactly one `usage` with `consumer` = the municipality and `module` = the module, found by those two references and created when missing. The usage pack SHALL map "Status" to `status` and "ICT TIME Classificatie" to `timeClassification` through lookups, "End of Life Business" to `startDateOutPhased`, and "Eigenaar afdeling" and "Eigenaar cluster" to `interneAnnotation`. `interneAnnotation` SHALL be written only when the usage is created or the field is empty, so a note an admin wrote is never overwritten. +For each imported module the service SHALL keep exactly one `usage` with `consumer` = the municipality and `module` = the module, found by those two references and created when missing. The usage pack SHALL map "Applicatie Status" to `status` and "Classificatie" to `timeClassification` through lookups, "End-of-Life Functioneel" to `startDateOutPhased`, and the sheet's `Beheer` constant, "Cluster" and "Applicatie Eigenaar (Afdeling)" to `interneAnnotation`, so the note records whether maintenance is arranged (`Beheer geregeld: nee` for "Onbeh Applicaties CMDB", `ja` for "Beheerde Applicaties CMDB"). Empty parts SHALL be left out of the note. `interneAnnotation` SHALL be written only when the usage is created or the field is empty, so a note an admin wrote is never overwritten. + +#### Scenario: The usage records whether maintenance is arranged +@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php imports a row from each sheet. + +- **GIVEN** a row on "Onbeh Applicaties CMDB" with "Cluster" `H10` and "Applicatie Eigenaar (Afdeling)" `H10 Accounting` +- **WHEN** it is imported +- **THEN** its usage SHALL have `interneAnnotation` = `Beheer geregeld: nee / H10 / H10 Accounting` +- **AND** a row on "Beheerde Applicaties CMDB" without a cluster SHALL get `Beheer geregeld: ja / ` #### Scenario: Portaliq can show the application to the municipality @e2e exclude Crosses into Portaliq, whose account claim is outside this change; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the usage references, and the manual test plan checks Portaliq's "Software we use". @@ -235,27 +268,35 @@ For each imported module the service SHALL keep exactly one `usage` with `consum #### Scenario: A re-import does not add a second usage @e2e exclude Covered by the re-import scenario of REQ-CMDB-006 and the service test. -- **GIVEN** module `APP-test123` already has a usage for "Gemeente Voorbeeldstad" +- **GIVEN** the module with APPID `2` already has a usage for "Gemeente Voorbeeldstad" - **WHEN** a newer export is imported for the same municipality -- **THEN** module `APP-test123` SHALL still have exactly one usage for "Gemeente Voorbeeldstad" +- **THEN** the module with APPID `2` SHALL still have exactly one usage for "Gemeente Voorbeeldstad" -### Requirement: REQ-CMDB-010 Owners SHALL become contact persons of the municipality through Nextcloud Contacts, never user accounts +### Requirement: REQ-CMDB-010 The owner SHALL become a contact person of the municipality through Nextcloud Contacts, never a user account, and SHALL never be publicly readable -The business owner pack SHALL map "Eigenaar", "Eigenaar e-mail" and "Eigenaar functie", and the technical owner pack SHALL map "FB contactpersoon 1". For each owner the service SHALL resolve a Nextcloud contact through `StackiqContactSyncService`: by e-mail when one is given, otherwise by an exact match on the display name, and otherwise by creating one. It SHALL then reuse or create one `contactPerson` with that `contactsUid`, `organization` = the municipality and `role` = "Eigenaar functie" when given, and SHALL set `usage.businessOwner` or `usage.technicalOwner` to it. The import SHALL NOT create Nextcloud user accounts, and SHALL NOT read the personnel number, phone, group mailbox or group owner columns. When Nextcloud Contacts is unavailable, the row SHALL be imported without owners and SHALL carry a warning. +The business owner pack SHALL map "Applicatie Eigenaar (Persoon)" (the display name, which may be a function instead of a person's name) and "Applicatie Eigenaar (Functie)" (the role). No technical owner SHALL be imported; the functional administrator columns SHALL NOT be read. For the owner the service SHALL resolve a Nextcloud contact through `StackiqContactSyncService` by an exact match on the display name, and otherwise by creating one. It SHALL then reuse or create one `contactPerson` with that `contactsUid`, `organization` = the municipality and `role` = "Applicatie Eigenaar (Functie)" when given, and SHALL set `usage.businessOwner` to it. The import SHALL NOT create Nextcloud user accounts. When Nextcloud Contacts is unavailable, the row SHALL be imported without an owner and SHALL carry a warning. `contactPerson` and `usage` SHALL have no public read rule, so the owner is never readable by an anonymous visitor; a published module SHALL refer to them by relation only. -#### Scenario: An owner with an e-mail address becomes the business owner +#### Scenario: The owner becomes the business owner @e2e exclude Needs a Contacts address book; tests/Unit/Service/CmdbExportImportServiceTest.php asserts the calls to a StackiqContactSyncService test double and the saved contactPerson. -- **GIVEN** the AIA row with "Eigenaar" `Achternaam, Voornaam`, "Eigenaar e-mail" `letter.achternaam@gemeente.nl` and "Eigenaar functie" `Afdelingshoofd` -- **WHEN** it is imported for "Gemeente Voorbeeldstad" -- **THEN** one `contactPerson` SHALL exist with the resolved `contactsUid`, `organization` = "Gemeente Voorbeeldstad" and `role` = `Afdelingshoofd` -- **AND** the usage of `AIA-AangetekendMailen` SHALL have `businessOwner` = that contact person +- **GIVEN** the "Onbeh" row with "Applicatie Eigenaar (Persoon)" `Achternaam, Voornaam` and "Applicatie Eigenaar (Functie)" `Afdelingshoofd`, and the "Beheerde" row whose person column holds the function `Teamleider Applicatiebeheer` +- **WHEN** they are imported for "Gemeente Voorbeeldstad" +- **THEN** one `contactPerson` SHALL exist per owner with the resolved `contactsUid`, `organization` = "Gemeente Voorbeeldstad" and the function as `role` +- **AND** each usage SHALL have `businessOwner` = its owner's contact person and no `technicalOwner` - **AND** no Nextcloud user account SHALL be created +#### Scenario: Imported owners are never readable anonymously +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** the anonymised export was imported, creating contact persons for its owners +- **WHEN** a visitor who is not signed in lists the `contactPerson` and `usage` objects through OpenRegister, or searches OpenCatalogi for an imported application +- **THEN** OpenRegister SHALL return no contact person and no usage +- **AND** the OpenCatalogi search hit SHALL carry no owner name, and its `contactPerson` and `usages` SHALL be empty or ids only + #### Scenario: The same owner on two rows is one contact person @e2e exclude Covered by the service test. -- **GIVEN** two rows with the same "Eigenaar e-mail" +- **GIVEN** two rows with the same "Applicatie Eigenaar (Persoon)" - **WHEN** they are imported - **THEN** exactly one `contactPerson` for that contact SHALL exist for the municipality, referenced by both usages @@ -265,11 +306,11 @@ The business owner pack SHALL map "Eigenaar", "Eigenaar e-mail" and "Eigenaar fu - **GIVEN** the Nextcloud Contacts app is disabled - **WHEN** the anonymised export is imported - **THEN** both modules and usages SHALL be saved without owners -- **AND** the AIA row's report entry SHALL carry the warning that owners were skipped because Contacts is unavailable +- **AND** each row with an owner SHALL carry the warning that owners were skipped because Contacts is unavailable ### Requirement: REQ-CMDB-011 Each row SHALL be processed in isolation and reported with its outcome -The service SHALL process every non-empty row in its own error boundary. An exception in one row SHALL mark that row `failed` with the reason and SHALL NOT stop the import or change the outcome of other rows. Rows whose cells are all empty SHALL be ignored and not counted. The response SHALL contain a summary (rows read, created, updated, unchanged, skipped, failed, warnings) and one entry per counted row with sheet, row number, Middel-ID, application name, outcome, reasons, warnings and the uuids of the module and usage. Report entries and log lines SHALL NOT contain owner names, e-mail addresses or other person data. The section SHALL render report values as text, never as HTML. +The service SHALL process every non-empty row in its own error boundary. An exception in one row SHALL mark that row `failed` with the reason and SHALL NOT stop the import or change the outcome of other rows. Rows whose cells are all empty SHALL be ignored and not counted. The response SHALL contain a summary (rows read, created, updated, unchanged, skipped, failed, warnings) and one entry per counted row with sheet, row number, APPID, application name, outcome, reasons, warnings and the uuids of the module and usage. Report entries and log lines SHALL NOT contain owner names, e-mail addresses or other person data. The section SHALL render report values as text, never as HTML. #### Scenario: Upload with a per-row report @e2e tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -277,7 +318,7 @@ The service SHALL process every non-empty row in its own error boundary. An exce - **GIVEN** a Nextcloud admin, "Gemeente Voorbeeldstad" selected, and the anonymised export - **WHEN** they start the import and it finishes - **THEN** the section SHALL show 2 rows read and 2 created -- **AND** the report SHALL list `Invoer AIA data` row 2 `AIA-AangetekendMailen` and `Invoer APP data` row 2 `APP-test123`, each with outcome `created` and a link to its module +- **AND** the report SHALL list `Onbeh Applicaties CMDB` row 2 APPID `1234` and `Beheerde Applicaties CMDB` row 2 APPID `2`, each with outcome `created` and a link to its module - **AND** the hundreds of formatted but empty rows in both sheets SHALL NOT appear in the report #### Scenario: One bad row does not stop the others @@ -289,23 +330,23 @@ The service SHALL process every non-empty row in its own error boundary. An exce - **AND** row 2 SHALL be `failed` with a reason naming the step that failed - **AND** the response SHALL be 200 with that summary -#### Scenario: A row of another kind is skipped with its reason +#### Scenario: A row without a name is skipped with its reason @e2e exclude Covered by the service test. -- **GIVEN** a row on "Invoer APP data" whose "Soort" is `Hardware` +- **GIVEN** a row on "Beheerde Applicaties CMDB" with an APPID but an empty "Applicatie Naam" - **WHEN** it is imported -- **THEN** it SHALL be `skipped` with reason `unsupported Soort "Hardware"` +- **THEN** it SHALL be `skipped` with reason `missing Applicatie Naam` ### Requirement: REQ-CMDB-012 Records missing from a newer export SHALL be left untouched -The import SHALL accept `missingRecords` with the value `keep`, which is also the default. It SHALL NOT change, depublish or delete a module, usage, organisation or contact person because its Middel-ID is absent from the upload. Any other value, including the reserved `mark` and `remove`, SHALL be refused with 422 `MISSING_RECORDS_UNSUPPORTED`. +The import SHALL accept `missingRecords` with the value `keep`, which is also the default. It SHALL NOT change, depublish or delete a module, usage, organisation or contact person because its APPID is absent from the upload. Any other value, including the reserved `mark` and `remove`, SHALL be refused with 422 `MISSING_RECORDS_UNSUPPORTED`. #### Scenario: An application dropped from the export stays @e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php imports two rows, then one, and asserts the other module and usage are unchanged. -- **GIVEN** modules `APP-test123` and `AIA-AangetekendMailen` were imported for "Gemeente Voorbeeldstad" -- **WHEN** a newer export that only contains `APP-test123` is imported -- **THEN** module `AIA-AangetekendMailen` and its usage SHALL be unchanged +- **GIVEN** the modules with APPID `1` and `7` were imported for "Gemeente Voorbeeldstad" +- **WHEN** a newer export that only contains APPID `1` is imported +- **THEN** the module with APPID `7` and its usage SHALL be unchanged #### Scenario: A reserved value is refused @e2e exclude Validation; tests/Unit/Controller/CmdbImportControllerTest.php. @@ -343,9 +384,9 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th ## Non-Functional Requirements -- **Performance:** an export of 1,100 rows SHALL import on the local rig without exceeding PHP's default memory limit, by loading only the source sheets in read-data-only mode. A re-import of an unchanged export SHALL make no `saveObject()` call for unchanged modules and usages. Lookups of organisations, modules and contact persons SHALL be cached per import run, so each distinct manufacturer, Middel-ID and contact is looked up at most once. -- **Security:** an uploaded third-party file is input: xlsx only, bounded size and row count, no formula evaluation, no external links, header-name resolution, per-row isolation, admin-only routes with CSRF (REQ-CMDB-001 to 003, 011). No cell value is ever rendered as HTML. -- **Privacy:** only the owner columns named in REQ-CMDB-010 are read into stackiq. The report and the logs contain no person data. Test fixtures are anonymised and carry no document metadata naming real people. +- **Performance:** an export of 1,100 rows SHALL import on the local rig without exceeding PHP's default memory limit, by loading only the source sheets in read-data-only mode. A re-import of an unchanged export SHALL make no `saveObject()` call for unchanged modules and usages. Lookups of organisations, modules and contact persons SHALL be cached per import run, so each distinct vendor, APPID and contact is looked up at most once. +- **Security:** an uploaded third-party file is input: xlsx only, bounded size and row count, no formula evaluation (cached values only), no external links, header-name resolution, per-row isolation, admin-only routes with CSRF (REQ-CMDB-001 to 003, 011). No cell value is ever rendered as HTML. +- **Privacy:** only the owner columns named in REQ-CMDB-010 are read into stackiq, and the objects holding them are never publicly readable. The report and the logs contain no person data. Test fixtures are anonymised and carry no document metadata naming real people. - **Accessibility:** Target WCAG 2.2 AA. The section uses Nextcloud and `@conduction/nextcloud-vue` components: labelled file input and municipality select (SC 1.3.1, 3.3.2; gates `form-label-association`, `nc-input-labels`), a labelled Cancel button (SC 4.1.2; gate `button-name`), a progress bar and summary announced through a polite live region (SC 4.1.3; `axe`), and a report table with header cells (SC 1.3.1; gate `table-headers`). New in 2.2: 2.4.11 Focus Not Obscured applies (the report must not hide focus behind sticky headers); 2.5.7 Dragging Movements does not apply (the file input works without drag and drop); 2.5.8 Target Size applies to the buttons (Nextcloud defaults); 3.2.6 Consistent Help does not apply (no help mechanism added); 3.3.7 Redundant Entry applies (the chosen municipality stays selected after an import); 3.3.8 Accessible Authentication does not apply (no authentication step). - **Internationalization:** Dutch and English MUST be supported (ADR-005) for the section, the error messages and the report reasons. @@ -353,15 +394,16 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th - [ ] A Nextcloud admin imports the anonymised TOPdesk export for a chosen municipality, and the report lists both data rows as created. - [ ] Importing the same export again creates no object, and reports both rows as unchanged. -- [ ] A changed "Naam" in a newer export updates the same module; `publicationDate` and fields the export does not map stay as they were. -- [ ] Rows with the same "Fabrikant" share one supplier organisation. +- [ ] A changed "Applicatie Naam" in a newer export updates the same module (matched on APPID); `publicationDate` and fields the export does not map stay as they were. +- [ ] Rows with the same "Vendor" share one supplier organisation. - [ ] Every imported module has one usage whose consumer is the municipality. -- [ ] A missing "Middel-ID" or "Naam" column stops the import with 422 naming the column and sheet; a non-xlsx or oversized file is rejected before reading. +- [ ] A missing "APPID" or "Applicatie Naam" column stops the import with 422 naming the column and sheet; a non-xlsx or oversized file is rejected before reading. - [ ] One failing row is reported as failed while the other rows are imported. +- [ ] The imported owner is not readable without signing in. - [ ] Imported modules are found by OpenCatalogi's search, and appear in Portaliq's "Software we use" for the municipality's account, once both apps are configured as the docs describe. ## Notes - Mapping decisions per column, including the columns that are not mapped because the target schema has no field, are listed in design.md. -- Connections from "Ouders" / "Kind-middelen", suites, hosting parties, the archive sheet and `missingRecords: mark|remove` are follow-ups (proposal, Out of Scope). +- Connections, suites, hosting parties ("Hostingpartij"), "Leverancier", the archive sheet "Gearchiveerde Applicaties" and `missingRecords: mark|remove` are follow-ups (proposal, Out of Scope). - Related: stackiq#373 (live TOPdesk connector), stackiq#1127 (record reconciliation), stackiq#1134 (ITSM exchange, the opposite direction), sbom-import and archimate-import (the upload patterns this follows). diff --git a/openspec/changes/cmdb-export-import/tasks.md b/openspec/changes/cmdb-export-import/tasks.md index c61621cd..a04d3196 100644 --- a/openspec/changes/cmdb-export-import/tasks.md +++ b/openspec/changes/cmdb-export-import/tasks.md @@ -6,17 +6,17 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S ### Task 1: Sanitised test fixtures - **spec_ref**: `SPEC#requirement-req-cmdb-002-the-workbook-shall-be-read-as-stored-data-without-evaluating-formulas-or-following-links` (cmdb-export-import#REQ-CMDB-002, also used by every other task) -- **files**: `tests/fixtures/cmdb/topdesk-export-anonymised.xlsx`, `tests/fixtures/cmdb/topdesk-missing-middel-id.xlsx`, `tests/fixtures/cmdb/topdesk-shuffled-columns.xlsx`, `tests/fixtures/cmdb/topdesk-formula-and-connection.xlsx`, `tests/fixtures/cmdb/README.md`, `tests/fixtures/cmdb/build-fixtures.py` +- **files**: `tests/fixtures/cmdb/topdesk-export-anonymised.xlsx`, `tests/fixtures/cmdb/topdesk-missing-appid.xlsx`, `tests/fixtures/cmdb/topdesk-shuffled-columns.xlsx`, `tests/fixtures/cmdb/topdesk-formula-and-connection.xlsx`, `tests/fixtures/cmdb/README.md`, `tests/fixtures/cmdb/build-fixtures.py` - **acceptance_criteria**: - GIVEN the anonymised test export from the WOO-586 plan folder WHEN it is copied to `topdesk-export-anonymised.xlsx` THEN `docProps/core.xml` has no creator or lastModifiedBy, and `docProps/custom.xml`, `customXml/` and `xl/connections.xml` are removed, with their entries in `[Content_Types].xml` and the rels files - GIVEN the sanitised fixture WHEN every shared string and cell value is scanned THEN no real person name, municipality domain, personnel number or phone number remains, only the placeholder values (`Achternaam, Voornaam`, `letter.achternaam@gemeente.nl`, `123456`) - - GIVEN `build-fixtures.py` WHEN it runs (Python stdlib zipfile only) THEN it derives the three variant fixtures from the sanitised one: no "Middel-ID" header on "Invoer APP data"; shuffled columns with header `Groepseigenaar mail⚡`; a formula cell in "Naam" with cached value `Rekenmodel` plus a synthetic `xl/connections.xml` + - GIVEN `build-fixtures.py` WHEN it runs (Python stdlib zipfile only) THEN it writes placeholder cached values into the formula cells of the mapped CMDB columns (idempotent) and derives the variant fixtures: no "APPID" header on "Beheerde Applicaties CMDB"; both CMDB sheets with shuffled columns and header `Vendor⚡`; on "Beheerde" a formula in "Applicatie Naam" with cached value `Rekenmodel`, a "Roepnaam" formula without a cached value, plus a synthetic `xl/connections.xml` - The original export of the municipality is never used or committed - [x] Implement - [x] Test (the scan is a PHPUnit test `tests/Unit/Fixtures/CmdbFixtureHygieneTest.php` that fails on metadata or non-placeholder person data) ### Task 2: Register fragment with external-id properties and seed modules -- **spec_ref**: `SPEC#requirement-req-cmdb-006-a-module-shall-be-matched-on-its-topdesk-middel-id-so-a-re-import-updates-instead-of-duplicating` (cmdb-export-import#REQ-CMDB-006) +- **spec_ref**: `SPEC#requirement-req-cmdb-006-a-module-shall-be-matched-on-its-topdesk-appid-so-a-re-import-updates-instead-of-duplicating` (cmdb-export-import#REQ-CMDB-006) - **files**: `lib/Settings/register.d/topdesk-cmdb-import.json`, `tests/Unit/Settings/TopdeskCmdbFragmentTest.php` - **acceptance_criteria**: - GIVEN all `register.d` fragments WHEN they are merged in filename order the way `SettingsService` does THEN `module.version` is `0.3.5` and `externalId`, `externalNumber`, `externalKey`, `externalCreatedAt`, `externalModifiedAt` exist, none required, with titles (hydra gate schema-property-titles) @@ -26,11 +26,11 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S ### Task 3: Import profile, mapping packs and their loader - **spec_ref**: `SPEC#requirement-req-cmdb-005-field-mapping-shall-be-declarative-and-executed-by-openregisters-mapping-engine` (cmdb-export-import#REQ-CMDB-005) -- **files**: `lib/Settings/cmdb-import/topdesk-profile.json`, `lib/Settings/cmdb-import/topdesk-module.json`, `lib/Settings/cmdb-import/topdesk-manufacturer.json`, `lib/Settings/cmdb-import/topdesk-municipality.json`, `lib/Settings/cmdb-import/topdesk-usage.json`, `lib/Settings/cmdb-import/topdesk-business-owner.json`, `lib/Settings/cmdb-import/topdesk-technical-owner.json`, `lib/Service/Cmdb/CmdbImportProfile.php`, `lib/Exception/CmdbImportException.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php` +- **files**: `lib/Settings/cmdb-import/topdesk-profile.json`, `lib/Settings/cmdb-import/topdesk-module.json`, `lib/Settings/cmdb-import/topdesk-manufacturer.json`, `lib/Settings/cmdb-import/topdesk-municipality.json`, `lib/Settings/cmdb-import/topdesk-usage.json`, `lib/Settings/cmdb-import/topdesk-business-owner.json`, `lib/Service/Cmdb/CmdbImportProfile.php`, `lib/Exception/CmdbImportException.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php` - **acceptance_criteria**: - - GIVEN the six packs WHEN each is passed to OpenRegister's `PackDefinitionValidator` THEN all are valid with `sourceFormat: excel` and `idStrategy: generate`, and they implement the column table in design.md + - GIVEN the five packs WHEN each is passed to OpenRegister's `PackDefinitionValidator` THEN all are valid with `sourceFormat: excel` and `idStrategy: generate`, and they implement the column table in design.md - GIVEN a pack with an unknown transform, or no `MappingEngine` in the container WHEN the profile loads THEN it throws `CmdbImportException` with code `MAPPING_UNAVAILABLE` and status 503 - - GIVEN the profile WHEN its referenced columns are listed THEN Personeelsnummer, phone, group-owner and group-mailbox columns are not among them + - GIVEN the profile WHEN its referenced columns are listed THEN no person or group column other than "Applicatie Eigenaar (Persoon)" / "(Functie)" is among them, and neither are the sheet constants - [x] Implement - [x] Test @@ -38,11 +38,11 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - **spec_ref**: `SPEC#requirement-req-cmdb-002-…` and `SPEC#requirement-req-cmdb-003-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422` (cmdb-export-import#REQ-CMDB-002, #REQ-CMDB-003, #REQ-CMDB-005) - **files**: `lib/Service/Cmdb/CmdbWorkbookReader.php`, `lib/Service/Cmdb/CmdbRowNormaliser.php`, `tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php`, `tests/Unit/Service/Cmdb/CmdbRowNormaliserTest.php` - **acceptance_criteria**: - - GIVEN the sanitised fixture WHEN it is read THEN exactly one row per source sheet is returned (empty formatted rows dropped), keyed by profile column names, with only allowlisted columns - - GIVEN the formula/connection fixture WHEN it is read THEN "Naam" is `Rekenmodel`, `getCalculatedValue()` is never called, and no HTTP client is involved - - GIVEN the shuffled fixture WHEN it is read THEN rows equal those of the original; GIVEN the missing-column fixture THEN `MISSING_COLUMN` names `Middel-ID` and `Invoer APP data`; GIVEN only "Blad1" THEN `NO_SOURCE_SHEET`; GIVEN more than `maxRowsPerSheet` rows THEN `TOO_MANY_ROWS` + - GIVEN the sanitised fixture WHEN it is read THEN exactly one row per CMDB sheet is returned (empty formatted rows and formula rows that cached `0` dropped), keyed by profile column names, with only allowlisted columns + - GIVEN the formula/connection fixture WHEN it is read THEN "Applicatie Naam" is `Rekenmodel`, "Roepnaam" is empty and listed in the row's `uncached`, `getCalculatedValue()` is never called, and no HTTP client is involved + - GIVEN the shuffled fixture WHEN it is read THEN rows equal those of the original; GIVEN the missing-column fixture THEN `MISSING_COLUMN` names `APPID` and `Beheerde Applicaties CMDB`; GIVEN only "Blad1" THEN `NO_SOURCE_SHEET`; GIVEN more than `maxRowsPerSheet` rows THEN `TOO_MANY_ROWS` - GIVEN a text file named `.xlsx`, or a `.xlsm` WHEN checked THEN `NOT_XLSX` before PhpSpreadsheet is touched; GIVEN PhpSpreadsheet absent THEN `READER_UNAVAILABLE` - - GIVEN serials `45111.380322627316`, `46232.552113113423`, `53359` and id `1234.0` WHEN normalised THEN `2023-07-04`, `2026-07-29`, `2046-02-01` and `"1234"` + - GIVEN serials `45111.380322627316`, `46232.552113113423`, `53359` and id `1234.0` WHEN normalised THEN `2023-07-04`, `2026-07-29`, `2046-02-01` and `"1234"`; GIVEN "BNN Classificatie" `NB` and "End-of-Life Functioneel" `49675` THEN both are empty - [x] Implement - [x] Test @@ -50,22 +50,24 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - **spec_ref**: `SPEC#requirement-req-cmdb-004-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin`, `SPEC#requirement-req-cmdb-006-…`, `SPEC#requirement-req-cmdb-007-a-newly-created-module-shall-get-a-publicationdate-and-an-existing-one-shall-keep-its-own`, `SPEC#requirement-req-cmdb-008-a-manufacturer-shall-become-one-supplier-organisation-however-many-rows-name-it`, `SPEC#requirement-req-cmdb-009-each-imported-application-shall-have-one-usage-that-links-it-to-the-municipality`, `SPEC#requirement-req-cmdb-012-records-missing-from-a-newer-export-shall-be-left-untouched` - **files**: `lib/Service/CmdbExportImportService.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php` - **acceptance_criteria**: - - GIVEN the sanitised fixture and "Gemeente Voorbeeldstad" WHEN imported THEN two modules with `externalKey` `topdesk::`, `publicationDate` = import start, `provider` set, and two usages with `consumer` = the municipality and `module` = the module - - GIVEN the same import twice WHEN run THEN 0 created / 2 unchanged and no `saveObject()` call for unchanged objects; GIVEN a changed "Naam" THEN one module updated, `website`, `publicationDate` and `depublicationDate` untouched + - GIVEN the sanitised fixture and "Gemeente Voorbeeldstad" WHEN imported THEN two modules with `externalKey` `topdesk::`, `publicationDate` = import start, `provider` set, and two usages with `consumer` = the municipality and `module` = the module + - GIVEN the same import twice WHEN run THEN 0 created / 2 unchanged and no `saveObject()` call for unchanged objects; GIVEN a changed "Applicatie Naam" THEN one module updated; GIVEN a changed "Applicatie Code" for the same APPID THEN the same module updated, `website`, `publicationDate` and `depublicationDate` untouched - GIVEN `municipalityName` twice THEN one Municipality; GIVEN the uuid of a Supplier THEN `MUNICIPALITY_INVALID` - - GIVEN "Fabfrikant", "Fabfrikant " and "FABFRIKANT" THEN one Supplier; GIVEN an existing Supplier with the same name THEN it is reused - - GIVEN `updateExisting=false` THEN matched rows are `skipped` (`exists`); GIVEN a second export without one Middel-ID THEN that module and usage are unchanged; GIVEN an unknown "Status" THEN `status` is dropped with a warning naming column and value - - GIVEN the module pack mapping "Roepnaam" to shortDescription (test-only pack) THEN the module carries it, with no code change + - GIVEN "Vendor" "Fabfrikant", "Fabfrikant " and "FABFRIKANT" THEN one Supplier; GIVEN an existing Supplier with the same name THEN it is reused + - GIVEN `updateExisting=false` THEN matched rows are `skipped` (`exists`); GIVEN a second export without one APPID THEN that module and usage are unchanged; GIVEN an unknown "Applicatie Status" THEN `status` is dropped with a warning naming column and value + - GIVEN the module pack mapping "Software Suite" to licentietype (test-only pack) THEN the module carries it, with no code change + - GIVEN a row from each sheet THEN the usage note starts with `Beheer geregeld: nee` (Onbeh) or `ja` (Beheerde), followed by the non-empty Cluster and Afdeling - Every new method carries `@spec openspec/changes/cmdb-export-import/tasks.md#task-5` (hydra gate spec-coverage) - [x] Implement - [x] Test ### Task 6: Owners as contact persons through Nextcloud Contacts -- **spec_ref**: `SPEC#requirement-req-cmdb-010-owners-shall-become-contact-persons-of-the-municipality-through-nextcloud-contacts-never-user-accounts` (cmdb-export-import#REQ-CMDB-010) +- **spec_ref**: `SPEC#requirement-req-cmdb-010-the-owner-shall-become-a-contact-person-of-the-municipality-through-nextcloud-contacts-never-a-user-account-and-shall-never-be-publicly-readable` (cmdb-export-import#REQ-CMDB-010) - **files**: `lib/Service/CmdbExportImportService.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php` - **acceptance_criteria**: - - GIVEN the AIA row WHEN imported THEN `StackiqContactSyncService` resolves the contact by e-mail, one `contactPerson` exists with that `contactsUid`, `organization` = municipality and `role` `Afdelingshoofd`, and it is the usage's `businessOwner` - - GIVEN "FB contactpersoon 1" without e-mail WHEN imported twice THEN one contact (exact display-name match) and one `contactPerson` + - GIVEN the fixture WHEN imported THEN each row's "Applicatie Eigenaar (Persoon)" (a name, or a function) resolves to a contact by display name, one `contactPerson` per owner exists with that `contactsUid`, `organization` = municipality and `role` = "Applicatie Eigenaar (Functie)", and it is the usage's `businessOwner`; no `technicalOwner` is written + - GIVEN an owner imported twice THEN one contact (exact display-name match) and one `contactPerson` + - GIVEN the merged register THEN `usage` and `contactPerson` have no public read rule and a `module` refers to them by relation only (`tests/Unit/Settings/CmdbPersonDataVisibilityTest.php`); GIVEN the rig THEN an anonymous OpenCatalogi search hit carries no owner and OpenRegister returns no contact person or usage anonymously (e2e) - GIVEN Contacts disabled WHEN imported THEN modules and usages are saved, owners skipped with a warning - GIVEN an imported `contactPerson` WHEN `OrganizationSyncService::performUserSync`'s selection is applied THEN it is not selected, and no Nextcloud user is created (if it would be, add an exclusion marker before shipping) - GIVEN any import WHEN the report and log lines are inspected THEN no owner name or e-mail appears @@ -77,7 +79,7 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/Cmdb/CmdbImportReport.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php` - **acceptance_criteria**: - GIVEN three rows where saving the second module throws WHEN imported THEN rows 1 and 3 are `created`, row 2 is `failed` naming the step, and the summary matches contract.md - - GIVEN duplicate Middel-ID rows, a missing Middel-ID and a Soort `Hardware` THEN they are `skipped` with the reasons in the spec + - GIVEN duplicate APPID rows (also across both sheets), a missing APPID and a missing "Applicatie Naam" THEN they are `skipped` with the reasons in the spec; GIVEN a formula without a cached value THEN the row is imported with a warning naming the column - GIVEN an `operationId` WHEN the import runs THEN a `cmdb_import` operation reports per-row progress, and after completion its statistics hold the report - GIVEN cancel requested after row 1 of three THEN one processed row, `cancelled: true`, row 1's objects kept - [x] Implement @@ -98,7 +100,7 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - **files**: `src/views/settings/sections/CmdbImport.vue`, `src/views/settings/StackiqSettings.vue`, `l10n/en.json`, `l10n/en.js`, `l10n/nl.json`, `l10n/nl.js`, `tests/e2e/spec-coverage/cmdb-import.spec.ts` - **acceptance_criteria**: - GIVEN a Nextcloud admin on stackiq's admin settings WHEN they choose "Gemeente Voorbeeldstad" and the sanitised fixture and press Import THEN a progress bar shows, then the summary (2 read, 2 created) and a `CnDataTable` report filterable by outcome with links to the modules - - GIVEN a second import of the same file THEN the report shows 2 unchanged; GIVEN the missing-column fixture THEN the section shows column `Middel-ID` and sheet `Invoer APP data`; GIVEN a CSV THEN it shows the `NOT_XLSX` message + - GIVEN a second import of the same file THEN the report shows 2 unchanged; GIVEN the missing-column fixture THEN the section shows column `APPID` and sheet `Beheerde Applicaties CMDB`; GIVEN a CSV THEN it shows the `NOT_XLSX` message - GIVEN the section WHEN the hydra gates run THEN admin-router, form-label-association, nc-input-labels, button-name, table-headers and modal-isolation pass, and no `v-html` renders report values - GIVEN a Dutch and an English locale THEN every new string, error message and report reason is translated - The e2e file references every `@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts` scenario in the spec (hydra gate e2e-coverage) @@ -109,12 +111,24 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (`S - **spec_ref**: `SPEC#purpose` - **files**: `docs/features/cmdb-import.md`, `docs/images/cmdb-import-*.png`, `docs/features/README.md` - **acceptance_criteria**: - - GIVEN the docs page WHEN an administrator reads it THEN it covers the steps, the expected file structure (sheets, required and mapped columns, the column table), the error codes and what to do, repeat-import behaviour (match on Middel-ID per municipality, unchanged rows, records missing from the export stay, publicationDate rule), where owner contacts end up, and how to adjust the mapping JSON + - GIVEN the docs page WHEN an administrator reads it THEN it covers the steps, the expected file structure (sheets, required and mapped columns, the column table), the error codes and what to do, repeat-import behaviour (match on APPID per municipality, unchanged rows, records missing from the export stay, publicationDate rule), where owner contacts end up, and how to adjust the mapping JSON - GIVEN the prerequisites section THEN it explains the OpenCatalogi catalogue (registers `stackiq`, schema `module`) and the Portaliq account claim `stackiq.organisationId`, needed to see the data there - GIVEN Playwright MCP on the rig WHEN screenshots are taken of the empty section, a running import and a finished report (sanitised fixture only) THEN they are committed under `docs/images/` - [ ] Implement - [ ] Test (screenshots reviewed: no data other than the sanitised fixture visible) +### Task 11: Rework to the CMDB sheets (WOO-586 Stap 4b, decisions of 2026-10-01) +- **spec_ref**: `SPEC#requirement-req-cmdb-003-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422`, `SPEC#requirement-req-cmdb-006-a-module-shall-be-matched-on-its-topdesk-appid-so-a-re-import-updates-instead-of-duplicating`, `SPEC#requirement-req-cmdb-010-the-owner-shall-become-a-contact-person-of-the-municipality-through-nextcloud-contacts-never-a-user-account-and-shall-never-be-publicly-readable` +- **files**: `lib/Settings/cmdb-import/*.json`, `lib/Service/Cmdb/*`, `lib/Service/CmdbExportImportService.php`, `lib/Settings/register.d/topdesk-cmdb-import.json`, `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `l10n/*`, `tests/fixtures/cmdb/*`, `tests/Unit/**/Cmdb*`, `tests/Unit/Settings/CmdbPersonDataVisibilityTest.php`, `tests/e2e/spec-coverage/cmdb-import.spec.ts`, `docs/features/cmdb-import.md`, `openapi.json`, `postman/stackiq-tests.json` +- **acceptance_criteria**: + - The source sheets are "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB"; the "Invoer" sheets are not read; columns resolve by header name per sheet + - `externalKey` = `topdesk::`; "Applicatie Code" → `externalId`, APPID → `externalNumber`; rows without APPID are skipped (`missing APPID`); the report row field is `appId` + - The column table of design.md is implemented, including `cloudDienstverleningsmodel`, `bbnLevel`, `timeClassification`, `startDateOutPhased` and the maintenance note; the technical-owner pack is removed + - Formula cells give their cached value; no cached value gives an empty cell and a row warning, never a failure + - The rig data of the first import is removed and the fixture re-imported twice (created, then unchanged); the owner is not readable anonymously +- [x] Implement +- [x] Test + ## Quality checklist - PHPUnit for all new business logic (`tests/Unit/`), at least 75% coverage of new code (ADR-009), using the sanitised xlsx fixtures (not mocked rows) for reader and service tests diff --git a/openspec/changes/cmdb-export-import/test-plan.md b/openspec/changes/cmdb-export-import/test-plan.md index 5e01ba85..19378bfe 100644 --- a/openspec/changes/cmdb-export-import/test-plan.md +++ b/openspec/changes/cmdb-export-import/test-plan.md @@ -10,11 +10,11 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (ab - **persona**: Noor Yilmaz (Municipal CISO / Functional Admin) - **preconditions**: Nextcloud admin; "Gemeente Voorbeeldstad" exists as type Municipality; no imported modules - **steps**: open stackiq admin settings, section "CMDB import", choose the municipality, choose the fixture, press Import -- **expected result**: progress bar during the run; summary 2 read / 2 created; report rows `Invoer AIA data` row 2 `AIA-AangetekendMailen` and `Invoer APP data` row 2 `APP-test123`, each `created` and linking to its module; no empty rows listed +- **expected result**: progress bar during the run; summary 2 read / 2 created; report rows `Onbeh Applicaties CMDB` row 2 APPID `1234` and `Beheerde Applicaties CMDB` row 2 APPID `2`, each `created` and linking to its module; no empty rows listed - **test command**: Playwright `tests/e2e/spec-coverage/cmdb-import.spec.ts`, `/test-functional`, `/test-persona-noor` ### TC-2: Re-import creates no duplicates -- **spec_ref**: `spec.md#requirement-req-cmdb-006-a-module-shall-be-matched-on-its-topdesk-middel-id-so-a-re-import-updates-instead-of-duplicating`, `#requirement-req-cmdb-009-each-imported-application-shall-have-one-usage-that-links-it-to-the-municipality` +- **spec_ref**: `spec.md#requirement-req-cmdb-006-a-module-shall-be-matched-on-its-topdesk-appid-so-a-re-import-updates-instead-of-duplicating`, `#requirement-req-cmdb-009-each-imported-application-shall-have-one-usage-that-links-it-to-the-municipality` - **type**: functional - **persona**: Noor Yilmaz - **preconditions**: TC-1 done; object counts of module, organization, usage, contactPerson recorded @@ -25,16 +25,16 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (ab ### TC-3: Changed fields update, publicationDate and unmapped fields are kept - **spec_ref**: `spec.md#requirement-req-cmdb-006-…`, `#requirement-req-cmdb-007-a-newly-created-module-shall-get-a-publicationdate-and-an-existing-one-shall-keep-its-own` - **type**: api -- **preconditions**: modules imported; an admin set `website` on `APP-test123` and depublished it -- **steps**: import rows where "Naam" of `APP-test123` is `naamtest124` -- **expected result**: same uuid, name `naamtest124`, `website` unchanged, `depublicationDate` unchanged, no new `publicationDate`; `AIA-AangetekendMailen` keeps its original `publicationDate` +- **preconditions**: modules imported; an admin set `website` on APPID `2` and depublished it +- **steps**: import rows where "Applicatie Naam" of APPID `2` is `naamtest124`, and where the "Applicatie Code" of APPID `42` changed +- **expected result**: same uuid, name `naamtest124`, `website` unchanged, `depublicationDate` unchanged, no new `publicationDate`; APPID `1234` keeps its original `publicationDate`; APPID `42` is the same module with the new `externalId` - **test command**: PHPUnit `tests/Unit/Service/CmdbExportImportServiceTest.php` ### TC-4: Manufacturer dedup - **spec_ref**: `spec.md#requirement-req-cmdb-008-a-manufacturer-shall-become-one-supplier-organisation-however-many-rows-name-it` - **type**: api - **preconditions**: an existing Supplier `Aangetekend B.V.` -- **steps**: import rows with "Fabrikant" `Fabfrikant`, `Fabfrikant `, `FABFRIKANT`, and the AIA row +- **steps**: import rows with "Vendor" `Fabfrikant`, `Fabfrikant `, `FABFRIKANT`, and the "Onbeh" row - **expected result**: one new Supplier `Fabfrikant`; `Aangetekend B.V.` reused; module and usage `provider` set accordingly - **test command**: PHPUnit `CmdbExportImportServiceTest` @@ -42,8 +42,8 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (ab - **spec_ref**: `spec.md#requirement-req-cmdb-001-the-import-endpoint-shall-accept-only-a-bounded-xlsx-upload-from-a-nextcloud-admin`, `#requirement-req-cmdb-003-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422`, `#requirement-req-cmdb-012-records-missing-from-a-newer-export-shall-be-left-untouched` - **type**: api - **preconditions**: admin session -- **steps**: post `applications.csv`; a text file named `.xlsx`; a 10 MB + 1 byte file; `topdesk-missing-middel-id.xlsx`; a workbook with only "Blad1"; the fixture with `missingRecords=remove`; the fixture without a municipality -- **expected result**: 400 `NOT_XLSX` (twice), 413 `FILE_TOO_LARGE`, 422 `MISSING_COLUMN` naming `Middel-ID` and `Invoer APP data`, 422 `NO_SOURCE_SHEET`, 422 `MISSING_RECORDS_UNSUPPORTED`, 422 `MUNICIPALITY_REQUIRED`; no object written in any case +- **steps**: post `applications.csv`; a text file named `.xlsx`; a 10 MB + 1 byte file; `topdesk-missing-appid.xlsx`; a workbook with only "Blad1"; the fixture with `missingRecords=remove`; the fixture without a municipality +- **expected result**: 400 `NOT_XLSX` (twice), 413 `FILE_TOO_LARGE`, 422 `MISSING_COLUMN` naming `APPID` and `Beheerde Applicaties CMDB`, 422 `NO_SOURCE_SHEET`, 422 `MISSING_RECORDS_UNSUPPORTED`, 422 `MUNICIPALITY_REQUIRED`; no object written in any case - **test command**: PHPUnit `CmdbImportControllerTest`, Newman (Postman collection), `/test-api`; the missing-column UI message also in Playwright ### TC-6: Authorisation and CSRF @@ -79,12 +79,12 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (ab - **test command**: PHPUnit `CmdbExportImportServiceTest` ### TC-10: Owners as contact persons, no user accounts -- **spec_ref**: `spec.md#requirement-req-cmdb-010-owners-shall-become-contact-persons-of-the-municipality-through-nextcloud-contacts-never-user-accounts` +- **spec_ref**: `spec.md#requirement-req-cmdb-010-the-owner-shall-become-a-contact-person-of-the-municipality-through-nextcloud-contacts-never-a-user-account-and-shall-never-be-publicly-readable` - **type**: security - **preconditions**: Contacts enabled (test double); separately disabled -- **steps**: import the AIA row twice and a second row with the same owner e-mail; run `performUserSync` selection on the result -- **expected result**: one contactPerson with `role` `Afdelingshoofd` and `organization` = municipality, set as `businessOwner` on both usages; no Nextcloud user created and the contactPerson not selected by the user sync; with Contacts disabled: no owners, a warning, modules and usages saved; report and log contain no owner name or e-mail -- **test command**: PHPUnit `CmdbExportImportServiceTest`, `/test-security` +- **steps**: import a row twice and a second row with the same "Applicatie Eigenaar (Persoon)"; import the fixture, whose "Beheerde" owner is a function; run `performUserSync` selection on the result; then, not signed in, list contact persons and usages through OpenRegister and search OpenCatalogi for `naamtest123` +- **expected result**: one contactPerson per owner with the function as `role` and `organization` = municipality, set as `businessOwner`; no `technicalOwner`; no Nextcloud user created and the contactPerson not selected by the user sync; with Contacts disabled: no owners, a warning, modules and usages saved; report and log contain no owner name; anonymously: no contact person or usage from OpenRegister, and the OpenCatalogi hit holds no owner name and only ids in `contactPerson` / `usages` +- **test command**: PHPUnit `CmdbExportImportServiceTest`, `CmdbPersonDataVisibilityTest`, Playwright `cmdb-import.spec.ts` (anonymous test), `/test-security` ### TC-11: OpenCatalogi finds an imported application - **spec_ref**: `spec.md#requirement-req-cmdb-007-…` @@ -129,11 +129,11 @@ Spec: `openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md` (ab | REQ-CMDB-003 header-name columns, 422 | TC-5, TC-7 | | REQ-CMDB-004 one municipality | TC-1, TC-5, PHPUnit (created once) | | REQ-CMDB-005 declarative mapping, dates | TC-8 | -| REQ-CMDB-006 upsert on Middel-ID | TC-2, TC-3, TC-14 | +| REQ-CMDB-006 upsert on APPID | TC-2, TC-3, TC-14 | | REQ-CMDB-007 publicationDate rule | TC-3, TC-11 | | REQ-CMDB-008 manufacturer dedup | TC-4 | | REQ-CMDB-009 usage per municipality | TC-2, TC-12 | -| REQ-CMDB-010 owners via Contacts | TC-10 | +| REQ-CMDB-010 owner via Contacts, never public | TC-10 | | REQ-CMDB-011 per-row isolation and report | TC-1, TC-9 | | REQ-CMDB-012 missing records kept | TC-5, PHPUnit (dropped row stays) | | REQ-CMDB-013 progress and cancel | TC-6, TC-9 | diff --git a/openspec/specs/cmdb-export-import/spec.md b/openspec/specs/cmdb-export-import/spec.md index 5d5078dc..0ccc1f53 100644 --- a/openspec/specs/cmdb-export-import/spec.md +++ b/openspec/specs/cmdb-export-import/spec.md @@ -9,7 +9,7 @@ built_by: openspec/changes/cmdb-export-import **Status**: in-progress **Scope**: stackiq **OpenSpec changes**: -- [cmdb-export-import](../../changes/cmdb-export-import/) _(active)_ — admin uploads a TOPdesk CMDB export (xlsx); stackiq upserts modules, manufacturer organisations, usages and owner contact persons for one municipality, matched on Middel-ID, mapped by OpenRegister migration packs (kind: code) +- [cmdb-export-import](../../changes/cmdb-export-import/) _(active)_ — admin uploads a TOPdesk CMDB export (xlsx); stackiq upserts modules, vendor organisations, usages and owner contact persons for one municipality from the two CMDB sheets, matched on APPID, mapped by OpenRegister migration packs (kind: code) ## Purpose @@ -34,7 +34,7 @@ umbrella requirement below anchors the capability until then. Stackiq MUST offer Nextcloud admins one import path for a TOPdesk CMDB export (xlsx) that writes only OpenRegister objects in the `stackiq` register (`module`, `organization`, `usage`, `contactPerson`), with no app-local table, -and that matches rows on the TOPdesk Middel-ID so that a repeated import +and that matches rows on the TOPdesk APPID so that a repeated import creates no duplicates. #### Scenario: A repeated import adds no objects diff --git a/postman/stackiq-tests.json b/postman/stackiq-tests.json index dacc0d08..f5544dc6 100644 --- a/postman/stackiq-tests.json +++ b/postman/stackiq-tests.json @@ -22976,7 +22976,8 @@ " pm.expect(json.summary.rowsRead).to.eql(2);", " pm.expect(json.summary.created + json.summary.unchanged + json.summary.updated).to.eql(2);", " pm.expect(json.summary.failed).to.eql(0);", - " pm.expect(json.rows.map(function (r) { return r.middelId; })).to.eql([\"AIA-AangetekendMailen\", \"APP-test123\"]);", + " pm.expect(json.rows.map(function (r) { return r.appId; })).to.eql([\"1234\", \"2\"]);", + " pm.expect(json.rows.map(function (r) { return r.sheet; })).to.eql([\"Onbeh Applicaties CMDB\", \"Beheerde Applicaties CMDB\"]);", " pm.environment.set(\"cmdb_operation_id\", json.operationId);", "});" ], diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js index 6c3fa1fa..f53a930f 100644 --- a/src/utils/cmdbImport.js +++ b/src/utils/cmdbImport.js @@ -19,7 +19,7 @@ import { generateUrl } from '@nextcloud/router' export const MAX_FILE_BYTES = 10 * 1024 * 1024 /** The two sheets the import reads (contract: NO_SOURCE_SHEET details). */ -export const SOURCE_SHEETS = ['Invoer AIA data', 'Invoer APP data'] +export const SOURCE_SHEETS = ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB'] /** Every row outcome the report can carry, in display order. */ export const OUTCOMES = ['created', 'updated', 'unchanged', 'skipped', 'failed'] @@ -348,7 +348,7 @@ export function errorText(error) { ), hint: t( 'stackiq', - 'The columns "Middel-ID" and "Naam" are required on every source sheet. Add the column to the export and try again. Nothing was imported.', + 'The columns "APPID" and "Applicatie Naam" are required on every source sheet. Add the column to the export and try again. Nothing was imported.', ), } case 'TOO_MANY_ROWS': @@ -480,7 +480,7 @@ export function reportRows(rows) { key: `${row.sheet ?? ''}:${row.row ?? index}:${index}`, sheet: String(row.sheet ?? ''), row: row.row ?? '', - middelId: String(row.middelId ?? ''), + appId: String(row.appId ?? ''), name: String(row.name ?? ''), outcome: String(row.outcome ?? ''), notes: [ diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue index cc236046..aa8718d9 100644 --- a/src/views/settings/sections/CmdbImport.vue +++ b/src/views/settings/sections/CmdbImport.vue @@ -86,7 +86,7 @@ {{ t( 'stackiq', - 'Excel workbook (.xlsx), at most 10 MB, with the sheet "Invoer AIA data" or "Invoer APP data".', + 'Excel workbook (.xlsx), at most 10 MB, with the sheet "Onbeh Applicaties CMDB" or "Beheerde Applicaties CMDB".', ) }}