Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
33585a1
feat(cmdb-import): archive or soft-delete applications that left the …
WilcoLouwerse Oct 12, 2026
7184a4a
feat(cmdb-import): offer the missing-records choice and an Archived q…
WilcoLouwerse Oct 12, 2026
07d533d
Merge branch 'feat/woo587-cmdb-import-archive-reconciliation-1-reader…
WilcoLouwerse Oct 12, 2026
d3601b3
fix(cmdb-import): do not cache a trash listing that failed to load
WilcoLouwerse Oct 12, 2026
f196420
docs(cmdb-import): point the two new @spec tags at the archived cmdb-…
WilcoLouwerse Oct 12, 2026
c4d2b33
Merge branch 'feat/woo587-cmdb-import-archive-reconciliation-2-reconc…
WilcoLouwerse Oct 12, 2026
f6ae270
fix(cmdb-import): stop telling the admin missing applications stay an…
WilcoLouwerse Oct 12, 2026
97c4e99
docs(cmdb-import): say what a split import with Keep leaves undone
WilcoLouwerse Oct 12, 2026
3671df0
fix(cmdb-import): stop counting rows as applications in the reconcili…
WilcoLouwerse Oct 12, 2026
bbf4209
fix(cmdb-import): say applications, not only rows, in the cancel texts
WilcoLouwerse Oct 12, 2026
8360c6a
Merge branch 'feat/woo587-cmdb-import-archive-reconciliation-1-reader…
WilcoLouwerse Oct 12, 2026
ac3305a
fix(cmdb-import): name archived, unarchived, deleted and restored in …
WilcoLouwerse Oct 12, 2026
08acba6
fix(cmdb-import): never soft-delete on an archive sheet with unreadab…
WilcoLouwerse Oct 12, 2026
f99c063
fix(cmdb-import): let the Archived tab of the Applications page ignor…
WilcoLouwerse Oct 12, 2026
b30cd7f
fix(cmdb-import): reconcile one application per import key
WilcoLouwerse Oct 12, 2026
b1d249a
test(cmdb-import): make the missing-applications choice and the delet…
WilcoLouwerse Oct 12, 2026
8a1562a
fix(cmdb-import): name the archive sheet where the section says which…
WilcoLouwerse Oct 12, 2026
a4570e7
test(cmdb-import): pin the totality promises of the reconciliation
WilcoLouwerse Oct 12, 2026
cdbec3d
docs(cmdb-import): say the archive sheet is read, and bounded, in eve…
WilcoLouwerse Oct 12, 2026
2795770
style(cmdb-import): bring the two reconciliation fixes within phpcs
WilcoLouwerse Oct 12, 2026
226b76c
Merge branch 'feat/woo587-cmdb-import-archive-reconciliation-2-reconc…
WilcoLouwerse Oct 12, 2026
2e89a1e
Merge branch 'feat/woo587-cmdb-import-archive-reconciliation-1-reader…
WilcoLouwerse Oct 12, 2026
9fb2dfd
docs(cmdb-import): state all three cases where an import archives and…
WilcoLouwerse Oct 12, 2026
e8772a7
docs(cmdb-import): state the full archive-only rule in the service do…
WilcoLouwerse Oct 12, 2026
2d288ac
docs(cmdb-import): correct the Modules page note on how quick filters…
WilcoLouwerse Oct 12, 2026
16de647
fix(cmdb-import): count the rows in the uncached-archive-key warning
WilcoLouwerse Oct 12, 2026
d805c5b
fix(cmdb-import): bring every usage of a leaving key to the same state
WilcoLouwerse Oct 12, 2026
9a5ffff
Merge branch 'feat/woo587-cmdb-import-archive-reconciliation-2-reconc…
WilcoLouwerse Oct 12, 2026
323d761
docs(cmdb-import): quote both forms of the uncached-APPID warning
WilcoLouwerse Oct 12, 2026
ddcffcc
Merge branch 'feat/woo587-cmdb-import-archive-reconciliation-1-reader…
WilcoLouwerse Oct 13, 2026
8dfa03a
feat(cmdb-import): shorten the help of the missing-applications choice
WilcoLouwerse Oct 13, 2026
dca8211
fix(cmdb-import): never soft-delete when a CMDB-sheet APPID is unread…
WilcoLouwerse Oct 13, 2026
18bd222
docs(openspec): tick the reconciliation tasks that this PR implements
WilcoLouwerse Oct 13, 2026
3d172db
Merge branch 'feat/woo587-cmdb-import-archive-reconciliation-2-reconc…
WilcoLouwerse Oct 13, 2026
27a2110
fix(cmdb-import): see a fully empty CMDB row with an uncached APPID; …
WilcoLouwerse Oct 13, 2026
c55a96d
test(cmdb-import): wait on a rendered list, not networkidle, and fail…
WilcoLouwerse Oct 13, 2026
4b70494
fix(applications): say on the Archived tab that facets and search are…
WilcoLouwerse Oct 13, 2026
3c17b47
docs(cmdb-import): tick the UI tasks and document the archive tab not…
WilcoLouwerse Oct 13, 2026
f1ca071
Merge branch 'feat/woo587-cmdb-import-archive-reconciliation-2-reconc…
WilcoLouwerse Oct 13, 2026
1719b19
fix(applications): keep the Archived-tab note in step with the list's…
WilcoLouwerse Oct 13, 2026
a1cca04
Merge remote-tracking branch 'origin/feat/woo587-cmdb-import-archive-…
WilcoLouwerse Oct 13, 2026
3fbc2f9
Merge pull request #1287 from ConductionNL/feat/woo587-cmdb-import-ar…
WilcoLouwerse Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
sheets becomes or updates a module, its vendor, a usage for the municipality
and a contact person for its owner; a repeat import matches on APPID, so it
updates instead of duplicating. See `docs/features/cmdb-import.md`.
- CMDB import, repeat imports: applications that left the CMDB sheets are
archived when the sheet "Gearchiveerde Applicaties" lists them and moved to
OpenRegister's trash when no sheet does; one that returns is unarchived or
restored, never duplicated. The new option `missingRecords` (`archive`, the
default, or `keep`) is a choice in the CMDB import section, and the report
counts archived, unarchived, deleted and restored applications. The module
(0.3.9) and usage (1.5.7) schemas opt into OpenRegister's archive state, and
the Applications and Applications in use pages get an "Archived" quick
filter. (cmdb-import-archive-reconciliation)

