diff --git a/docs/features/portfolio-value-assessment.md b/docs/features/portfolio-value-assessment.md new file mode 100644 index 000000000..e37335a17 --- /dev/null +++ b/docs/features/portfolio-value-assessment.md @@ -0,0 +1,44 @@ + + +# Value assessment + +An information manager scores each application the organisation uses on business value, technical fit and risk. The scores sit next to the cost stackiq already adds up from contracts, and they point to a TIME class. The TIME class the organisation records stays the decision: the scores back it up or question it, they never change it. + +Specification: [`openspec/specs/application-value-assessment/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/application-value-assessment/spec.md). + +## Scoring an application + +Open the application under **Applications in use** and edit it. Fill in: + +- **Business value**: how much the organisation depends on it, from 1 (little) to 5 (critical). +- **Technical fit**: how well it fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good). +- **Risk**: the risk the organisation sees in running it, from 1 (low) to 5 (high). +- **Scored on**: the date you set the scores. + +When you save, stackiq fills in **Suggested TIME classification**: + +| Business value | Technical fit | Suggested class | +|---|---|---| +| 3 or more | 3 or more | Invest | +| 3 or more | below 3 | Migrate | +| below 3 | 3 or more | Tolerate | +| below 3 | below 3 | Eliminate | + +While either score is missing there is no suggestion. The **Value assessment** section of the page shows the scores, the recorded TIME class and the suggestion side by side. + +## Risk signals + +Below the data of the page, **Risk signals** shows what backs a risk score: whether the version you run is past its end of support (or withdrawn), and how many known vulnerabilities are linked to the application. You set the risk score yourself; the signals only inform it. + +## The portfolio report + +The portfolio report (**Reports**, then **Portfolio rationalization**) adds, for the organisation you select: + +- **Business value against technical fit**: one circle per scored application, its size the annualised cost. The circle's colour is the suggested class; a dark ring marks a recorded class that differs from the scores. Applications without both scores are counted below the chart. +- Two columns in the table: **Suggested by scores** and **Value / fit / risk**. +- The switch **Recorded class differs from scores**, which lists only the applications whose recorded class and suggestion disagree. + +The CSV export carries the columns businessValue, technicalFit, riskScore, scoredOn, suggestedTimeClassification and timeMismatch. diff --git a/l10n/en.js b/l10n/en.js index 78ad2bf27..f81da82a9 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -868,7 +868,40 @@ OC.L10N.register( "Loading the roadmap": "Loading the roadmap", "The roadmap could not be loaded.": "The roadmap could not be loaded.", "The supplier has not published a roadmap for this application": "The supplier has not published a roadmap for this application", - "No versions with a date yet": "No versions with a date yet" + "No versions with a date yet": "No versions with a date yet", + "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: business value {value}, technical fit {fit}, annualised cost {cost}", + "%n application in use is not scored yet.": "%n application in use is not scored yet.", + "%n applications in use are not scored yet.": "%n applications in use are not scored yet.", + "%n vulnerability linked to this application": "%n vulnerability linked to this application", + "%n vulnerabilities linked to this application": "%n vulnerabilities linked to this application", + "Business value": "Business value", + "Business value against technical fit": "Business value against technical fit", + "Business value against technical fit for %n scored application in use": "Business value against technical fit for %n scored application in use", + "Business value against technical fit for %n scored applications in use": "Business value against technical fit for %n scored applications in use", + "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.", + "Known vulnerabilities": "Known vulnerabilities", + "Loading the risk signals": "Loading the risk signals", + "No end of support date known": "No end of support date known", + "Not scored": "Not scored", + "Passed on {date}": "Passed on {date}", + "Recorded class differs from scores": "Recorded class differs from scores", + "Risk score": "Risk score", + "Risk signals": "Risk signals", + "Suggested by scores": "Suggested by scores", + "Supported until {date}": "Supported until {date}", + "Technical fit": "Technical fit", + "The risk signals could not be loaded.": "The risk signals could not be loaded.", + "This version was withdrawn": "This version was withdrawn", + "Value / fit / risk": "Value / fit / risk", + "Value assessment": "Value assessment", + "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "How much the organisation depends on this application, from 1 (little) to 5 (critical).", + "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).", + "Risk": "Risk", + "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.", + "Scored on": "Scored on", + "The date the scores were set.": "The date the scores were set.", + "Suggested TIME classification": "Suggested TIME classification", + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index a8fc89421..21f9325c5 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -867,6 +867,39 @@ "Loading the roadmap": "Loading the roadmap", "The roadmap could not be loaded.": "The roadmap could not be loaded.", "The supplier has not published a roadmap for this application": "The supplier has not published a roadmap for this application", - "No versions with a date yet": "No versions with a date yet" + "No versions with a date yet": "No versions with a date yet", + "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: business value {value}, technical fit {fit}, annualised cost {cost}", + "%n application in use is not scored yet.": "%n application in use is not scored yet.", + "%n applications in use are not scored yet.": "%n applications in use are not scored yet.", + "%n vulnerability linked to this application": "%n vulnerability linked to this application", + "%n vulnerabilities linked to this application": "%n vulnerabilities linked to this application", + "Business value": "Business value", + "Business value against technical fit": "Business value against technical fit", + "Business value against technical fit for %n scored application in use": "Business value against technical fit for %n scored application in use", + "Business value against technical fit for %n scored applications in use": "Business value against technical fit for %n scored applications in use", + "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.", + "Known vulnerabilities": "Known vulnerabilities", + "Loading the risk signals": "Loading the risk signals", + "No end of support date known": "No end of support date known", + "Not scored": "Not scored", + "Passed on {date}": "Passed on {date}", + "Recorded class differs from scores": "Recorded class differs from scores", + "Risk score": "Risk score", + "Risk signals": "Risk signals", + "Suggested by scores": "Suggested by scores", + "Supported until {date}": "Supported until {date}", + "Technical fit": "Technical fit", + "The risk signals could not be loaded.": "The risk signals could not be loaded.", + "This version was withdrawn": "This version was withdrawn", + "Value / fit / risk": "Value / fit / risk", + "Value assessment": "Value assessment", + "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "How much the organisation depends on this application, from 1 (little) to 5 (critical).", + "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).", + "Risk": "Risk", + "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.", + "Scored on": "Scored on", + "The date the scores were set.": "The date the scores were set.", + "Suggested TIME classification": "Suggested TIME classification", + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision." } } diff --git a/l10n/nl.js b/l10n/nl.js index b2a3bd845..5d5db0a5b 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -938,7 +938,40 @@ OC.L10N.register( "Loading the roadmap": "Roadmap laden", "The roadmap could not be loaded.": "De roadmap kon niet worden geladen.", "The supplier has not published a roadmap for this application": "De leverancier heeft geen roadmap voor deze applicatie gepubliceerd", - "No versions with a date yet": "Nog geen versies met een datum" + "No versions with a date yet": "Nog geen versies met een datum", + "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: bedrijfswaarde {value}, technische geschiktheid {fit}, jaarlijkse kosten {cost}", + "%n application in use is not scored yet.": "%n applicatie in gebruik heeft nog geen score.", + "%n applications in use are not scored yet.": "%n applicaties in gebruik hebben nog geen score.", + "%n vulnerability linked to this application": "%n kwetsbaarheid gekoppeld aan deze applicatie", + "%n vulnerabilities linked to this application": "%n kwetsbaarheden gekoppeld aan deze applicatie", + "Business value": "Bedrijfswaarde", + "Business value against technical fit": "Bedrijfswaarde tegenover technische geschiktheid", + "Business value against technical fit for %n scored application in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicatie in gebruik met een score", + "Business value against technical fit for %n scored applications in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicaties in gebruik met een score", + "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Elke cirkel is een applicatie in gebruik, de grootte staat voor de jaarlijkse kosten. Een donkere rand markeert een vastgelegde TIME-klasse die afwijkt van de scores.", + "Known vulnerabilities": "Bekende kwetsbaarheden", + "Loading the risk signals": "Risicosignalen laden", + "No end of support date known": "Geen datum voor einde ondersteuning bekend", + "Not scored": "Geen score", + "Passed on {date}": "Verlopen op {date}", + "Recorded class differs from scores": "Vastgelegde klasse wijkt af van de scores", + "Risk score": "Risicoscore", + "Risk signals": "Risicosignalen", + "Suggested by scores": "Voorgesteld door de scores", + "Supported until {date}": "Ondersteund tot {date}", + "Technical fit": "Technische geschiktheid", + "The risk signals could not be loaded.": "De risicosignalen konden niet worden geladen.", + "This version was withdrawn": "Deze versie is ingetrokken", + "Value / fit / risk": "Waarde / geschiktheid / risico", + "Value assessment": "Waardebeoordeling", + "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "Hoe sterk de organisatie van deze applicatie afhangt, van 1 (weinig) tot 5 (kritiek).", + "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "Hoe goed de applicatie past bij de architectuur en de standaarden die de organisatie volgt, van 1 (slecht) tot 5 (goed).", + "Risk": "Risico", + "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "Het risico dat de organisatie ziet in het gebruik van deze applicatie, van 1 (laag) tot 5 (hoog). De pagina toont het einde van de ondersteuning en bekende kwetsbaarheden ernaast.", + "Scored on": "Gescoord op", + "The date the scores were set.": "De datum waarop de scores zijn vastgesteld.", + "Suggested TIME classification": "Voorgestelde TIME-classificatie", + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index d4af67c76..f84eed6e2 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -937,6 +937,39 @@ "Loading the roadmap": "Roadmap laden", "The roadmap could not be loaded.": "De roadmap kon niet worden geladen.", "The supplier has not published a roadmap for this application": "De leverancier heeft geen roadmap voor deze applicatie gepubliceerd", - "No versions with a date yet": "Nog geen versies met een datum" + "No versions with a date yet": "Nog geen versies met een datum", + "{name}: business value {value}, technical fit {fit}, annualised cost {cost}": "{name}: bedrijfswaarde {value}, technische geschiktheid {fit}, jaarlijkse kosten {cost}", + "%n application in use is not scored yet.": "%n applicatie in gebruik heeft nog geen score.", + "%n applications in use are not scored yet.": "%n applicaties in gebruik hebben nog geen score.", + "%n vulnerability linked to this application": "%n kwetsbaarheid gekoppeld aan deze applicatie", + "%n vulnerabilities linked to this application": "%n kwetsbaarheden gekoppeld aan deze applicatie", + "Business value": "Bedrijfswaarde", + "Business value against technical fit": "Bedrijfswaarde tegenover technische geschiktheid", + "Business value against technical fit for %n scored application in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicatie in gebruik met een score", + "Business value against technical fit for %n scored applications in use": "Bedrijfswaarde tegenover technische geschiktheid voor %n applicaties in gebruik met een score", + "Each circle is an application in use, its size the annualised cost. A dark ring marks a recorded TIME class that differs from the scores.": "Elke cirkel is een applicatie in gebruik, de grootte staat voor de jaarlijkse kosten. Een donkere rand markeert een vastgelegde TIME-klasse die afwijkt van de scores.", + "Known vulnerabilities": "Bekende kwetsbaarheden", + "Loading the risk signals": "Risicosignalen laden", + "No end of support date known": "Geen datum voor einde ondersteuning bekend", + "Not scored": "Geen score", + "Passed on {date}": "Verlopen op {date}", + "Recorded class differs from scores": "Vastgelegde klasse wijkt af van de scores", + "Risk score": "Risicoscore", + "Risk signals": "Risicosignalen", + "Suggested by scores": "Voorgesteld door de scores", + "Supported until {date}": "Ondersteund tot {date}", + "Technical fit": "Technische geschiktheid", + "The risk signals could not be loaded.": "De risicosignalen konden niet worden geladen.", + "This version was withdrawn": "Deze versie is ingetrokken", + "Value / fit / risk": "Waarde / geschiktheid / risico", + "Value assessment": "Waardebeoordeling", + "How much the organisation depends on this application, from 1 (little) to 5 (critical).": "Hoe sterk de organisatie van deze applicatie afhangt, van 1 (weinig) tot 5 (kritiek).", + "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).": "Hoe goed de applicatie past bij de architectuur en de standaarden die de organisatie volgt, van 1 (slecht) tot 5 (goed).", + "Risk": "Risico", + "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.": "Het risico dat de organisatie ziet in het gebruik van deze applicatie, van 1 (laag) tot 5 (hoog). De pagina toont het einde van de ondersteuning en bekende kwetsbaarheden ernaast.", + "Scored on": "Gescoord op", + "The date the scores were set.": "De datum waarop de scores zijn vastgesteld.", + "Suggested TIME classification": "Voorgestelde TIME-classificatie", + "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.": "De TIME-klasse waar de bedrijfswaarde en de technische geschiktheid op wijzen. Berekend bij het opslaan van het gebruik; de vastgelegde TIME-classificatie blijft het besluit." } } diff --git a/lib/Service/PortfolioReportDerivation.php b/lib/Service/PortfolioReportDerivation.php index 324079fe3..19b79d12d 100644 --- a/lib/Service/PortfolioReportDerivation.php +++ b/lib/Service/PortfolioReportDerivation.php @@ -252,4 +252,112 @@ static function ($object) { $results ); }//end normalizeResults() + /** + * Read a 1 to 5 score, or null when it is absent or out of range. + * + * @param mixed $value The stored value. + * + * @return int|null The score. + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-001-an-organisation-scores-each-application-it-uses-on-value-fit-and-risk + */ + private function score(mixed $value): ?int { + if (is_numeric($value) === false) { + return null; + } + + $score = (int)$value; + if ($score < 1 || $score > 5) { + return null; + } + + return $score; + }//end score() + + /** + * The TIME class business value and technical fit point to. The same rule as + * the usage schema's `suggestedTimeClassification` calculation + * (`lib/Settings/register.d/value-assessment.json`), used when a usage was + * saved before that calculation existed. + * + * @param int|null $businessValue The business value, 1 to 5. + * @param int|null $technicalFit The technical fit, 1 to 5. + * + * @return string|null Invest, Migrate, Tolerate or Eliminate; null while a score is missing. + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-001-an-organisation-scores-each-application-it-uses-on-value-fit-and-risk + */ + public function suggestTimeClassification(?int $businessValue, ?int $technicalFit): ?string { + if ($businessValue === null || $technicalFit === null) { + return null; + } + + if ($businessValue >= 3) { + if ($technicalFit >= 3) { + return 'Invest'; + } + + return 'Migrate'; + } + + if ($technicalFit >= 3) { + return 'Tolerate'; + } + + return 'Eliminate'; + }//end suggestTimeClassification() + + /** + * The value assessment of one gebruik: its scores, the suggested TIME class + * (the stored calculation, else the same rule over the scores) and whether + * the recorded class differs from that suggestion. + * + * @param array $usage The gebruik data bag. + * @param string|null $classification The recorded TIME class. + * @param string|null $storedSuggestion The materialised suggestion, normalised. + * + * @return array The score fields of the row. + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ + public function valueAssessment(array $usage, ?string $classification, ?string $storedSuggestion): array { + $value = $this->score(value: $usage['businessValue'] ?? null); + $fit = $this->score(value: $usage['technicalFit'] ?? null); + + $suggested = $storedSuggestion; + if ($suggested === null) { + $suggested = $this->suggestTimeClassification(businessValue: $value, technicalFit: $fit); + } + + $scoredOn = null; + if (is_string($usage['scoredOn'] ?? null) === true && $usage['scoredOn'] !== '') { + $scoredOn = $usage['scoredOn']; + } + + return [ + 'businessValue' => $value, + 'technicalFit' => $fit, + 'riskScore' => $this->score(value: $usage['riskScore'] ?? null), + 'scoredOn' => $scoredOn, + 'suggestedTimeClassification' => $suggested, + 'timeMismatch' => $classification !== null && $suggested !== null && $classification !== $suggested, + ]; + }//end valueAssessment() + + /** + * The CSV cell for the mismatch flag. + * + * @param array $row A report row. + * + * @return string "yes" or "no". + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ + public function mismatchLabel(array $row): string { + if (($row['timeMismatch'] ?? false) === true) { + return 'yes'; + } + + return 'no'; + }//end mismatchLabel() }//end class diff --git a/lib/Service/PortfolioReportService.php b/lib/Service/PortfolioReportService.php index 56187fdb1..18401a959 100644 --- a/lib/Service/PortfolioReportService.php +++ b/lib/Service/PortfolioReportService.php @@ -181,6 +181,12 @@ public function buildCsv(string $organisationUuid): string { 'hostingModel', 'annualisedCost', 'oneOffCost', + 'businessValue', + 'technicalFit', + 'riskScore', + 'scoredOn', + 'suggestedTimeClassification', + 'timeMismatch', ] ); @@ -198,6 +204,12 @@ public function buildCsv(string $organisationUuid): string { implode('|', $row['hostingModel']), (string)$row['annualisedCost'], (string)$row['oneOffCost'], + (string)($row['businessValue'] ?? ''), + (string)($row['technicalFit'] ?? ''), + (string)($row['riskScore'] ?? ''), + $row['scoredOn'] ?? '', + $row['suggestedTimeClassification'] ?? '', + $this->derivation->mismatchLabel(row: $row), ] ); } @@ -309,8 +321,13 @@ private function buildRow(array $usage, array $cfg, array $contractsByGebruik, D } $classification = $this->normalizeClassification(value: $usage['timeClassification'] ?? null); + $scores = $this->derivation->valueAssessment( + usage: $usage, + classification: $classification, + storedSuggestion: $this->normalizeClassification(value: $usage['suggestedTimeClassification'] ?? null) + ); - return [ + return $scores + [ 'uuid' => $gebruikId, 'moduleId' => $moduleId, 'moduleName' => $module['name'] ?? $module['title'] ?? $moduleId, diff --git a/lib/Settings/register.d/value-assessment.json b/lib/Settings/register.d/value-assessment.json new file mode 100644 index 000000000..29d4d4b91 --- /dev/null +++ b/lib/Settings/register.d/value-assessment.json @@ -0,0 +1,145 @@ +{ + "components": { + "schemas": { + "usage": { + "version": "1.5.3", + "properties": { + "businessValue": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "title": "Business value", + "description": "How much the organisation depends on this application, from 1 (little) to 5 (critical).", + "visible": true, + "facetable": true, + "order": 35, + "example": "4" + }, + "technicalFit": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "title": "Technical fit", + "description": "How well the application fits the architecture and the standards the organisation follows, from 1 (poor) to 5 (good).", + "visible": true, + "facetable": true, + "order": 36, + "example": "4" + }, + "riskScore": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "title": "Risk", + "description": "The risk the organisation sees in running this application, from 1 (low) to 5 (high). The page shows end of support and known vulnerabilities next to it.", + "visible": true, + "facetable": true, + "order": 37, + "example": "4" + }, + "scoredOn": { + "type": "string", + "format": "date", + "title": "Scored on", + "description": "The date the scores were set.", + "visible": true, + "facetable": false, + "order": 38, + "example": "2026-10-01" + }, + "suggestedTimeClassification": { + "type": "string", + "enum": [ + "Tolerate", + "Invest", + "Migrate", + "Eliminate" + ], + "title": "Suggested TIME classification", + "description": "The TIME class the business value and technical fit point to. Calculated when the usage is saved; the recorded TIME classification stays the decision.", + "visible": true, + "facetable": true, + "hideOnForm": true, + "order": 39 + } + }, + "configuration": { + "x-openregister-calculations": { + "suggestedTimeClassification": { + "type": "string", + "materialise": true, + "description": "Invest when business value and technical fit are both 3 or more, Migrate when only value is, Tolerate when only fit is, Eliminate when neither is; empty while either score is missing.", + "expression": { + "if": [ + { + "or": [ + { + "eq": [ + { + "prop": "businessValue" + }, + null + ] + }, + { + "eq": [ + { + "prop": "technicalFit" + }, + null + ] + } + ] + }, + { + "lit": null + }, + { + "if": [ + { + "gte": [ + { + "prop": "businessValue" + }, + 3 + ] + }, + { + "if": [ + { + "gte": [ + { + "prop": "technicalFit" + }, + 3 + ] + }, + "Invest", + "Migrate" + ] + }, + { + "if": [ + { + "gte": [ + { + "prop": "technicalFit" + }, + 3 + ] + }, + "Tolerate", + "Eliminate" + ] + } + ] + } + ] + } + } + } + } + } + } + } +} diff --git a/lib/Settings/stackiq_mock_register.json b/lib/Settings/stackiq_mock_register.json index 07cbadb7e..c13d6d0ba 100644 --- a/lib/Settings/stackiq_mock_register.json +++ b/lib/Settings/stackiq_mock_register.json @@ -9023,7 +9023,12 @@ "plannedReplacementDate": "2026-03-01", "timeClassification": "Tolerate", "timeRationale": "Voorbeeld Timerationale 1", - "timeReviewDate": "2026-03-01" + "timeReviewDate": "2026-03-01", + "businessValue": 1, + "technicalFit": 2, + "riskScore": 4, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Eliminate" }, { "@self": { @@ -9065,7 +9070,12 @@ "plannedReplacementDate": "2026-03-02", "timeClassification": "Invest", "timeRationale": "Voorbeeld Timerationale 2", - "timeReviewDate": "2026-03-02" + "timeReviewDate": "2026-03-02", + "businessValue": 5, + "technicalFit": 4, + "riskScore": 2, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Invest" }, { "@self": { @@ -9109,7 +9119,12 @@ "plannedReplacementDate": "2026-03-03", "timeClassification": "Migrate", "timeRationale": "Voorbeeld Timerationale 3", - "timeReviewDate": "2026-03-03" + "timeReviewDate": "2026-03-03", + "businessValue": 5, + "technicalFit": 2, + "riskScore": 3, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Migrate" }, { "@self": { @@ -10594,7 +10609,12 @@ "plannedReplacementDate": "2026-03-01", "timeClassification": "Tolerate", "timeRationale": "Voorbeeld Timerationale 1", - "timeReviewDate": "2026-03-01" + "timeReviewDate": "2026-03-01", + "businessValue": 2, + "technicalFit": 4, + "riskScore": 2, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Tolerate" }, { "@self": { @@ -10636,7 +10656,12 @@ "plannedReplacementDate": "2026-03-02", "timeClassification": "Invest", "timeRationale": "Voorbeeld Timerationale 2", - "timeReviewDate": "2026-03-02" + "timeReviewDate": "2026-03-02", + "businessValue": 4, + "technicalFit": 5, + "riskScore": 1, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Invest" }, { "@self": { @@ -10680,7 +10705,12 @@ "plannedReplacementDate": "2026-03-03", "timeClassification": "Migrate", "timeRationale": "Voorbeeld Timerationale 3", - "timeReviewDate": "2026-03-03" + "timeReviewDate": "2026-03-03", + "businessValue": 4, + "technicalFit": 1, + "riskScore": 5, + "scoredOn": "2026-09-01", + "suggestedTimeClassification": "Migrate" }, { "@self": { diff --git a/openspec/changes/lifecycle-application-value-assessment/.openspec.yaml b/openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/.openspec.yaml similarity index 100% rename from openspec/changes/lifecycle-application-value-assessment/.openspec.yaml rename to openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/.openspec.yaml diff --git a/openspec/changes/lifecycle-application-value-assessment/design.md b/openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/design.md similarity index 79% rename from openspec/changes/lifecycle-application-value-assessment/design.md rename to openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/design.md index 66208d74c..aa1ce68f7 100644 --- a/openspec/changes/lifecycle-application-value-assessment/design.md +++ b/openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/design.md @@ -46,3 +46,10 @@ The demo usages get scores that land in all four suggested classes, one of them ## Risks - Existing usages have no scores; the chart shows "not scored" as a count instead of a point. + +## Changes at build (2026-09-30) + +- D3: the scores, the recorded TIME class and the suggestion got their own `data` section on the usage page (`gb-assessment`, "Value assessment"); `UsageRiskSignals` is a body widget placed after the data. `timeClassification` moved from the first data section into that section. +- D4: the value against fit chart is a small SVG component (`src/components/portfolio/ValueFitPlot.vue`) instead of `CnChartWidget`: the library's chart takes series of numbers, and a bubble per usage with its own colour, ring and tooltip needs per-point styling it does not expose. The mismatch filter is an `NcCheckboxRadioSwitch` over the table. +- D4: `buildRow()` falls back to the same rule in `PortfolioReportDerivation::suggestTimeClassification()` for a usage saved before the calculation existed (no materialised value yet); a test keeps that rule equal to the declared expression. +- The fragment moves `usage` to 1.5.3. diff --git a/openspec/changes/lifecycle-application-value-assessment/proposal.md b/openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/proposal.md similarity index 100% rename from openspec/changes/lifecycle-application-value-assessment/proposal.md rename to openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/proposal.md diff --git a/openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md b/openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/specs/application-value-assessment/spec.md similarity index 94% rename from openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md rename to openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/specs/application-value-assessment/spec.md index c795f9e05..f6911110a 100644 --- a/openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md +++ b/openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/specs/application-value-assessment/spec.md @@ -28,7 +28,7 @@ A usage SHALL record business value, technical fit and risk, each from 1 to 5, a The usage page SHALL show, next to the risk score, the end-of-support state of the version the organisation runs and the number of vulnerabilities linked to the application. #### Scenario: Signals that back a high risk score -@e2e exclude Read-only widget; tests/vitest/usageRiskSignals.spec.js covers the EOL state and the vulnerability count. +@e2e exclude Read-only widget; tests/vitest/valueAssessment.spec.js (riskSignals, the usage page) covers the EOL state and the vulnerability count. - **GIVEN** a usage whose version passed its end of support and whose application has two linked vulnerabilities - **WHEN** the information manager opens the usage page diff --git a/openspec/changes/lifecycle-application-value-assessment/tasks.md b/openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/tasks.md similarity index 74% rename from openspec/changes/lifecycle-application-value-assessment/tasks.md rename to openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/tasks.md index f4fb5ecfe..bbd6e0cba 100644 --- a/openspec/changes/lifecycle-application-value-assessment/tasks.md +++ b/openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/tasks.md @@ -8,16 +8,16 @@ - **acceptance_criteria**: - GIVEN a usage with business value 5 and technical fit 2 WHEN it is saved THEN its suggested class is Migrate - GIVEN a usage with no technical fit WHEN it is saved THEN its suggested class is empty -- [ ] Implement -- [ ] Test (PHPUnit `tests/Unit/Settings/ValueAssessmentFragmentTest.php`: fields, calculation shape and the four mappings) +- [x] Implement +- [x] Test (PHPUnit `tests/Unit/Settings/ValueAssessmentFragmentTest.php`: fields, calculation shape and the four mappings; the expression also ran through OpenRegister's real CalculationAnnotationValidator and CalculationEvaluator, 36 combinations, 0 mismatches) ### Task 2: Risk signals on the usage page - **spec_ref**: openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md#requirement-req-ava-002-the-usage-page-shows-the-risk-signals-next-to-the-risk-score - **files**: `src/components/portfolio/UsageRiskSignals.vue`, `src/customComponents.js`, `src/manifest.d/usages.json` - **acceptance_criteria**: - GIVEN a usage whose version is past end of support and whose application has two vulnerabilities WHEN the page opens THEN both signals show next to the risk score -- [ ] Implement -- [ ] Test (vitest `tests/vitest/usageRiskSignals.spec.js`) +- [x] Implement +- [x] Test (vitest `tests/vitest/valueAssessment.spec.js`: riskSignals and the usage page wiring; built as one spec file with the report tests) ### Task 3: Portfolio report additions - **spec_ref**: openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict @@ -26,16 +26,16 @@ - GIVEN scored usages WHEN the report opens THEN the value against fit chart shows one point per scored usage sized by cost - GIVEN a usage recorded Tolerate with scores that suggest Eliminate WHEN the user picks the mismatch filter THEN it is listed - GIVEN the CSV export WHEN it downloads THEN it holds the score and suggestion columns -- [ ] Implement -- [ ] Test (PHPUnit `tests/Unit/Controller/PortfolioReportControllerTest.php` and a service test for the new row fields; Playwright `tests/e2e/workflows/portfolio-value.spec.ts`) +- [x] Implement +- [x] Test (PHPUnit `tests/Unit/Service/PortfolioReportServiceTest.php` testRowsCarryTheScoresAndTheMismatch and testTheCsvCarriesTheScoreColumns; vitest `tests/vitest/valueAssessment.spec.js` valueFitPoints and mismatchRows; Playwright `tests/e2e/workflows/portfolio-value.spec.ts`, written and listed, not run: no seeded instance) ### Task 4: Documentation - **spec_ref**: openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict - **files**: `docs/features/portfolio-value-assessment.md`, `docs/images/portfolio-value-fit.png` - **acceptance_criteria**: - GIVEN the docs site WHEN a reader opens Value assessment THEN scoring, the suggestion and the chart are explained with a screenshot -- [ ] Implement -- [ ] Test (docs build, screenshot with Playwright) +- [x] Implement +- [ ] Test (docs build, screenshot with Playwright): the page `docs/features/portfolio-value-assessment.md` is written; its screenshot waits for a seeded instance ## Verification diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index fa3ef27bd..9f8a945d3 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -4567,18 +4567,19 @@ "name": "Score each application on business value, cost and risk to decide where to invest.", "origin": "tender", "originUrl": "https://www.tenderned.nl/aankondigingen/overzicht/398728", - "stackiq": "no", + "stackiq": "yes", "built": { - "state": "specified", - "evidence": "lib/Settings/softwarecatalogus_register.json:3092 usage.timeClassification holds only the TIME verdict and its rationale; no business value, technical fit or risk score property on module or usage, and PortfolioReport (src/manifest.json:1040) plots the TIME quadrant only", - "owner": "ConductionNL/stackiq" + "state": "built", + "evidence": "lib/Settings/register.d/value-assessment.json usage.businessValue, technicalFit, riskScore, scoredOn and the materialised x-openregister-calculations suggestedTimeClassification; usage page Value assessment section and src/components/portfolio/UsageRiskSignals.vue; lib/Service/PortfolioReportService.php row and CSV columns with timeMismatch; src/components/portfolio/ValueFitPlot.vue and the mismatch switch on PortfolioReport; tests/Unit/Settings/ValueAssessmentFragmentTest.php, tests/Unit/Service/PortfolioReportServiceTest.php, tests/vitest/valueAssessment.spec.js", + "owner": "ConductionNL/stackiq", + "change": "2026-09-30-lifecycle-application-value-assessment" }, - "reachedOn": "nothing reaches it", + "reachedOn": "usage page Value assessment and Risk signals; Reports > Portfolio rationalization value against fit chart and Recorded class differs from scores switch", "provider": "stackiq", "providerHow": "read-from-code", "feature": "portfolio-reporting", "featureConfidence": "medium", - "note": "Helmond REQ41 asks for analysis of application use, cost, risk and value. stackiq records a TIME classification (life-time-classification) without the scores that would justify it. Specified in openspec/changes/lifecycle-application-value-assessment (OpenSpec pass 2026-09-27).", + "note": "Helmond REQ41 asks for analysis of application use, cost, risk and value. stackiq records a TIME classification (life-time-classification) without the scores that would justify it. Specified in openspec/changes/lifecycle-application-value-assessment (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment.", "vng-softwarecatalogus": "unknown", "evidence": { "vng-softwarecatalogus": "unknown: no value, cost or risk scoring is described; searched the public manuals and FAQ at https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier (read 2026-09-26)", diff --git a/openspec/specs/application-value-assessment/spec.md b/openspec/specs/application-value-assessment/spec.md new file mode 100644 index 000000000..0934be6a7 --- /dev/null +++ b/openspec/specs/application-value-assessment/spec.md @@ -0,0 +1,40 @@ +# application-value-assessment Specification + +## Purpose +Each application in use carries scores for business value, technical fit and risk, next to its cost, so a TIME decision has recorded reasons. Matrix row `stackiq:life-value-assessment`. + +## Requirements + +### Requirement: REQ-AVA-001 An organisation scores each application it uses on value, fit and risk + +A usage SHALL record business value, technical fit and risk, each from 1 to 5, and the date they were scored. Stackiq SHALL derive a suggested TIME class from value and fit: Invest when both are 3 or more, Migrate when value is 3 or more and fit is lower, Tolerate when fit is 3 or more and value is lower, Eliminate when both are lower, and no suggestion while either is missing. The recorded TIME class SHALL NOT change by itself. + +#### Scenario: An information manager scores an application +@e2e tests/e2e/workflows/portfolio-value.spec.ts + +- **GIVEN** a usage of application X recorded as Tolerate +- **WHEN** the information manager sets business value 5 and technical fit 2 on its page and saves +- **THEN** the page shows the suggested class Migrate +- **AND** the recorded class still reads Tolerate + +### Requirement: REQ-AVA-002 The usage page shows the risk signals next to the risk score + +The usage page SHALL show, next to the risk score, the end-of-support state of the version the organisation runs and the number of vulnerabilities linked to the application. + +#### Scenario: Signals that back a high risk score +@e2e exclude Read-only widget; tests/vitest/valueAssessment.spec.js (riskSignals, the usage page) covers the EOL state and the vulnerability count. + +- **GIVEN** a usage whose version passed its end of support and whose application has two linked vulnerabilities +- **WHEN** the information manager opens the usage page +- **THEN** it shows end of support passed and two vulnerabilities next to the risk score + +### Requirement: REQ-AVA-003 The portfolio report plots value against fit and flags classes the scores contradict + +The portfolio report SHALL plot the organisation's scored usages by business value and technical fit with annualised cost as point size, SHALL show the suggested class beside the recorded one, SHALL offer a filter on usages whose recorded class differs from the suggestion, and SHALL include the scores and the suggestion in its CSV export. + +#### Scenario: Finding the classes to revisit +@e2e tests/e2e/workflows/portfolio-value.spec.ts + +- **GIVEN** three scored usages, one recorded Tolerate whose scores suggest Eliminate +- **WHEN** the information manager opens the portfolio report and picks "Recorded class differs from scores" +- **THEN** only that usage is listed with Tolerate recorded and Eliminate suggested diff --git a/src/components/portfolio/UsageRiskSignals.vue b/src/components/portfolio/UsageRiskSignals.vue new file mode 100644 index 000000000..424cf2586 --- /dev/null +++ b/src/components/portfolio/UsageRiskSignals.vue @@ -0,0 +1,231 @@ + + + + + + diff --git a/src/components/portfolio/ValueFitPlot.vue b/src/components/portfolio/ValueFitPlot.vue new file mode 100644 index 000000000..de6b28eda --- /dev/null +++ b/src/components/portfolio/ValueFitPlot.vue @@ -0,0 +1,296 @@ + + + + + + diff --git a/src/customComponents.js b/src/customComponents.js index 25912a30f..02650884e 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -24,6 +24,7 @@ import ApplicationContractsPanel from './components/contracts/ApplicationContrac import ContractApprovalPanel from './components/contracts/ContractApprovalPanel.vue' import ContractSeatsPanel from './components/contracts/ContractSeatsPanel.vue' import OrganisationMergePanel from './components/organisations/OrganisationMergePanel.vue' +import UsageRiskSignals from './components/portfolio/UsageRiskSignals.vue' import ReviewsPanel from './components/reviews/ReviewsPanel.vue' import ProductRoadmap from './components/roadmap/ProductRoadmap.vue' import SbomComponentsPanel from './components/sbom/SbomComponentsPanel.vue' @@ -97,6 +98,12 @@ export default { // it reads the module and its versions, which no built-in widget combines. ProductRoadmap, + // End of support of the version a usage runs and the vulnerabilities of its + // application, next to the risk score on the usage page + // (lifecycle-application-value-assessment): it joins the usage, its version + // and the vulnerabilities, which no built-in widget does. + UsageRiskSignals, + // AI Act evidence per tag on the AI system page (landscape-ai-system-inventory): // it reads the object's files and their tags, which no built-in widget lists per tag. AiActChecklist, diff --git a/src/icons.js b/src/icons.js index 6a08b1924..865c514c0 100644 --- a/src/icons.js +++ b/src/icons.js @@ -57,6 +57,7 @@ import PackageVariantClosed from 'vue-material-design-icons/PackageVariantClosed import PowerPlugOutline from 'vue-material-design-icons/PowerPlugOutline.vue' import PuzzleOutline from 'vue-material-design-icons/PuzzleOutline.vue' import RobotOutline from 'vue-material-design-icons/RobotOutline.vue' +import ScaleBalance from 'vue-material-design-icons/ScaleBalance.vue' import ShieldAlert from 'vue-material-design-icons/ShieldAlert.vue' import ShieldAlertOutline from 'vue-material-design-icons/ShieldAlertOutline.vue' import ShieldCheckOutline from 'vue-material-design-icons/ShieldCheckOutline.vue' @@ -118,6 +119,7 @@ export default { PowerPlugOutline, PuzzleOutline, RobotOutline, + ScaleBalance, ShieldAlert, ShieldAlertOutline, ShieldCheckOutline, diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json index c44c634f0..fac1de13e 100644 --- a/src/manifest.d/usages.json +++ b/src/manifest.d/usages.json @@ -41,17 +41,22 @@ "config": { "register": "@resolve:voorzieningen_register", "schema": "usage", - "_note": "A usage is read for what runs where and who owns it: data 8 wide (application, organisation, version, status, owners, phase dates, cloud model, annotation), documents 4 wide at the right (DPIA, contract, processing agreement), then the related panel. Status transitions come from the schema's x-openregister-lifecycle.", + "_note": "A usage is read for what runs where and who owns it (lifecycle-application-value-assessment added the value assessment section with the scores and the suggested TIME class, and the risk signals after it): data 8 wide (application, organisation, version, status, owners, phase dates, cloud model, annotation), documents 4 wide at the right (DPIA, contract, processing agreement), then the related panel. Status transitions come from the schema's x-openregister-lifecycle.", "lifecycleActions": { "field": "status" }, "widgets": [ - { "id": "gb-data", "type": "data", "title": "Application in use", "icon": "OfficeBuilding", "content": { "columns": 2, "include": [ "module", "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner", "startDateAcquisition", "startDatePlanned", "startDateInProduction", "startDateOutPhasing", "startDateOutPhased", "cloudDienstverleningsmodel", "timeClassification", "interneAnnotation" ] } }, + { "id": "gb-data", "type": "data", "title": "Application in use", "icon": "OfficeBuilding", "content": { "columns": 2, "include": [ "module", "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner", "startDateAcquisition", "startDatePlanned", "startDateInProduction", "startDateOutPhasing", "startDateOutPhased", "cloudDienstverleningsmodel", "interneAnnotation" ] } }, + { "id": "gb-assessment", "type": "data", "title": "Value assessment", "icon": "ScaleBalance", "content": { "columns": 2, "include": [ "businessValue", "technicalFit", "riskScore", "scoredOn", "timeClassification", "suggestedTimeClassification", "timeRationale", "timeReviewDate" ] } }, { "id": "gb-files", "type": "integration", "integrationId": "files", "title": "Documents", "icon": "FolderOutline" }, { "id": "gb-related", "type": "related", "title": "Connections and services", "icon": "LinkVariant" } ], "layout": [ { "id": "1", "widgetId": "gb-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 8 }, { "id": "2", "widgetId": "gb-files", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }, - { "id": "3", "widgetId": "gb-related", "gridX": 8, "gridY": 4, "gridWidth": 4, "gridHeight": 4 } + { "id": "3", "widgetId": "gb-related", "gridX": 8, "gridY": 4, "gridWidth": 4, "gridHeight": 4 }, + { "id": "4", "widgetId": "gb-assessment", "gridX": 0, "gridY": 8, "gridWidth": 8, "gridHeight": 5 } + ], + "bodyWidgets": [ + { "id": "gb-risk-signals", "component": "UsageRiskSignals", "props": { "objectId": "@objectId" }, "placement": "after-data", "colSpan": 12 } ], "sidebar": { "enabled": true, diff --git a/src/utils/valueAssessment.js b/src/utils/valueAssessment.js new file mode 100644 index 000000000..b0ea6c3fc --- /dev/null +++ b/src/utils/valueAssessment.js @@ -0,0 +1,112 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The value assessment of the applications an organisation uses: the risk + * signals next to the risk score, and the value against fit plot and the + * mismatch filter of the portfolio report. + * + * @spec openspec/specs/application-value-assessment/spec.md + */ +import { endOfSupportState } from './lifecyclePhase.js' +import { refId } from './maintenance.js' + +const MIN_RADIUS = 6 +const MAX_RADIUS = 22 +const SPREAD = 0.18 + +/** + * The risk signals of a usage: end of support of the version it runs and the + * number of vulnerabilities linked to its application. + * + * @param {object|null} version The moduleVersion the usage runs. + * @param {Array|null} vulnerabilities Vulnerabilities to count. + * @param {string} moduleId The application of the usage. + * @param {Date} [now] The current moment. + * @return {{endOfSupportPassed: boolean, endOfSupportDate: (string|null), withdrawn: boolean, vulnerabilityCount: number}} The signals. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-002-the-usage-page-shows-the-risk-signals-next-to-the-risk-score + */ +export function riskSignals(version, vulnerabilities, moduleId, now = new Date()) { + const eol = endOfSupportState(version || {}, now) + const count = (Array.isArray(vulnerabilities) ? vulnerabilities : []).filter( + (vulnerability) => { + const data = vulnerability?.object || vulnerability || {} + const modules = Array.isArray(data.modules) ? data.modules : [] + return modules.some((module) => refId(module) === moduleId) + }, + ).length + return { + endOfSupportPassed: eol.passed, + endOfSupportDate: eol.endDate, + withdrawn: eol.withdrawn, + vulnerabilityCount: count, + } +} + +/** + * Whether a report row has both scores the plot needs. + * + * @param {object} row A portfolio report row. + * @return {boolean} True when value and fit are set. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ +function isScored(row) { + return ( + Number.isInteger(row?.businessValue) && Number.isInteger(row?.technicalFit) + ) +} + +/** + * The points of the value against fit plot: one per scored usage, its radius + * by annualised cost (square root, so the area follows the cost), and a small + * offset for points that share a cell. + * + * @param {Array|null} rows Portfolio report rows. + * @return {{points: Array, notScored: number}} The points and the count of usages without both scores. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ +export function valueFitPoints(rows) { + const list = Array.isArray(rows) ? rows : [] + const scored = list.filter(isScored) + const maxCost = Math.max( + 0, + ...scored.map((row) => Number(row.annualisedCost) || 0), + ) + const seen = {} + const points = scored.map((row) => { + const cost = Math.max(0, Number(row.annualisedCost) || 0) + const share = maxCost > 0 ? Math.sqrt(cost / maxCost) : 0 + const cell = `${row.technicalFit}:${row.businessValue}` + const index = seen[cell] || 0 + seen[cell] = index + 1 + const angle = index * 2.4 + return { + uuid: row.uuid, + label: row.moduleName, + fit: row.technicalFit, + value: row.businessValue, + cost, + radius: MIN_RADIUS + share * (MAX_RADIUS - MIN_RADIUS), + offset: + index === 0 + ? [0, 0] + : [Math.cos(angle) * SPREAD, Math.sin(angle) * SPREAD], + recorded: row.timeClassification || null, + suggested: row.suggestedTimeClassification || null, + } + }) + return { points, notScored: list.length - scored.length } +} + +/** + * The rows whose recorded TIME class differs from the class the scores suggest. + * + * @param {Array|null} rows Portfolio report rows. + * @return {Array} The mismatching rows. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ +export function mismatchRows(rows) { + return (Array.isArray(rows) ? rows : []).filter( + (row) => row?.timeMismatch === true, + ) +} diff --git a/src/views/organisaties/PortfolioReport.vue b/src/views/organisaties/PortfolioReport.vue index e62e4636c..6482c4387 100644 --- a/src/views/organisaties/PortfolioReport.vue +++ b/src/views/organisaties/PortfolioReport.vue @@ -121,6 +121,14 @@ :height="260" /> + +
+

