diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87505881a..69d2c85b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/docs/features/README.md b/docs/features/README.md
index 7fa964f0a..c57ff8486 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 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.
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 32550932b..e71756d73 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -17,9 +17,11 @@ updates:
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.
+newer export later and the same applications are updated, not duplicated;
+applications that left the export are archived or moved to the trash (see
+[Repeat imports](#repeat-imports)).
-Specification: [`openspec/specs/cmdb-export-import/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/cmdb-export-import/spec.md) (change archived in [`openspec/changes/archive/2026-10-05-cmdb-export-import/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/archive/2026-10-05-cmdb-export-import)).
+Specification: [`openspec/specs/cmdb-export-import/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/cmdb-export-import/spec.md) (change archived in [`openspec/changes/archive/2026-10-05-cmdb-export-import/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/archive/2026-10-05-cmdb-export-import); archiving and deleting on a repeat import in [`openspec/changes/cmdb-import-archive-reconciliation/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/cmdb-import-archive-reconciliation)).
## Who can import
@@ -77,9 +79,16 @@ page in stackiq.
and the summary counts them as *created unpublished*. Applications that
were imported before keep their publication as it is, whichever you
choose.
-6. 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. The section says whether the server
+6. **Applications missing from the export.** **Archive or delete them** is
+ the default: an application of this municipality that is on neither CMDB
+ sheet any more is archived or moved to the trash after the last row (see
+ [Repeat imports](#repeat-imports)). Choose **Keep them as they are** to
+ leave such applications untouched.
+7. Press **Import**. A progress bar shows how many rows have been processed.
+ **Cancel import** stops the import before the next row, or, while the
+ applications are compared with the export, before the next application;
+ what was already processed stays: the rows imported, and the applications
+ archived or deleted so far. The section says whether the server
accepted the cancel; one pressed before the server has started on the
rows cannot take effect yet, and the section says so.
@@ -92,11 +101,16 @@ When the import finishes, the section shows:
- the **summary**: rows read, created, updated, unchanged, skipped, failed
and warnings, and, when publishing was off, how many applications were
- created unpublished;
+ created unpublished; after a repeat import also how many applications
+ were archived, unarchived, deleted (moved to the trash) or restored, when
+ there are any. The sentence above it, which a screen reader announces,
+ names those counts too, for example *Applications: 1 archived, 1 deleted.*;
- **warnings for the whole file**, for example an optional column that is
missing;
- the **rows** table: sheet, row number, APPID, application, outcome,
- and the reasons and warnings for that row. Filter it with **Show rows with
+ and the reasons and warnings for that row. An application archived or
+ deleted because it left the export has no row number; its sheet is
+ `Gearchiveerde Applicaties` when that sheet listed it. Filter it with **Show rows with
outcome**, and sort it by clicking a column header. It shows 100 rows at a
time; **Show 100 more rows** adds the next ones. The application name links
to the module in stackiq.
@@ -106,8 +120,15 @@ the same organisation.
## The file
-The import reads the two CMDB sheets of the export and ignores all others,
-including the `Invoer` sheets they are derived from:
+The import reads the two CMDB sheets of the export, and of the sheet
+`Gearchiveerde Applicaties` only the `APPID` column (see
+[Repeat imports](#repeat-imports)). It ignores all other sheets, including
+the `Invoer` sheets they are derived from. The archive sheet is read in every
+import, also with **Keep them as they are**, and it is bounded like the two
+CMDB sheets (see [Limits](#limits)): an archive sheet over the limits refuses
+the import even when you keep missing applications. Delete the sheet from a
+copy of the export to import it without one; the import then archives and
+never deletes.
| Sheet | What it holds | Recorded on the usage |
|---|---|---|
@@ -120,8 +141,8 @@ 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
-by default (see [Limits](#limits)).
+are ignored and not counted. A sheet, the archive sheet included, may hold at
+most 10,000 rows with data by default (see [Limits](#limits)).
Two columns are **required** on every CMDB sheet that is present: `APPID`
and `Applicatie Naam`. Every other column is optional; when one is missing,
@@ -213,9 +234,9 @@ colliding.
- **Known APPID, nothing changed**: nothing is saved; the row is reported
as *unchanged*. Importing the same export twice creates nothing the second
time.
-- **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.
+- **APPID missing from a newer export**: see
+ [Applications that left the export](#applications-that-left-the-export)
+ below.
- Each application keeps exactly one usage for the municipality.
- **Known APPID, but the application belongs to another organisation**: an
application found by its match key is only updated when the municipality
@@ -243,6 +264,61 @@ 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.
+### Applications that left the export
+
+With **Archive or delete them** (the default; `missingRecords=archive` in
+the API), the import compares the municipality's applications with the
+export after the last row. It looks only at the municipality's own usages
+and the applications its import created, never at an application only
+another organisation uses or one added by hand. For an application whose
+APPID is on neither CMDB sheet:
+
+| The APPID is | The application and its usage |
+|---|---|
+| on the sheet `Gearchiveerde Applicaties` | are **archived**: OpenRegister's archive state, with the reason `cmdb-import: on sheet "Gearchiveerde Applicaties"`. Reported as *archived*. |
+| on no sheet at all | are **deleted**: moved to OpenRegister's trash, where they stay restorable for 30 days. Reported as *deleted*. |
+| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store), or an `APPID` on one of the two CMDB sheets has no cached value (the import cannot tell which application that row stands for) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten; for a CMDB sheet the warning names that sheet). Such a workbook therefore never moves anything to the trash. |
+
+An archived or deleted application disappears from the stackiq lists, from
+OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick
+filter **Archived** on the **Applications** and **Applications in use** pages
+lists the archive. On the **Applications** page the **Archived** tab lists
+the whole archive: it ignores the GEMMA filters and the search box (the page says so in a note under the search box while that tab is active), because
+their counts and matches describe the working set, which leaves archived
+applications out. An OpenCatalogi caller finds archived applications by
+adding `_archived=true` to its request. Contact persons are never archived
+or deleted: an owner is shared with the municipality's other applications.
+An application that is already in the state the export asks for is left
+alone and not reported again.
+
+When an archived or deleted application **returns** in a later export, it is
+taken out of the archive (*unarchived*) or out of the trash (*restored*),
+and then updated as usual. No second application is created. With **Update
+existing records** off, it is skipped as `exists` and stays archived or in
+the trash. When an application was deleted by hand and a later import
+created it again, the working one is updated and the deleted one stays in
+the trash.
+
+The comparison runs only when the import went through all rows: an import
+cancelled or failed before the comparison archives or deletes nothing.
+**Cancel import** during the comparison stops it before the next
+application; what was archived or deleted until then stays so, and the
+result lists it. During the comparison the progress line reads
+*Checking the applications of this municipality against the export* without
+a count, and the bar stays full: the comparison covers every application of
+the municipality, not only the ones missing from the export, so its length
+is not known in rows. Each
+application is handled on its own; one that cannot be archived is reported
+as *failed* with the step (`archive`, `restore` or `delete`) and the others
+continue. Archiving needs module schema 0.3.9 and usage schema 1.5.7 or
+later, which declare that their objects can be archived; on an older register configuration each application fails
+with the step `archive`, and **Force Update** at the top of the stackiq admin
+settings fixes it.
+
+With **Keep them as they are** (`missingRecords=keep`), the application, its
+usage and its contact persons are left as they are: not changed, archived,
+depublished or deleted.
+
## Owners
The owner becomes a **contact person of the municipality**, never a
@@ -281,19 +357,20 @@ 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 the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read, or split the export. |
+| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read. Or split the export and import every part with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
| `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 `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 rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other. |
-| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import them one after the other. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
+| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
+| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads (the two CMDB sheets and the archive sheet, in every import) or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
| `MUNICIPALITY_AMBIGUOUS` | More than one municipality has the typed name. The import does not guess which one. | Pick the municipality from the list instead of typing its name. |
| `IMPORT_IN_PROGRESS` | Another CMDB import is running. Only one import runs at a time. | Wait until it has finished and try again. |
| `FIELD_INVALID` | A form field of the request has a value the import does not accept, for example an `updateExisting` or `publish` that is neither `true` nor `false`. The message names the field. | Not reachable from the section; reported for API callers. |
| `UPLOAD_FAILED` | The file reached the server but could not be stored there. | Try again; the Nextcloud log has the details. |
-| `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. |
+| `MISSING_RECORDS_UNSUPPORTED` | The request asked for something other than `keep` or `archive` for records missing from the export, for example `mark` or `remove`. The details list the accepted values. | Not reachable from the section; reported for API callers. |
+| `ARCHIVE_UNAVAILABLE` | **Archive or delete them** was chosen, but the OpenRegister on this server cannot archive or restore objects (it is missing or too old). Nothing was read or written. | Choose **Keep them as they are** and import again, or update OpenRegister. |
| `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. |
@@ -322,9 +399,9 @@ every import:
| Setting | Default | What it limits |
|---|---|---|
| `maxFileBytes` | `10485760` (10 MB) | the size of the uploaded file |
-| `maxRowsPerSheet` | `10000` | the rows with data on one CMDB sheet |
+| `maxRowsPerSheet` | `10000` | the rows with data on one CMDB sheet, and on the archive sheet |
| `maxUncompressedBytes` | `52428800` (50 MB) | the size of the workbook once unpacked, checked before a sheet is parsed |
-| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet |
+| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet; the archive sheet is read, so it is bounded like a CMDB sheet |
| `maxSharedStrings` | `200000` | the number of different texts in the workbook's shared-strings table, each formatted piece of a text with mixed formatting counted as one more, also in text written in a cell itself, counted before a sheet is parsed |
| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per sheet the import reads (the two CMDB sheets and the archive sheet, also when the workbook has no archive sheet) before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated |
diff --git a/l10n/en.js b/l10n/en.js
index 8dd84a2f4..61a8eb769 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -982,7 +982,7 @@ 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 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 a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.",
"Importing the export…": "Importing the export…",
"Importing…": "Importing…",
"APPID": "APPID",
@@ -999,7 +999,7 @@ OC.L10N.register(
"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.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".",
"Row": "Row",
"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",
@@ -1017,7 +1017,7 @@ OC.L10N.register(
"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 import was cancelled. The rows and applications processed before it stopped are kept.": "The import was cancelled. The rows and applications 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}\".",
@@ -1038,7 +1038,23 @@ OC.L10N.register(
"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.",
+ "Applications missing from the export can only be archived or kept.": "Applications missing from the export can only be archived or kept.",
+ "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
+ "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.",
+ "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
+ "Unarchived": "Unarchived",
+ "Restored": "Restored",
+ "Archived": "Archived",
+ "Deleted": "Deleted",
+ "Applications missing from the export": "Applications missing from the export",
+ "Archive or delete them": "Archive or delete them",
+ "Keep them as they are": "Keep them as they are",
+ "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.",
+ "Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
+ "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
+ "Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
"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.",
@@ -1086,15 +1102,15 @@ OC.L10N.register(
"The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.",
"The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.",
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
- "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
"The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.",
"Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.",
"Show {count} more rows": "Show {count} more rows",
"The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.",
@@ -1125,14 +1141,21 @@ OC.L10N.register(
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.",
- "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.",
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "{count} archived": "{count} archived",
+ "{count} unarchived": "{count} unarchived",
+ "{count} deleted": "{count} deleted",
+ "{count} restored": "{count} restored",
+ "Applications: {changes}.": "Applications: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "This tab lists the whole archive. It ignores the GEMMA filters and the search term.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 313f968ea..efb114389 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -981,7 +981,7 @@
"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 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 a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.",
"Importing the export…": "Importing the export…",
"Importing…": "Importing…",
"APPID": "APPID",
@@ -998,7 +998,7 @@
"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.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".",
"Row": "Row",
"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",
@@ -1016,7 +1016,7 @@
"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 import was cancelled. The rows and applications processed before it stopped are kept.": "The import was cancelled. The rows and applications 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}\".",
@@ -1037,7 +1037,23 @@
"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.",
+ "Applications missing from the export can only be archived or kept.": "Applications missing from the export can only be archived or kept.",
+ "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.",
+ "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.",
+ "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.",
+ "Unarchived": "Unarchived",
+ "Restored": "Restored",
+ "Archived": "Archived",
+ "Deleted": "Deleted",
+ "Applications missing from the export": "Applications missing from the export",
+ "Archive or delete them": "Archive or delete them",
+ "Keep them as they are": "Keep them as they are",
+ "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.",
+ "Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
+ "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
+ "Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
"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.",
@@ -1085,15 +1101,15 @@
"The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.",
"The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.",
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
- "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
"The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.",
"Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.",
"Show {count} more rows": "Show {count} more rows",
"The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.",
@@ -1124,13 +1140,20 @@
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.",
- "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.",
"The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "{count} archived": "{count} archived",
+ "{count} unarchived": "{count} unarchived",
+ "{count} deleted": "{count} deleted",
+ "{count} restored": "{count} restored",
+ "Applications: {changes}.": "Applications: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "This tab lists the whole archive. It ignores the GEMMA filters and the search term.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 3151813d6..cdf48b52b 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1050,7 +1050,7 @@ 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 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 a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd of verwijderd, tenzij je kiest ze te laten zoals ze zijn.",
"Importing the export…": "De export wordt geïmporteerd…",
"Importing…": "Importeren…",
"APPID": "APPID",
@@ -1067,7 +1067,7 @@ OC.L10N.register(
"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.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Verwijder tabbladen die de import niet leest en probeer het opnieuw. Of splits de export en importeer elk deel met \"Laat ze zoals ze zijn\".",
"Row": "Rij",
"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",
@@ -1085,7 +1085,7 @@ OC.L10N.register(
"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 import was cancelled. The rows and applications processed before it stopped are kept.": "De import is geannuleerd. De rijen en applicaties 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}\".",
@@ -1106,7 +1106,23 @@ OC.L10N.register(
"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.",
+ "Applications missing from the export can only be archived or kept.": "Applicaties die in de export ontbreken, kunnen alleen worden gearchiveerd of behouden.",
+ "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
+ "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in %2$s rijen (%3$s), dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Unarchived": "Uit archief gehaald",
+ "Restored": "Hersteld",
+ "Archived": "Gearchiveerd",
+ "Deleted": "Verwijderd",
+ "Applications missing from the export": "Applicaties die in de export ontbreken",
+ "Archive or delete them": "Archiveren of verwijderen",
+ "Keep them as they are": "Laat ze zoals ze zijn",
+ "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
+ "Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
+ "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
+ "Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
"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.",
@@ -1154,15 +1170,15 @@ OC.L10N.register(
"The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.",
"The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.",
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
- "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
"The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "De import wordt geannuleerd. Hij stopt voor de volgende rij of, tijdens het vergelijken van de applicaties met de export, voor de volgende applicatie; wat al verwerkt is blijft staan.",
"Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.",
"Show {count} more rows": "Toon nog {count} rijen",
"The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.",
@@ -1193,14 +1209,21 @@ OC.L10N.register(
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.",
- "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest \"{first}\" en \"{second}\", en de kolom APPID van \"{archive}\"), en importeer die kopie.",
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "{count} archived": "{count} gearchiveerd",
+ "{count} unarchived": "{count} uit het archief gehaald",
+ "{count} deleted": "{count} verwijderd",
+ "{count} restored": "{count} teruggezet",
+ "Applications: {changes}.": "Applicaties: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "Dit tabblad toont het hele archief. De GEMMA-filters en de zoekterm worden genegeerd.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 680f6f853..88f4a002a 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1049,7 +1049,7 @@
"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 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 a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd of verwijderd, tenzij je kiest ze te laten zoals ze zijn.",
"Importing the export…": "De export wordt geïmporteerd…",
"Importing…": "Importeren…",
"APPID": "APPID",
@@ -1066,7 +1066,7 @@
"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.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Verwijder tabbladen die de import niet leest en probeer het opnieuw. Of splits de export en importeer elk deel met \"Laat ze zoals ze zijn\".",
"Row": "Rij",
"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",
@@ -1084,7 +1084,7 @@
"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 import was cancelled. The rows and applications processed before it stopped are kept.": "De import is geannuleerd. De rijen en applicaties 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}\".",
@@ -1105,7 +1105,23 @@
"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.",
+ "Applications missing from the export can only be archived or kept.": "Applicaties die in de export ontbreken, kunnen alleen worden gearchiveerd of behouden.",
+ "Applications missing from the export cannot be archived: OpenRegister is missing or too old. Choose to keep them, or update OpenRegister.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd: OpenRegister ontbreekt of is te oud. Kies ervoor ze te behouden, of werk OpenRegister bij.",
+ "Sheet \"%1$s\" has no column \"%2$s\", so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\", dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in rij %2$s, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%1$s\" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.": "Tabblad \"%1$s\" heeft een APPID zonder opgeslagen waarde in %2$s rijen (%3$s), dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Sheet \"%s\" was not found, so applications missing from the export are archived, not deleted.": "Tabblad \"%s\" is niet gevonden, dus applicaties die in de export ontbreken, worden gearchiveerd en niet verwijderd.",
+ "Unarchived": "Uit archief gehaald",
+ "Restored": "Hersteld",
+ "Archived": "Gearchiveerd",
+ "Deleted": "Verwijderd",
+ "Applications missing from the export": "Applicaties die in de export ontbreken",
+ "Archive or delete them": "Archiveren of verwijderen",
+ "Keep them as they are": "Laat ze zoals ze zijn",
+ "An application of this municipality that is on neither CMDB sheet any more is archived when the sheet \"Gearchiveerde Applicaties\" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
+ "Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
+ "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
+ "Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
"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.",
@@ -1153,15 +1169,15 @@
"The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.",
"The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.",
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
- "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
"The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "De import wordt geannuleerd. Hij stopt voor de volgende rij of, tijdens het vergelijken van de applicaties met de export, voor de volgende applicatie; wat al verwerkt is blijft staan.",
"Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.",
"Show {count} more rows": "Toon nog {count} rijen",
"The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.",
@@ -1192,13 +1208,20 @@
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.",
- "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.",
- "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest \"{first}\" en \"{second}\", en de kolom APPID van \"{archive}\"), en importeer die kopie.",
"The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.",
"Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "{count} archived": "{count} gearchiveerd",
+ "{count} unarchived": "{count} uit het archief gehaald",
+ "{count} deleted": "{count} verwijderd",
+ "{count} restored": "{count} teruggezet",
+ "Applications: {changes}.": "Applicaties: {changes}.",
+ "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "Dit tabblad toont het hele archief. De GEMMA-filters en de zoekterm worden genegeerd.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer."
}
}
diff --git a/lib/Controller/CmdbImportController.php b/lib/Controller/CmdbImportController.php
index 7d1c00a8f..bc7869cbe 100644
--- a/lib/Controller/CmdbImportController.php
+++ b/lib/Controller/CmdbImportController.php
@@ -86,7 +86,8 @@ 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.
@@ -94,6 +95,7 @@ public function __construct(
* @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 {
@@ -247,15 +249,21 @@ protected function iniBytes(string $name): ?int {
* @return array{path: string, options: array}|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.
@@ -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)),
],
@@ -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.'),
@@ -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()
diff --git a/lib/Exception/CmdbImportException.php b/lib/Exception/CmdbImportException.php
index 31404c505..65127a9fe 100644
--- a/lib/Exception/CmdbImportException.php
+++ b/lib/Exception/CmdbImportException.php
@@ -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.
@@ -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,
];
/**
diff --git a/lib/Service/Cmdb/CmdbImportReport.php b/lib/Service/Cmdb/CmdbImportReport.php
index 730365fde..2615c8b07 100644
--- a/lib/Service/Cmdb/CmdbImportReport.php
+++ b/lib/Service/Cmdb/CmdbImportReport.php
@@ -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.
@@ -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.
@@ -180,14 +184,25 @@ 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()
/**
@@ -195,21 +210,28 @@ public function processed(): int {
*
* `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,
];
diff --git a/lib/Service/Cmdb/CmdbWorkbookReader.php b/lib/Service/Cmdb/CmdbWorkbookReader.php
index 3ba065a4d..4b80393b8 100644
--- a/lib/Service/Cmdb/CmdbWorkbookReader.php
+++ b/lib/Service/Cmdb/CmdbWorkbookReader.php
@@ -175,8 +175,11 @@ public function isAvailable(): bool {
* @param CmdbImportProfile $profile The import profile.
*
* @return array `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.
@@ -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(
@@ -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()
/**
@@ -434,20 +451,38 @@ private function sourceSheets(string $path, CmdbImportProfile $profile): array {
* @param array $sheetNames The source sheets the workbook has.
* @param array> $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> $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> 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);
}
@@ -732,12 +767,20 @@ private function resolveColumns(object $worksheet, array $referenced): array {
* @param int $limit The maximum number of non-empty rows.
* @param array|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, uncached: array}>
*
* @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++) {
@@ -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;
}
diff --git a/lib/Service/CmdbExportImportService.php b/lib/Service/CmdbExportImportService.php
index 1efc049d7..1cce85f47 100644
--- a/lib/Service/CmdbExportImportService.php
+++ b/lib/Service/CmdbExportImportService.php
@@ -30,7 +30,15 @@
* - `publicationDate` is set to the import's start on create, unless the
* admin chose not to publish (`publish` false), and never written on
* update; neither is `depublicationDate`.
- * - Records missing from a newer export are left untouched.
+ * - Records missing from a newer export are left untouched with
+ * `missingRecords` `keep`. With `archive` (the default) the municipality's
+ * usages and their modules are reconciled after the last row: archived
+ * when the APPID is on the archive sheet, soft-deleted when it is on no
+ * sheet, archived only when the workbook has no archive sheet, the sheet
+ * has no APPID column or an APPID in it has no cached value
+ * (openspec/changes/cmdb-import-archive-reconciliation, design D5). A
+ * module or usage matched among archived or soft-deleted objects is
+ * unarchived or restored before it is updated, never duplicated.
* - Owners become contact persons, never Nextcloud user accounts, and no
* report entry or log line carries an owner name or e-mail address.
* - One import runs per register at a time: every match is find-then-create,
@@ -55,6 +63,7 @@
use DateTimeImmutable;
use DateTimeZone;
+use JsonSerializable;
use OCA\OpenRegister\Contract\ObjectServiceInterface;
use OCA\Stackiq\Exception\CmdbImportException;
use OCA\Stackiq\Service\Cmdb\CmdbImportProfile;
@@ -68,6 +77,7 @@
use OCP\Lock\LockedException;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
+use RuntimeException;
use Throwable;
/**
@@ -75,6 +85,12 @@
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
*
+ * @phpstan-type ArchiveSheet array{sheet: string, present: bool, keyColumnMissing: bool, uncachedRows: array, keys: array}
+ *
+ * @phpstan-type ScopeExtra array{schemaKey: string, uuid: string, state: array{archived: bool, deleted: bool}}
+ * @phpstan-type ScopeStates array
+ * @phpstan-type ScopeEntry array{key: string, usage: object, module: object, states: ScopeStates, extras?: array}
+ *
* @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
@@ -111,6 +127,58 @@ class CmdbExportImportService {
*/
public const SCHEMA_MAPPER_CLASS = 'OCA\OpenRegister\Db\SchemaMapper';
+ /**
+ * OpenRegister's archive handler, behind POST and DELETE .../archive (not a public contract).
+ */
+ public const ARCHIVE_HANDLER_CLASS = 'OCA\OpenRegister\Service\Object\ArchiveHandler';
+
+ /**
+ * OpenRegister's object mapper, whose restoreObject() empties the trash of one object (not a public contract).
+ */
+ public const OBJECT_MAPPER_CLASS = 'OCA\OpenRegister\Db\MagicMapper';
+
+ /**
+ * What happens to records missing from the export when the request does not say.
+ */
+ public const DEFAULT_MISSING_RECORDS = 'archive';
+
+ /**
+ * The start of every archive reason the import writes, so the audit trail says who archived and why.
+ */
+ private const REASON_PREFIX = 'cmdb-import: ';
+
+ /**
+ * The search lens that lists the archive alone (OpenRegister's `_archived=true`).
+ *
+ * The state of a stored object is read from the lens that found it, never
+ * from the archive marker of a search result: OpenRegister's list search
+ * does not hydrate that marker (design D9).
+ *
+ * @var array
+ */
+ private const ARCHIVED_LENS = ['_archived' => 'true'];
+
+ /**
+ * The state of an object found without a lens.
+ *
+ * @var array{archived: bool, deleted: bool}
+ */
+ private const STATE_WORKING = ['archived' => false, 'deleted' => false];
+
+ /**
+ * The state of an object found with ARCHIVED_LENS.
+ *
+ * @var array{archived: bool, deleted: bool}
+ */
+ private const STATE_ARCHIVED = ['archived' => true, 'deleted' => false];
+
+ /**
+ * The state of an object found in the trash; whether it was archived too is read when it is restored.
+ *
+ * @var array{archived: bool, deleted: bool}
+ */
+ private const STATE_DELETED = ['archived' => false, 'deleted' => true];
+
/**
* The properties every match search and the module key rely on, per schema.
*
@@ -177,6 +245,13 @@ class CmdbExportImportService {
*/
private const UNMATCHED_STATUSES = ['merged', 'Inactive'];
+ /**
+ * How many row numbers an archive-sheet warning names before it cuts the list.
+ *
+ * @var int
+ */
+ private const WARNED_ROWS = 10;
+
/**
* Page size for loading the organisations a name may match.
*/
@@ -222,6 +297,27 @@ class CmdbExportImportService {
*/
private array $winningSheets = [];
+ /**
+ * The CMDB-sheet rows of this run whose key column the reader could not see (a formula without a cached value).
+ *
+ * @var array> Sheet name => row numbers.
+ */
+ private array $unreadableKeyRows = [];
+
+ /**
+ * OpenRegister's archive handler and object mapper, once resolved.
+ *
+ * @var array{handler: object, mapper: object}|null
+ */
+ private ?array $archiveServices = null;
+
+ /**
+ * The register's soft-deleted modules and usages, read once per run: schema key => uuid => entity.
+ *
+ * @var array{module: array, usage: array}|null
+ */
+ private ?array $trash = null;
+
/**
* Contact UIDs by e-mail or display name, per run.
*
@@ -300,17 +396,16 @@ public function assertXlsx(string $path, string $fileName): void {
}//end assertXlsx()
/**
- * Whether a `missingRecords` value is supported (only `keep`).
- *
- * @param string $mode The requested value.
+ * The accepted `missingRecords` values: the profile's modes (`keep`, `archive`); any other is refused.
*
- * @return bool
+ * @return array
*
* @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 supportsMissingRecords(string $mode): bool {
- return $mode === 'keep';
- }//end supportsMissingRecords()
+ public function missingRecordsModes(): array {
+ return $this->profile->missingRecordsModes();
+ }//end missingRecordsModes()
/**
* Ask a running import to stop between rows.
@@ -349,19 +444,21 @@ public function requestCancel(string $operationId): bool {
*
* @param string $path The xlsx file, already checked by assertXlsx().
* @param array $options municipalityUuid, municipalityName, updateExisting, publish,
- * operationId, and fileName (the upload's name, for the audit log line).
+ * missingRecords (keep or archive, default archive), operationId,
+ * and fileName (the upload's name, for the audit log line).
*
* @return array The report (contract.md).
*
* @throws CmdbImportException MAPPING_UNAVAILABLE, NOT_CONFIGURED, SCHEMA_OUTDATED, IMPORT_IN_PROGRESS,
* WORKBOOK_TOO_LARGE, READER_UNAVAILABLE, NOT_XLSX, NO_SOURCE_SHEET,
* MISSING_COLUMN, TOO_MANY_ROWS, MUNICIPALITY_REQUIRED,
- * MUNICIPALITY_INVALID or MUNICIPALITY_AMBIGUOUS.
+ * MUNICIPALITY_INVALID, MUNICIPALITY_AMBIGUOUS or ARCHIVE_UNAVAILABLE.
* @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
+ * @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 import(string $path, array $options): array {
$this->resetRun();
@@ -371,6 +468,10 @@ public function import(string $path, array $options): array {
$this->profile->load();
$this->engine = $this->resolveEngine();
$this->coordinates = $this->resolveCoordinates();
+ if (self::missingRecordsFrom(options: $options) === 'archive') {
+ // Refused before the lock and the file: an import that cannot reconcile must not half-run.
+ $this->archiveServices();
+ }
$lock = $this->acquireImportLock(register: $this->coordinates['register']);
try {
@@ -442,29 +543,21 @@ private function acquireImportLock(int $register): string {
* @return array The report.
*
* @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
*/
private function runImport(string $path, array $options, string $startedAt): array {
$workbook = $this->reader->read(path: $path, profile: $this->profile);
$municipality = $this->resolveMunicipality(options: $options);
$operationId = $this->operationIdFrom(options: $options);
+ $missingRecords = self::missingRecordsFrom(options: $options);
$rows = array_values($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']));
- if ($municipality['created'] === true) {
- $report->addImportWarnings(
- warnings: [
- [
- 'sheet' => '',
- 'message' => $this->l10n->t(
- 'No municipality named "%s" was found, so it was created. Check the name if you meant an existing one.',
- [$municipality['name']]
- ),
- ],
- ]
- );
- }
+ $archive = $this->archiveSheet(workbook: $workbook);
+ $this->unreadableKeyRows = $this->unreadableKeyRows(rows: $rows, droppedRows: (array)($workbook['uncachedKeyRows'] ?? []));
+ $this->addRunWarnings(report: $report, municipality: $municipality, archive: $archive, missingRecords: $missingRecords);
$this->progressTracker->startOperation(
operationType: self::OPERATION_TYPE,
@@ -488,6 +581,7 @@ private function runImport(string $path, array $options, string $startedAt): arr
'municipalityCreated' => $municipality['created'],
'updateExisting' => $updateExisting,
'publish' => $publish,
+ 'missingRecords' => $missingRecords,
];
$this->logger->info('CmdbExportImportService: import started', array_merge($audit, ['rows' => count($rows)]));
try {
@@ -507,6 +601,16 @@ private function runImport(string $path, array $options, string $startedAt): arr
$this->progressTracker->updateProgress(processedItems: ($index + 1));
}
+ if ($missingRecords === 'archive' && $report->isCancelled() === false) {
+ $this->reconcile(
+ municipalityUuid: $municipality['uuid'],
+ archive: $archive,
+ operationId: $operationId,
+ processedItems: count($rows),
+ report: $report
+ );
+ }
+
$result = $report->toArray();
$this->finishOperation(report: $report->toStoredArray(maxRows: self::STORED_REPORT_ROWS));
} catch (Throwable $e) {
@@ -528,6 +632,86 @@ private function runImport(string $path, array $options, string $startedAt): arr
return $result;
}//end runImport()
+ /**
+ * Add the run's own warnings.
+ *
+ * An archive sheet that is missing, has no key column or has unreadable keys, a CMDB sheet with
+ * unreadable keys, and a created municipality.
+ *
+ * @param CmdbImportReport $report The report.
+ * @param array{uuid: string, name: string, created: bool} $municipality The consumer, as resolveMunicipality() found it.
+ * @param ArchiveSheet $archive The archive sheet.
+ * @param string $missingRecords The import's `missingRecords` mode.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/archive/2026-10-05-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
+ */
+ private function addRunWarnings(CmdbImportReport $report, array $municipality, array $archive, string $missingRecords): void {
+ if ($missingRecords === 'archive' && ($archive['present'] === false || $archive['uncachedRows'] !== [])) {
+ $report->addImportWarnings(warnings: [['sheet' => $archive['sheet'], 'message' => $this->archiveSheetWarning(archive: $archive)]]);
+ }
+
+ if ($missingRecords === 'archive') {
+ foreach ($this->unreadableKeyRows as $sheet => $rowNumbers) {
+ $cmdbSheet = ['sheet' => $sheet, 'keyColumnMissing' => false, 'uncachedRows' => $rowNumbers];
+ $report->addImportWarnings(warnings: [['sheet' => $sheet, 'message' => $this->archiveSheetWarning(archive: $cmdbSheet)]]);
+ }
+ }
+
+ if ($municipality['created'] === true) {
+ $report->addImportWarnings(
+ warnings: [
+ [
+ 'sheet' => '',
+ 'message' => $this->l10n->t(
+ 'No municipality named "%s" was found, so it was created. Check the name if you meant an existing one.',
+ [$municipality['name']]
+ ),
+ ],
+ ]
+ );
+ }
+ }//end addRunWarnings()
+
+ /**
+ * The CMDB-sheet rows whose key column is a formula without a cached value, per sheet.
+ *
+ * Such a row reads as empty and is skipped, so its application may be
+ * missing from `winningSheets` although it is on a CMDB sheet. A run with
+ * one is not authoritative for a deletion, as with an archive sheet that
+ * has an unreadable key.
+ *
+ * @param array}> $rows The reader rows.
+ * @param array> $droppedRows The rows the reader dropped as empty because their key
+ * has no cached value (they are not in `$rows`), per sheet.
+ *
+ * @return array> Sheet name => row numbers.
+ *
+ * @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
+ */
+ private function unreadableKeyRows(array $rows, array $droppedRows): array {
+ $key = $this->profile->keyColumn();
+ $found = [];
+ foreach ($droppedRows as $sheet => $rowNumbers) {
+ $found[(string)$sheet] = array_map('intval', array_values((array)$rowNumbers));
+ }
+
+ foreach ($rows as $row) {
+ if (in_array($key, ($row['uncached'] ?? []), true) === true) {
+ $found[$row['sheet']][] = $row['row'];
+ }
+ }
+
+ foreach ($found as $sheet => $rowNumbers) {
+ sort($rowNumbers);
+ $found[$sheet] = $rowNumbers;
+ }
+
+ return $found;
+ }//end unreadableKeyRows()
+
/**
* Process one row in its own error boundary and add its outcome.
*
@@ -748,11 +932,20 @@ private function translateImportWarnings(array $warnings): array {
* @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.
+ * @return string restored when either was restored from the trash, else unarchived when either was
+ * unarchived, else created when the module was created, updated when anything was saved,
+ * else unchanged.
*
* @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-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private static function rowOutcome(string $module, string $usage): string {
+ foreach ([CmdbImportReport::RESTORED, CmdbImportReport::UNARCHIVED] as $revived) {
+ if ($module === $revived || $usage === $revived) {
+ return $revived;
+ }
+ }
+
if ($module === CmdbImportReport::CREATED) {
return CmdbImportReport::CREATED;
}
@@ -781,7 +974,7 @@ private function finishOperation(array $report): void {
}
$this->progressTracker->completeOperation(finalStatistics: ['report' => $report]);
- // A cancel that came in after the last row's check has nothing left to stop.
+ // A cancel that came in after the last check has nothing left to stop.
$this->progressTracker->clearCancelRequested(operationId: (string)$report['operationId']);
}//end finishOperation()
@@ -1028,19 +1221,19 @@ private function resolveManufacturer(array $values, int $rowNumber): ?string {
* @param string $matchKey The APPID's match key.
* @param array{updateExisting: bool} $options Whether a match is updated.
*
- * @return array{existing: object|null, uuid: string|null, skipReason: string|null} The stored module, the uuid
- * to report for a skipped row,
- * and why the row is skipped.
+ * @return array{existing: array{entity: object, state: array{archived: bool, deleted: bool}}|null, uuid: string|null, skipReason: string|null}
+ * The stored module with its state, the uuid to report for a skipped row, and why the row is skipped.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private function matchModule(string $externalKey, string $municipalityUuid, string $matchKey, array $options): array {
- $existing = $this->findOne(schemaKey: 'module', filters: ['externalKey' => $externalKey]);
+ $existing = $this->findInAnyState(schemaKey: 'module', filters: ['externalKey' => $externalKey]);
if ($existing === null) {
return ['existing' => null, 'uuid' => null, 'skipReason' => null];
}
- $uuid = (string)$existing->getUuid();
+ $uuid = (string)$existing['entity']->getUuid();
if ($this->moduleBelongsTo(moduleUuid: $uuid, municipalityUuid: $municipalityUuid) === false) {
$this->logger->warning(
'CmdbExportImportService: import key belongs to a module another organisation uses; row not imported',
@@ -1062,7 +1255,8 @@ private function matchModule(string $externalKey, string $municipalityUuid, stri
*
* @param array $data The mapped module fields.
* @param string $externalKey The module's import key.
- * @param object|null $existing The module matchModule() found, or null to create one.
+ * @param array{entity: object, state: array{archived: bool, deleted: bool}}|null $existing The module matchModule()
+ * found, or null to create one.
* @param string|null $providerUuid The supplier, when there is one.
* @param string|null $publicationDate ISO start time of the import for a module that is published
* when created, or null to create it unpublished.
@@ -1075,7 +1269,7 @@ private function matchModule(string $externalKey, string $municipalityUuid, stri
private function importModule(
array $data,
string $externalKey,
- ?object $existing,
+ ?array $existing,
?string $providerUuid,
?string $publicationDate,
CmdbImportReport $report,
@@ -1102,6 +1296,8 @@ private function importModule(
* together with the usages: the module must already have a usage whose
* consumer is this municipality, or, before the first import, no usage
* at all. A module only another organisation uses is never taken over.
+ * This municipality's usage counts in every state: an archived or
+ * soft-deleted usage still says the module is the municipality's.
*
* @param string $moduleUuid The module found by its import key.
* @param string $municipalityUuid The consumer of this import.
@@ -1109,9 +1305,10 @@ private function importModule(
* @return bool
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private function moduleBelongsTo(string $moduleUuid, string $municipalityUuid): bool {
- if ($this->findOne(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]) !== null) {
+ if ($this->findInAnyState(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]) !== null) {
return true;
}
@@ -1123,19 +1320,22 @@ private function moduleBelongsTo(string $moduleUuid, string $municipalityUuid):
*
* @param array $data The mapped module fields.
* @param string $externalKey The match key.
- * @param object|null $existing The stored module, or null to create one.
+ * @param array{entity: object, state: array{archived: bool, deleted: bool}}|null $existing The stored module and its
+ * state, or null to create one.
* @param string|null $providerUuid The supplier, when there is one.
* @param string|null $publicationDate ISO start time of the import for a module that is published
* when created, or null to create it unpublished.
*
- * @return array{uuid: string, outcome: string} Outcome created, updated or unchanged.
+ * @return array{uuid: string, outcome: string} Outcome created, updated or unchanged, or restored or
+ * unarchived when the stored module was revived first.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private function upsertModule(
array $data,
string $externalKey,
- ?object $existing,
+ ?array $existing,
?string $providerUuid,
?string $publicationDate,
): array {
@@ -1154,14 +1354,15 @@ private function upsertModule(
return ['uuid' => $this->save(schemaKey: 'module', data: $create, uuid: null), 'outcome' => CmdbImportReport::CREATED];
}
- $uuid = (string)$existing->getUuid();
- $merged = $this->merge(target: 'module', stored: $existing->getObject(), mapped: $data);
+ $uuid = (string)$existing['entity']->getUuid();
+ $revived = $this->revive(found: $existing);
+ $merged = $this->merge(target: 'module', stored: $revived['entity']->getObject(), mapped: $data);
if ($merged === null) {
- return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED];
+ return ['uuid' => $uuid, 'outcome' => ($revived['outcome'] ?? CmdbImportReport::UNCHANGED)];
}
$this->save(schemaKey: 'module', data: $merged, uuid: $uuid);
- return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED];
+ return ['uuid' => $uuid, 'outcome' => ($revived['outcome'] ?? CmdbImportReport::UPDATED)];
}//end upsertModule()
/**
@@ -1173,9 +1374,11 @@ private function upsertModule(
* @param string|null $providerUuid The supplier, when there is one.
* @param array{businessOwner: string|null} $owners The owner contact person.
*
- * @return array{uuid: string, outcome: string}
+ * @return array{uuid: string, outcome: string} Outcome created, updated or unchanged, or restored or
+ * unarchived when the stored usage was revived first.
*
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
*/
private function upsertUsage(array $data, string $municipalityUuid, string $moduleUuid, ?string $providerUuid, array $owners): array {
if (isset($data['interneAnnotation']) === true && is_string($data['interneAnnotation']) === true) {
@@ -1199,19 +1402,20 @@ private function upsertUsage(array $data, string $municipalityUuid, string $modu
}
}
- $existing = $this->findOne(schemaKey: 'usage', filters: ['consumer' => $municipalityUuid, 'module' => $moduleUuid]);
+ $existing = $this->findInAnyState(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);
+ $uuid = (string)$existing['entity']->getUuid();
+ $revived = $this->revive(found: $existing);
+ $merged = $this->merge(target: 'usage', stored: $revived['entity']->getObject(), mapped: $data);
if ($merged === null) {
- return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UNCHANGED];
+ return ['uuid' => $uuid, 'outcome' => ($revived['outcome'] ?? CmdbImportReport::UNCHANGED)];
}
$this->save(schemaKey: 'usage', data: $merged, uuid: $uuid);
- return ['uuid' => $uuid, 'outcome' => CmdbImportReport::UPDATED];
+ return ['uuid' => $uuid, 'outcome' => ($revived['outcome'] ?? CmdbImportReport::UPDATED)];
}//end upsertUsage()
/**
@@ -1633,7 +1837,7 @@ private function organisationsOfType(string $type): array {
}//end organisationsOfType()
/**
- * The one object matching every filter, or null.
+ * The working object matching every filter, or null.
*
* @param string $schemaKey The coordinates key of the schema.
* @param array $filters Field => exact value.
@@ -1643,10 +1847,67 @@ private function organisationsOfType(string $type): array {
* @spec openspec/changes/cmdb-export-import/tasks.md#task-5
*/
private function findOne(string $schemaKey, array $filters): ?object {
+ return $this->search(schemaKey: $schemaKey, filters: $filters, lens: []);
+ }//end findOne()
+
+ /**
+ * The object matching every filter in any state, with that state, or null.
+ *
+ * A working object wins over an archived one, and an archived one over one
+ * in the trash: an object deleted by hand may share its import key with the
+ * one a later import created, and bringing the deleted one back would
+ * duplicate it. The state is the lens that found the object (design D9):
+ * a search without a lens finds only working objects, the archive lens only
+ * archived ones, and the trash is read from OpenRegister's trash listing,
+ * because its list search cannot return a soft-deleted object.
+ *
+ * @param string $schemaKey module or usage.
+ * @param array $filters Field => exact value.
+ *
+ * @return array{entity: object, state: array{archived: bool, deleted: bool}}|null
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function findInAnyState(string $schemaKey, array $filters): ?array {
+ $entity = $this->search(schemaKey: $schemaKey, filters: $filters, lens: []);
+ if ($entity !== null) {
+ return ['entity' => $entity, 'state' => self::STATE_WORKING];
+ }
+
+ $entity = $this->search(schemaKey: $schemaKey, filters: $filters, lens: self::ARCHIVED_LENS);
+ if ($entity !== null) {
+ return ['entity' => $entity, 'state' => self::STATE_ARCHIVED];
+ }
+
+ foreach (($this->trash()[$schemaKey] ?? []) as $entity) {
+ if (self::matchesFilters(entity: $entity, filters: $filters) === true) {
+ return ['entity' => $entity, 'state' => self::STATE_DELETED];
+ }
+ }
+
+ return null;
+ }//end findInAnyState()
+
+ /**
+ * The first object a search with one lens returns that matches every filter, or null.
+ *
+ * The filters are checked again on the result: a filter OpenRegister cannot
+ * apply must not turn into a match.
+ *
+ * @param string $schemaKey The coordinates key of the schema.
+ * @param array $filters Field => exact value.
+ * @param array $lens No lens (working objects) or ARCHIVED_LENS.
+ *
+ * @return object|null The entity (ObjectEntityInterface).
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function search(string $schemaKey, array $filters, array $lens): ?object {
$coordinates = $this->coordinates();
$results = $coordinates['objectService']->searchObjects(
query: array_merge(
['@self' => ['register' => $coordinates['register'], 'schema' => $coordinates[$schemaKey]], '_limit' => 10],
+ $lens,
$filters
),
_rbac: false,
@@ -1657,22 +1918,34 @@ private function findOne(string $schemaKey, array $filters): ?object {
}
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) {
+ if (self::matchesFilters(entity: $entity, filters: $filters) === true) {
return $entity;
}
}
return null;
- }//end findOne()
+ }//end search()
+
+ /**
+ * Whether an object's data has every filter's value.
+ *
+ * @param object $entity The entity (ObjectEntityInterface).
+ * @param array $filters Field => exact value.
+ *
+ * @return bool
+ *
+ * @spec openspec/changes/archive/2026-10-05-cmdb-export-import/tasks.md#task-5
+ */
+ private static function matchesFilters(object $entity, array $filters): bool {
+ $data = $entity->getObject();
+ foreach ($filters as $field => $value) {
+ if (self::relationUuid(value: ($data[$field] ?? null)) !== $value) {
+ return false;
+ }
+ }
+
+ return true;
+ }//end matchesFilters()
/**
* Save an object through OpenRegister and return its uuid.
@@ -1699,6 +1972,797 @@ private function save(string $schemaKey, array $data, ?string $uuid): string {
return (string)$entity->getUuid();
}//end save()
+ /**
+ * The `missingRecords` mode of an import: the option, or DEFAULT_MISSING_RECORDS.
+ *
+ * @param array $options The import options.
+ *
+ * @return string
+ *
+ * @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 static function missingRecordsFrom(array $options): string {
+ $mode = ($options['missingRecords'] ?? null);
+ if (is_string($mode) === false || $mode === '') {
+ return self::DEFAULT_MISSING_RECORDS;
+ }
+
+ return $mode;
+ }//end missingRecordsFrom()
+
+ /**
+ * The archive sheet as the reader found it, with its APPIDs as match keys.
+ *
+ * @param array $workbook What the reader returned.
+ *
+ * @return ArchiveSheet
+ *
+ * @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
+ */
+ private function archiveSheet(array $workbook): array {
+ $archive = ($workbook['archive'] ?? []);
+ $key = $this->profile->keyColumn();
+ $keys = [];
+ foreach (($archive['appIds'] ?? []) as $appId) {
+ $values = $this->normaliser->normalise(cells: [$key => $appId], dateColumns: [], idColumns: $this->profile->idColumns());
+ $matchKey = self::matchKey(appId: ($values[$key] ?? ''));
+ if ($matchKey !== '') {
+ $keys[$matchKey] = true;
+ }
+ }
+
+ return [
+ 'sheet' => (string)($archive['sheet'] ?? ($this->profile->archiveSheetName() ?? '')),
+ 'present' => (($archive['present'] ?? false) === true),
+ 'keyColumnMissing' => (($archive['keyColumnMissing'] ?? false) === true),
+ 'uncachedRows' => array_values(array_map('intval', (array)($archive['uncachedRows'] ?? []))),
+ 'keys' => $keys,
+ ];
+ }//end archiveSheet()
+
+ /**
+ * The report warning of an archiving import whose archive sheet is absent or has no key column, or has unreadable keys.
+ *
+ * Also the warning of a CMDB sheet with unreadable keys: it is handed the sheet's name and rows in the archive sheet's shape.
+ *
+ * @param array{sheet: string, keyColumnMissing: bool, uncachedRows: array} $archive The archive sheet, or a CMDB sheet.
+ *
+ * @return string
+ *
+ * @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
+ */
+ private function archiveSheetWarning(array $archive): string {
+ if ($archive['uncachedRows'] !== []) {
+ // A long list is cut: the warning counts the rows and names where to look, not every one.
+ $count = count($archive['uncachedRows']);
+ $rows = implode(', ', array_slice($archive['uncachedRows'], 0, self::WARNED_ROWS));
+ if ($count > self::WARNED_ROWS) {
+ $rows .= ', …';
+ }
+
+ if ($count === 1) {
+ return $this->l10n->t(
+ 'Sheet "%1$s" has an APPID without a cached value in row %2$s, so applications missing from the export are archived, not deleted.',
+ [$archive['sheet'], $rows]
+ );
+ }
+
+ return $this->l10n->t(
+ 'Sheet "%1$s" has an APPID without a cached value in %2$s rows (%3$s), so applications missing from the export are archived, not deleted.',
+ [$archive['sheet'], $count, $rows]
+ );
+ }
+
+ if ($archive['keyColumnMissing'] === true) {
+ return $this->l10n->t(
+ 'Sheet "%1$s" has no column "%2$s", so applications missing from the export are archived, not deleted.',
+ [$archive['sheet'], $this->profile->keyColumn()]
+ );
+ }
+
+ return $this->l10n->t('Sheet "%s" was not found, so applications missing from the export are archived, not deleted.', [$archive['sheet']]);
+ }//end archiveSheetWarning()
+
+ /**
+ * Resolve OpenRegister's archive handler and object mapper, failing closed.
+ *
+ * @return array{handler: object, mapper: object} The handler, with `archive()` and `unarchive()`, and the
+ * mapper, with `restoreObject()` and `findDeletedAcrossAllMagicTables()`.
+ *
+ * @throws CmdbImportException ARCHIVE_UNAVAILABLE.
+ *
+ * @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
+ */
+ private function archiveServices(): array {
+ if ($this->archiveServices !== null) {
+ return $this->archiveServices;
+ }
+
+ $previous = null;
+ try {
+ $handler = $this->container->get(static::ARCHIVE_HANDLER_CLASS);
+ $mapper = $this->container->get(static::OBJECT_MAPPER_CLASS);
+ } catch (Throwable $e) {
+ $this->logger->warning('CmdbExportImportService: OpenRegister archive services could not be resolved', ['exception' => get_class($e)]);
+ $previous = $e;
+ $handler = null;
+ $mapper = null;
+ }
+
+ if (is_object($handler) === false || method_exists($handler, 'archive') === false || method_exists($handler, 'unarchive') === false
+ || is_object($mapper) === false || method_exists($mapper, 'restoreObject') === false
+ || method_exists($mapper, 'findDeletedAcrossAllMagicTables') === false
+ ) {
+ throw new CmdbImportException(
+ errorCode: CmdbImportException::ARCHIVE_UNAVAILABLE,
+ message: 'OpenRegister ArchiveHandler or MagicMapper is not available',
+ previous: $previous
+ );
+ }
+
+ $this->archiveServices = ['handler' => $handler, 'mapper' => $mapper];
+ return $this->archiveServices;
+ }//end archiveServices()
+
+ /**
+ * Whether an object as OpenRegister's mapper reads it by uuid is archived and whether it is in the trash.
+ *
+ * Read from its `@self` metadata. Only for an object the mapper read
+ * itself (the one restoreObject() answers): a list search result carries
+ * no archive marker (design D9).
+ *
+ * @param object $entity The entity (ObjectEntityInterface).
+ *
+ * @return array{archived: bool, deleted: bool}
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private static function objectState(object $entity): array {
+ $self = [];
+ if ($entity instanceof JsonSerializable) {
+ $serialised = $entity->jsonSerialize();
+ if (is_array($serialised) === true && is_array($serialised['@self'] ?? null) === true) {
+ $self = $serialised['@self'];
+ }
+ }
+
+ return [
+ 'archived' => (self::isEmptyValue(value: ($self['archived'] ?? null)) === false),
+ 'deleted' => (self::isEmptyValue(value: ($self['deleted'] ?? null)) === false),
+ ];
+ }//end objectState()
+
+ /**
+ * Bring a matched module or usage back before it is written: out of the trash, then out of the archive.
+ *
+ * OpenRegister refuses a data write to an archived object, and the
+ * archive handler cannot reach one in the trash, hence the order. The
+ * trash listing does not say whether an object was archived before it was
+ * deleted; the restored object, read by the mapper, does. The update then
+ * starts from that object: the trash listing decodes every string that
+ * parses as JSON, so its copy of the data must not be written back.
+ *
+ * @param array{entity: object, state: array{archived: bool, deleted: bool}} $found The matched entity and its state.
+ *
+ * @return array{outcome: string|null, entity: object} restored, unarchived, or null when it was neither, and the
+ * entity whose data the update starts from.
+ *
+ * @throws CmdbImportException ARCHIVE_UNAVAILABLE when the archive services are missing.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function revive(array $found): array {
+ $entity = $found['entity'];
+ $archived = $found['state']['archived'];
+ if ($found['state']['deleted'] === false && $archived === false) {
+ return ['outcome' => null, 'entity' => $entity];
+ }
+
+ $uuid = (string)$entity->getUuid();
+ $outcome = null;
+ if ($found['state']['deleted'] === true) {
+ $restored = $this->restore(uuid: $uuid);
+ $outcome = CmdbImportReport::RESTORED;
+ if (is_object($restored) === true && method_exists($restored, 'getObject') === true) {
+ $entity = $restored;
+ $archived = self::objectState(entity: $restored)['archived'];
+ }
+ }
+
+ if ($archived === true) {
+ $this->archiveServices()['handler']->unarchive(identifier: $uuid, reason: self::REASON_PREFIX . 'back in the export');
+ $outcome = ($outcome ?? CmdbImportReport::UNARCHIVED);
+ }
+
+ return ['outcome' => $outcome, 'entity' => $entity];
+ }//end revive()
+
+ /**
+ * Take one object out of the trash, and out of this run's copy of the trash.
+ *
+ * @param string $uuid The object.
+ *
+ * @return mixed What MagicMapper::restoreObject() answers: the object as it reads it.
+ *
+ * @throws CmdbImportException ARCHIVE_UNAVAILABLE when the archive services are missing.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function restore(string $uuid): mixed {
+ $restored = $this->archiveServices()['mapper']->restoreObject(uuid: $uuid);
+ if ($this->trash !== null) {
+ unset($this->trash['module'][$uuid], $this->trash['usage'][$uuid]);
+ }
+
+ return $restored;
+ }//end restore()
+
+ /**
+ * The register's soft-deleted modules and usages, read once per run.
+ *
+ * OpenRegister's list search cannot return a soft-deleted object, whatever
+ * `_includeDeleted` says, so the trash is read from the trash listing that
+ * the Deleted page uses (design D9). That listing scans every magic table
+ * of the instance, so it is read once, lazily, and only this register's
+ * modules and usages are kept. Its objects carry no archive or trash
+ * marker; everything in it is deleted. Without the archive services (a
+ * `keep` import on an OpenRegister without them) the trash cannot be read:
+ * nothing in it is matched, and the log says so. A listing that throws is
+ * not cached, so the next row reads it again.
+ *
+ * It is read for `keep` as well: matching is not tied to the mode (REQ-CMDB-016),
+ * and an application a person soft-deleted by hand must be restored, not
+ * created a second time. The listing cannot be narrowed, so the cost is one
+ * scan per run, paid on the first row no working or archived object matches.
+ *
+ * @return array{module: array, usage: array} Schema key => uuid => entity.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private function trash(): array {
+ if ($this->trash !== null) {
+ return $this->trash;
+ }
+
+ try {
+ $services = $this->archiveServices();
+ } catch (CmdbImportException $e) {
+ $this->logger->warning('CmdbExportImportService: OpenRegister trash is not readable; soft-deleted modules and usages are not matched');
+ $this->trash = ['module' => [], 'usage' => []];
+ return $this->trash;
+ }
+
+ // Cached only once the listing was read: a listing that fails fails its row, and the
+ // next row reads it again instead of matching against an empty trash and duplicating.
+ $coordinates = $this->coordinates();
+ $deleted = $services['mapper']->findDeletedAcrossAllMagicTables(limit: null, offset: null, _rbac: false, _multitenancy: false);
+ $this->trash = self::registerTrash(deleted: $deleted, coordinates: $coordinates);
+
+ return $this->trash;
+ }//end trash()
+
+ /**
+ * The modules and usages of this register among the instance's soft-deleted objects.
+ *
+ * @param mixed $deleted What MagicMapper::findDeletedAcrossAllMagicTables() answered.
+ * @param array $coordinates The register and schema ids, from coordinates().
+ *
+ * @return array{module: array, usage: array} Schema key => uuid => entity.
+ *
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-that-returns-to-the-export-shall-be-unarchived-or-restored-never-duplicated-req-cmdb-016
+ */
+ private static function registerTrash(mixed $deleted, array $coordinates): array {
+ $trash = ['module' => [], 'usage' => []];
+ if (is_array($deleted) === false) {
+ return $trash;
+ }
+
+ foreach ($deleted as $entity) {
+ if (is_object($entity) === false || (string)$entity->getRegister() !== (string)$coordinates['register'] || $entity->getUuid() === null) {
+ continue;
+ }
+
+ foreach (['module', 'usage'] as $schemaKey) {
+ if ((string)$entity->getSchema() === (string)$coordinates[$schemaKey]) {
+ $trash[$schemaKey][(string)$entity->getUuid()] = $entity;
+ }
+ }
+ }
+
+ return $trash;
+ }//end registerTrash()
+
+ /**
+ * Archive or soft-delete every application of the municipality whose APPID left the CMDB sheets.
+ *
+ * Runs after the last row of an import that was not cancelled, under the
+ * same register lock. The whole scope is loaded before anything is
+ * written, so the paging is not shifted by the writes. Every application
+ * has its own error boundary. A cancel is honoured before the scope is
+ * loaded and before each application: what was archived or deleted so far
+ * stays, and the report says the run was cancelled.
+ *
+ * @param string $municipalityUuid The consumer of this import.
+ * @param ArchiveSheet $archive The archive sheet.
+ * @param string $operationId The operation id, for the archive reason.
+ * @param int $processedItems The rows already counted as processed.
+ * @param CmdbImportReport $report The report.
+ *
+ * @return void
+ *
+ * @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
+ */
+ private function reconcile(string $municipalityUuid, array $archive, string $operationId, int $processedItems, CmdbImportReport $report): void {
+ // A cancel that came in during the last row stops the import before anything is archived or deleted.
+ if ($this->progressTracker->isCancelRequested(operationId: $operationId) === true) {
+ $report->markCancelled();
+ return;
+ }
+
+ $scope = $this->reconciliationScope(municipalityUuid: $municipalityUuid);
+ $this->progressTracker->setPhase(phase: 'reconciling', data: ['total_items' => ($processedItems + count($scope))]);
+
+ foreach ($scope as $index => $application) {
+ if ($this->progressTracker->isCancelRequested(operationId: $operationId) === true) {
+ $report->markCancelled();
+ return;
+ }
+
+ // Every APPID on a CMDB sheet was handled by its row, whatever the row's outcome.
+ if (isset($this->winningSheets[$application['key']]) === false) {
+ $this->reconcileApplication(application: $application, archive: $archive, operationId: $operationId, report: $report);
+ }
+
+ $this->progressTracker->updateProgress(processedItems: ($processedItems + $index + 1));
+ }
+ }//end reconcile()
+
+ /**
+ * The municipality's usages, in every state, each with its module when that module carries this municipality's import key.
+ *
+ * A usage of a hand-made module, or of a module another organisation
+ * imported, is left out: only what this municipality's import created is
+ * reconciled. Working and archived usages are paged with one lens each,
+ * soft-deleted ones come from the trash (design D9); each object keeps the
+ * state its lens or the trash gave it. When an import key occurs more than
+ * once, only the entry the import would match is kept (onePerKey()).
+ *
+ * @param string $municipalityUuid The consumer of this import.
+ *
+ * @return array}>
+ * Per application, the APPID's match key, the usage, the module and the state of both by schema key.
+ *
+ * @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
+ */
+ private function reconciliationScope(string $municipalityUuid): array {
+ $usages = $this->usagesInEveryState(municipalityUuid: $municipalityUuid);
+
+ $owned = [];
+ foreach ($usages as $usage) {
+ $data = $usage['entity']->getObject();
+ $moduleUuid = self::relationUuid(value: ($data['module'] ?? null));
+ // The filter is checked again: a filter OpenRegister cannot apply must not widen the scope.
+ if (self::relationUuid(value: ($data['consumer'] ?? null)) === $municipalityUuid && $moduleUuid !== null && $usage['entity']->getUuid() !== null) {
+ $owned[] = $usage + ['module' => $moduleUuid];
+ }
+ }
+
+ $prefix = $this->profile->externalKeyPrefix() . ':' . $municipalityUuid . ':';
+ $modules = $this->modulesByUuid(uuids: array_values(array_unique(array_column($owned, 'module'))));
+ $scope = [];
+ foreach ($owned as $usage) {
+ $module = ($modules[$usage['module']] ?? null);
+ if ($module === null) {
+ continue;
+ }
+
+ $externalKey = (string)($module['entity']->getObject()['externalKey'] ?? '');
+ if (str_starts_with($externalKey, $prefix) === true && strlen($externalKey) > strlen($prefix)) {
+ $scope[] = [
+ 'key' => substr($externalKey, strlen($prefix)),
+ 'usage' => $usage['entity'],
+ 'module' => $module['entity'],
+ 'states' => ['usage' => $usage['state'], 'module' => $module['state']],
+ ];
+ }
+ }
+
+ return self::onePerKey(scope: $scope);
+ }//end reconciliationScope()
+
+ /**
+ * One application per import key: the entry the import would match, with the other usages of the key as extras.
+ *
+ * The import takes a working module before an archived one, and an archived
+ * one before one in the trash (design D4), so a module deleted by hand next
+ * to the one a later import created is never revived. The reconciliation
+ * follows the same order: the entry with the best module state is the
+ * application, the best usage state breaks a tie, and the first one listed
+ * breaks the rest. The other entries of the key are not dropped: their
+ * usages, and their modules when those are not in the trash, are carried
+ * as `extras` and brought to the same state, so a stray usage of a stale
+ * duplicate does not stay visible. Nothing in the trash is restored for an
+ * extra, and nothing the application itself holds is listed twice.
+ *
+ * @param array $scope The entries.
+ *
+ * @return array
+ *
+ * @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
+ */
+ private static function onePerKey(array $scope): array {
+ $preferred = [];
+ $ranks = [];
+ $entries = [];
+ foreach ($scope as $application) {
+ $rank = ((self::stateRank(state: $application['states']['module']) * 3) + self::stateRank(state: $application['states']['usage']));
+ $key = $application['key'];
+ $entries[$key][] = $application;
+ if (isset($ranks[$key]) === false || $rank < $ranks[$key]) {
+ $ranks[$key] = $rank;
+ $preferred[$key] = $application;
+ }
+ }
+
+ foreach ($preferred as $key => $application) {
+ $preferred[$key]['extras'] = self::extrasOf(application: $application, entries: $entries[$key]);
+ }
+
+ return array_values($preferred);
+ }//end onePerKey()
+
+ /**
+ * The usages and modules of the other entries of a key that the reconciliation brings to the same state.
+ *
+ * A usage or module appears once, and not when the application holds it
+ * itself. One in the trash is left out: it is not visible, and restoring
+ * a stale duplicate is what the preference rule exists to prevent.
+ *
+ * @param ScopeEntry $application The preferred entry of the key.
+ * @param array $entries Every entry of the key.
+ *
+ * @return array Usages first, then modules.
+ *
+ * @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
+ */
+ private static function extrasOf(array $application, array $entries): array {
+ $seen = [(string)$application['usage']->getUuid() => true, (string)$application['module']->getUuid() => true];
+ $found = ['usage' => [], 'module' => []];
+ foreach ($entries as $entry) {
+ foreach (['usage', 'module'] as $schemaKey) {
+ $uuid = (string)$entry[$schemaKey]->getUuid();
+ if (isset($seen[$uuid]) === false && $entry['states'][$schemaKey]['deleted'] === false) {
+ $seen[$uuid] = true;
+ $found[$schemaKey][] = ['schemaKey' => $schemaKey, 'uuid' => $uuid, 'state' => $entry['states'][$schemaKey]];
+ }
+ }
+ }
+
+ return array_merge($found['usage'], $found['module']);
+ }//end extrasOf()
+
+ /**
+ * How far down the preference order a state is: working 0, archived 1, deleted 2.
+ *
+ * @param array{archived: bool, deleted: bool} $state The state.
+ *
+ * @return int
+ *
+ * @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
+ */
+ private static function stateRank(array $state): int {
+ if ($state['deleted'] === true) {
+ return 2;
+ }
+
+ if ($state['archived'] === true) {
+ return 1;
+ }
+
+ return 0;
+ }//end stateRank()
+
+ /**
+ * The usages that name this municipality as consumer, in every state, each with that state.
+ *
+ * Working and archived usages are paged with one lens each; soft-deleted
+ * ones come from the trash, which is not filtered by consumer, so the
+ * caller checks the consumer of every usage.
+ *
+ * @param string $municipalityUuid The consumer of this import.
+ *
+ * @return array
+ *
+ * @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
+ */
+ private function usagesInEveryState(string $municipalityUuid): array {
+ $coordinates = $this->coordinates();
+ $usages = [];
+ foreach ([[[], self::STATE_WORKING], [self::ARCHIVED_LENS, self::STATE_ARCHIVED]] as [$lens, $state]) {
+ $offset = 0;
+ do {
+ $page = $coordinates['objectService']->searchObjects(
+ query: array_merge(
+ [
+ '@self' => ['register' => $coordinates['register'], 'schema' => $coordinates['usage']],
+ 'consumer' => $municipalityUuid,
+ '_limit' => self::PAGE_SIZE,
+ '_offset' => $offset,
+ ],
+ $lens
+ ),
+ _rbac: false,
+ _multitenancy: false
+ );
+ if (is_array($page) === false) {
+ $page = [];
+ }
+
+ foreach ($page as $usage) {
+ $usages[] = ['entity' => $usage, 'state' => $state];
+ }
+
+ $offset += self::PAGE_SIZE;
+ $pageSize = count($page);
+ } while ($pageSize === self::PAGE_SIZE);
+ }//end foreach
+
+ foreach ($this->trash()['usage'] as $usage) {
+ $usages[] = ['entity' => $usage, 'state' => self::STATE_DELETED];
+ }
+
+ return $usages;
+ }//end usagesInEveryState()
+
+ /**
+ * The modules with these uuids, in every state, each with its state.
+ *
+ * Per chunk of PAGE_SIZE uuids one search without a lens and one with
+ * the archive lens; the rest is looked up in the trash.
+ *
+ * @param array $uuids The module uuids.
+ *
+ * @return array Uuid => entity and state.
+ *
+ * @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
+ */
+ private function modulesByUuid(array $uuids): array {
+ $coordinates = $this->coordinates();
+ $modules = [];
+ foreach (array_chunk($uuids, self::PAGE_SIZE) as $chunk) {
+ // The chunks are disjoint, so a uuid found in an earlier chunk is never overwritten.
+ $modules += $this->modulesInChunk(chunk: $chunk, coordinates: $coordinates);
+ }
+
+ foreach ($uuids as $uuid) {
+ $trashed = ($this->trash()['module'][$uuid] ?? null);
+ if (isset($modules[$uuid]) === false && $trashed !== null) {
+ $modules[$uuid] = ['entity' => $trashed, 'state' => self::STATE_DELETED];
+ }
+ }
+
+ return $modules;
+ }//end modulesByUuid()
+
+ /**
+ * The working and archived modules of one chunk of uuids; a module both lenses return keeps the first state.
+ *
+ * @param array $chunk At most PAGE_SIZE module uuids.
+ * @param array $coordinates The object service and register and schema ids, from coordinates().
+ *
+ * @return array Uuid => entity and state.
+ *
+ * @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
+ */
+ private function modulesInChunk(array $chunk, array $coordinates): array {
+ $modules = [];
+ foreach ([[[], self::STATE_WORKING], [self::ARCHIVED_LENS, self::STATE_ARCHIVED]] as [$lens, $state]) {
+ $results = $coordinates['objectService']->searchObjects(
+ query: array_merge(
+ ['@self' => ['register' => $coordinates['register'], 'schema' => $coordinates['module']], '_ids' => $chunk, '_limit' => count($chunk)],
+ $lens
+ ),
+ _rbac: false,
+ _multitenancy: false
+ );
+ if (is_array($results) === false) {
+ continue;
+ }
+
+ foreach ($results as $entity) {
+ $uuid = (string)$entity->getUuid();
+ if (in_array($uuid, $chunk, true) === true && isset($modules[$uuid]) === false) {
+ $modules[$uuid] = ['entity' => $entity, 'state' => $state];
+ }
+ }
+ }
+
+ return $modules;
+ }//end modulesInChunk()
+
+ /**
+ * Archive or soft-delete one application that left the CMDB sheets, usage first, in its own error boundary.
+ *
+ * The writes go in this order: the application's own usage, the other
+ * usages of its key, its module, the other modules. A failure stops the
+ * application there and reports one `failed` row (the step and the
+ * exception class, no list of what was already written). What it leaves:
+ * a failing own usage leaves everything as it was; a failing extra usage
+ * leaves the own usage in the new state, that extra and the module as
+ * they were (so the extra stays visible and the module stays working);
+ * a failing module leaves every usage in the new state and the module
+ * as it was. The next import reconciles the application again.
+ * An application already in the state the export asks for is not reported.
+ *
+ * @param ScopeEntry $application The application.
+ * @param ArchiveSheet $archive The archive sheet.
+ * @param string $operationId The operation id, for the archive reason.
+ * @param CmdbImportReport $report The report.
+ *
+ * @return void
+ *
+ * @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
+ */
+ private function reconcileApplication(array $application, array $archive, string $operationId, CmdbImportReport $report): void {
+ $moduleData = $application['module']->getObject();
+ $onArchiveSheet = ($archive['present'] === true && isset($archive['keys'][$application['key']]) === true);
+ $entry = [
+ 'sheet' => '',
+ 'row' => 0,
+ 'appId' => (string)($moduleData['externalNumber'] ?? $application['key']),
+ 'name' => (string)($moduleData['name'] ?? ''),
+ ];
+
+ $target = CmdbImportReport::ARCHIVED;
+ $reason = self::REASON_PREFIX . 'not in the export (' . $operationId . ')';
+ if ($onArchiveSheet === true) {
+ $entry['sheet'] = $archive['sheet'];
+ $reason = self::REASON_PREFIX . 'on sheet "' . $archive['sheet'] . '" (' . $operationId . ')';
+ } elseif ($archive['present'] === true && $archive['uncachedRows'] === [] && $this->unreadableKeyRows === []) {
+ // A sheet with an unreadable key (the archive sheet or a CMDB sheet) may list this application, so the
+ // workbook is not authoritative for a deletion.
+ $target = CmdbImportReport::DELETED;
+ }
+
+ $uuids = ['module' => (string)$application['module']->getUuid(), 'usage' => (string)$application['usage']->getUuid()];
+ $step = 'archive';
+ $changed = false;
+ try {
+ foreach (self::itemsOf(application: $application, restoreDeleted: $onArchiveSheet) as $item) {
+ foreach (self::transitions(state: $item['state'], target: $target, restoreDeleted: $item['restore']) as $step) {
+ $this->transition(step: $step, schemaKey: $item['schemaKey'], uuid: $item['uuid'], reason: $reason);
+ $changed = true;
+ }
+ }
+ } catch (Throwable $e) {
+ // The module's stored values are taken out of the log line, as a row's cells are.
+ $values = array_map('strval', array_filter($moduleData, static fn (mixed $value): bool => is_scalar($value)));
+ $this->failRow(report: $report, entry: $entry, step: $step, e: $e, warnings: [], uuids: [$uuids['module'], $uuids['usage']], values: $values);
+ return;
+ }
+
+ if ($changed === true) {
+ $this->addRow(report: $report, entry: $entry, outcome: $target, moduleUuid: $uuids['module'], usageUuid: $uuids['usage']);
+ }
+ }//end reconcileApplication()
+
+ /**
+ * What one application brings to the state the export asks for: its usage and the other usages of its key, then its module and the other modules.
+ *
+ * Own usage, extra usages, own module, extra modules. Usages come before
+ * modules, so a failure leaves usages in the new state and a module at
+ * worst unchanged; it does not leave a module changed under a usage that
+ * still shows. An extra usage that fails does hold back the module.
+ *
+ * Only the application's own objects are restored from the trash, and only
+ * when `restoreDeleted` says so; the extras never are.
+ *
+ * @param ScopeEntry $application The application.
+ * @param bool $restoreDeleted Whether the application's own objects in the trash are restored to be archived.
+ *
+ * @return array
+ *
+ * @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
+ */
+ private static function itemsOf(array $application, bool $restoreDeleted): array {
+ $items = [];
+ foreach (['usage', 'module'] as $schemaKey) {
+ // Every usage is written before any module, whoever holds it.
+ $items[] = [
+ 'schemaKey' => $schemaKey,
+ 'uuid' => (string)$application[$schemaKey]->getUuid(),
+ 'state' => $application['states'][$schemaKey],
+ 'restore' => $restoreDeleted,
+ ];
+ foreach (($application['extras'] ?? []) as $extra) {
+ if ($extra['schemaKey'] === $schemaKey) {
+ $items[] = $extra + ['restore' => false];
+ }
+ }
+ }
+
+ return $items;
+ }//end itemsOf()
+
+ /**
+ * The steps that bring one module or usage to the state the export asks for (design D5).
+ *
+ * Deleted: soft-delete what is not in the trash yet. Archived: archive
+ * what is working; an object in the trash is restored first only when the
+ * archive sheet lists it, so a workbook without the sheet never takes
+ * anything out of the trash.
+ *
+ * @param array{archived: bool, deleted: bool} $state The object's state.
+ * @param string $target archived or deleted.
+ * @param bool $restoreDeleted Whether an object in the trash is restored to be archived.
+ *
+ * @return array delete, restore and archive, in order; empty when nothing changes.
+ *
+ * @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
+ */
+ private static function transitions(array $state, string $target, bool $restoreDeleted): array {
+ if ($target === CmdbImportReport::DELETED) {
+ if ($state['deleted'] === true) {
+ return [];
+ }
+
+ return ['delete'];
+ }
+
+ if ($state['deleted'] === true) {
+ if ($restoreDeleted === true) {
+ return ['restore', 'archive'];
+ }
+
+ return [];
+ }
+
+ if ($state['archived'] === true) {
+ return [];
+ }
+
+ return ['archive'];
+ }//end transitions()
+
+ /**
+ * Run one step through OpenRegister: soft delete, restore from the trash, or archive.
+ *
+ * @param string $step delete, restore or archive.
+ * @param string $schemaKey The coordinates key of the schema.
+ * @param string $uuid The object.
+ * @param string $reason The archive reason.
+ *
+ * @return void
+ *
+ * @throws RuntimeException When OpenRegister does not delete the object.
+ *
+ * @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
+ */
+ private function transition(string $step, string $schemaKey, string $uuid, string $reason): void {
+ if ($step === 'delete') {
+ $coordinates = $this->coordinates();
+ $deleted = $coordinates['objectService']->deleteObject(
+ uuid: $uuid,
+ register: $coordinates['register'],
+ schema: $coordinates[$schemaKey],
+ _rbac: false,
+ _multitenancy: false
+ );
+ if ($deleted !== true) {
+ throw new RuntimeException('OpenRegister did not delete the object');
+ }
+
+ return;
+ }
+
+ if ($step === 'restore') {
+ $this->restore(uuid: $uuid);
+ return;
+ }
+
+ $this->archiveServices()['handler']->archive(identifier: $uuid, reason: $reason);
+ }//end transition()
+
/**
* Resolve OpenRegister's mapping engine.
*
@@ -1915,6 +2979,8 @@ private function resetRun(): void {
$this->winningSheets = [];
$this->contactUids = [];
$this->contactPersons = [];
+ $this->archiveServices = null;
+ $this->trash = null;
}//end resetRun()
/**
diff --git a/lib/Service/ProgressTracker.php b/lib/Service/ProgressTracker.php
index b0c9ce229..5a9706ada 100644
--- a/lib/Service/ProgressTracker.php
+++ b/lib/Service/ProgressTracker.php
@@ -62,6 +62,11 @@ class ProgressTracker {
'processing_relationships' => ['weight' => 15, 'description' => 'Processing relationships'],
'processing_organizations' => ['weight' => 10, 'description' => 'Processing organizations'],
'processing_views' => ['weight' => 10, 'description' => 'Processing views'],
+ // The CMDB import's reconciliation of records missing from the source
+ // (openspec/changes/cmdb-import-archive-reconciliation). Weight 0, so the
+ // weighted percentage of the ArchiMate import's phases is unchanged; the
+ // CMDB section computes its own percentage from the item counts.
+ 'reconciling' => ['weight' => 0, 'description' => 'Reconciling records missing from the source'],
'finalizing' => ['weight' => 5, 'description' => 'Finalizing import'],
'completed' => ['weight' => 0, 'description' => 'Completed'],
];
diff --git a/openapi.json b/openapi.json
index 7ee628b87..f456fc209 100644
--- a/openapi.json
+++ b/openapi.json
@@ -62,10 +62,11 @@
"missingRecords": {
"type": "string",
"enum": [
- "keep"
+ "keep",
+ "archive"
],
- "default": "keep",
- "description": "Only keep is accepted; mark and remove are reserved"
+ "default": "archive",
+ "description": "What happens to the municipality's applications whose APPID is no longer on either CMDB sheet. archive: after the last row, archive module and usage when the sheet \"Gearchiveerde Applicaties\" lists the APPID, soft-delete them when no sheet does, and archive only when the workbook has no archive sheet, the sheet has no APPID column, an APPID in it has no cached value, or an APPID on a CMDB sheet has no cached value (that application may be the one the reader could not see); an archived or deleted application that returns is unarchived or restored. keep: leave them as they are. mark and remove are reserved and refused with 422 MISSING_RECORDS_UNSUPPORTED"
},
"operationId": {
"type": "string",
@@ -128,7 +129,7 @@
}
},
"422": {
- "description": "MISSING_RECORDS_UNSUPPORTED, MUNICIPALITY_REQUIRED, MUNICIPALITY_INVALID, MUNICIPALITY_AMBIGUOUS (more than one live Municipality has the typed name; details.matches lists their uuids), NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS",
+ "description": "MISSING_RECORDS_UNSUPPORTED (details.accepted lists keep and archive), MUNICIPALITY_REQUIRED, MUNICIPALITY_INVALID, MUNICIPALITY_AMBIGUOUS (more than one live Municipality has the typed name; details.matches lists their uuids), NO_SOURCE_SHEET, MISSING_COLUMN or TOO_MANY_ROWS",
"content": {
"application/json": {
"schema": {
@@ -148,7 +149,7 @@
}
},
"503": {
- "description": "MAPPING_UNAVAILABLE, READER_UNAVAILABLE, NOT_CONFIGURED, or SCHEMA_OUTDATED (a stackiq schema lacks a property the import matches on, or the module schema predates 0.3.8; details.schema names it and details.missing lists the properties, and externalKey.authorization.update for a missing admin-only write rule)",
+ "description": "MAPPING_UNAVAILABLE, READER_UNAVAILABLE, NOT_CONFIGURED, or SCHEMA_OUTDATED (a stackiq schema lacks a property the import matches on, or the module schema predates 0.3.8; details.schema names it and details.missing lists the properties, and externalKey.authorization.update for a missing admin-only write rule), or ARCHIVE_UNAVAILABLE (missingRecords=archive, but OpenRegister's archive handler or object mapper is missing; nothing is read or written)",
"content": {
"application/json": {
"schema": {
@@ -499,6 +500,10 @@
"unchanged",
"skipped",
"failed",
+ "archived",
+ "unarchived",
+ "deleted",
+ "restored",
"warnings",
"unpublished"
],
@@ -524,6 +529,22 @@
"failed": {
"type": "integer"
},
+ "archived": {
+ "type": "integer",
+ "description": "Applications whose module and usage this import archived because they left the CMDB sheets"
+ },
+ "unarchived": {
+ "type": "integer",
+ "description": "Rows whose archived module or usage this import unarchived before updating it"
+ },
+ "deleted": {
+ "type": "integer",
+ "description": "Applications whose module and usage this import soft-deleted because no sheet lists them any more"
+ },
+ "restored": {
+ "type": "integer",
+ "description": "Rows whose soft-deleted module or usage this import restored from the trash before updating it"
+ },
"warnings": {
"type": "integer"
},
@@ -571,7 +592,8 @@
"type": "string"
},
"row": {
- "type": "integer"
+ "type": "integer",
+ "description": "The sheet row, or 0 for an application the reconciliation archived or deleted"
},
"appId": {
"type": "string"
@@ -586,7 +608,11 @@
"updated",
"unchanged",
"skipped",
- "failed"
+ "failed",
+ "archived",
+ "unarchived",
+ "deleted",
+ "restored"
]
},
"reasons": {
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/design.md b/openspec/changes/cmdb-import-archive-reconciliation/design.md
index 53fc943f8..5329cc153 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/design.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/design.md
@@ -71,7 +71,7 @@ With `updateExisting=false`, a matched row is skipped as `exists` before anythin
The profile names the sheet (`archiveSheet.name`: "Gearchiveerde Applicaties"). The reader lists the workbook's sheets, and when the archive sheet is present it is loaded with the CMDB sheets under the same bounds: it counts as a read sheet for `CmdbPartReferences` (an oversized archive sheet is refused, not blanked) and for the referenced-string budget, its row span is bounded like a source sheet, and `TOO_MANY_ROWS` names it. Only its APPID column is resolved and read; the allowlist does not grow. The reader returns `archive: {present, keyColumnMissing, appIds, uncachedRows}` next to `rows`.
- Sheet absent, or present without an APPID column: `present` false (with `keyColumnMissing`), and the service adds a report warning. The reconciliation then archives every missing application and soft-deletes nothing: a workbook saved without the sheet must not empty the trash-bound set by accident.
-- A row whose APPID is a formula without a cached value reads as empty, as on the CMDB sheets (REQ-CMDB-002), so the sheet would silently list one application fewer. The reader therefore lists such rows (their numbers) in `uncachedRows`, and the service treats the sheet like an absent one for the decision between archive and delete: it adds a warning naming the sheet and the rows, archives every missing application and soft-deletes nothing. A tool that saves formulas without cached values must not turn archived applications into trash.
+- A row whose APPID is a formula without a cached value reads as empty, as on the CMDB sheets (REQ-CMDB-002), so the sheet would silently list one application fewer. The reader therefore lists such rows (their numbers) in `uncachedRows`, and the service treats the sheet like an absent one for the decision between archive and delete: it adds a warning naming the sheet and the rows, archives every missing application and soft-deletes nothing. A tool that saves formulas without cached values must not turn archived applications into trash. The same holds for a CMDB sheet: a row whose APPID has no cached value is skipped, so that application may be on a CMDB sheet and still look absent. The service notes such rows (`unreadableKeyRows`, per sheet) and, with any of them, archives instead of soft-deleting and warns naming the CMDB sheet and the rows, as for the archive sheet.
- The archive sheet's other columns are not read; `Applicatie Status` there is always `Verwijderd` in the municipality's export and adds nothing.
### D4. Matching widens to archived and deleted objects, and revives what it matches
@@ -94,10 +94,12 @@ Per application, with `processed` = every APPID match key on the CMDB sheets (wh
| on the archive sheet | deleted | restore, then archive | `archived` |
| nowhere, sheet present | working or archived | soft-delete usage, then module | `deleted` |
| nowhere, sheet present | deleted | nothing | — |
-| nowhere, sheet absent or incomplete (no key column, or `uncachedRows` not empty) | working | archive usage, then module | `archived` |
+| nowhere, sheet absent or incomplete (no key column, `uncachedRows` not empty, or a CMDB-sheet row with an unreadable APPID) | working | archive usage, then module | `archived` |
| nowhere, sheet absent or incomplete | archived or deleted | nothing | — |
-The usage is written before the module, so a failure between the two leaves the municipality's usage in the new state and the module visible, never the reverse. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why.
+The table decides on one entry per key. When a municipality has several usages and modules under the same import key (D4 says earlier imports created exactly such duplicates, a module deleted by hand next to the one a later import created), the scope keeps the entry the import would match: the best module state wins (working, then archived, then deleted), the best usage state breaks a tie, the first listed breaks the rest. The other usages of the key follow the application, so a stray usage stays visible nowhere: a working or archived usage of a stale duplicate, a second usage of the same module, and a working or archived duplicate module are archived or soft-deleted as the key asks, once, in the same error boundary and the same report row (usages first, then modules). What is in the trash is left as it is: a stale trashed duplicate is not restored into the archive when the key is on the archive sheet, and is not touched when it is on no sheet.
+
+The writes go in this order: the application's own usage, the other usages of its key, its module, the other modules. A failure stops that application and gives one `failed` report row (step and exception class only). A failing own usage leaves everything as it was; a failing extra usage leaves the own usage in the new state and that extra and the module as they were (the extra stays visible, the module stays working); a failing module leaves every usage in the new state and the module as it was. Never is a module changed while a usage of the key still shows. The next import reconciles the application again. A module another municipality still uses is archived or deleted all the same when this municipality's usage says so: the module carries this municipality's import key, so it is this municipality's record (REQ-CMDB-006 allows a shared module only when the other organisation's usage was added by hand). The archive reason is `cmdb-import: on sheet "Gearchiveerde Applicaties"` or `cmdb-import: not in the export`, plus the operation id, so the audit trail says why.
Each application runs in its own boundary: a failure is a `failed` row naming the step (`archive`, `restore`, `delete`) and the exception class, as row failures do, and the other applications continue. Contact persons are never touched.
@@ -194,8 +196,8 @@ No seed objects change. The fragment's three seed modules stay as they are; the
## Risks / Trade-offs
- [The default archives on the first re-import after the upgrade] → The section shows the choice with help text; the report lists every archived and deleted application; nothing is hard-deleted.
-- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value] → archive only, never delete, plus a warning (D3).
-- [A failure between the usage and the module] → the usage is written first, so the municipality's view is right and the module is at worst still visible (D5).
+- [A workbook saved without the archive sheet, or whose archive sheet has no APPID column or an APPID without a cached value, or a CMDB sheet with an APPID without a cached value] → archive only, never delete, plus a warning (D3).
+- [A failure part way through an application] → usages are written before modules, so a module is never changed under a usage that still shows; but a failing extra usage leaves the own usage changed, that extra visible and the module unchanged, and the row only says `failed`. The next import retries (D5).
- [Reviving before writing adds calls per returning row] → only for rows whose match is archived or deleted; a row whose module is working costs the same searches as before, a new row one more search (the archive lens).
- [The trash listing scans every magic table of the instance] → read once per import, not per row (D9).
- [OpenRegister internals change shape] → guarded resolution, 503 before the file is read (D7).
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
index dd4017f30..d9685e710 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/proposal.md
@@ -34,7 +34,7 @@ None.
### In Scope
- The sheet "Gearchiveerde Applicaties" (columns Applicatiecomponent, APPID, Applicatie Code, Applicatie Naam, Roepnaam, Applicatie Status, Bron, Datum Interface, Datum wijziging) is read for its APPID column only, when it is present.
-- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value): archived only, with a warning in the report.
+- After every row of a completed import: the municipality's usages, with their modules, are compared with the APPIDs on the CMDB sheets and on the archive sheet. Missing and on the archive sheet: module and usage archived. Missing everywhere: module and usage soft-deleted. Without the archive sheet, or when it has no APPID column or an APPID the reader cannot see (a formula without a cached value, on the archive sheet or on a CMDB sheet): archived only, with a warning in the report.
- A returning APPID: an archived module or usage is unarchived, a soft-deleted one is restored from the trash, and the row is then updated as before.
- `missingRecords`: `keep` leaves everything as it is (the behaviour so far), `archive` reconciles and is the default; `remove` stays reserved and is refused.
- The settings section offers the choice with help text; the report shows the new outcomes in its summary and rows.
@@ -78,7 +78,7 @@ None. `ArchiveHandler` and `MagicMapper` come from OpenRegister, which stackiq a
**Severity:** Medium — **Mitigation:** only the municipality's own usages and the modules they point at are in scope, never a supplier's or another municipality's module; nothing is hard-deleted; the choice is visible in the section with help text, and the report lists every archived and deleted application. A cancelled or failed import never reconciles.
### Risk 2: A workbook without a usable archive sheet soft-deletes everything that moved there
-**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see (a formula without a cached value), missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows).
+**Severity:** Medium — **Mitigation:** without the sheet, without its APPID column, or with an APPID the reader cannot see on it or on a CMDB sheet (a formula without a cached value), missing applications are archived, never soft-deleted, and the report warns, naming the sheet (and the rows).
### Risk 3: The archive services are OpenRegister internals
**Severity:** Low — **Mitigation:** resolved through the container behind a guard; when missing, an import with `missingRecords=archive` is refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read, and `keep` still works.
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
index 78903af22..d6e445624 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ OpenRegister: `OCA\OpenRegister\Contract\ObjectServiceInterface` (`searchObjects
### 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)
-With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
+With `missingRecords` `archive`, after every row of an import that was not cancelled and did not fail, the import SHALL reconcile, under the register lock it already holds, every `usage` whose `consumer` is the imported municipality together with the `module` it points at, including archived and soft-deleted ones, and only those whose module carries this municipality's import key (`topdesk::`). It SHALL read the APPID column of the sheet "Gearchiveerde Applicaties" when the workbook has it, under the same size and row bounds as the CMDB sheets and without reading any other column of it. For an application whose APPID is on neither CMDB sheet: when the archive sheet lists the APPID, the import SHALL archive the usage and then the module through OpenRegister's archive state with a reason that names the import, restoring a soft-deleted one from the trash first; when no sheet lists it, the import SHALL soft-delete the usage and then the module through OpenRegister's trash. When the workbook has no archive sheet, or the sheet has no APPID column, or a row of the sheet holds an APPID formula without a cached value (read as empty, as REQ-CMDB-002 has it for the CMDB sheets, and listed by the reader in `archive.uncachedRows`), the import SHALL archive and SHALL NOT soft-delete, and SHALL add a warning to the report; for an uncached row the warning SHALL name the sheet and the row numbers. The same holds when a row of a CMDB sheet holds an APPID formula without a cached value (the row is skipped as empty, so its application may look absent): the import SHALL archive and SHALL NOT soft-delete, and the warning SHALL name that CMDB sheet and the rows. When a municipality has more than one module or usage under the same import key (a module deleted by hand next to the one a later import created), the import SHALL reconcile as the application the one it would match, a working one before an archived one and an archived one before one in the trash, and SHALL bring every other usage of that key, and every other module of it that is not in the trash, to the same state, so no stray usage stays visible; it SHALL leave what is in the trash as it is, so a stale duplicate is never restored into the archive. An application that is already in the state the export asks for SHALL be left alone and not reported. The import SHALL NOT touch contact persons, organisations, modules that carry another municipality's import key or no import key, or usages of another organisation. Each application SHALL be reconciled in its own error boundary: a failure SHALL be a `failed` report row naming the step and the exception class, and the other applications SHALL still be reconciled. The reconciliation SHALL report its progress as the phase `reconciling` of the import's operation. A cancel SHALL also be honoured after the last row and before each application of the reconciliation: the import SHALL stop there, SHALL keep what it archived or deleted so far, and SHALL report the run as cancelled. The report SHALL count `archived` and `deleted` in its summary and SHALL list each as a row with the APPID, the application name and both uuids, never person data. When OpenRegister's archive handler or object mapper is not available, an import with `missingRecords` `archive` SHALL be refused with 503 `ARCHIVE_UNAVAILABLE` before the file is read.
#### Scenario: An application that moved to the archive sheet is archived
@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -36,6 +36,15 @@ With `missingRecords` `archive`, after every row of an import that was not cance
- **THEN** the module and the usage of `7` SHALL be archived and SHALL NOT be deleted
- **AND** the report SHALL carry a warning that the sheet "Gearchiveerde Applicaties" was not found
+#### Scenario: A stale duplicate under the same import key is left in the trash
+@e2e exclude Data condition from earlier imports; tests/Unit/Service/CmdbExportImportServiceTest.php testAStaleDuplicateIsNeverRestoredByTheReconciliation stores a working and a trashed module and usage for APPID `7`, lists `7` on the archive sheet, and asserts the working pair is archived, no `restore` happens and the trashed pair stays deleted; testEveryOtherUsageOfAKeyFollowsTheApplication asserts the stray and second usages follow.
+
+- **GIVEN** a working module with APPID `7` and its usage, and a module with the same import key in the trash with its own usage
+- **WHEN** an export without `7` on the CMDB sheets, with `7` on "Gearchiveerde Applicaties", is imported with `archive`
+- **THEN** the working module and usage SHALL be archived
+- **AND** the module and the usage in the trash SHALL stay there, and nothing SHALL be restored
+- **AND** a working usage that points at the module in the trash, or a second usage of the working module, SHALL be archived as well
+
#### Scenario: An archive row whose APPID has no cached value never turns an archive into a deletion
@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testAnUncachedArchiveRowArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the sheet and the row; tests/Unit/Service/Cmdb/CmdbWorkbookReaderTest.php testAnUncachedArchiveKeyIsListedNotDropped asserts the reader lists the row.
@@ -44,6 +53,14 @@ With `missingRecords` `archive`, after every row of an import that was not cance
- **THEN** the module and the usage of `7` SHALL be archived and SHALL NOT be deleted
- **AND** the report SHALL carry a warning naming the sheet "Gearchiveerde Applicaties" and the row
+#### Scenario: A CMDB row whose APPID has no cached value never turns an archive into a deletion
+@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testAnUncachedCmdbRowArchivesInsteadOfDeleting asserts the archive state, no delete, and the warning naming the CMDB sheet and the row.
+
+- **GIVEN** the module with APPID `8` and its usage were imported, `8` is on no readable row, and a row of a CMDB sheet holds its APPID in a formula that carries no cached value
+- **WHEN** the workbook is imported with `archive` and a readable archive sheet that does not list `8`
+- **THEN** the module and the usage of `8` SHALL be archived and SHALL NOT be deleted
+- **AND** the report SHALL carry a warning naming the CMDB sheet and the row
+
#### Scenario: Keeping missing records, a cancelled import and another municipality leave everything as it is
@e2e exclude Covered by the service test; tests/Unit/Service/CmdbExportImportServiceTest.php testRecordsMissingFromTheExportStayWhenKept, testACancelledImportDoesNotReconcile and testAnotherMunicipalitysUsagesAreNeverReconciled assert no archive, no delete and no reconciliation row.
@@ -156,7 +173,7 @@ The import SHALL accept `missingRecords` with the values `keep` and `archive`; `
### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014)
-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, choose what happens to applications missing from the export (archive, the default, or keep) with help text that says what each does, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including the four new ones; a reconciliation row SHALL show no row number. Every control SHALL have a visible label, and every string SHALL be translatable.
+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, choose what happens to applications missing from the export (archive, the default, or keep) with a one-sentence help text that says what each does and the details in the section's information panel, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including the four new ones; a reconciliation row SHALL show no row number. Every control SHALL have a visible label, and every string SHALL be translatable.
(Previously: the section had no choice for missing records; the request always sent `keep`.)
@@ -170,7 +187,7 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th
- **AND** filtering the table on `created` SHALL show the two imported rows
#### Scenario: The admin chooses to keep missing applications
-@e2e exclude Covered by the Jest test; src/utils/cmdbImport.spec.js asserts buildImportForm() sends `archive` by default and `keep` when chosen, and src/views/settings/sections/CmdbImport.spec.js asserts the section's default.
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
- **GIVEN** the section
- **WHEN** the admin selects "Keep them as they are" and starts the import
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
index 17bee62a1..76d347861 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md
@@ -4,7 +4,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp
## Implementation Tasks
-Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemented here. Tasks 3-7 are unticked because their code lands in the follow-up PRs (reconciliation in #1286, UI in #1287).
+Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented in #1286 (the reader's tasks 1-2 come from the base PR). Task 6 (the UI) is implemented in #1287; task 7 has its fixture, docs and e2e spec written there, and the Playwright run is the one thing not done yet.
### Task 1: Schema opt-in and the import profile
- **spec_ref**: `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`, `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012`
@@ -37,22 +37,23 @@ Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemente
- GIVEN OpenRegister's search results (no archive marker; no soft-deleted row, `_includeDeleted` or not) THEN the state comes from the lens that found the object and from the trash listing, read once per import (design D9)
- GIVEN `updateExisting` false THEN the row is skipped as `exists` and nothing is revived
- GIVEN a deleted and a working module (and usage) sharing one import key THEN the working one is updated and nothing is restored
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 4: Reconciliation after the rows
- **spec_ref**: `SPEC#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`
- **files**: `lib/Service/CmdbExportImportService.php`, `lib/Service/ProgressTracker.php`, `lib/Exception/CmdbImportException.php`, `tests/Unit/Service/CmdbExportImportServiceTest.php`
- **acceptance_criteria**:
- GIVEN APPIDs 1 and 7 imported WHEN an export with row 1 and 7 on the archive sheet is imported THEN module and usage of 7 are archived with a reason, the report row is `archived`, and the owner contact person is untouched
- - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value THEN they are archived instead and the report warns
+ - GIVEN APPID 7 on no sheet THEN module and usage are soft-deleted and the row is `deleted`; GIVEN the archive sheet absent, without an APPID column, or with an APPID that has no cached value, or a CMDB-sheet row whose APPID has no cached value THEN they are archived instead and the report warns
- GIVEN APPID 7 soft-deleted and listed on the archive sheet THEN module and usage are restored and archived; GIVEN it already archived or, when on no sheet, already deleted THEN nothing is written
+ - GIVEN a working pair and a trashed pair under the same import key and the key on the archive sheet THEN the working pair is archived, nothing is restored and the trashed pair stays deleted; GIVEN a further working usage of the key (of the trashed module, or a second one of the working module) THEN it is archived or deleted with the application
- GIVEN `missingRecords` `keep`, a cancelled import, or a usage of another municipality THEN nothing is archived or deleted
- GIVEN a cancel during the last row THEN nothing is archived or deleted; GIVEN a cancel during the reconciliation THEN it stops before the next application and the report is cancelled
- GIVEN the archive of one application throws THEN that application is a `failed` row and the others are reconciled; GIVEN no ArchiveHandler THEN 503 `ARCHIVE_UNAVAILABLE` before reading
- GIVEN the step runs THEN the operation is in phase `reconciling` and its progress advances per application
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 5: Controller option
- **spec_ref**: `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012`
@@ -60,8 +61,8 @@ Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemente
- **acceptance_criteria**:
- GIVEN no `missingRecords` THEN the service receives `archive`; GIVEN `keep` or `archive` THEN it is passed through; GIVEN `remove` or `mark` THEN 422 `MISSING_RECORDS_UNSUPPORTED` with `details.accepted` `["keep", "archive"]` and no import
- GIVEN `ARCHIVE_UNAVAILABLE` from the service THEN 503 with its own translated message
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 6: Settings section, report words and the archive quick filter
- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014`, `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`
@@ -70,9 +71,10 @@ Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemente
- GIVEN the section THEN a labelled choice "Applications missing from the export" offers Archive (default) and Keep with help text, and `buildImportForm()` sends the choice as `missingRecords`
- GIVEN a report with the new outcomes THEN the summary shows their tiles, the outcome filter offers them, and a reconciliation row shows no row number
- GIVEN the Applications and Applications in use pages THEN a quick filter "Archived" sends `_archived=true`
+ - GIVEN the Applications page with the "Archived" tab active THEN a note under the toolbar says the tab ignores the GEMMA filters and the search term, and it is gone on any other tab
- Every new string is in `l10n/en.json` and `l10n/nl.json`, and the generated `l10n/*.js` are current
-- [ ] Implement
-- [ ] Test
+- [x] Implement
+- [x] Test
### Task 7: Fixture, docs and e2e
- **spec_ref**: `SPEC#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`
@@ -81,7 +83,7 @@ Status in this PR (the archive-sheet reader slice): Tasks 1 and 2 are implemente
- GIVEN `build-fixtures.py` THEN it derives `topdesk-archived-applications.xlsx`: APPID 1234 on the archive sheet only, APPID 2 on no sheet, both CMDB sheets present and empty; the hygiene test passes on it
- GIVEN the e2e THEN after the first import, importing the variant archives 1234 and soft-deletes 2, the Applications page lists 1234 under "Archived", and importing the original again reports 1234 `unarchived` and 2 `restored` with the object counts back to the first import's
- GIVEN the docs THEN "Repeat imports" describes archive, soft delete, the archive sheet, the option and the return of an application, and the error table has `ARCHIVE_UNAVAILABLE` and the new meaning of `MISSING_RECORDS_UNSUPPORTED`
-- [ ] Implement
+- [x] Implement
- [ ] Test
- Status: PHPUnit covers the fixture round trip (testTheArchivedApplicationsFixtureArchivesDeletesAndBringsBack); the Playwright test is written and not run yet, because no Nextcloud was reachable from the worktree.
diff --git a/postman/stackiq-tests.json b/postman/stackiq-tests.json
index 385214b7c..0da0499b8 100644
--- a/postman/stackiq-tests.json
+++ b/postman/stackiq-tests.json
@@ -23073,9 +23073,10 @@
"listen": "test",
"script": {
"exec": [
- "pm.test(\"Only missingRecords=keep is accepted\", function () {",
+ "pm.test(\"Only missingRecords=keep or archive is accepted\", function () {",
" pm.response.to.have.status(422);",
" pm.expect(pm.response.json().error).to.eql(\"MISSING_RECORDS_UNSUPPORTED\");",
+ " pm.expect(pm.response.json().details.accepted).to.eql([\"keep\", \"archive\"]);",
"});"
],
"type": "text/javascript"
diff --git a/src/icons.js b/src/icons.js
index 865c514c0..34ffbfdfb 100644
--- a/src/icons.js
+++ b/src/icons.js
@@ -17,6 +17,7 @@ import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
import ApplicationOutline from 'vue-material-design-icons/ApplicationOutline.vue'
+import ArchiveOutline from 'vue-material-design-icons/ArchiveOutline.vue'
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import BookOpenVariant from 'vue-material-design-icons/BookOpenVariant.vue'
import BookOpenVariantOutline from 'vue-material-design-icons/BookOpenVariantOutline.vue'
@@ -79,6 +80,7 @@ export default {
AccountMultiple,
AlertCircle,
ApplicationOutline,
+ ArchiveOutline,
ArrowRight,
BookOpenVariant,
BookOpenVariantOutline,
diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json
index 66990ad7d..eec9d1242 100644
--- a/src/manifest.d/usages.json
+++ b/src/manifest.d/usages.json
@@ -35,7 +35,8 @@
{ "label": "Planned", "filter": { "status": "Planned" } },
{ "label": "In production", "filter": { "status": "In production" }, "icon": "CheckCircle" },
{ "label": "To be phased out", "filter": { "status": "To be phased out" }, "icon": "AlertCircle" },
- { "label": "Phased out", "filter": { "status": "Phased out" } }
+ { "label": "Phased out", "filter": { "status": "Phased out" } },
+ { "label": "Archived", "filter": { "_archived": "true" }, "icon": "ArchiveOutline" }
],
"sidebar": { "enabled": true, "showMetadata": true },
"documentationUrl": "https://stackiq.conduction.nl"
diff --git a/src/manifest.json b/src/manifest.json
index 22b321684..1541f303e 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -616,7 +616,7 @@
"type": "custom",
"title": "Applications",
"component": "FacetedCatalogIndexView",
- "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { _ids: matchedObjectIds } (OpenRegister's list parameter; the objects endpoint ignores a plain id filter). Columns are objects: `provider` is a reference, so it renders through the fkResolve cell widget (uuid → organisation name) and carries its own label — object-typed properties get no schema-generated column, so a bare key showed the raw uuid under the header 'provider'. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { _ids: ... } narrowing (disjoint keys, additive AND, no clobber).",
+ "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { _ids: matchedObjectIds } (OpenRegister's list parameter; the objects endpoint ignores a plain id filter). Columns are objects: `provider` is a reference, so it renders through the fkResolve cell widget (uuid → organisation name) and carries its own label — object-typed properties get no schema-generated column, so a bare key showed the raw uuid under the header 'provider'. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { _ids: ... } narrowing (disjoint keys, additive AND, no clobber). The one exception is the Archived tab: its filter carries an empty-string `_ids` that replaces the facet id set in the merge, because that set is built from the working set, which leaves archived applications out, so ANDing it with `_archived=true` could never match. That tab lists the whole archive and ignores the GEMMA facets and the search term.",
"config": {
"register": "@resolve:voorzieningen_register",
"schema": "module",
@@ -662,6 +662,14 @@
"dpiaStatus": ["not required", "required"]
},
"icon": "AlertCircle"
+ },
+ {
+ "label": "Archived",
+ "filter": {
+ "_archived": "true",
+ "_ids": ""
+ },
+ "icon": "ArchiveOutline"
}
]
}
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index ab7fa7080..2c83018a0 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -31,6 +31,8 @@ export const PROFILE_DEFAULTS = Object.freeze({
maxFileBytes: 10 * 1024 * 1024,
maxRowsPerSheet: 10000,
sheets: Object.freeze(['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']),
+ // Read for its APPID column only, in every import.
+ archiveSheet: 'Gearchiveerde Applicaties',
})
/**
@@ -42,8 +44,27 @@ export const PROFILE_DEFAULTS = Object.freeze({
*/
export const AS_TEXT = Object.freeze({ escape: false, sanitize: false })
-/** Every row outcome the report can carry, in display order. */
-export const OUTCOMES = ['created', 'updated', 'unchanged', 'skipped', 'failed']
+/**
+ * Every row outcome the report can carry, in display order: the outcomes of a
+ * sheet row, then those of an application that left the export or came back.
+ */
+export const OUTCOMES = [
+ 'created',
+ 'updated',
+ 'unchanged',
+ 'skipped',
+ 'failed',
+ 'unarchived',
+ 'restored',
+ 'archived',
+ 'deleted',
+]
+
+/**
+ * What the import may do with applications missing from the export (the
+ * profile's `missingRecords`), the default first.
+ */
+export const MISSING_RECORDS_MODES = Object.freeze(['archive', 'keep'])
/**
* The words for one row outcome.
@@ -51,6 +72,7 @@ export const OUTCOMES = ['created', 'updated', 'unchanged', 'skipped', 'failed']
* @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-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome-req-cmdb-011
+ * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
*/
export function outcomeLabel(outcome) {
switch (outcome) {
@@ -64,6 +86,14 @@ export function outcomeLabel(outcome) {
return t('stackiq', 'Skipped')
case 'failed':
return t('stackiq', 'Failed')
+ case 'unarchived':
+ return t('stackiq', 'Unarchived')
+ case 'restored':
+ return t('stackiq', 'Restored')
+ case 'archived':
+ return t('stackiq', 'Archived')
+ case 'deleted':
+ return t('stackiq', 'Deleted')
default:
return String(outcome ?? '')
}
@@ -141,16 +171,19 @@ export function checkFile(file) {
* @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 {boolean} [options.publish] Whether the modules the import creates are published; true when left out
+ * @param {string} [options.missingRecords] What happens to applications missing from the export: 'archive' (when left out) or 'keep'
* @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-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin-req-cmdb-004
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-newly-created-module-shall-get-a-publicationdate-when-the-admin-publishes-and-an-existing-one-shall-keep-its-own-req-cmdb-007
+ * @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
*/
export function buildImportForm({
file,
municipality,
updateExisting,
publish = true,
+ missingRecords = MISSING_RECORDS_MODES[0],
operationId,
}) {
const form = new FormData()
@@ -162,7 +195,7 @@ export function buildImportForm({
}
form.append('updateExisting', updateExisting ? 'true' : 'false')
form.append('publish', publish ? 'true' : 'false')
- form.append('missingRecords', 'keep')
+ form.append('missingRecords', missingRecords)
form.append('operationId', operationId)
return form
}
@@ -465,6 +498,7 @@ const KNOWN_ERRORS = new Set([
'SCHEMA_OUTDATED',
'IMPORT_IN_PROGRESS',
'MUNICIPALITY_AMBIGUOUS',
+ 'ARCHIVE_UNAVAILABLE',
'OPERATION_NOT_FOUND',
'IMPORT_INTERRUPTED',
'NOT_SIGNED_IN',
@@ -503,7 +537,7 @@ function workbookTooLarge(details) {
+ ' '
+ t(
'stackiq',
- 'Split the rows over two copies of the export and import them one after the other.',
+ 'Split the rows over two copies of the export and import both with "Keep them as they are": an import that archives treats the applications of the other copy as missing.',
)
if (Number(details.maxPartBytes) > 0) {
const sizes = {
@@ -532,10 +566,11 @@ function workbookTooLarge(details) {
+ ' '
+ t(
'stackiq',
- 'Delete the sheets the import does not read from a copy of the export (it reads only "{first}" and "{second}"), and import that copy.',
+ 'Delete the sheets the import does not read from a copy of the export (it reads "{first}" and "{second}", and the APPID column of "{archive}"), and import that copy.',
{
first: PROFILE_DEFAULTS.sheets[0],
second: PROFILE_DEFAULTS.sheets[1],
+ archive: PROFILE_DEFAULTS.archiveSheet,
},
AS_TEXT,
)
@@ -619,14 +654,14 @@ export function errorText(error) {
),
hint: t(
'stackiq',
- 'Remove sheets the import does not read, or split the export, and try again.',
+ 'Remove sheets the import does not read and try again. Or split the export and import every part with "Keep them as they are".',
),
}
case 'MISSING_RECORDS_UNSUPPORTED':
return {
title: t(
'stackiq',
- 'Records missing from the export can only be kept.',
+ 'Applications missing from the export can only be archived or kept.',
),
hint: '',
}
@@ -733,13 +768,13 @@ export function errorText(error) {
Number(details.limit) > 0
? t(
'stackiq',
- 'A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.',
+ 'A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with "Keep them as they are".',
{ limit: Number(details.limit).toLocaleString() },
AS_TEXT,
)
: t(
'stackiq',
- 'Split the export and import the parts one after the other.',
+ 'Split the export and import the parts one after the other with "Keep them as they are".',
),
}
case 'MAPPING_UNAVAILABLE':
@@ -807,6 +842,17 @@ export function errorText(error) {
'Choose the municipality from the list instead of typing its name. Nothing was imported.',
),
}
+ case 'ARCHIVE_UNAVAILABLE':
+ return {
+ title: t(
+ 'stackiq',
+ 'Applications missing from the export cannot be archived.',
+ ),
+ hint: t(
+ 'stackiq',
+ 'OpenRegister is missing or too old to archive objects. Choose "Keep them as they are" and import again, or update OpenRegister. Nothing was imported.',
+ ),
+ }
case 'OPERATION_NOT_FOUND':
return {
title: t('stackiq', 'This import is no longer running.'),
@@ -886,9 +932,17 @@ export function cancelFailureText(error) {
* server has set them, because the tracker's own percentage is weighted by
* the phases of the ArchiMate import.
*
+ * In the phase `reconciling` the server's counts add the applications of the
+ * municipality to the rows, and those applications include ones that are on
+ * the sheets and need no change. They are neither rows nor applications
+ * missing from the export, so the view shows no "N of M" for this phase and
+ * keeps the bar where the rows left it (full) instead of letting it fall
+ * back when the total grows.
+ *
* @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-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013
+ * @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
*/
export function cmdbProgressView(progress) {
if (!progress) {
@@ -900,6 +954,17 @@ export function cmdbProgressView(progress) {
total > 0
? Math.min(100, Math.round((processed / total) * 100))
: Number(progress.percentage) || 0
+ if (progress.phase === 'reconciling') {
+ return {
+ percentage: 100,
+ detail: t(
+ 'stackiq',
+ 'Checking the applications of this municipality against the export',
+ {},
+ AS_TEXT,
+ ),
+ }
+ }
return {
percentage,
detail:
@@ -960,9 +1025,13 @@ export function sortReportRows(rows, key, order) {
/**
* The rows of the report as the table shows them.
*
+ * An application the import archived or deleted after its rows has row 0
+ * in the report and no row number in the table.
+ *
* @param {Array