### Changed
- EOL feed: the register and schema slugs the feature reads from are corrected.
Expand Down
2 changes: 1 addition & 1 deletion docs/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
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. By default it archives the applications that moved to the sheet "Gearchiveerde Applicaties" and moves those on no sheet any more to OpenRegister's trash, and brings either back when it returns; the admin can choose to keep them instead. The Applications and Applications in use pages list the archive under the quick filter "Archived". 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.

Expand Down
117 changes: 97 additions & 20 deletions docs/features/cmdb-import.md

Large diffs are not rendered by default.

45 changes: 34 additions & 11 deletions l10n/en.js

Large diffs are not rendered by default.

45 changes: 34 additions & 11 deletions l10n/en.json

Large diffs are not rendered by default.

45 changes: 34 additions & 11 deletions l10n/nl.js

Large diffs are not rendered by default.

45 changes: 34 additions & 11 deletions l10n/nl.json

Large diffs are not rendered by default.

22 changes: 17 additions & 5 deletions lib/Controller/CmdbImportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,16 @@ public function __construct(
* Import a TOPdesk CMDB export for one municipality.
*
* Multipart fields: `cmdbFile`, `municipalityUuid` or `municipalityName`,
* `updateExisting` (default true), `publish` (default true), `missingRecords` (only `keep`) and
* `updateExisting` (default true), `publish` (default true), `missingRecords` (`keep` or `archive`,
* default `archive`) 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.
*
* @auth admin-only the import writes with RBAC and multitenancy off, across tenants, so only a full Nextcloud admin may run it.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-8
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012
*/
public function import(): JSONResponse {
try {
Expand Down Expand Up @@ -247,15 +249,21 @@ protected function iniBytes(string $name): ?int {
* @return array{path: string, options: array<string, mixed>}|JSONResponse The import input, or the first error.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-8
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012
*/
private function readOptions(string $path, string $fileName): array|JSONResponse {
$missingRecords = $this->stringParam(name: 'missingRecords', default: 'keep');
$missingRecords = $this->stringParam(name: 'missingRecords', default: CmdbExportImportService::DEFAULT_MISSING_RECORDS);
if ($missingRecords === null) {
return $this->invalidField(field: 'missingRecords');
}

if ($this->importService->supportsMissingRecords(mode: $missingRecords) === false) {
return $this->error(code: 'MISSING_RECORDS_UNSUPPORTED', status: Http::STATUS_UNPROCESSABLE_ENTITY, details: ['accepted' => ['keep']]);
$accepted = $this->importService->missingRecordsModes();
if (in_array($missingRecords, $accepted, true) === false) {
return $this->error(
code: 'MISSING_RECORDS_UNSUPPORTED',
status: Http::STATUS_UNPROCESSABLE_ENTITY,
details: ['accepted' => $accepted]
);
}

// An unrecognised value is refused rather than read as true: true is the mode that overwrites.
Expand Down Expand Up @@ -293,6 +301,7 @@ private function readOptions(string $path, string $fileName): array|JSONResponse
'municipalityName' => $municipalityName,
'updateExisting' => $updateExisting,
'publish' => $publish,
'missingRecords' => $missingRecords,
'operationId' => $this->request->getParam('operationId'),
'fileName' => basename(str_replace('\\', '/', $fileName)),
],
Expand Down Expand Up @@ -394,7 +403,7 @@ private function message(string $code, array $details): string {
'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.'),
'MISSING_RECORDS_UNSUPPORTED' => $this->l10n->t('Applications missing from the export can only be archived or kept.'),
'FIELD_INVALID' => $this->fieldMessage(field: (string)($details['field'] ?? ''), accepted: $accepted),
'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.'),
Expand All @@ -410,6 +419,9 @@ private function message(string $code, array $details): string {
'SCHEMA_OUTDATED' => $this->l10n->t('The stackiq register is out of date; import its configuration again.'),
'IMPORT_IN_PROGRESS' => $this->l10n->t('Another CMDB import is running; try again when it has finished.'),
'MUNICIPALITY_AMBIGUOUS' => $this->l10n->t('Several municipalities have this name; choose one from the list.'),
'ARCHIVE_UNAVAILABLE' => $this->l10n->t(
'Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.'
),
default => $this->l10n->t('The import failed. The details are in the Nextcloud log.'),
};
}//end message()
Expand Down
2 changes: 2 additions & 0 deletions lib/Exception/CmdbImportException.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class CmdbImportException extends RuntimeException {
public const SCHEMA_OUTDATED = 'SCHEMA_OUTDATED';
public const IMPORT_IN_PROGRESS = 'IMPORT_IN_PROGRESS';
public const MUNICIPALITY_AMBIGUOUS = 'MUNICIPALITY_AMBIGUOUS';
public const ARCHIVE_UNAVAILABLE = 'ARCHIVE_UNAVAILABLE';

/**
* HTTP status per error code.
Expand All @@ -70,6 +71,7 @@ class CmdbImportException extends RuntimeException {
self::SCHEMA_OUTDATED => 503,
self::IMPORT_IN_PROGRESS => 409,
self::MUNICIPALITY_AMBIGUOUS => 422,
self::ARCHIVE_UNAVAILABLE => 503,
];

/**
Expand Down
32 changes: 27 additions & 5 deletions lib/Service/Cmdb/CmdbImportReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ class CmdbImportReport {
public const UNCHANGED = 'unchanged';
public const SKIPPED = 'skipped';
public const FAILED = 'failed';
public const ARCHIVED = 'archived';
public const UNARCHIVED = 'unarchived';
public const DELETED = 'deleted';
public const RESTORED = 'restored';

/**
* The row entries, in processing order.
Expand Down Expand Up @@ -90,7 +94,7 @@ public function __construct(
* Add one row outcome.
*
* @param string $sheet The sheet name.
* @param int $row The 1-based sheet row number.
* @param int $row The 1-based sheet row number, or 0 for an application the reconciliation archived or deleted.
* @param string $appId The APPID ('' when missing).
* @param string $name The application name ('' when missing).
* @param string $outcome One of the outcome constants.
Expand Down Expand Up @@ -180,36 +184,54 @@ public function markCancelled(): void {
}//end markCancelled()

/**
* The number of rows processed so far.
* Whether the run stopped on a cancel.
*
* @return bool
*
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
public function isCancelled(): bool {
return $this->cancelled;
}//end isCancelled()

/**
* The number of sheet rows processed so far; reconciliation entries (row 0) are not sheet rows.
*
* @return int
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-7
*/
public function processed(): int {
return count($this->rows);
return count(array_filter($this->rows, static fn (array $row): bool => $row['row'] > 0));
}//end processed()

/**
* The summary counts.
*
* `unpublished` counts the modules created without a publication date;
* a row whose module was created but whose usage then failed counts too.
* `processed` counts sheet rows only; every outcome, also of a
* reconciliation entry, is counted under its own key.
*
* @return array{rowsRead: int, processed: int, created: int, updated: int, unchanged: int, skipped: int, failed: int,
* warnings: int, unpublished: int}
* archived: int, unarchived: int, deleted: int, restored: int, warnings: int, unpublished: int}
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-7
* @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015
*/
public function summary(): array {
$summary = [
'rowsRead' => $this->rowsRead,
'processed' => count($this->rows),
'processed' => $this->processed(),
self::CREATED => 0,
self::UPDATED => 0,
self::UNCHANGED => 0,
self::SKIPPED => 0,
self::FAILED => 0,
self::ARCHIVED => 0,
self::UNARCHIVED => 0,
self::DELETED => 0,
self::RESTORED => 0,
'warnings' => 0,
'unpublished' => $this->unpublished,
];
Expand Down
59 changes: 51 additions & 8 deletions lib/Service/Cmdb/CmdbWorkbookReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,11 @@ public function isAvailable(): bool {
* @param CmdbImportProfile $profile The import profile.
*
* @return array<string, mixed> `rows` (list of {sheet, row, cells, uncached}), `importWarnings`
* (list of {sheet, message}), `date1904` (bool) and `archive`
* ({sheet, present, keyColumnMissing, appIds, uncachedRows}).
* (list of {sheet, message}), `date1904` (bool), `archive`
* ({sheet, present, keyColumnMissing, appIds, uncachedRows}) and `uncachedKeyRows`
* (source sheet => numbers of the rows dropped as empty because their key is a
* formula without a cached value; a row with other content is in `rows`, its
* `uncached` lists the key).
*
* @throws CmdbImportException WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET,
* MISSING_COLUMN or TOO_MANY_ROWS.
Expand Down Expand Up @@ -369,7 +372,15 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s

$spreadsheet = $this->load(path: $path, sheetNames: $loaded, filter: new CmdbReadFilter(lastRow: $lastRow, columns: $letters));
try {
$rows = $this->readSourceRows(spreadsheet: $spreadsheet, sheetNames: $present, columnsPerSheet: $resolved['columns'], limit: $limit);
$uncachedKeyRows = [];
$rows = $this->readSourceRows(
spreadsheet: $spreadsheet,
sheetNames: $present,
columnsPerSheet: $resolved['columns'],
limit: $limit,
keyColumn: $profile->keyColumn(),
uncachedKeyRows: $uncachedKeyRows
);
if ($archiveColumns !== []) {
$unseen = [];
$archiveRows = $this->readRows(
Expand All @@ -389,7 +400,13 @@ private function readBounded(string $path, CmdbImportProfile $profile, object $s
$spreadsheet->disconnectWorksheets();
}

return ['rows' => $rows, 'importWarnings' => $resolved['warnings'], 'date1904' => $date1904, 'archive' => $archive];
return [
'rows' => $rows,
'importWarnings' => $resolved['warnings'],
'date1904' => $date1904,
'archive' => $archive,
'uncachedKeyRows' => $uncachedKeyRows,
];
}//end readBounded()

/**
Expand Down Expand Up @@ -434,20 +451,38 @@ private function sourceSheets(string $path, CmdbImportProfile $profile): array {
* @param array<int, string> $sheetNames The source sheets the workbook has.
* @param array<string, array<string, string>> $columnsPerSheet Sheet name => column letter => column name.
* @param int $limit The row limit per sheet.
* @param string $keyColumn The key column's name.
* @param array<string, array<int, int>> $uncachedKeyRows Filled, per sheet, with the number of each row that was
* dropped as empty only because its key is a formula
* without a cached value.
*
* @return array<int, array<string, mixed>> See read().
*
* @throws CmdbImportException TOO_MANY_ROWS.
*/
private function readSourceRows(object $spreadsheet, array $sheetNames, array $columnsPerSheet, int $limit): array {
private function readSourceRows(
object $spreadsheet,
array $sheetNames,
array $columnsPerSheet,
int $limit,
string $keyColumn,
array &$uncachedKeyRows,
): array {
$rows = [];
foreach ($sheetNames as $sheetName) {
$unseen = [];
$sheetRows = $this->readRows(
worksheet: $spreadsheet->getSheetByName($sheetName),
columns: $columnsPerSheet[$sheetName],
sheetName: $sheetName,
limit: $limit
limit: $limit,
unseen: $unseen,
keyColumn: $keyColumn
);
if ($unseen !== []) {
$uncachedKeyRows[$sheetName] = array_values((array)$unseen);
}

array_push($rows, ...$sheetRows);
}

Expand Down Expand Up @@ -732,12 +767,20 @@ private function resolveColumns(object $worksheet, array $referenced): array {
* @param int $limit The maximum number of non-empty rows.
* @param array<int, int>|null $unseen Filled with the number of each row that read as empty only because
* a formula in it has no cached value; null to not collect them.
* @param string|null $keyColumn When set, only a row whose uncached columns include this one is collected.
*
* @return array<int, array{sheet: string, row: int, cells: array<string, mixed>, uncached: array<int, string>}>
*
* @throws CmdbImportException TOO_MANY_ROWS.
*/
private function readRows(object $worksheet, array $columns, string $sheetName, int $limit, ?array &$unseen = null): array {
private function readRows(
object $worksheet,
array $columns,
string $sheetName,
int $limit,
?array &$unseen = null,
?string $keyColumn = null,
): array {
$rows = [];
$lastRow = (int)$worksheet->getHighestDataRow();
for ($rowNumber = 2; $rowNumber <= $lastRow; $rowNumber++) {
Expand All @@ -747,7 +790,7 @@ private function readRows(object $worksheet, array $columns, string $sheetName,
rowNumber: $rowNumber
);
if ($empty === true) {
if ($uncached !== [] && $unseen !== null) {
if ($uncached !== [] && $unseen !== null && ($keyColumn === null || in_array($keyColumn, $uncached, true) === true)) {
$unseen[] = $rowNumber;
}

Expand Down
Loading
Loading