+ {{ t('stackiq', 'Business value against technical fit') }} +

+ +
+

@@ -176,6 +184,13 @@

{{ t('stackiq', 'Applications in use') }}

+ + {{ t('stackiq', 'Recorded class differs from scores') }} +

{{ t('stackiq', 'Application') }} + + {{ t('stackiq', 'Suggested by scores') }} + + + {{ t('stackiq', 'Value / fit / risk') }} + {{ t('stackiq', 'Rationale') }} @@ -220,6 +241,19 @@ :key="row.uuid" data-testid="pr-row"> {{ row.moduleName }} + + + {{ + quadrantLabel( + row.suggestedTimeClassification, + ) + }} + + — + + {{ scoresLabel(row) }} {{ row.timeRationale || '—' }} {{ row.timeReviewDate || '—' }} {{ row.lifecyclePhase }} @@ -262,6 +296,7 @@ import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { NcButton, + NcCheckboxRadioSwitch, NcEmptyContent, NcLoadingIcon, NcNoteCard, @@ -270,6 +305,7 @@ import { import ChartBoxOutline from 'vue-material-design-icons/ChartBoxOutline.vue' import Download from 'vue-material-design-icons/Download.vue' import Refresh from 'vue-material-design-icons/Refresh.vue' +import ValueFitPlot from '../../components/portfolio/ValueFitPlot.vue' import { useLiveCollections } from '../../composables/useLiveCollections.js' import { objectStore } from '../../store/store.js' import { resolveUuid } from '../../utils/lifecyclePhase.js' @@ -281,6 +317,7 @@ import { QUADRANT_ORDER, quadrantColor, } from '../../utils/portfolioReport.js' +import { mismatchRows } from '../../utils/valueAssessment.js' /** * @class PortfolioReport @@ -300,11 +337,13 @@ export default { name: 'PortfolioReport', components: { NcButton, + NcCheckboxRadioSwitch, NcLoadingIcon, NcSelect, NcEmptyContent, NcNoteCard, CnChartWidget, + ValueFitPlot, Refresh, Download, ChartBoxOutline, @@ -330,6 +369,7 @@ export default { error: null, selectedOrg: null, report: null, + onlyMismatch: false, } }, @@ -434,12 +474,14 @@ export default { * * @return {Array<{key: string, rows: Array}>} Grouped rows. * @spec openspec/changes/portfolio-rationalization-time/specs/portfolio-rationalization-time/spec.md#requirement-portfolio-rationalization-report-aggregates-per-organisation + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict */ groupedRows() { if (!this.report) { return [] } - return groupRowsByQuadrant(this.report.rows || []) + const rows = this.report.rows || [] + return groupRowsByQuadrant(this.onlyMismatch ? mismatchRows(rows) : rows) }, }, @@ -595,6 +637,25 @@ export default { return map[key] || key }, + /** + * The three scores of a row as "value / fit / risk", a dash for a missing one. + * + * @param {object} row A report row. + * @return {string} The label. + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ + scoresLabel(row) { + const scores = [row.businessValue, row.technicalFit, row.riskScore] + if (scores.every((score) => score === null || score === undefined)) { + return '—' + } + return scores + .map((score) => + score === null || score === undefined ? '-' : score, + ) + .join(' / ') + }, + // Exposed to the template as methods — thin re-exports of the pure // `portfolioReport.js` utils (vitest-covered), since Vue 2 Options // API templates cannot call a bare imported function directly. @@ -712,6 +773,15 @@ export default { color: var(--color-text-maxcontrast); } +.pr-mismatchFilter { + margin-bottom: 12px; +} + +.pr-mismatch { + font-weight: bold; + color: var(--color-warning-text); +} + .pr-loading { margin: 40px auto; display: block; diff --git a/tests/Unit/Service/PortfolioReportServiceTest.php b/tests/Unit/Service/PortfolioReportServiceTest.php index ae5cf7e9d..52577a415 100644 --- a/tests/Unit/Service/PortfolioReportServiceTest.php +++ b/tests/Unit/Service/PortfolioReportServiceTest.php @@ -476,4 +476,116 @@ function (array $query): array { $this->assertSame(5, $report['totalGebruiken']); $this->assertSame(1, $report['includedGebruiken']); }//end testBuildReportDisclosesTruncation() + /** + * A service over one page of gebruiken, with no relations to resolve. + * + * @param array> $usages The gebruik rows the search returns. + * + * @return PortfolioReportService + */ + private function serviceOver(array $usages): PortfolioReportService { + $objectService = $this->createMock(ObjectServiceInterface::class); + $objectService->method('searchObjectsPaginated')->willReturnCallback( + static function (array $query) use ($usages): array { + if (($query['@self']['schema'] ?? null) === 20) { + return ['results' => $usages, 'total' => count($usages)]; + } + return ['results' => [], 'total' => 0]; + } + ); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($objectService); + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getInstalledApps')->willReturn(['openregister']); + $settingsService = $this->createMock(SettingsService::class); + $settingsService->method('getVoorzieningenConfig')->willReturn( + ['register' => '1', 'gebruik_schema' => '20', 'contract_schema' => '21', 'module_schema' => '22', 'moduleVersie_schema' => '23'] + ); + $config = $this->createMock(IAppConfig::class); + $config->method('getValueInt')->willReturn(500); + + $reflection = new ReflectionClass(PortfolioReportService::class); + $service = $reflection->newInstanceWithoutConstructor(); + foreach ( + [ + 'settingsService' => $settingsService, + 'appManager' => $appManager, + 'container' => $container, + 'logger' => $this->createMock(LoggerInterface::class), + 'config' => $config, + 'derivation' => new PortfolioReportDerivation(), + ] as $propertyName => $value + ) { + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + $property->setValue($service, $value); + } + + return $service; + }//end serviceOver() + + /** + * The scored gebruiken of the value assessment. + * + * @return array> + */ + private function scoredUsages(): array { + return [ + ['id' => 'g-1', 'consumer' => 'org-a', 'timeClassification' => 'Tolerate', 'businessValue' => 1, 'technicalFit' => 2, 'riskScore' => 4, 'scoredOn' => '2026-09-01', 'suggestedTimeClassification' => 'Eliminate'], + ['id' => 'g-2', 'consumer' => 'org-a', 'timeClassification' => 'Invest', 'businessValue' => 5, 'technicalFit' => 4, 'riskScore' => 1, 'suggestedTimeClassification' => 'Invest'], + ['id' => 'g-3', 'consumer' => 'org-a', 'timeClassification' => 'Migrate', 'businessValue' => 5, 'technicalFit' => 2], + ['id' => 'g-4', 'consumer' => 'org-a', 'timeClassification' => 'Tolerate'], + ]; + }//end scoredUsages() + + /** + * Each row carries the scores, the suggested class and whether the recorded class differs from it. + * + * @return void + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ + public function testRowsCarryTheScoresAndTheMismatch(): void { + $rows = $this->serviceOver($this->scoredUsages())->buildReport('org-a')['rows']; + + $this->assertSame(1, $rows[0]['businessValue']); + $this->assertSame(2, $rows[0]['technicalFit']); + $this->assertSame(4, $rows[0]['riskScore']); + $this->assertSame('2026-09-01', $rows[0]['scoredOn']); + $this->assertSame('Eliminate', $rows[0]['suggestedTimeClassification']); + $this->assertTrue($rows[0]['timeMismatch']); + + $this->assertFalse($rows[1]['timeMismatch']); + + // No stored suggestion yet (saved before the calculation existed): the report derives it with the same rule. + $this->assertSame('Migrate', $rows[2]['suggestedTimeClassification']); + $this->assertFalse($rows[2]['timeMismatch']); + + // Not scored: no suggestion and no mismatch. + $this->assertNull($rows[3]['businessValue']); + $this->assertNull($rows[3]['suggestedTimeClassification']); + $this->assertFalse($rows[3]['timeMismatch']); + }//end testRowsCarryTheScoresAndTheMismatch() + + /** + * The CSV export writes the score and suggestion columns. + * + * @return void + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-003-the-portfolio-report-plots-value-against-fit-and-flags-classes-the-scores-contradict + */ + public function testTheCsvCarriesTheScoreColumns(): void { + $lines = array_map('str_getcsv', explode("\n", trim($this->serviceOver($this->scoredUsages())->buildCsv('org-a')))); + $header = $lines[0]; + foreach (['businessValue', 'technicalFit', 'riskScore', 'scoredOn', 'suggestedTimeClassification', 'timeMismatch'] as $column) { + $this->assertContains($column, $header); + } + + $first = array_combine($header, $lines[1]); + $this->assertSame('1', $first['businessValue']); + $this->assertSame('Eliminate', $first['suggestedTimeClassification']); + $this->assertSame('yes', $first['timeMismatch']); + $this->assertSame('', array_combine($header, $lines[4])['businessValue']); + }//end testTheCsvCarriesTheScoreColumns() }//end class diff --git a/tests/Unit/Settings/ValueAssessmentFragmentTest.php b/tests/Unit/Settings/ValueAssessmentFragmentTest.php new file mode 100644 index 000000000..c930d9507 --- /dev/null +++ b/tests/Unit/Settings/ValueAssessmentFragmentTest.php @@ -0,0 +1,242 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-001-an-organisation-scores-each-application-it-uses-on-value-fit-and-risk + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Settings; + +use OCA\Stackiq\Service\PortfolioReportDerivation; +use OCA\Stackiq\Service\SettingsService; +use Opis\JsonSchema\Validator; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * Merges register.d/value-assessment.json into the monolith with SettingsService's own merge. + * + * @coversNothing + */ +class ValueAssessmentFragmentTest extends TestCase { + + private const SCORES = ['businessValue', 'technicalFit', 'riskScore']; + + /** + * The usage schema after the fragment is merged in. + * + * @return array The schema. + */ + private function usageSchema(): array { + $dir = __DIR__ . '/../../../lib/Settings'; + $base = json_decode((string) file_get_contents($dir . '/softwarecatalogus_register.json'), true); + $fragment = json_decode((string) file_get_contents($dir . '/register.d/value-assessment.json'), true); + $this->assertIsArray($fragment, 'register.d/value-assessment.json must exist and parse'); + + $merge = new ReflectionMethod(SettingsService::class, 'deepMergeConfig'); + $merged = $merge->invoke(null, $base, $fragment); + + return $merged['components']['schemas']['usage']; + }//end usageSchema() + + /** + * The declared expression of the suggested class. + * + * @return mixed The expression. + */ + private function expression(): mixed { + $calc = $this->usageSchema()['configuration']['x-openregister-calculations']['suggestedTimeClassification']; + return $calc['expression']; + }//end expression() + + /** + * Evaluate the operators the expression uses, with OpenRegister's semantics + * (CalculationEvaluator: gte is false on null, eq is loose, a bare scalar is a literal). + * + * @param mixed $node The expression node. + * @param array $data The usage data. + * + * @return mixed The value. + */ + private function evaluate(mixed $node, array $data): mixed { + if (is_array($node) === false) { + return $node; + } + + $op = (string) array_key_first($node); + $args = $node[$op]; + switch ($op) { + case 'prop': + return ($data[$args] ?? null); + case 'lit': + return $args; + case 'if': + if ($this->evaluate($args[0], $data) === true) { + return $this->evaluate($args[1], $data); + } + return $this->evaluate($args[2], $data); + case 'or': + foreach ($args as $arg) { + if ($this->evaluate($arg, $data) === true) { + return true; + } + } + return false; + case 'eq': + return $this->evaluate($args[0], $data) == $this->evaluate($args[1], $data); + case 'gte': + $a = $this->evaluate($args[0], $data); + $b = $this->evaluate($args[1], $data); + return $a !== null && $b !== null && $a >= $b; + } + + $this->fail('The expression uses an operator this test does not model: ' . $op); + }//end evaluate() + + /** + * The three scores run from 1 to 5, the date is a date, and none is required. + * + * @return void + */ + public function testTheScoresRunFromOneToFiveAndAreOptional(): void { + $schema = $this->usageSchema(); + foreach (self::SCORES as $field) { + $property = $schema['properties'][$field]; + $this->assertSame('integer', $property['type'], $field); + $this->assertSame(1, $property['minimum'], $field); + $this->assertSame(5, $property['maximum'], $field); + $this->assertNotEmpty($property['title'], $field); + } + + $this->assertSame('date', $schema['properties']['scoredOn']['format']); + $required = ($schema['required'] ?? []); + $this->assertSame([], array_values(array_intersect([...self::SCORES, 'scoredOn', 'suggestedTimeClassification'], $required))); + }//end testTheScoresRunFromOneToFiveAndAreOptional() + + /** + * The suggestion carries the TIME enum, is not on the form, and is a materialised calculation. + * + * @return void + */ + public function testTheSuggestionIsADeclaredMaterialisedCalculation(): void { + $schema = $this->usageSchema(); + $suggestion = $schema['properties']['suggestedTimeClassification']; + + $this->assertSame($schema['properties']['timeClassification']['enum'], $suggestion['enum']); + $this->assertTrue($suggestion['hideOnForm']); + + $calc = $schema['configuration']['x-openregister-calculations']['suggestedTimeClassification']; + $this->assertSame('string', $calc['type']); + $this->assertTrue($calc['materialise']); + $this->assertArrayNotHasKey('dependsOn', $calc); + + // The merge keeps the usage lifecycle next to the calculation. + $this->assertArrayHasKey('x-openregister-lifecycle', $schema['configuration']); + }//end testTheSuggestionIsADeclaredMaterialisedCalculation() + + /** + * The four mappings of the spec, and no suggestion while a score is missing. + * + * @return void + */ + public function testTheExpressionGivesTheFourClasses(): void { + $cases = [ + [5, 2, 'Migrate'], + [4, 4, 'Invest'], + [2, 5, 'Tolerate'], + [1, 2, 'Eliminate'], + [3, 3, 'Invest'], + [5, null, null], + [null, 4, null], + ]; + foreach ($cases as [$value, $fit, $expected]) { + $data = array_filter(['businessValue' => $value, 'technicalFit' => $fit], static fn ($v) => $v !== null); + $this->assertSame($expected, $this->evaluate($this->expression(), $data), json_encode($data)); + } + }//end testTheExpressionGivesTheFourClasses() + + /** + * The report's own rule agrees with the declared expression on every combination. + * + * @return void + */ + public function testTheReportRuleMatchesTheDeclaredExpression(): void { + $derivation = new PortfolioReportDerivation(); + foreach ([null, 1, 2, 3, 4, 5] as $value) { + foreach ([null, 1, 2, 3, 4, 5] as $fit) { + $data = array_filter(['businessValue' => $value, 'technicalFit' => $fit], static fn ($v) => $v !== null); + $this->assertSame( + $this->evaluate($this->expression(), $data), + $derivation->suggestTimeClassification(businessValue: $value, technicalFit: $fit), + json_encode($data) + ); + } + } + }//end testTheReportRuleMatchesTheDeclaredExpression() + + /** + * The schema version moves above the version before this change. + * + * @return void + */ + public function testTheSchemaVersionMovesUp(): void { + $this->assertTrue(version_compare($this->usageSchema()['version'], '1.5.2', '>')); + }//end testTheSchemaVersionMovesUp() + + /** + * The scored demo usages pass the merged schema and land in all four suggested classes, + * with one recorded class that differs from its suggestion. + * + * @return void + */ + public function testTheSeedsAreValidAndCoverTheFourClasses(): void { + $schema = $this->usageSchema(); + $mock = json_decode((string) file_get_contents(__DIR__ . '/../../../lib/Settings/stackiq_mock_register.json'), true); + $seeds = array_values( + array_filter( + $mock['components']['objects'], + static fn (array $o) => ($o['@self']['schema'] ?? '') === 'usage' && isset($o['businessValue']) === true + ) + ); + $this->assertGreaterThanOrEqual(4, count($seeds)); + + $fields = [...self::SCORES, 'scoredOn', 'suggestedTimeClassification', 'timeClassification']; + $shape = ['type' => 'object', 'properties' => array_intersect_key($schema['properties'], array_flip($fields))]; + foreach ($shape['properties'] as $name => $property) { + $shape['properties'][$name] = array_intersect_key($property, array_flip(['type', 'enum', 'minimum', 'maximum', 'format'])); + } + + $validator = new Validator(); + $suggested = []; + $mismatches = 0; + foreach ($seeds as $seed) { + $payload = array_intersect_key($seed, array_flip($fields)); + $result = $validator->validate(json_decode((string) json_encode($payload)), (string) json_encode($shape)); + $this->assertTrue($result->isValid(), $seed['@self']['slug']); + + $expected = $this->evaluate($this->expression(), $payload); + $this->assertSame($expected, $seed['suggestedTimeClassification'], $seed['@self']['slug']); + $suggested[$expected] = true; + if ($expected !== ($seed['timeClassification'] ?? null)) { + $mismatches++; + } + } + + ksort($suggested); + $this->assertSame(['Eliminate', 'Invest', 'Migrate', 'Tolerate'], array_keys($suggested)); + $this->assertGreaterThanOrEqual(1, $mismatches); + }//end testTheSeedsAreValidAndCoverTheFourClasses() +}//end class diff --git a/tests/e2e/workflows/portfolio-value.spec.ts b/tests/e2e/workflows/portfolio-value.spec.ts new file mode 100644 index 000000000..add7c408e --- /dev/null +++ b/tests/e2e/workflows/portfolio-value.spec.ts @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * Value assessment: scoring a usage on its page and finding the usages whose + * recorded TIME class differs from the class the scores suggest in the + * portfolio report. + * + * Seeds an organisation, three applications and three usages carrying this + * run's RUN_ID through the objects API (the call the edit form makes), and + * removes exactly those rows afterwards. The calculation, the report row and + * the CSV columns are covered by tests/Unit/Settings/ValueAssessmentFragmentTest.php + * and tests/Unit/Service/PortfolioReportServiceTest.php; the plot, the filter + * and the risk signals by tests/vitest/valueAssessment.spec.js. + * + * @spec openspec/specs/application-value-assessment/spec.md + */ +import type { APIRequestContext } from '@playwright/test' +import type { VoorzieningenConfig } from './_fixtures.ts' + +import { expect, test } from '@playwright/test' +import { + createObject, + deleteObject, + newApiContext, + resolveConfig, + RUN_ID, +} from './_fixtures.ts' +import { dismissSupportDialog, gotoAppRoute } from './_ui.ts' + +let apiCtx: APIRequestContext +let cfg: VoorzieningenConfig +const seeded: Array<[string, string]> = [] +const ids: Record = {} +const organisation = `${RUN_ID} municipality` +const appX = `${RUN_ID} application X` +const appY = `${RUN_ID} application Y` +const appZ = `${RUN_ID} application Z` + +/** + * Create a row and remember it for cleanup. + * + * @param schema The schema slug. + * @param data The object. + * @return The new id. + */ +async function seed(schema: string, data: Record): Promise { + const id = await createObject(apiCtx, cfg.register, schema, data) + seeded.push([schema, id]) + return id +} + +test.beforeAll(async () => { + apiCtx = await newApiContext() + cfg = await resolveConfig(apiCtx) + ids.org = await seed('organization', { name: organisation }) + ids.x = await seed('module', { name: appX }) + ids.y = await seed('module', { name: appY }) + ids.z = await seed('module', { name: appZ }) + ids.usageX = await seed('usage', { + consumer: ids.org, + module: ids.x, + status: 'In production', + timeClassification: 'Tolerate', + }) + ids.usageY = await seed('usage', { + consumer: ids.org, + module: ids.y, + status: 'In production', + timeClassification: 'Tolerate', + businessValue: 2, + technicalFit: 1, + scoredOn: '2026-09-01', + }) + ids.usageZ = await seed('usage', { + consumer: ids.org, + module: ids.z, + status: 'In production', + timeClassification: 'Invest', + businessValue: 5, + technicalFit: 4, + scoredOn: '2026-09-01', + }) +}) + +test.afterAll(async () => { + if (!apiCtx) return + for (const [schema, id] of seeded.reverse()) { + await deleteObject(apiCtx, cfg.register, schema, id) + } + await apiCtx.dispose() +}) + +// @e2e application-value-assessment::an-information-manager-scores-an-application +test('scoring value 5 and fit 2 suggests Migrate and keeps Tolerate recorded', async ({ + page, +}) => { + const res = await apiCtx.put( + `/index.php/apps/openregister/api/objects/${cfg.register}/usage/${ids.usageX}`, + { + data: { + consumer: ids.org, + module: ids.x, + status: 'In production', + timeClassification: 'Tolerate', + businessValue: 5, + technicalFit: 2, + scoredOn: '2026-09-30', + }, + }, + ) + expect(res.ok()).toBe(true) + await gotoAppRoute(page, `/gebruik/${ids.usageX}`) + await dismissSupportDialog(page) + await expect(page.getByText('Value assessment').first()).toBeVisible({ + timeout: 30000, + }) + await expect(page.getByText('Migrate').first()).toBeVisible() + await expect(page.getByText('Tolerate').first()).toBeVisible() +}) + +// @e2e application-value-assessment::finding-the-classes-to-revisit +test('the mismatch filter lists a usage whose scores contradict its class and leaves out one that agrees', async ({ + page, +}) => { + await gotoAppRoute(page, '/portfolio-report') + await dismissSupportDialog(page) + await page.getByLabel('Organisation').first().click() + await page.getByRole('option', { name: organisation }).first().click() + await expect(page.getByTestId('pr-value-fit')).toBeVisible({ timeout: 30000 }) + await page.getByTestId('pr-mismatch-filter').click() + const rows = page.getByTestId('pr-row') + await expect(rows.filter({ hasText: appY })).toBeVisible({ timeout: 30000 }) + await expect(rows.filter({ hasText: appY })).toContainText('Eliminate') + await expect(rows.filter({ hasText: appZ })).toHaveCount(0) +}) diff --git a/tests/vitest/valueAssessment.spec.js b/tests/vitest/valueAssessment.spec.js new file mode 100644 index 000000000..0cbbf50da --- /dev/null +++ b/tests/vitest/valueAssessment.spec.js @@ -0,0 +1,179 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * The value assessment: risk signals on the usage page (the version's end of + * support and the application's vulnerabilities), the value against fit plot + * and the mismatch filter of the portfolio report, and the page wiring. + * + * @spec openspec/specs/application-value-assessment/spec.md + */ +import * as fs from 'fs' +import * as path from 'path' +import { describe, expect, it } from 'vitest' +import { buildManifest } from '../../node_modules/@conduction/nextcloud-vue/src/utils/buildManifest.js' +import base from '../../src/manifest.json' +import menuLayout from '../../src/menu-layout.json' +import { + mismatchRows, + riskSignals, + valueFitPoints, +} from '../../src/utils/valueAssessment.js' + +const now = new Date('2026-10-01T09:00:00Z') + +describe('riskSignals', () => { + it('shows end of support passed and two vulnerabilities of the application', () => { + const version = { dateEndSupport: '2026-06-30' } + const vulnerabilities = [ + { id: 'v1', modules: ['app-x'] }, + { id: 'v2', modules: [{ id: 'app-x' }, 'app-y'] }, + { id: 'v3', modules: ['app-y'] }, + ] + expect(riskSignals(version, vulnerabilities, 'app-x', now)).toEqual({ + endOfSupportPassed: true, + endOfSupportDate: '2026-06-30', + withdrawn: false, + vulnerabilityCount: 2, + }) + }) + + it('reads a version that is still supported and an application without vulnerabilities', () => { + const signals = riskSignals( + { object: { dateEndSupport: '2027-12-31' } }, + [], + 'app-x', + now, + ) + expect(signals.endOfSupportPassed).toBe(false) + expect(signals.vulnerabilityCount).toBe(0) + }) + + it('does not throw without a version', () => { + expect(riskSignals(null, null, 'app-x', now)).toEqual({ + endOfSupportPassed: false, + endOfSupportDate: null, + withdrawn: false, + vulnerabilityCount: 0, + }) + }) +}) + +const rows = [ + { + uuid: 'a', + moduleName: 'A', + businessValue: 1, + technicalFit: 2, + annualisedCost: 1000, + timeClassification: 'Tolerate', + suggestedTimeClassification: 'Eliminate', + timeMismatch: true, + }, + { + uuid: 'b', + moduleName: 'B', + businessValue: 5, + technicalFit: 4, + annualisedCost: 50000, + timeClassification: 'Invest', + suggestedTimeClassification: 'Invest', + timeMismatch: false, + }, + { + uuid: 'c', + moduleName: 'C', + businessValue: 4, + technicalFit: 4, + annualisedCost: 0, + timeClassification: null, + suggestedTimeClassification: 'Invest', + timeMismatch: false, + }, + { + uuid: 'd', + moduleName: 'D', + businessValue: null, + technicalFit: 3, + annualisedCost: 200, + timeClassification: 'Tolerate', + suggestedTimeClassification: null, + timeMismatch: false, + }, +] + +describe('valueFitPoints', () => { + it('gives one point per scored usage and counts the rest as not scored', () => { + const { points, notScored } = valueFitPoints(rows) + expect(points.map((p) => p.uuid)).toEqual(['a', 'b', 'c']) + expect(notScored).toBe(1) + expect(points[0]).toMatchObject({ fit: 2, value: 1, label: 'A' }) + }) + + it('sizes the points by annualised cost, largest cost largest point', () => { + const { points } = valueFitPoints(rows) + const radius = Object.fromEntries(points.map((p) => [p.uuid, p.radius])) + expect(radius.b).toBeGreaterThan(radius.a) + expect(radius.a).toBeGreaterThan(radius.c) + expect(radius.c).toBeGreaterThan(0) + }) + + it('spreads points that share a cell so each stays visible', () => { + const { points } = valueFitPoints([ + { uuid: 'x', businessValue: 3, technicalFit: 3, annualisedCost: 0 }, + { uuid: 'y', businessValue: 3, technicalFit: 3, annualisedCost: 0 }, + ]) + expect(points[0].offset).not.toEqual(points[1].offset) + }) +}) + +describe('mismatchRows', () => { + it('keeps only the usages whose recorded class differs from the scores', () => { + expect(mismatchRows(rows).map((r) => r.uuid)).toEqual(['a']) + expect(mismatchRows(null)).toEqual([]) + }) +}) + +describe('the usage page', () => { + const dir = path.resolve(__dirname, '../../src/manifest.d') + const merged = buildManifest( + base, + fs + .readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .sort() + .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))), + menuLayout, + ) + const page = merged.pages.find((p) => p.id === 'GebruikDetail') + + it('shows the scores and the suggested class in their own section', () => { + const assessment = page.config.widgets.find((w) => w.id === 'gb-assessment') + expect(assessment.content.include).toEqual( + expect.arrayContaining([ + 'businessValue', + 'technicalFit', + 'riskScore', + 'scoredOn', + 'timeClassification', + 'suggestedTimeClassification', + ]), + ) + expect(page.config.layout.some((l) => l.widgetId === 'gb-assessment')).toBe( + true, + ) + }) + + it('places the risk signals after the data and registers the component', () => { + const signals = page.config.bodyWidgets.find( + (w) => w.component === 'UsageRiskSignals', + ) + expect(signals.props).toEqual({ objectId: '@objectId' }) + expect(signals.placement).toBe('after-data') + const registry = fs.readFileSync( + path.resolve(__dirname, '../../src/customComponents.js'), + 'utf8', + ) + expect(registry).toMatch(/^\s*UsageRiskSignals,$/m) + }) +})