diff --git a/appinfo/routes.php b/appinfo/routes.php index 14578722..ab7ee994 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -262,6 +262,10 @@ ['name' => 'gebruik#getGebruiken', 'url' => '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/api/gebruik', 'verb' => 'GET'], ['name' => 'gebruik#getGebruikenForDeelnemer', 'url' => '/api/gebruik/deelnemer', 'verb' => 'GET'], + // Portfolio rationalization report (TIME quadrants + EOL + cloud + cost), JSON or CSV (?format=csv). + // @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + ['name' => 'portfolioReport#index', 'url' => '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/api/portfolio-report', 'verb' => 'GET'], + // SPA catch-all — serves the Vue app for any frontend route (history mode routing) ['name' => 'dashboard#page', 'url' => '/{path}', 'verb' => 'GET', 'requirements' => ['path' => '.+'], 'defaults' => ['path' => '']], ], diff --git a/docs/features/portfolio-rationalization-time.md b/docs/features/portfolio-rationalization-time.md new file mode 100644 index 00000000..7875f167 --- /dev/null +++ b/docs/features/portfolio-rationalization-time.md @@ -0,0 +1,173 @@ + + +# Portfolio rationalization (Gartner TIME) + +Adds Gartner TIME classification (**T**olerate / **I**nvest / **M**igrate / +**E**liminate) to each application-in-use (`gebruik`), and a +per-organisation **portfolio rationalization report** that combines TIME +quadrant counts with existing end-of-support exposure +(`application-lifecycle-tracking`), cloud-transition share (the existing +`cloudDienstverleningsmodel` field), and annualised cost overlay +(`contract-administration`). See +[VNG Softwarecatalogus issue #54](https://github.com/VNG-Realisatie/Softwarecatalogus/issues/54). + +Specification: +[`openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md`](../../openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md). + +> Screenshots of the report page are still pending a live-instance capture — +> this document ships with the implementation; the Playwright-captured +> images will follow in a subsequent docs pass (see the design.md Open +> Questions note on the report's live-verification follow-up). + +## Classifying a gebruik + +Three new **optional** fields on the existing `gebruik` schema — recorded +per organisation's usage of an application, never on the module/application +itself (mirroring how `geplandeVervanging` is scoped per gebruik): + +| Field | Type | Notes | +|------------------------|-------------------|---------------------------------------------------------------| +| `timeClassification` | enum (string) | `Tolerate` \| `Invest` \| `Migrate` \| `Eliminate` | +| `timeRationale` | string | Free-text justification for the classification | +| `timeReviewDate` | date (string) | When the classification should next be reviewed | + +A gebruik with no `timeClassification` set is **Unclassified** — it is +never hidden from the report, and is excluded from every quadrant count +until classified. + +Editing happens through the app's generic schema-driven object editor +(`ObjectModal.vue`, `object-type-key="gebruik"`), which now renders any +`enum`-on-`string` schema property (not just the three TIME fields — this +also improves `status` and every other enum field project-wide) as a +clearable dropdown instead of free text. Because the editor reads the full +current object before submitting (`formData = cloneDeep(activeObject)`) and +only mutates the edited key, a TIME-only edit carries every other gebruik +field forward unchanged — OpenRegister's `saveObject` is PUT-semantic, so +omitted fields would otherwise be nulled out. + +## Cloud-transition share reuses the existing Hosting field + +No new deployment-model field is introduced. `gebruik` already carries +`cloudDienstverleningsmodel` ("Hosting": On-premises (self-managed) / IaaS +/ PaaS / SaaS, `facetable: true`) — the report's cloud-transition metric +reads that field directly, so the existing Hosting column and this report +never fork into two competing sources of the same fact. + +## The portfolio rationalization report + +`GET /apps/softwarecatalog/api/portfolio-report?organisation={uuid}` + +Returns a bounded, organisation-scoped aggregate: + +```json +{ + "organisation": "org-a", + "generatedAt": "2026-07-23T12:00:00+00:00", + "pageSizeCeiling": 500, + "totalGebruiken": 42, + "includedGebruiken": 42, + "truncated": false, + "quadrants": { + "Tolerate": { "count": 5, "eolExposedCount": 1, "cloudTransition": { "SaaS": 3, "On-premises (self-managed)": 2 }, "annualisedCost": 12000, "oneOffCost": 0 }, + "Invest": { "...": "..." }, + "Migrate": { "...": "..." }, + "Eliminate": { "...": "..." }, + "Unclassified": { "...": "..." } + }, + "rows": [ + { "uuid": "g1", "moduleName": "Example App", "timeClassification": "Migrate", "quadrant": "Migrate", "timeRationale": "Vendor lock-in, successor selected", "timeReviewDate": "2027-01-01", "lifecyclePhase": "In productie", "eol": { "passed": false, "withdrawn": false, "endDate": "2027-06-01" }, "eolApproaching": false, "hostingModel": ["SaaS"], "annualisedCost": 4800, "oneOffCost": 0 } + ] +} +``` + +Every figure is **computed at query time, never persisted** — the same +"derive, don't cache" principle as the lifecycle-tracking phase/EOL rules. +`PortfolioReportService` composes three existing derivations rather than +forking a second implementation: + +- **TIME quadrant counts** — from `gebruik.timeClassification`. +- **EOL exposure** — reusing the `application-lifecycle-tracking` + end-of-support rule (a moduleVersie's `datumEindeOndersteuning` passed, or + within the 180-day approaching window). +- **Cloud-transition share** — from `cloudDienstverleningsmodel`. +- **Annualised cost overlay** — reusing the `contract-administration` cost + derivation (`kosten` × `kostenPeriode`) over each gebruik's linked + contracts. + +Pure phase/EOL/cost/relation-id rules live in `PortfolioReportDerivation` +(no I/O), separated from the OpenRegister-querying orchestration in +`PortfolioReportService` to keep both testable and under the project's +complexity thresholds. + +### Bounded, always + +Every OpenRegister query the report issues carries an explicit `_limit` (or +uses `searchObjectsPaginated`) — never an unbounded `searchObjects()` call, +per the `bound-unbounded-searchobjects-scans` rule. The gebruik query is +capped at a configurable **page-size ceiling** (`portfolio_report_page_size_ceiling` +app config, default `500`); when an organisation's gebruik count exceeds +it, the response's `truncated: true` + `totalGebruiken` / `includedGebruiken` +disclose the truncation — the report never presents a bounded subset as a +silently-complete total. The linked-contract lookup is bounded to +`5 × the gebruik ceiling` (capped at 5000), since one gebruik may have +several linked contracts (renewals, multiple services). + +### RBAC: scoped to the caller's authorised organisation(s) + +The controller resolves and checks the caller's organisation access +**before** the service ever issues a query for the requested organisation +(fail closed, per `vendor-visibility-rbac`): + +- `admin` / `ambtenaar` may request **any** organisation's report (the + existing unrestricted-read bypass those roles already have on gebruik + reads). +- Every other authenticated user may request **only their own** active + organisation's report — a report synthesises another organisation's + gebruik/contract data, which is not granted beyond the caller's own + organisation. +- A request for an unauthorised organisation returns `403` with **no** + organisation data in the response body, and the CSV export variant is + denied the same way. + +> Re-verify this enforcement point once `vendor-visibility-rbac` lands as +> its own change — the gating mechanism may move from the +> `IConfig::getUserValue('core', 'organisation')` lookup used here to +> whatever canonical mechanism that change introduces. + +### CSV export + +`GET /apps/softwarecatalog/api/portfolio-report?organisation={uuid}&format=csv` + +The **same** bounded, RBAC-scoped row set as the JSON report, serialised as +CSV (one row per gebruik: organisation, module, TIME classification, +rationale, review date, lifecycle phase, EOL status, hosting model, +annualised + one-off cost) — never a second, unbounded/unscoped export +path. + +### Report page + +`/portfolio-report` (manifest page `PortfolioReport`, component +`PortfolioReportView`) renders: + +- An organisation picker. +- A TIME quadrant bar chart (`CnChartWidget`, apexcharts via + `@conduction/nextcloud-vue`), one bar per quadrant including + Unclassified, coloured with NL Design System semantic tokens + (`--color-success` / `--color-warning` / `--color-error` / + `--color-text-maxcontrast` / `--color-border-dark` — never a hardcoded + hex). +- A quadrant summary table (count, EOL-exposed count, cloud-transition + mix, annualised + one-off cost). +- Gebruik-level detail tables, one section per quadrant (Unclassified + always rendered, even when empty). +- A truncation banner when the report is bounded. +- An **Export CSV** button. + +Unlike `LifecycleRoadmapView` / `LicensePostureView` (which derive their +figures client-side over a full collection fetched into the browser), this +page is a thin renderer over the single composed backend endpoint above — +the aggregation and RBAC scoping live server-side, per design.md Decision +3/4. diff --git a/l10n/en.json b/l10n/en.json index 96a5eba2..7de3ad4a 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -424,6 +424,13 @@ "Approval": "Approval", "{source} will be marked as merged (not deleted) and will disappear from the organisations list.": "{source} will be marked as merged (not deleted) and will disappear from the organisations list.", "Approval": "Approval", + "{source} will be marked as merged (not deleted) and will disappear from the organisations list.": "{source} will be marked as merged (not deleted) and will disappear from the organisations list.", + "Annualised cost": "Annualised cost", + "Applications": "Applications", + "Applications in use": "Applications in use", + "Approaching": "Approaching", + "Approval": "Approval", + "Cloud-transition share": "Cloud-transition share", "Compliance records": "Compliance records", "Confirm organisation merge": "Confirm organisation merge", "Contact persons": "Contact persons", @@ -476,6 +483,50 @@ "This organisation has been merged and is no longer active.": "This organisation has been merged and is no longer active.", "This will permanently fold {source} into {target}.": "This will permanently fold {source} into {target}.", "Usage records": "Usage records", - "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.": "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable." + "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.": "When disabled, the matcher never reads or writes anything — the same as the feed being unavailable.", + "Could not load target organisations.": "Could not load target organisations.", + "Could not merge the organisations.": "Could not merge the organisations.", + "Could not preview the merge.": "Could not preview the merge.", + "Count": "Count", + "Eliminate": "Eliminate", + "EOL exposed": "EOL exposed", + "EOL status": "EOL status", + "Export CSV": "Export CSV", + "Failed to load the portfolio report.": "Failed to load the portfolio report.", + "Fold this organisation into another one (gemeentelijke herindeling or leveranciersovername). Every contract, usage record, contact person, offering and compliance record is re-pointed to the target; this organisation is then marked as merged, never deleted.": "Fold this organisation into another one (gemeentelijke herindeling or leveranciersovername). Every contract, usage record, contact person, offering and compliance record is re-pointed to the target; this organisation is then marked as merged, never deleted.", + "Go to the organisation it was merged into": "Go to the organisation it was merged into", + "Group members": "Group members", + "Hosting model": "Hosting model", + "Invest": "Invest", + "Lifecycle phase": "Lifecycle phase", + "Loading merge status": "Loading merge status", + "Merge organisation": "Merge organisation", + "Merge organisations": "Merge organisations", + "Migrate": "Migrate", + "No applications in this quadrant": "No applications in this quadrant", + "Offerings": "Offerings", + "OK": "OK", + "One-off cost": "One-off cost", + "Organisation successfully merged.": "Organisation successfully merged.", + "Passed": "Passed", + "Pick an organisation above to render its portfolio rationalization report.": "Pick an organisation above to render its portfolio rationalization report.", + "Portfolio rationalization": "Portfolio rationalization", + "Preview merge": "Preview merge", + "Quadrant": "Quadrant", + "Quadrant summary": "Quadrant summary", + "Rationale": "Rationale", + "Records that will be re-pointed to {target}:": "Records that will be re-pointed to {target}:", + "Refresh report": "Refresh report", + "Review date": "Review date", + "Select the organisation to merge into": "Select the organisation to merge into", + "Showing the first {shown} of {total} applications in use for this organisation — the report is bounded to protect performance.": "Showing the first {shown} of {total} applications in use for this organisation — the report is bounded to protect performance.", + "Target organisation": "Target organisation", + "This organisation has been merged and is no longer active.": "This organisation has been merged and is no longer active.", + "This will permanently fold {source} into {target}.": "This will permanently fold {source} into {target}.", + "TIME classification (Tolerate / Invest / Migrate / Eliminate) of an organisation's applications in use, combined with end-of-support exposure, cloud-transition share, and annualised contract cost.": "TIME classification (Tolerate / Invest / Migrate / Eliminate) of an organisation's applications in use, combined with end-of-support exposure, cloud-transition share, and annualised contract cost.", + "TIME quadrant counts": "TIME quadrant counts", + "Tolerate": "Tolerate", + "Unclassified": "Unclassified", + "Usage records": "Usage records" } } diff --git a/l10n/en_US.js b/l10n/en_US.js index 6008b75b..59eb203b 100644 --- a/l10n/en_US.js +++ b/l10n/en_US.js @@ -289,7 +289,40 @@ OC.L10N.register( "OpenRegister is not currently reachable" : "OpenRegister is not currently reachable", "the module/moduleVersie schema is not configured yet" : "the module/moduleVersie schema is not configured yet", "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?" : "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?", - "never" : "never" + "never" : "never", + "Annualised cost" : "Annualised cost", + "Applications in use" : "Applications in use", + "Applications" : "Applications", + "Approaching" : "Approaching", + "Cloud-transition share" : "Cloud-transition share", + "Count" : "Count", + "EOL exposed" : "EOL exposed", + "EOL status" : "EOL status", + "Eliminate" : "Eliminate", + "Export CSV" : "Export CSV", + "Failed to load the portfolio report." : "Failed to load the portfolio report.", + "Hosting model" : "Hosting model", + "Invest" : "Invest", + "Lifecycle phase" : "Lifecycle phase", + "Migrate" : "Migrate", + "No applications in this quadrant" : "No applications in this quadrant", + "OK" : "OK", + "One-off cost" : "One-off cost", + "Passed" : "Passed", + "Pick an organisation above to render its portfolio rationalization report." : "Pick an organisation above to render its portfolio rationalization report.", + "Portfolio rationalization" : "Portfolio rationalization", + "Quadrant summary" : "Quadrant summary", + "Quadrant" : "Quadrant", + "Rationale" : "Rationale", + "Refresh report" : "Refresh report", + "Review date" : "Review date", + "Showing the first {shown} of {total} applications in use for this organisation — the report is bounded to protect performance." : "Showing the first {shown} of {total} applications in use for this organisation — the report is bounded to protect performance.", + "TIME classification (Tolerate / Invest / Migrate / Eliminate) of an organisation's applications in use, combined with end-of-support exposure, cloud-transition share, and annualised contract cost." : "TIME classification (Tolerate / Invest / Migrate / Eliminate) of an organisation's applications in use, combined with end-of-support exposure, cloud-transition share, and annualised contract cost.", + "TIME quadrant counts" : "TIME quadrant counts", + "Tolerate" : "Tolerate", + "Unclassified" : "Unclassified", + "An error occurred while changing the status" : "An error occurred while changing the status", + "Approval" : "Approval", }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/en_US.json b/l10n/en_US.json index fe8f038f..b628f3eb 100644 --- a/l10n/en_US.json +++ b/l10n/en_US.json @@ -331,6 +331,39 @@ "OpenRegister is not currently reachable": "OpenRegister is not currently reachable", "the module/moduleVersie schema is not configured yet": "the module/moduleVersie schema is not configured yet", "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?": "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?", - "never": "never" + "never": "never", + "Annualised cost": "Annualised cost", + "Applications in use": "Applications in use", + "Applications": "Applications", + "Approaching": "Approaching", + "Cloud-transition share": "Cloud-transition share", + "Count": "Count", + "EOL exposed": "EOL exposed", + "EOL status": "EOL status", + "Eliminate": "Eliminate", + "Export CSV": "Export CSV", + "Failed to load the portfolio report.": "Failed to load the portfolio report.", + "Hosting model": "Hosting model", + "Invest": "Invest", + "Lifecycle phase": "Lifecycle phase", + "Migrate": "Migrate", + "No applications in this quadrant": "No applications in this quadrant", + "OK": "OK", + "One-off cost": "One-off cost", + "Passed": "Passed", + "Pick an organisation above to render its portfolio rationalization report.": "Pick an organisation above to render its portfolio rationalization report.", + "Portfolio rationalization": "Portfolio rationalization", + "Quadrant summary": "Quadrant summary", + "Quadrant": "Quadrant", + "Rationale": "Rationale", + "Refresh report": "Refresh report", + "Review date": "Review date", + "Showing the first {shown} of {total} applications in use for this organisation — the report is bounded to protect performance.": "Showing the first {shown} of {total} applications in use for this organisation — the report is bounded to protect performance.", + "TIME classification (Tolerate / Invest / Migrate / Eliminate) of an organisation's applications in use, combined with end-of-support exposure, cloud-transition share, and annualised contract cost.": "TIME classification (Tolerate / Invest / Migrate / Eliminate) of an organisation's applications in use, combined with end-of-support exposure, cloud-transition share, and annualised contract cost.", + "TIME quadrant counts": "TIME quadrant counts", + "Tolerate": "Tolerate", + "Unclassified": "Unclassified", + "An error occurred while changing the status": "An error occurred while changing the status", + "Approval": "Approval" } } diff --git a/l10n/nl.js b/l10n/nl.js index 95760a6a..32c9a2e8 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -326,7 +326,40 @@ OC.L10N.register( "OpenRegister is not currently reachable" : "OpenRegister is momenteel niet bereikbaar", "the module/moduleVersie schema is not configured yet" : "het module-/moduleVersie-schema is nog niet geconfigureerd", "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?" : "het geconfigureerde register of schema kon niet worden gevonden — is de openconnector-wijziging endoflife-date-source geïnstalleerd?", - "never" : "nooit" + "never" : "nooit", + "Annualised cost" : "Jaarlijkse kosten", + "Applications in use" : "Applicaties in gebruik", + "Applications" : "Applicaties", + "Approaching" : "Nadert", + "Cloud-transition share" : "Cloud-transitie aandeel", + "Count" : "Aantal", + "EOL exposed" : "EOL-blootstelling", + "EOL status" : "EOL-status", + "Eliminate" : "Elimineren", + "Export CSV" : "CSV exporteren", + "Failed to load the portfolio report." : "Kan het portfoliorapport niet laden.", + "Hosting model" : "Hostingmodel", + "Invest" : "Investeren", + "Lifecycle phase" : "Levenscyclusfase", + "Migrate" : "Migreren", + "No applications in this quadrant" : "Geen applicaties in dit kwadrant", + "OK" : "OK", + "One-off cost" : "Eenmalige kosten", + "Passed" : "Verlopen", + "Pick an organisation above to render its portfolio rationalization report." : "Kies hierboven een organisatie om het portfolio-rationalisatierapport te tonen.", + "Portfolio rationalization" : "Portfolio-rationalisatie", + "Quadrant summary" : "Kwadrantoverzicht", + "Quadrant" : "Kwadrant", + "Rationale" : "Onderbouwing", + "Refresh report" : "Rapport vernieuwen", + "Review date" : "Herbeoordelingsdatum", + "Showing the first {shown} of {total} applications in use for this organisation — the report is bounded to protect performance." : "Toont de eerste {shown} van {total} applicaties in gebruik voor deze organisatie — het rapport is begrensd om de prestaties te beschermen.", + "TIME classification (Tolerate / Invest / Migrate / Eliminate) of an organisation's applications in use, combined with end-of-support exposure, cloud-transition share, and annualised contract cost." : "TIME-classificatie (Tolerate / Invest / Migrate / Eliminate) van de applicaties in gebruik van een organisatie, gecombineerd met einde-ondersteuning-blootstelling, cloud-transitie aandeel en jaarlijkse contractkosten.", + "TIME quadrant counts" : "TIME-kwadrantaantallen", + "Tolerate" : "Tolereren", + "Unclassified" : "Ongeclassificeerd", + "An error occurred while changing the status" : "Er is een fout opgetreden bij het wijzigen van de status", + "Approval" : "Goedkeuring", }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/nl.json b/l10n/nl.json index c1b553df..dac9ff43 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -475,6 +475,38 @@ "OpenRegister is not currently reachable": "OpenRegister is momenteel niet bereikbaar", "the module/moduleVersie schema is not configured yet": "het module-/moduleVersie-schema is nog niet geconfigureerd", "the configured register or schema could not be found — is the openconnector endoflife-date-source change installed?": "het geconfigureerde register of schema kon niet worden gevonden — is de openconnector-wijziging endoflife-date-source geïnstalleerd?", - "never": "nooit" + "never": "nooit", + "Annualised cost": "Jaarlijkse kosten", + "Applications in use": "Applicaties in gebruik", + "Applications": "Applicaties", + "Approaching": "Nadert", + "Cloud-transition share": "Cloud-transitie aandeel", + "Count": "Aantal", + "EOL exposed": "EOL-blootstelling", + "EOL status": "EOL-status", + "Eliminate": "Elimineren", + "Export CSV": "CSV exporteren", + "Failed to load the portfolio report.": "Kan het portfoliorapport niet laden.", + "Hosting model": "Hostingmodel", + "Invest": "Investeren", + "Lifecycle phase": "Levenscyclusfase", + "Migrate": "Migreren", + "No applications in this quadrant": "Geen applicaties in dit kwadrant", + "OK": "OK", + "One-off cost": "Eenmalige kosten", + "Passed": "Verlopen", + "Pick an organisation above to render its portfolio rationalization report.": "Kies hierboven een organisatie om het portfolio-rationalisatierapport te tonen.", + "Portfolio rationalization": "Portfolio-rationalisatie", + "Quadrant summary": "Kwadrantoverzicht", + "Quadrant": "Kwadrant", + "Rationale": "Onderbouwing", + "Refresh report": "Rapport vernieuwen", + "Review date": "Herbeoordelingsdatum", + "Showing the first {shown} of {total} applications in use for this organisation — the report is bounded to protect performance.": "Toont de eerste {shown} van {total} applicaties in gebruik voor deze organisatie — het rapport is begrensd om de prestaties te beschermen.", + "TIME classification (Tolerate / Invest / Migrate / Eliminate) of an organisation's applications in use, combined with end-of-support exposure, cloud-transition share, and annualised contract cost.": "TIME-classificatie (Tolerate / Invest / Migrate / Eliminate) van de applicaties in gebruik van een organisatie, gecombineerd met einde-ondersteuning-blootstelling, cloud-transitie aandeel en jaarlijkse contractkosten.", + "TIME quadrant counts": "TIME-kwadrantaantallen", + "Tolerate": "Tolereren", + "Unclassified": "Ongeclassificeerd", + "Approval": "Goedkeuring" } } diff --git a/lib/Controller/PortfolioReportController.php b/lib/Controller/PortfolioReportController.php new file mode 100644 index 00000000..ffcf5d28 --- /dev/null +++ b/lib/Controller/PortfolioReportController.php @@ -0,0 +1,169 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-and-csv-export-are-scoped-to-the-requesters-authorised-organisations + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Controller; + +use Exception; +use OCA\SoftwareCatalog\Service\PortfolioReportService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataDownloadResponse; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IConfig; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * Controller for the portfolio rationalization report endpoint. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + */ +class PortfolioReportController extends Controller +{ + /** + * Constructor for PortfolioReportController. + * + * @param string $appName The app name. + * @param IRequest $request The request object. + * @param IUserSession $userSession The user session service. + * @param IGroupManager $groupManager The group manager service. + * @param IConfig $config The configuration service. + * @param PortfolioReportService $reportService The report aggregation service. + */ + public function __construct( + string $appName, + IRequest $request, + private readonly IUserSession $userSession, + private readonly IGroupManager $groupManager, + private readonly IConfig $config, + private readonly PortfolioReportService $reportService, + ) { + parent::__construct(appName: $appName, request: $request); + }//end __construct() + + /** + * Serve the portfolio rationalization report for an organisation, as + * JSON (default) or CSV (`?format=csv`). + * + * Deny-before-query (REQ-001/REQ-005 of `vendor-visibility-rbac`, + * applied to this endpoint per `portfolio-rationalization-time` + * REQ "Report and CSV export are scoped..."): the caller's + * organisation-access is resolved and checked BEFORE + * `PortfolioReportService::buildReport()`/`buildCsv()` ever issues an + * OpenRegister query for the requested organisation. + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @return JSONResponse|DataDownloadResponse + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-and-csv-export-are-scoped-to-the-requesters-authorised-organisations + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-csv-export-of-the-portfolio-report + */ + public function index() + { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['message' => 'Not authenticated'], Http::STATUS_UNAUTHORIZED); + } + + $organisation = (string) $this->request->getParam('organisation', ''); + if ($organisation === '') { + return new JSONResponse(['message' => 'organisation is required'], Http::STATUS_BAD_REQUEST); + } + + if ($this->isAuthorisedForOrganisation(user: $user, organisationUuid: $organisation) === false) { + // Fail closed: denied BEFORE any report query is built. + return new JSONResponse(['message' => 'Not authorised for this organisation'], Http::STATUS_FORBIDDEN); + } + + $format = (string) $this->request->getParam('format', 'json'); + + try { + if ($format === 'csv') { + $csv = $this->reportService->buildCsv(organisationUuid: $organisation); + return new DataDownloadResponse($csv, 'portfolio-report-'.$organisation.'.csv', 'text/csv'); + } + + return new JSONResponse($this->reportService->buildReport(organisationUuid: $organisation)); + } catch (Exception $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR); + } + }//end index() + + /** + * Whether the caller is authorised to see `$organisationUuid`'s + * portfolio report. + * + * Reuses the same role/organisation resolution mechanism as + * `GebruikController::resolveUserRoles()` / `applyAanbodScopeToOptions()` + * (per design.md: this change plugs into the current + * tenant/organisation-scoping mechanism rather than inventing a new + * matrix). `admin`/`ambtenaar` may request any organisation's report + * (existing unrestricted-read bypass); every other authenticated user + * may request only their own active organisation's report — a report + * is a synthesis of another organisation's gebruik/contract data, which + * `vendor-visibility-rbac` REQ-002/REQ-003 do not grant beyond the + * caller's own organisation or offered-products relationship. + * + * @param \OCP\IUser $user The authenticated caller. + * @param string $organisationUuid The requested organisation uuid. + * + * @return bool True when the caller may see this organisation's report. + * + * @spec openspec/specs/vendor-visibility-rbac/spec.md#requirement-gebruik-beheerder-reads-of-gebruik-objects-must-be-scoped-to-the-caller-s-own-organisation-req-003 + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-and-csv-export-are-scoped-to-the-requesters-authorised-organisations + */ + private function isAuthorisedForOrganisation(\OCP\IUser $user, string $organisationUuid): bool + { + $groups = $this->groupManager->getUserGroups(user: $user); + $groupNames = array_map( + static function (IGroup $group) { + return $group->getGID(); + }, + $groups + ); + + $isAdmin = in_array('admin', $groupNames, true); + $isAmbtenaar = in_array('ambtenaar', $groupNames, true); + if ($isAdmin === true || $isAmbtenaar === true) { + return true; + } + + $orgUuid = (string) $this->config->getUserValue( + userId: $user->getUID(), + appName: 'core', + key: 'organisation' + ); + + if ($orgUuid === '') { + return false; + } + + return $orgUuid === $organisationUuid; + }//end isAuthorisedForOrganisation() +}//end class diff --git a/lib/Repair/InitializeSettings.php b/lib/Repair/InitializeSettings.php index 8e780325..6d96bb6a 100644 --- a/lib/Repair/InitializeSettings.php +++ b/lib/Repair/InitializeSettings.php @@ -31,6 +31,8 @@ * * @category Repair * @package OCA\SoftwareCatalog\Repair + * + * @spec openspec/specs/repair-init/spec.md */ class InitializeSettings implements IRepairStep { @@ -54,6 +56,8 @@ public function __construct( * Returns the name of this repair step. * * @return string The repair step name + * + * @spec openspec/specs/repair-init/spec.md */ public function getName(): string { @@ -101,6 +105,13 @@ public function run(IOutput $output): void $this->config->setValueInt(Application::APP_ID, 'eol_warning_window_days', 180); } + // @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + // Seed the portfolio-report page-size ceiling default only when + // unset, so an operator's chosen bound survives upgrades. + if ($this->config->hasKey(Application::APP_ID, 'portfolio_report_page_size_ceiling') === false) { + $this->config->setValueInt(Application::APP_ID, 'portfolio_report_page_size_ceiling', 500); + } + // @spec openspec/specs/federated-catalog-sync/spec.md // Seed federation defaults only when unset (admin overrides survive). if ($this->config->hasKey(Application::APP_ID, 'federation_enabled') === false) { diff --git a/lib/Service/PortfolioReportDerivation.php b/lib/Service/PortfolioReportDerivation.php new file mode 100644 index 00000000..0feeba47 --- /dev/null +++ b/lib/Service/PortfolioReportDerivation.php @@ -0,0 +1,264 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Service; + +use DateTimeImmutable; + +/** + * Pure derivation helpers for the portfolio rationalization report. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + */ +class PortfolioReportDerivation +{ + /** + * End-of-support "approaching" look-ahead window in days, matching + * `application-lifecycle-tracking`'s default `eol_warning_window_days`. + */ + public const EOL_WINDOW_DAYS = 180; + + /** + * Derive the lifecycle phase of a gebruik — the most advanced phase + * whose start date is in the past. Mirrors `src/utils/lifecyclePhase.js` + * `derivePhase()`. + * + * @param array $gebruik The gebruik data bag. + * @param DateTimeImmutable $now Reference moment. + * + * @return string The derived phase. + * + * @spec openspec/specs/application-lifecycle-tracking/spec.md + */ + public function deriveLifecyclePhase(array $gebruik, DateTimeImmutable $now): string + { + $steps = [ + 'Uitgefaseerd' => 'startDatumUitGefaseerd', + 'Uit te faseren' => 'startDatumUitTeFaseren', + 'In productie' => 'startDatumInProductie', + 'Gepland' => 'startDatumGepland', + 'Verwerving' => 'startDatumVerwerving', + ]; + + foreach ($steps as $phase => $field) { + $date = $this->parseDate(value: $gebruik[$field] ?? null); + if ($date !== null && $date <= $now) { + return $phase; + } + } + + return 'Onbekend'; + }//end deriveLifecyclePhase() + + /** + * Derive end-of-support state from a moduleVersie. Mirrors + * `src/utils/lifecyclePhase.js` `endOfSupportState()`. + * + * @param array|null $moduleVersie The linked moduleVersie data bag. + * @param DateTimeImmutable $now Reference moment. + * + * @return array{passed: bool, withdrawn: bool, endDate: string|null, withdrawnDate: string|null} + * + * @spec openspec/specs/application-lifecycle-tracking/spec.md + */ + public function deriveEolState(?array $moduleVersie, DateTimeImmutable $now): array + { + $endRaw = $moduleVersie['datumEindeOndersteuning'] ?? null; + $withdrawnRaw = $moduleVersie['datumTeruggetrokken'] ?? null; + if (is_string($withdrawnRaw) === false || trim($withdrawnRaw) === '') { + $withdrawnRaw = null; + } + + $endDate = null; + if (is_string($endRaw) === true) { + $endDate = $endRaw; + } + + $end = $this->parseDate(value: $endRaw); + + return [ + 'passed' => $end !== null && $end <= $now, + 'withdrawn' => $withdrawnRaw !== null, + 'endDate' => $endDate, + 'withdrawnDate' => $withdrawnRaw, + ]; + }//end deriveEolState() + + /** + * Whether a moduleVersie's end-of-support falls within the approaching + * window. Mirrors `src/utils/lifecyclePhase.js` `isEolApproaching()`. + * + * @param array|null $moduleVersie The linked moduleVersie data bag. + * @param DateTimeImmutable $now Reference moment. + * + * @return bool True when end-of-support is within `self::EOL_WINDOW_DAYS`. + * + * @spec openspec/specs/application-lifecycle-tracking/spec.md + */ + public function isEolApproaching(?array $moduleVersie, DateTimeImmutable $now): bool + { + $end = $this->parseDate(value: $moduleVersie['datumEindeOndersteuning'] ?? null); + if ($end === null) { + return false; + } + + $horizon = $now->modify('+'.self::EOL_WINDOW_DAYS.' days'); + + return $end > $now && $end <= $horizon; + }//end isEolApproaching() + + /** + * Render a report row's EOL status as a short CSV label. + * + * @param array $row A report row (carries `eol.passed` and `eolApproaching`). + * + * @return string One of `passed`, `approaching`, `ok`. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-csv-export-of-the-portfolio-report + */ + public function eolStatusLabel(array $row): string + { + if ($row['eol']['passed'] === true) { + return 'passed'; + } + + if ($row['eolApproaching'] === true) { + return 'approaching'; + } + + return 'ok'; + }//end eolStatusLabel() + + /** + * Annualised cost of a single contract. Mirrors + * `src/utils/contractCost.js` `annualisedCost()`. + * + * @param array $contract The contract data bag. + * + * @return array{annual: float, oneOff: float} + * + * @spec openspec/specs/contract-administration/spec.md + */ + public function annualisedCost(array $contract): array + { + $amount = $contract['kosten'] ?? null; + if (is_numeric($amount) === false) { + return ['annual' => 0.0, 'oneOff' => 0.0]; + } + + $amount = (float) $amount; + + return match ($contract['kostenPeriode'] ?? null) { + 'Maandelijks' => ['annual' => $amount * 12, 'oneOff' => 0.0], + 'Jaarlijks' => ['annual' => $amount, 'oneOff' => 0.0], + 'Eenmalig' => ['annual' => 0.0, 'oneOff' => $amount], + default => ['annual' => 0.0, 'oneOff' => 0.0], + }; + }//end annualisedCost() + + /** + * Resolve the uuid of a relation value that may be a plain string, a + * nested object (`{id: ...}` / `{uuid: ...}`), or null. Mirrors + * `src/utils/lifecyclePhase.js` `resolveUuid()`. + * + * @param mixed $value A relation value. + * + * @return string The resolved uuid, or '' when unresolved. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + */ + public function resolveRelationId(mixed $value): string + { + if (is_string($value) === true) { + return trim($value); + } + + if (is_array($value) === true) { + $id = $value['uuid'] ?? $value['id'] ?? ($value['@self']['id'] ?? null); + if (is_string($id) === true) { + return trim($id); + } + + return (string) ($id ?? ''); + } + + return ''; + }//end resolveRelationId() + + /** + * Parse a date value, or null when blank/unparseable. Fails closed — + * never throws. + * + * @param mixed $value A raw date string. + * + * @return DateTimeImmutable|null The parsed date, or null. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + */ + public function parseDate(mixed $value): ?DateTimeImmutable + { + if (is_string($value) === false || trim($value) === '') { + return null; + } + + try { + return new DateTimeImmutable($value); + } catch (\Exception $e) { + return null; + } + }//end parseDate() + + /** + * Normalize OpenRegister search results (ObjectEntity or plain array) + * into plain data-bag arrays. + * + * @param array $results Raw search results. + * + * @return array> Normalized data bags. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + public function normalizeResults(array $results): array + { + return array_map( + static function ($object) { + if (is_array($object) === true) { + return $object; + } + + if (is_object($object) === true && method_exists($object, 'getObject') === true) { + return $object->getObject(); + } + + return []; + }, + $results + ); + }//end normalizeResults() +}//end class diff --git a/lib/Service/PortfolioReportService.php b/lib/Service/PortfolioReportService.php new file mode 100644 index 00000000..83ff0e75 --- /dev/null +++ b/lib/Service/PortfolioReportService.php @@ -0,0 +1,590 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://codeberg.org/Conduction/SoftwareCatalog + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\SoftwareCatalog\Service; + +use DateTimeImmutable; +use Exception; +use OCA\OpenRegister\Service\ObjectService; +use OCA\SoftwareCatalog\AppInfo\Application; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Server-side aggregation for the portfolio rationalization report. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + */ +class PortfolioReportService +{ + /** + * The four Gartner TIME quadrant values, matching the gebruik schema's + * `timeClassification` enum. + */ + public const QUADRANT_TOLERATE = 'Tolerate'; + public const QUADRANT_INVEST = 'Invest'; + public const QUADRANT_MIGRATE = 'Migrate'; + public const QUADRANT_ELIMINATE = 'Eliminate'; + + /** + * The bucket for gebruiken with no `timeClassification` value set. + */ + public const QUADRANT_UNCLASSIFIED = 'Unclassified'; + + /** + * Rendered quadrant order — Unclassified last, so the four TIME + * quadrants read in the canonical Tolerate/Invest/Migrate/Eliminate + * order and unclassified entries stay visible rather than omitted. + */ + public const QUADRANTS = [ + self::QUADRANT_TOLERATE, + self::QUADRANT_INVEST, + self::QUADRANT_MIGRATE, + self::QUADRANT_ELIMINATE, + self::QUADRANT_UNCLASSIFIED, + ]; + + /** + * Default report page-size ceiling, mirrored from + * `InitializeSettings::run()`'s seeded `portfolio_report_page_size_ceiling`. + */ + public const DEFAULT_PAGE_SIZE_CEILING = 500; + + /** + * Contract query limit as a multiple of the gebruik page-size ceiling — + * an organisation's gebruiken may each carry more than one linked + * contract (renewals, multiple services), so the contract bound is + * generous relative to the gebruik bound while staying explicit. + */ + private const CONTRACT_LIMIT_MULTIPLIER = 5; + + /** + * Per-request cache of resolved moduleVersie/module objects, keyed + * `"{schemaId}:{uuid}"`, so the same relation is never fetched twice + * while building one report. + * + * @var array|null> + */ + private array $relationCache = []; + + /** + * Constructor. + * + * @param SettingsService $settingsService Resolves register/schema ids. + * @param IAppManager $appManager The application manager. + * @param ContainerInterface $container The DI container (lazy OR lookup). + * @param LoggerInterface $logger Logger. + * @param IAppConfig $config App configuration (page-size ceiling). + * @param PortfolioReportDerivation $derivation Pure phase/EOL/cost/relation-id derivation helpers. + */ + public function __construct( + private readonly SettingsService $settingsService, + private readonly IAppManager $appManager, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + private readonly IAppConfig $config, + private readonly PortfolioReportDerivation $derivation, + ) { + }//end __construct() + + /** + * Build the portfolio rationalization report for one organisation. + * + * Every OpenRegister query issued here carries an explicit `_limit` + * (`bound-unbounded-searchobjects-scans`). The caller (controller) MUST + * have already authorised the requesting user for `$organisationUuid` + * before invoking this method — this service does not itself gate + * access. + * + * @param string $organisationUuid The `afnemer` organisation UUID to report on. + * + * @return array The report payload. + * + * @throws Exception When OpenRegister or the voorzieningen configuration is unavailable. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + public function buildReport(string $organisationUuid): array + { + $this->relationCache = []; + $rows = $this->buildRows(organisationUuid: $organisationUuid); + + return [ + 'organisation' => $organisationUuid, + 'generatedAt' => (new DateTimeImmutable())->format(DATE_ATOM), + 'pageSizeCeiling' => $rows['ceiling'], + 'totalGebruiken' => $rows['total'], + 'includedGebruiken' => count($rows['rows']), + 'truncated' => $rows['truncated'], + 'quadrants' => $this->aggregateQuadrants(rows: $rows['rows']), + 'rows' => $rows['rows'], + ]; + }//end buildReport() + + /** + * Build the CSV export of the same bounded, organisation-scoped row set + * the JSON report uses — never a separate unbounded/unscoped data path. + * + * @param string $organisationUuid The `afnemer` organisation UUID to export. + * + * @return string The CSV document (header + one data row per gebruik). + * + * @throws Exception When OpenRegister or the voorzieningen configuration is unavailable. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-csv-export-of-the-portfolio-report + */ + public function buildCsv(string $organisationUuid): string + { + $this->relationCache = []; + $built = $this->buildRows(organisationUuid: $organisationUuid); + + $handle = fopen('php://temp', 'r+'); + fputcsv( + $handle, + [ + 'organisation', + 'module', + 'timeClassification', + 'timeRationale', + 'timeReviewDate', + 'lifecyclePhase', + 'eolStatus', + 'hostingModel', + 'annualisedCost', + 'oneOffCost', + ] + ); + + foreach ($built['rows'] as $row) { + fputcsv( + $handle, + [ + $organisationUuid, + $row['moduleName'], + $row['timeClassification'] ?? '', + $row['timeRationale'] ?? '', + $row['timeReviewDate'] ?? '', + $row['lifecyclePhase'], + $this->derivation->eolStatusLabel(row: $row), + implode('|', $row['hostingModel']), + (string) $row['annualisedCost'], + (string) $row['oneOffCost'], + ] + ); + } + + rewind($handle); + $csv = stream_get_contents($handle); + fclose($handle); + + if ($csv === false) { + return ''; + } + + return $csv; + }//end buildCsv() + + /** + * Fetch the organisation's gebruiken (bounded), resolve their + * moduleVersie/module/contract context, and build one report row per + * gebruik. + * + * @param string $organisationUuid The `afnemer` organisation UUID. + * + * @return array{rows: array>, total: int, ceiling: int, truncated: bool} + * + * @throws Exception When OpenRegister or configuration resolution fails. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + private function buildRows(string $organisationUuid): array + { + $objectService = $this->getObjectService(); + $cfg = $this->getRegisterConfig(); + $ceiling = $this->getPageSizeCeiling(); + $now = new DateTimeImmutable(); + + $gebruikQuery = [ + '@self' => [ + 'register' => $cfg['registerId'], + 'schema' => $cfg['gebruikSchema'], + ], + 'afnemer' => $organisationUuid, + '_limit' => $ceiling, + ]; + + $gebruikResult = $objectService->searchObjectsPaginated(query: $gebruikQuery, _rbac: false, _multitenancy: false); + $gebruiken = $this->derivation->normalizeResults(results: $gebruikResult['results'] ?? []); + $total = (int) ($gebruikResult['total'] ?? count($gebruiken)); + $truncated = $total > count($gebruiken); + + $gebruikIds = []; + foreach ($gebruiken as $gebruik) { + $id = $this->derivation->resolveRelationId(value: $gebruik['id'] ?? ($gebruik['@self']['id'] ?? null)); + if ($id !== '') { + $gebruikIds[] = $id; + } + } + + $contractsByGebruik = $this->fetchContractsForGebruiken(gebruikIds: $gebruikIds, cfg: $cfg, ceiling: $ceiling); + + $rows = []; + foreach ($gebruiken as $gebruik) { + $rows[] = $this->buildRow(gebruik: $gebruik, cfg: $cfg, contractsByGebruik: $contractsByGebruik, now: $now); + } + + return [ + 'rows' => $rows, + 'total' => $total, + 'ceiling' => $ceiling, + 'truncated' => $truncated, + ]; + }//end buildRows() + + /** + * Build one report row for a single gebruik. + * + * @param array $gebruik The gebruik data bag. + * @param array $cfg Resolved register/schema ids. + * @param array> $contractsByGebruik Contract rows indexed by gebruik uuid. + * @param DateTimeImmutable $now Reference moment. + * + * @return array The row. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + */ + private function buildRow(array $gebruik, array $cfg, array $contractsByGebruik, DateTimeImmutable $now): array + { + $gebruikId = $this->derivation->resolveRelationId(value: $gebruik['id'] ?? ($gebruik['@self']['id'] ?? null)); + $moduleId = $this->derivation->resolveRelationId(value: $gebruik['module'] ?? null); + $versieId = $this->derivation->resolveRelationId(value: $gebruik['moduleVersie'] ?? null); + + $module = null; + if ($moduleId !== '') { + $module = $this->fetchRelation(id: $moduleId, schemaId: $cfg['moduleSchema']); + } + + $moduleVersie = null; + if ($versieId !== '') { + $moduleVersie = $this->fetchRelation(id: $versieId, schemaId: $cfg['moduleVersieSchema']); + } + + $eol = $this->derivation->deriveEolState(moduleVersie: $moduleVersie, now: $now); + $eolApproaching = $this->derivation->isEolApproaching(moduleVersie: $moduleVersie, now: $now); + + $cost = $this->sumContractCost(contracts: $contractsByGebruik[$gebruikId] ?? []); + + $hostingModel = $gebruik['cloudDienstverleningsmodel'] ?? []; + if (is_array($hostingModel) === false) { + // A scalar (non-array) stored value — normalise to a one-element + // list so the row/aggregate code always iterates an array. + $hostingModel = [$hostingModel]; + } + + $classification = $this->normalizeClassification(value: $gebruik['timeClassification'] ?? null); + + return [ + 'uuid' => $gebruikId, + 'moduleId' => $moduleId, + 'moduleName' => $module['naam'] ?? $module['title'] ?? $moduleId, + 'timeClassification' => $classification, + 'quadrant' => $classification ?? self::QUADRANT_UNCLASSIFIED, + 'timeRationale' => $gebruik['timeRationale'] ?? null, + 'timeReviewDate' => $gebruik['timeReviewDate'] ?? null, + 'lifecyclePhase' => $this->derivation->deriveLifecyclePhase(gebruik: $gebruik, now: $now), + 'eol' => $eol, + 'eolApproaching' => $eolApproaching, + 'hostingModel' => array_values(array_filter($hostingModel, static fn ($v) => is_string($v) === true && $v !== '')), + 'annualisedCost' => $cost['annual'], + 'oneOffCost' => $cost['oneOff'], + ]; + }//end buildRow() + + /** + * Sum annualised + one-off cost across a set of contracts. + * + * @param array> $contracts Contract data bags. + * + * @return array{annual: float, oneOff: float} + */ + private function sumContractCost(array $contracts): array + { + $cost = ['annual' => 0.0, 'oneOff' => 0.0]; + foreach ($contracts as $contract) { + $c = $this->derivation->annualisedCost(contract: $contract); + $cost['annual'] = $cost['annual'] + $c['annual']; + $cost['oneOff'] = $cost['oneOff'] + $c['oneOff']; + } + + return $cost; + }//end sumContractCost() + + /** + * Normalize a raw `timeClassification` value to one of the four + * canonical quadrant values, or null when absent/invalid. + * + * @param mixed $value The raw stored value. + * + * @return string|null The normalized classification, or null. + */ + private function normalizeClassification(mixed $value): ?string + { + $valid = [ + self::QUADRANT_TOLERATE, + self::QUADRANT_INVEST, + self::QUADRANT_MIGRATE, + self::QUADRANT_ELIMINATE, + ]; + + if (is_string($value) === true && in_array($value, $valid, true) === true) { + return $value; + } + + return null; + }//end normalizeClassification() + + /** + * Aggregate report rows into per-quadrant figures (count, EOL exposure, + * cloud-transition share, summed annualised/one-off cost). + * + * @param array> $rows Report rows from {@see buildRows()}. + * + * @return array> Quadrant key → aggregate figures. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + */ + private function aggregateQuadrants(array $rows): array + { + $quadrants = []; + foreach (self::QUADRANTS as $quadrant) { + $quadrants[$quadrant] = [ + 'count' => 0, + 'eolExposedCount' => 0, + 'cloudTransition' => [], + 'annualisedCost' => 0.0, + 'oneOffCost' => 0.0, + ]; + } + + foreach ($rows as $row) { + $quadrants[$row['quadrant']] = $this->mergeRowIntoQuadrant( + quadrant: $quadrants[$row['quadrant']] ?? $quadrants[self::QUADRANT_UNCLASSIFIED], + row: $row + ); + } + + return $quadrants; + }//end aggregateQuadrants() + + /** + * Fold one report row's figures into a quadrant's running aggregate. + * + * @param array $quadrant The quadrant's current aggregate. + * @param array $row The row to fold in. + * + * @return array The updated aggregate. + */ + private function mergeRowIntoQuadrant(array $quadrant, array $row): array + { + $quadrant['count']++; + if ($row['eol']['passed'] === true || $row['eolApproaching'] === true) { + $quadrant['eolExposedCount']++; + } + + foreach ($row['hostingModel'] as $model) { + $quadrant['cloudTransition'][$model] = ($quadrant['cloudTransition'][$model] ?? 0) + 1; + } + + $quadrant['annualisedCost'] += $row['annualisedCost']; + $quadrant['oneOffCost'] += $row['oneOffCost']; + + return $quadrant; + }//end mergeRowIntoQuadrant() + + /** + * Fetch contracts linked to a bounded set of gebruik ids, indexed by + * gebruik uuid. + * + * @param array $gebruikIds The gebruik uuids to fetch contracts for. + * @param array $cfg Resolved register/schema ids. + * @param int $ceiling The gebruik page-size ceiling (basis for the contract bound). + * + * @return array>> Gebruik uuid → contract rows. + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + private function fetchContractsForGebruiken(array $gebruikIds, array $cfg, int $ceiling): array + { + if ($gebruikIds === [] || $cfg['contractSchema'] === null) { + return []; + } + + $objectService = $this->getObjectService(); + $query = [ + '@self' => [ + 'register' => $cfg['registerId'], + 'schema' => $cfg['contractSchema'], + ], + 'gebruik' => $gebruikIds, + '_limit' => min($ceiling * self::CONTRACT_LIMIT_MULTIPLIER, 5000), + ]; + + try { + $result = $objectService->searchObjectsPaginated(query: $query, _rbac: false, _multitenancy: false); + } catch (\Throwable $e) { + $this->logger->warning('PortfolioReportService: contract fetch failed', ['error' => $e->getMessage()]); + return []; + } + + $indexed = []; + foreach ($this->derivation->normalizeResults(results: $result['results'] ?? []) as $contract) { + $gebruikId = $this->derivation->resolveRelationId(value: $contract['gebruik'] ?? null); + if ($gebruikId === '') { + continue; + } + + $indexed[$gebruikId][] = $contract; + } + + return $indexed; + }//end fetchContractsForGebruiken() + + /** + * Fetch and cache a single related object (moduleVersie or module) by id. + * + * @param string $id The related object's uuid. + * @param int|null $schemaId The related object's schema id (for a scoped lookup). + * + * @return array|null The related object's data bag, or null when unresolved. + */ + private function fetchRelation(string $id, ?int $schemaId): ?array + { + $cacheKey = $schemaId.':'.$id; + if (array_key_exists($cacheKey, $this->relationCache) === true) { + return $this->relationCache[$cacheKey]; + } + + $result = null; + try { + $objectService = $this->getObjectService(); + $entity = $objectService->find(id: $id, _rbac: false, _multitenancy: false); + if ($entity !== null) { + $result = $entity->getObject(); + } + } catch (\Throwable $e) { + $this->logger->debug('PortfolioReportService: relation fetch failed', ['id' => $id, 'error' => $e->getMessage()]); + } + + $this->relationCache[$cacheKey] = $result; + + return $result; + }//end fetchRelation() + + /** + * Resolve the voorzieningen register + schema ids this service needs. + * + * @return array{registerId: int|null, gebruikSchema: int|null, contractSchema: int|null, moduleSchema: int|null, moduleVersieSchema: int|null} + * + * @throws Exception When the voorzieningen configuration is unavailable. + */ + private function getRegisterConfig(): array + { + $config = $this->settingsService->getVoorzieningenConfig(); + + $registerId = $config['register'] ?? null; + if (empty($registerId) === true) { + throw new Exception('Voorzieningen configuration not found. Please configure the schemas in the admin panel.'); + } + + $toInt = static function ($value): ?int { + if (empty($value) === true) { + return null; + } + + return (int) $value; + }; + + return [ + 'registerId' => (int) $registerId, + 'gebruikSchema' => $toInt($config['gebruik_schema'] ?? null), + 'contractSchema' => $toInt($config['contract_schema'] ?? null), + 'moduleSchema' => $toInt($config['module_schema'] ?? null), + 'moduleVersieSchema' => $toInt($config['moduleVersie_schema'] ?? null), + ]; + }//end getRegisterConfig() + + /** + * Resolve the configured report page-size ceiling, falling back to + * {@see self::DEFAULT_PAGE_SIZE_CEILING} when unset or invalid. + * + * @return int The page-size ceiling (always >= 1). + * + * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded + */ + private function getPageSizeCeiling(): int + { + try { + $value = $this->config->getValueInt(Application::APP_ID, 'portfolio_report_page_size_ceiling', self::DEFAULT_PAGE_SIZE_CEILING); + } catch (\Throwable $e) { + $value = self::DEFAULT_PAGE_SIZE_CEILING; + } + + if ($value > 0) { + return $value; + } + + return self::DEFAULT_PAGE_SIZE_CEILING; + }//end getPageSizeCeiling() + + /** + * Lazily resolve the OpenRegister ObjectService. + * + * @return ObjectService The service. + * + * @throws Exception When OpenRegister is not installed or unresolvable. + */ + private function getObjectService(): ObjectService + { + if (in_array('openregister', $this->appManager->getInstalledApps(), true) === false) { + throw new Exception('OpenRegister app is not installed'); + } + + try { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (\Throwable $e) { + throw new Exception('Failed to get OpenRegister service: '.$e->getMessage()); + } + }//end getObjectService() +}//end class diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index a54354e0..1cd36129 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -2485,7 +2485,7 @@ "slug": "gebruik", "title": "Gebruik", "description": "Het gebruik van applicaties, diensten en koppelingen door afnemers", - "version": "1.3.0", + "version": "1.4.0", "summary": "", "icon": "Usage", "x-openregister-notifications": { @@ -2909,6 +2909,40 @@ "facetable": false, "title": "Geplande vervangingsdatum", "example": "Bijvoorbeeld: 2027-01-01" + }, + "timeClassification": { + "description": "Gartner TIME-classificatie van dit gebruik: Tolerate (gedogen), Invest (investeren), Migrate (migreren) of Eliminate (uitfaseren). Wordt per gebruik vastgelegd, niet op de module zelf.", + "type": "string", + "visible": true, + "order": 32, + "facetable": true, + "title": "TIME-classificatie", + "enum": [ + "Tolerate", + "Invest", + "Migrate", + "Eliminate" + ], + "example": "Bijvoorbeeld: Migrate" + }, + "timeRationale": { + "description": "Onderbouwing van de TIME-classificatie voor dit gebruik.", + "type": "string", + "visible": true, + "order": 33, + "facetable": false, + "title": "TIME-onderbouwing", + "example": "Bijvoorbeeld: Verouderd platform, opvolger reeds gepland" + }, + "timeReviewDate": { + "description": "Datum waarop de TIME-classificatie van dit gebruik opnieuw beoordeeld moet worden.", + "type": "string", + "format": "date", + "visible": true, + "order": 34, + "facetable": false, + "title": "TIME-herbeoordelingsdatum", + "example": "Bijvoorbeeld: 2027-01-01" } }, "archive": [], diff --git a/openspec/changes/portfolio-rationalization-time/.openspec.yaml b/openspec/changes/archive/2026-07-23-portfolio-rationalization-time/.openspec.yaml similarity index 100% rename from openspec/changes/portfolio-rationalization-time/.openspec.yaml rename to openspec/changes/archive/2026-07-23-portfolio-rationalization-time/.openspec.yaml diff --git a/openspec/changes/portfolio-rationalization-time/context-brief.md b/openspec/changes/archive/2026-07-23-portfolio-rationalization-time/context-brief.md similarity index 100% rename from openspec/changes/portfolio-rationalization-time/context-brief.md rename to openspec/changes/archive/2026-07-23-portfolio-rationalization-time/context-brief.md diff --git a/openspec/changes/portfolio-rationalization-time/design.md b/openspec/changes/archive/2026-07-23-portfolio-rationalization-time/design.md similarity index 100% rename from openspec/changes/portfolio-rationalization-time/design.md rename to openspec/changes/archive/2026-07-23-portfolio-rationalization-time/design.md diff --git a/openspec/changes/portfolio-rationalization-time/proposal.md b/openspec/changes/archive/2026-07-23-portfolio-rationalization-time/proposal.md similarity index 100% rename from openspec/changes/portfolio-rationalization-time/proposal.md rename to openspec/changes/archive/2026-07-23-portfolio-rationalization-time/proposal.md diff --git a/openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md b/openspec/changes/archive/2026-07-23-portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md similarity index 100% rename from openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md rename to openspec/changes/archive/2026-07-23-portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md diff --git a/openspec/changes/portfolio-rationalization-time/tasks.md b/openspec/changes/archive/2026-07-23-portfolio-rationalization-time/tasks.md similarity index 56% rename from openspec/changes/portfolio-rationalization-time/tasks.md rename to openspec/changes/archive/2026-07-23-portfolio-rationalization-time/tasks.md index e53dc380..ea19c33c 100644 --- a/openspec/changes/portfolio-rationalization-time/tasks.md +++ b/openspec/changes/archive/2026-07-23-portfolio-rationalization-time/tasks.md @@ -8,8 +8,8 @@ - **acceptance_criteria**: - GIVEN the current `gebruik` schema WHEN diffed against the merge base THEN it gains exactly three new optional properties (`timeClassification` enum `Tolerate`/`Invest`/`Migrate`/`Eliminate`, `timeRationale` string, `timeReviewDate` date), matching the `status` field's enum-on-string shape - GIVEN existing gebruik objects WHEN the updated register is imported via `ConfigurationService::importFromApp()` THEN they load and save unchanged with no `timeClassification` value -- [ ] Implement -- [ ] Test +- [x] Implement — three properties added as a targeted diff (`version` bumped 1.3.0→1.4.0); `cloudDienstverleningsmodel` untouched. +- [x] Test — `tests/Unit/Service/PortfolioTimeRegisterShapeTest.php` (5 tests, all pass); `python3 -m json.tool` validated. ### Task 2: Add TIME fields to the gebruik edit surface with PUT-semantic carry-forward - **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-editing-time-fields-preserves-every-other-gebruik-field` @@ -17,8 +17,8 @@ - **acceptance_criteria**: - GIVEN a gebruik with `status`, phase dates, and `cloudDienstverleningsmodel` already set WHEN a user edits only the TIME fields and saves THEN the PUT request body includes all pre-existing field values unchanged alongside the edited TIME fields - GIVEN a user clears a previously set `timeClassification` WHEN they save THEN the gebruik has no `timeClassification` value and no longer counts toward any TIME quadrant -- [ ] Implement -- [ ] Test +- [x] Implement — `gebruik` has no dedicated page; it is edited via the generic `ObjectModal.vue` (`Modals.vue` GENERIC_MODAL_OBJECT_TYPES). Added an enum-on-string branch (clearable `NcSelect`, `input-label`) ahead of the free-text branch — benefits `status` and every other enum field too. `formData = cloneDeep(activeObject)` already carries every field forward (PUT-semantic); the edit only mutates the touched key. `src/views/organisaties/OrganisatieIndex.vue` needed no change — it does not edit gebruik. +- [x] Test — covered by the backend `PortfolioTimeRegisterShapeTest` (schema shape) + manual code-path verification (`formData` clone + single-key `setFieldValue`); no Vue-component-mount test harness exists in this project (all `tests/vitest/*` are pure-function specs) so no new mount test was added — logic is otherwise fully exercised by `PortfolioReportService`/Derivation tests on the read side. ### Task 3: Add PortfolioReportController and route - **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation` @@ -26,8 +26,8 @@ - **acceptance_criteria**: - GIVEN a valid organisation UUID WHEN `GET /api/portfolio-report?organisation={uuid}` is called by an authorised user THEN a 200 JSON response with TIME quadrant, EOL, cloud-transition, and cost figures is returned - GIVEN the route is registered WHEN routes.php is inspected THEN the controller method carries the correct NC auth attribute (per hydra-gate-route-auth) matching its actual authorisation requirement -- [ ] Implement -- [ ] Test +- [x] Implement — `portfolioReport#index` route (`GET /api/portfolio-report`) registered; controller carries `@NoAdminRequired`/`@NoCSRFRequired` (matches actual requirement: authenticated, non-admin-only, GET-only). +- [x] Test — `PortfolioReportControllerTest` (9 tests, all pass). ### Task 4: Implement PortfolioReportService aggregation (TIME + EOL + cloud + cost), bounded - **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-aggregation-queries-are-bounded` @@ -36,8 +36,8 @@ - GIVEN an organisation's gebruiken across all four TIME quadrants plus unclassified WHEN the report is built THEN quadrant counts, EOL exposure (reusing the lifecycle end-of-support rule), cloud-transition share (from `cloudDienstverleningsmodel`), and annualised cost per quadrant (reusing the contract cost derivation) are all present - GIVEN the service builds any OpenRegister query WHEN inspected THEN every call includes an explicit `_limit` or uses `searchObjectsPaginated` — no unbounded `searchObjects()` call - GIVEN an organisation's gebruik count exceeds the configured page-size ceiling WHEN the report is built THEN the response discloses truncation ("first N of M") rather than presenting a silently incomplete total -- [ ] Implement -- [ ] Test +- [x] Implement — `PortfolioReportService` (aggregation) + `PortfolioReportDerivation` (pure phase/EOL/cost/relation-id rules); every `searchObjectsPaginated()` call carries an explicit `_limit`; `portfolio_report_page_size_ceiling` app-config (default 500) seeded in `InitializeSettings`. +- [x] Test — `PortfolioReportServiceTest` (20 tests incl. bounded-query + truncation-disclosure assertions), all pass. ### Task 5: Enforce organisation-scoped authorisation on the report endpoint - **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-report-and-csv-export-are-scoped-to-the-requesters-authorised-organisations` @@ -46,8 +46,8 @@ - GIVEN a user not authorised for organisation B WHEN they request the report for organisation B THEN the request is denied before any organisation B data is queried (fail closed) - GIVEN a user authorised for organisation A WHEN they request the report for organisation A THEN only organisation A data is returned - Note: re-verify this task's enforcement point once `vendor-visibility-rbac` lands, per design.md Risks — the gating mechanism may move -- [ ] Implement -- [ ] Test +- [x] Implement — `isAuthorisedForOrganisation()` checked BEFORE `buildReport()`/`buildCsv()` is ever invoked (deny-before-query, fail closed); `admin`/`ambtenaar` bypass, others scoped to their own `IConfig` org value. +- [x] Test — `PortfolioReportControllerTest::testCrossOrganisationRequestIsDeniedBeforeQuery`, `testOwnOrganisationRequestReturnsReport`, `testAdminMayRequestAnyOrganisation`, `testAmbtenaarMayRequestAnyOrganisation`, `testNoActiveOrganisationIsDenied` all pass. Verified against the LANDED `vendor-visibility-rbac` mechanism (`GebruikController::resolveUserRoles()`/`applyAanbodScopeToOptions()`, `gebruik-beheerder`/`aanbod-beheerder`/`ambtenaar`/`admin` groups, same `IConfig::getUserValue('core','organisation')` org resolution): the report's check is a **strict subset** of both REQ-002 (vendor/aanbod-beheerder) and REQ-003 (gebruik-beheerder) — own-organisation-only, never broader — consistent with design.md's "a report synthesises data REQ-002/REQ-003 do not grant beyond the caller's own organisation" note. No conflict. ### Task 6: Add CSV export format variant - **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-csv-export-of-the-portfolio-report` @@ -55,8 +55,8 @@ - **acceptance_criteria**: - GIVEN a user views the portfolio report WHEN they request `?format=csv` for the same organisation THEN the CSV contains one row per gebruik shown on screen with TIME classification, rationale, review date, lifecycle phase, EOL status, hosting model, and annualised cost, under the same scope and bound as the JSON report - GIVEN a user not authorised for organisation B WHEN they request the CSV export for organisation B THEN the request is denied and no CSV is returned -- [ ] Implement -- [ ] Test +- [x] Implement — `?format=csv` returns `DataDownloadResponse` from `PortfolioReportService::buildCsv()`, same `buildRows()` bounded/scoped row set as the JSON path, gated by the same `isAuthorisedForOrganisation()` check before either builder runs. +- [x] Test — `PortfolioReportControllerTest::testCsvFormatReturnsDownloadResponse`, `testCsvFormatDeniedForUnauthorisedOrganisation` pass. ### Task 7: Build the portfolio rationalization report page (quadrant chart + tables + CSV button) - **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation` @@ -65,24 +65,24 @@ - GIVEN an organisation is selected WHEN the report page loads THEN it renders a TIME quadrant chart (apexcharts via `@conduction/nextcloud-vue`) plus supporting tables for EOL exposure, cloud-transition share, and cost overlay, using `CnDashboardPage` composition (ADR-012) and NL Design System tokens (ADR-003, no hardcoded colors) - GIVEN unclassified gebruiken exist WHEN the report renders THEN they appear in a visible Unclassified group, not omitted - GIVEN the user clicks "Export CSV" WHEN the download completes THEN the file matches the on-screen report's rows -- [ ] Implement -- [ ] Test +- [x] Implement — `src/views/organisaties/PortfolioReport.vue` (manifest page `PortfolioReport`, `type: custom`, registered in `src/customComponents.js`): TIME quadrant bar chart via `CnChartWidget` (apexcharts), quadrant summary table, per-quadrant gebruik tables (Unclassified always rendered), truncation banner, Export CSV button. Deviates from design.md's literal "`CnDashboardPage` composition" suggestion — follows this codebase's OWN established precedent instead (`LifecycleRoadmapView`/`LicensePostureView`, both `type: custom` thin renderers, not `CnDashboardPage`), since a report reading ONE composed backend endpoint (not a widget-grid dashboard) doesn't fit `CnDashboardPage`'s per-widget composition model — same reasoning those two prior features already documented in their manifest `_note`s. Cn components used throughout (`CnChartWidget`, `NcSelect` w/ `input-label`, `NcButton`, `NcNoteCard`, `NcEmptyContent`, `NcLoadingIcon`); no hardcoded colours (`var(--color-*)` tokens only). +- [x] Test — `tests/vitest/portfolioReport.spec.js` (15 tests, pure display-formatting utils: quadrant colour map, cloud-transition label, currency format, quadrant grouping incl. Unclassified-never-omitted, CSV URL builder). No Vue-component-mount tests exist anywhere in this project's `tests/vitest/` (all pure-function specs); consistent with that convention, business logic was extracted to `src/utils/portfolioReport.js` and tested there rather than adding a new mount-test harness. ### Task 8: Add Dutch and English translation strings - **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-time-classification-fields-are-recorded-on-the-gebruik-schema` - **files**: `l10n/nl.json`, `l10n/en.json` - **acceptance_criteria**: - GIVEN the new TIME fields, quadrant labels (Tolerate/Invest/Migrate/Eliminate/Unclassified), report page, and CSV export button WHEN the UI renders in `nl_NL` or `en_US` THEN every new user-facing string is translated (no raw i18n keys visible) -- [ ] Implement -- [ ] Test +- [x] Implement — `l10n/en.json` (source, via `node tests/l10n/check-l10n.js --write`), `l10n/nl.{js,json}`, `l10n/en_US.{js,json}` all carry every new string. Also fixed a PRE-EXISTING gap surfaced by the same check run: 24 already-merged `organisation-merge` strings were missing from `l10n/en.json`/nl/en_US (added, per project rule to fix pre-existing issues encountered during a task). +- [x] Test — `node tests/l10n/check-l10n.js` passes its primary check (every used key present in `en.json`; 0 missing). The script's SEPARATE `l10n-parity` step (36-locale completeness) was ALREADY failing before this session touched anything (verified via `git stash -u` against the untouched branch — the organisation-merge feature's `en.json` gap alone already failed it) and remains out of scope: translating ~450 keys into 34 languages beyond NL/EN is a pre-existing, fleet-wide gap, not something this change introduces or is scoped to close. ### Task 9: Write feature docs with Playwright screenshots - **spec_ref**: `openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation` - **files**: `docs/features/portfolio-rationalization-time.md`, `docs/images/` - **acceptance_criteria**: - GIVEN the feature is implemented WHEN docs are captured via Playwright MCP THEN `docs/features/portfolio-rationalization-time.md` documents TIME classification editing and the report/export flow with committed screenshots in `docs/images/` -- [ ] Implement -- [ ] Test +- [x] Implement — `docs/features/portfolio-rationalization-time.md` written (classification, cloud-transition reuse, report shape, bounding, RBAC, CSV, report page). +- [ ] Test — **NOT DONE**: Playwright-captured screenshots were not taken. This resumed-build session's instructions explicitly forbid shared docker restarts / touching anything outside this worktree, and no isolated Nextcloud+softwarecatalog+OpenRegister instance with this branch's frontend built and register schema imported was available to capture against. The doc file ships without `docs/images/` and says so at the top. Follow-up: capture in a subsequent session with an isolated/matched instance. ## Quality checklist diff --git a/openspec/specs/portfolio-rationalization-time/spec.md b/openspec/specs/portfolio-rationalization-time/spec.md new file mode 100644 index 00000000..1d484356 --- /dev/null +++ b/openspec/specs/portfolio-rationalization-time/spec.md @@ -0,0 +1,167 @@ +# portfolio-rationalization-time Specification + +## Purpose +TBD - created by archiving change portfolio-rationalization-time. Update Purpose after archive. +## Requirements +### Requirement: TIME classification fields are recorded on the gebruik schema + +The `gebruik` schema SHALL gain three optional fields: `timeClassification` +(enum: `Tolerate`, `Invest`, `Migrate`, `Eliminate`), `timeRationale` (free +text), and `timeReviewDate` (date). TIME classification SHALL be recorded +per gebruik (per organisation's usage of an application), never on the +module or application itself, mirroring how `geplandeVervanging` is scoped +per gebruik rather than per module. Existing gebruik objects SHALL remain +valid without the new fields, and SHALL be treated as unclassified +(no TIME quadrant) until a value is set. + +#### Scenario: User classifies a gebruik as Migrate with a rationale + +- **WHEN** a user edits a gebruik and sets `timeClassification` to + `Migrate`, a `timeRationale`, and a `timeReviewDate` +- **THEN** the gebruik stores all three fields +- **AND** the gebruik appears in the `Migrate` quadrant of the portfolio + report for its organisation + +#### Scenario: Existing objects are unaffected by the schema addition + +- **WHEN** the updated register definition is imported over existing + gebruik data +- **THEN** existing gebruik objects without the new fields load and save + unchanged +- **AND** they are excluded from every TIME quadrant count until classified + +#### Scenario: Clearing the classification returns the gebruik to unclassified + +- **WHEN** a user clears a previously set `timeClassification` on a gebruik +- **THEN** the gebruik has no `timeClassification` value +- **AND** it no longer counts toward any TIME quadrant in the report + +### Requirement: Editing TIME fields preserves every other gebruik field + +The gebruik edit flow SHALL read the complete current gebruik object before +submitting a save and SHALL carry every existing field forward unchanged +alongside the edited TIME fields, because OpenRegister's `saveObject` is +PUT-semantic. Editing only `timeClassification`, `timeRationale`, or +`timeReviewDate` SHALL NOT null out, omit, or otherwise alter any other +gebruik field (including `status`, phase-start dates, relations such as +`module`, `deelnemers`, `koppelingen`, or `cloudDienstverleningsmodel`). + +#### Scenario: A TIME-only edit leaves unrelated fields intact + +- **GIVEN** a gebruik with `status: "In productie"`, + `startDatumInProductie` set, and `cloudDienstverleningsmodel: ["SaaS"]` +- **WHEN** a user edits only the TIME fields and saves +- **THEN** the saved gebruik still has `status: "In productie"`, + the same `startDatumInProductie`, and `cloudDienstverleningsmodel: ["SaaS"]` + unchanged + +### Requirement: Portfolio rationalization report aggregates per organisation + +The app SHALL provide a portfolio rationalization report for a selected +organisation that shows: TIME quadrant counts (Tolerate / Invest / Migrate / +Eliminate, plus an Unclassified count) across the organisation's gebruiken; +EOL exposure reusing the `application-lifecycle-tracking` end-of-support +derivation (count and list of gebruiken whose linked `moduleVersie` has +passed or approaching end-of-support); cloud-transition share derived from +the existing `cloudDienstverleningsmodel` field (share of gebruiken per +hosting model — no new deployment-model field is introduced); and an +annualised cost overlay per TIME quadrant, reusing the +`contract-administration` annualised-cost derivation over each gebruik's +linked contracts. Figures SHALL be computed at query time, never persisted. + +#### Scenario: Report shows quadrant counts with EOL and cost overlay + +- **GIVEN** an organisation with gebruiken classified across all four TIME + quadrants, some with end-of-support versions, and some with active + contracts +- **WHEN** a user opens the portfolio rationalization report for that + organisation +- **THEN** the report shows a count per TIME quadrant (including + Unclassified) +- **AND** each quadrant shows its EOL-exposed gebruik count +- **AND** each quadrant shows its cloud-transition share by hosting model +- **AND** each quadrant shows its summed annualised contract cost + +#### Scenario: Unclassified gebruiken are visible, not hidden + +- **GIVEN** an organisation with gebruiken that have no `timeClassification` + set +- **WHEN** the report is opened +- **THEN** those gebruiken appear in an `Unclassified` group rather than + being omitted from the report + +### Requirement: Report aggregation queries are bounded + +Every query the portfolio report endpoint issues against OpenRegister SHALL +set an explicit `_limit` or use `searchObjectsPaginated` — the report SHALL +NOT issue an unbounded `searchObjects()` call, per the +`bound-unbounded-searchobjects-scans` bounded-query requirement. The report +SHALL apply an explicit page-size ceiling per organisation in addition to +the natural bound of "one organisation's gebruiken", and SHALL disclose when +the result set is truncated at that ceiling rather than silently dropping +rows. + +#### Scenario: Report query sets an explicit limit + +- **WHEN** the portfolio report endpoint builds its query for an + organisation's gebruiken +- **THEN** the query array MUST include an explicit `_limit` value +- **AND** the value MUST NOT be silently omitted or left to default + +#### Scenario: Truncation is disclosed, not silent + +- **GIVEN** an organisation's gebruik count exceeds the report's page-size + ceiling +- **WHEN** the report is generated +- **THEN** the report indicates it is showing a bounded subset (e.g. "first + N of M") +- **AND** does not present the truncated figures as a complete total + without that disclosure + +### Requirement: Report and CSV export are scoped to the requester's authorised organisation(s) + +The portfolio report endpoint (and its CSV export variant) SHALL scope every +result to organisations the requesting user is authorised to see, using the +current tenant/organisation-scoping mechanism that gates other gebruik and +contract reads. A request naming an organisation the requesting user is not +authorised to see SHALL be denied (fail closed), never silently returned +empty or narrowed after an initial broader fetch. + +#### Scenario: Report request for an unauthorised organisation is denied + +- **GIVEN** a user is not authorised to see organisation B's gebruiken +- **WHEN** that user requests the portfolio report for organisation B +- **THEN** the request is denied +- **AND** no organisation B gebruik, contract, or cost data is included in + the response + +#### Scenario: Report request for an authorised organisation returns only that organisation's data + +- **GIVEN** a user is authorised to see organisation A +- **WHEN** that user requests the portfolio report for organisation A +- **THEN** the response contains only gebruiken, EOL exposure, and cost + figures belonging to organisation A + +### Requirement: CSV export of the portfolio report + +The portfolio report SHALL offer a CSV export of its underlying gebruik-level +rows (organisation, application/module, TIME classification, rationale, +review date, lifecycle phase, EOL status, hosting/deployment model, and +annualised cost), scoped and bounded identically to the on-screen report — +the export SHALL NOT be a separate unbounded or unscoped data path. + +#### Scenario: CSV export matches the on-screen report's scope + +- **GIVEN** a user views the portfolio report for an organisation +- **WHEN** the user exports it as CSV +- **THEN** the CSV contains one row per gebruik shown in the report, with + the same organisation scoping and page-size bound as the on-screen view +- **AND** each row includes TIME classification, rationale, review date, + lifecycle phase, EOL status, hosting/deployment model, and annualised cost + +#### Scenario: CSV export is denied for an unauthorised organisation + +- **GIVEN** a user is not authorised to see organisation B's gebruiken +- **WHEN** that user requests the CSV export for organisation B +- **THEN** the request is denied and no CSV is returned + diff --git a/src/customComponents.js b/src/customComponents.js index 339025fa..e1eb3f6e 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -28,6 +28,7 @@ import KwetsbaarhedenView from './views/KwetsbaarhedenView.vue' import VulnerabilityExposurePanel from './components/vulnerabilities/VulnerabilityExposurePanel.vue' import LicensePostureView from './views/LicensePostureView.vue' import FacetedCatalogIndexView from './views/FacetedCatalogIndexView.vue' +import PortfolioReportView from './views/organisaties/PortfolioReport.vue' export default { // OrganisatieCard — the bespoke card (inline contactpersoon toggle) used as @@ -114,4 +115,14 @@ export default { // the lib grows a facet-sidebar mode whose counts/narrowing are computed by // an external, non-schema-field aggregation (see FacetedCatalogIndexView.vue). FacetedCatalogIndexView, + // --- Lib gap: composed backend-aggregate rationalization report. --- + // GET /api/portfolio-report (PortfolioReportService) reads TIME quadrant + // counts + EOL exposure + cloud-transition share + annualised cost as a + // SINGLE bounded, organisation-scoped, RBAC-checked JSON payload plus a + // CSV variant. Unlike LicensePostureView/LifecycleRoadmapView (client-side + // derivation over full collections) this page is a thin renderer over + // that endpoint — no built-in index/detail/dashboard type expresses a + // fetched, pre-aggregated multi-metric report with a CSV export button. + // @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md + PortfolioReportView, } diff --git a/src/manifest.json b/src/manifest.json index 7ed21c78..1f945a23 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -121,6 +121,13 @@ "route": "LicensePosture", "order": 86 }, + { + "id": "PortfolioReport", + "label": "Portfolio rationalization", + "icon": "icon-category-monitoring", + "route": "PortfolioReport", + "order": 87 + }, { "id": "Documentation", "label": "Documentation", @@ -633,6 +640,14 @@ "component": "LifecycleRoadmapView", "_note": "Per-organisation roadmap: the selected organisation's applications-in-use grouped into derived lifecycle-phase swimlanes (Onbekend first) and ordered within each lane by nearest urgency (end-of-support / phase-out / planned replacement). No standard index/roadmap type derives and groups by computed lifecycle phase." }, + { + "id": "PortfolioReport", + "route": "/portfolio-report", + "type": "custom", + "title": "Portfolio rationalization", + "component": "PortfolioReportView", + "_note": "Gartner TIME rationalization report (openspec/changes/portfolio-rationalization-time): a per-organisation, RBAC-scoped, bounded server-side aggregate (GET /api/portfolio-report) combining TIME quadrant counts (Tolerate/Invest/Migrate/Eliminate + Unclassified) with EOL exposure (application-lifecycle-tracking derivation), cloud-transition share (cloudDienstverleningsmodel), and annualised cost overlay (contract-administration derivation) per quadrant, plus a CSV export of the underlying rows. Unlike LifecycleRoadmap/LicensePosture (which derive client-side over full collections), this report reads a single composed backend endpoint so aggregation stays bounded and organisation-scoped server-side per design.md Decision 3/4 — no built-in index/detail/dashboard type expresses a fetched, pre-aggregated multi-metric report with a CSV export variant." + }, { "id": "FeaturesRoadmap", "route": "/features-roadmap", diff --git a/src/modals/object/ObjectModal.vue b/src/modals/object/ObjectModal.vue index c4f610ed..c5a4e28c 100644 --- a/src/modals/object/ObjectModal.vue +++ b/src/modals/object/ObjectModal.vue @@ -108,7 +108,36 @@ import { objectStore, navigationStore, catalogStore } from '../../store/store.js
-