From dad8c75b6d396e7fde43a422b9822904c24a3647 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 23:59:24 +0200 Subject: [PATCH 1/9] test(portfolio): the value, fit and risk scores, the declared suggested class, the report row and CSV columns, red before the change --- .../Service/PortfolioReportServiceTest.php | 112 ++++++++ .../Settings/ValueAssessmentFragmentTest.php | 242 ++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 tests/Unit/Settings/ValueAssessmentFragmentTest.php diff --git a/tests/Unit/Service/PortfolioReportServiceTest.php b/tests/Unit/Service/PortfolioReportServiceTest.php index ae5cf7e9d..c13b8bfa8 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/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 + */ + 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/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 + */ + 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..f498c58cb --- /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/changes/lifecycle-application-value-assessment/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 From 05bdb1013d77afe4df89af8339838b402f40583b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 00:02:24 +0200 Subject: [PATCH 2/9] test(portfolio): risk signals, the value against fit plot, the mismatch filter and the usage page wiring, red before the change --- tests/vitest/valueAssessment.spec.js | 141 +++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 tests/vitest/valueAssessment.spec.js diff --git a/tests/vitest/valueAssessment.spec.js b/tests/vitest/valueAssessment.spec.js new file mode 100644 index 000000000..bc220e56d --- /dev/null +++ b/tests/vitest/valueAssessment.spec.js @@ -0,0 +1,141 @@ +/** + * 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/changes/lifecycle-application-value-assessment/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) + }) +}) From d455c35f3571b53d0247a7651137cbdc46e1b7c5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 00:04:26 +0200 Subject: [PATCH 3/9] wip(portfolio): scores, suggested class, report row and CSV, risk signals on the usage page (tests green, gates not run) --- lib/Service/PortfolioReportDerivation.php | 54 +++++ lib/Service/PortfolioReportService.php | 68 +++++- lib/Settings/register.d/value-assessment.json | 145 ++++++++++++ lib/Settings/stackiq_mock_register.json | 42 +++- src/components/portfolio/UsageRiskSignals.vue | 211 ++++++++++++++++++ src/customComponents.js | 7 + src/icons.js | 2 + src/manifest.d/usages.json | 11 +- src/utils/valueAssessment.js | 102 +++++++++ 9 files changed, 632 insertions(+), 10 deletions(-) create mode 100644 lib/Settings/register.d/value-assessment.json create mode 100644 src/components/portfolio/UsageRiskSignals.vue create mode 100644 src/utils/valueAssessment.js diff --git a/lib/Service/PortfolioReportDerivation.php b/lib/Service/PortfolioReportDerivation.php index 324079fe3..b7ba4ec28 100644 --- a/lib/Service/PortfolioReportDerivation.php +++ b/lib/Service/PortfolioReportDerivation.php @@ -252,4 +252,58 @@ 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/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md#requirement-req-ava-001-an-organisation-scores-each-application-it-uses-on-value-fit-and-risk + */ + public 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/changes/lifecycle-application-value-assessment/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() }//end class diff --git a/lib/Service/PortfolioReportService.php b/lib/Service/PortfolioReportService.php index 56187fdb1..b7b592444 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->mismatchLabel(row: $row), ] ); } @@ -309,8 +321,9 @@ private function buildRow(array $usage, array $cfg, array $contractsByGebruik, D } $classification = $this->normalizeClassification(value: $usage['timeClassification'] ?? null); + $scores = $this->buildScores(usage: $usage, classification: $classification); - return [ + return $scores + [ 'uuid' => $gebruikId, 'moduleId' => $moduleId, 'moduleName' => $module['name'] ?? $module['title'] ?? $moduleId, @@ -327,6 +340,59 @@ private function buildRow(array $usage, array $cfg, array $contractsByGebruik, D ]; }//end buildRow() + /** + * 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. + * + * @return array The score fields of the row. + * + * @spec 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 + */ + private function buildScores(array $usage, ?string $classification): array { + $value = $this->derivation->score(value: $usage['businessValue'] ?? null); + $fit = $this->derivation->score(value: $usage['technicalFit'] ?? null); + + $suggested = $this->normalizeClassification(value: $usage['suggestedTimeClassification'] ?? null); + if ($suggested === null) { + $suggested = $this->derivation->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->derivation->score(value: $usage['riskScore'] ?? null), + 'scoredOn' => $scoredOn, + 'suggestedTimeClassification' => $suggested, + 'timeMismatch' => $classification !== null && $suggested !== null && $classification !== $suggested, + ]; + }//end buildScores() + + /** + * The CSV cell for the mismatch flag. + * + * @param array $row A report row. + * + * @return string "yes" or "no". + * + * @spec 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 + */ + private function mismatchLabel(array $row): string { + if (($row['timeMismatch'] ?? false) === true) { + return 'yes'; + } + + return 'no'; + }//end mismatchLabel() + /** * Sum annualised + one-off cost across a set of contracts. * 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/src/components/portfolio/UsageRiskSignals.vue b/src/components/portfolio/UsageRiskSignals.vue new file mode 100644 index 000000000..40023d1b3 --- /dev/null +++ b/src/components/portfolio/UsageRiskSignals.vue @@ -0,0 +1,211 @@ + + + + + + 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..6988cfdee --- /dev/null +++ b/src/utils/valueAssessment.js @@ -0,0 +1,102 @@ +/** + * 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/changes/lifecycle-application-value-assessment/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/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 + */ +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/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 + */ +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/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 + */ +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/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 + */ +export function mismatchRows(rows) { + return (Array.isArray(rows) ? rows : []).filter((row) => row?.timeMismatch === true) +} From 178fb2229f1f00cef2711449274ccf33d1080c55 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 04:25:20 +0200 Subject: [PATCH 4/9] wip: saved by the coordinator after the 30 Sep 04:20 process restart, not verified by the gate --- src/components/portfolio/ValueFitPlot.vue | 268 +++++++++++++++++++++ src/views/organisaties/PortfolioReport.vue | 66 ++++- 2 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 src/components/portfolio/ValueFitPlot.vue diff --git a/src/components/portfolio/ValueFitPlot.vue b/src/components/portfolio/ValueFitPlot.vue new file mode 100644 index 000000000..97316d036 --- /dev/null +++ b/src/components/portfolio/ValueFitPlot.vue @@ -0,0 +1,268 @@ + + + + + + diff --git a/src/views/organisaties/PortfolioReport.vue b/src/views/organisaties/PortfolioReport.vue index e62e4636c..9a0c07cd4 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,15 @@ :key="row.uuid" data-testid="pr-row"> {{ row.moduleName }} + + + {{ quadrantLabel(row.suggestedTimeClassification) }} + + — + + {{ scoresLabel(row) }} {{ row.timeRationale || '—' }} {{ row.timeReviewDate || '—' }} {{ row.lifecyclePhase }} @@ -262,6 +292,7 @@ import { translate as t } from '@nextcloud/l10n' import { generateUrl } from '@nextcloud/router' import { NcButton, + NcCheckboxRadioSwitch, NcEmptyContent, NcLoadingIcon, NcNoteCard, @@ -272,6 +303,7 @@ import Download from 'vue-material-design-icons/Download.vue' import Refresh from 'vue-material-design-icons/Refresh.vue' import { useLiveCollections } from '../../composables/useLiveCollections.js' import { objectStore } from '../../store/store.js' +import ValueFitPlot from '../../components/portfolio/ValueFitPlot.vue' import { resolveUuid } from '../../utils/lifecyclePhase.js' import { buildCsvExportUrl, @@ -281,6 +313,7 @@ import { QUADRANT_ORDER, quadrantColor, } from '../../utils/portfolioReport.js' +import { mismatchRows } from '../../utils/valueAssessment.js' /** * @class PortfolioReport @@ -300,11 +333,13 @@ export default { name: 'PortfolioReport', components: { NcButton, + NcCheckboxRadioSwitch, NcLoadingIcon, NcSelect, NcEmptyContent, NcNoteCard, CnChartWidget, + ValueFitPlot, Refresh, Download, ChartBoxOutline, @@ -330,6 +365,7 @@ export default { error: null, selectedOrg: null, report: null, + onlyMismatch: false, } }, @@ -434,12 +470,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/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 */ groupedRows() { if (!this.report) { return [] } - return groupRowsByQuadrant(this.report.rows || []) + const rows = this.report.rows || [] + return groupRowsByQuadrant(this.onlyMismatch ? mismatchRows(rows) : rows) }, }, @@ -595,6 +633,23 @@ 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/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 + */ + 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 +767,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; From 2093ebe551d63050f7a0bb6c8797813bf0eed528 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 04:30:02 +0200 Subject: [PATCH 5/9] feat(portfolio): English and Dutch text for the value assessment --- l10n/en.js | 35 ++++++++++++++++++++++++++++++++++- l10n/en.json | 35 ++++++++++++++++++++++++++++++++++- l10n/nl.js | 35 ++++++++++++++++++++++++++++++++++- l10n/nl.json | 35 ++++++++++++++++++++++++++++++++++- 4 files changed, 136 insertions(+), 4 deletions(-) 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." } } From b2e16c6c76c4590823595aab126bc855dedfd09d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 04:34:27 +0200 Subject: [PATCH 6/9] test(portfolio): the two value assessment scenarios in Playwright, the docs page, and the change's tasks and design brought up to date --- docs/features/portfolio-value-assessment.md | 44 ++++++ .../design.md | 7 + .../application-value-assessment/spec.md | 2 +- .../tasks.md | 16 +-- tests/e2e/workflows/portfolio-value.spec.ts | 135 ++++++++++++++++++ 5 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 docs/features/portfolio-value-assessment.md create mode 100644 tests/e2e/workflows/portfolio-value.spec.ts 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/openspec/changes/lifecycle-application-value-assessment/design.md b/openspec/changes/lifecycle-application-value-assessment/design.md index 66208d74c..aa1ce68f7 100644 --- a/openspec/changes/lifecycle-application-value-assessment/design.md +++ b/openspec/changes/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/specs/application-value-assessment/spec.md b/openspec/changes/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/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/lifecycle-application-value-assessment/tasks.md index f4fb5ecfe..bbd6e0cba 100644 --- a/openspec/changes/lifecycle-application-value-assessment/tasks.md +++ b/openspec/changes/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/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) +}) From b00b024a053e85506ba2156bbfd2d5bc467901b1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 04:35:23 +0200 Subject: [PATCH 7/9] chore(openspec): archive lifecycle-application-value-assessment, row life-value-assessment built, spec tags point at the main spec --- lib/Service/PortfolioReportDerivation.php | 4 +- lib/Service/PortfolioReportService.php | 4 +- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../application-value-assessment/spec.md | 0 .../tasks.md | 0 openspec/parity/capabilities.json | 13 +++--- .../application-value-assessment/spec.md | 40 +++++++++++++++++++ src/components/portfolio/UsageRiskSignals.vue | 8 ++-- src/components/portfolio/ValueFitPlot.vue | 18 ++++----- src/utils/valueAssessment.js | 10 ++--- src/views/organisaties/PortfolioReport.vue | 4 +- .../Service/PortfolioReportServiceTest.php | 4 +- .../Settings/ValueAssessmentFragmentTest.php | 2 +- tests/vitest/valueAssessment.spec.js | 2 +- 16 files changed, 75 insertions(+), 34 deletions(-) rename openspec/changes/{lifecycle-application-value-assessment => archive/2026-09-30-lifecycle-application-value-assessment}/.openspec.yaml (100%) rename openspec/changes/{lifecycle-application-value-assessment => archive/2026-09-30-lifecycle-application-value-assessment}/design.md (100%) rename openspec/changes/{lifecycle-application-value-assessment => archive/2026-09-30-lifecycle-application-value-assessment}/proposal.md (100%) rename openspec/changes/{lifecycle-application-value-assessment => archive/2026-09-30-lifecycle-application-value-assessment}/specs/application-value-assessment/spec.md (100%) rename openspec/changes/{lifecycle-application-value-assessment => archive/2026-09-30-lifecycle-application-value-assessment}/tasks.md (100%) create mode 100644 openspec/specs/application-value-assessment/spec.md diff --git a/lib/Service/PortfolioReportDerivation.php b/lib/Service/PortfolioReportDerivation.php index b7ba4ec28..c90efadcb 100644 --- a/lib/Service/PortfolioReportDerivation.php +++ b/lib/Service/PortfolioReportDerivation.php @@ -259,7 +259,7 @@ static function ($object) { * * @return int|null The score. * - * @spec openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md#requirement-req-ava-001-an-organisation-scores-each-application-it-uses-on-value-fit-and-risk + * @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 score(mixed $value): ?int { if (is_numeric($value) === false) { @@ -285,7 +285,7 @@ public function score(mixed $value): ?int { * * @return string|null Invest, Migrate, Tolerate or Eliminate; null while a score is missing. * - * @spec openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md#requirement-req-ava-001-an-organisation-scores-each-application-it-uses-on-value-fit-and-risk + * @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) { diff --git a/lib/Service/PortfolioReportService.php b/lib/Service/PortfolioReportService.php index b7b592444..8d8b19958 100644 --- a/lib/Service/PortfolioReportService.php +++ b/lib/Service/PortfolioReportService.php @@ -350,7 +350,7 @@ private function buildRow(array $usage, array $cfg, array $contractsByGebruik, D * * @return array The score fields of the row. * - * @spec 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 + * @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 */ private function buildScores(array $usage, ?string $classification): array { $value = $this->derivation->score(value: $usage['businessValue'] ?? null); @@ -383,7 +383,7 @@ private function buildScores(array $usage, ?string $classification): array { * * @return string "yes" or "no". * - * @spec 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 + * @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 */ private function mismatchLabel(array $row): string { if (($row['timeMismatch'] ?? false) === true) { 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 100% rename from openspec/changes/lifecycle-application-value-assessment/design.md rename to openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/design.md 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 100% 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 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 100% rename from openspec/changes/lifecycle-application-value-assessment/tasks.md rename to openspec/changes/archive/2026-09-30-lifecycle-application-value-assessment/tasks.md 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 index 40023d1b3..86e3b1eba 100644 --- a/src/components/portfolio/UsageRiskSignals.vue +++ b/src/components/portfolio/UsageRiskSignals.vue @@ -96,7 +96,7 @@ export default { watch: { /** - * @spec 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 + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-002-the-usage-page-shows-the-risk-signals-next-to-the-risk-score */ objectId() { this.load() @@ -104,7 +104,7 @@ export default { }, /** - * @spec 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 + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-002-the-usage-page-shows-the-risk-signals-next-to-the-risk-score */ mounted() { this.load() @@ -118,7 +118,7 @@ export default { * Load the usage, the version it runs and the vulnerabilities of its application. * * @return {Promise} - * @spec 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 + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-002-the-usage-page-shows-the-risk-signals-next-to-the-risk-score */ async load() { this.loading = true @@ -154,7 +154,7 @@ export default { * * @param {string} type The schema slug. * @return {void} - * @spec 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 + * @spec openspec/specs/application-value-assessment/spec.md#requirement-req-ava-002-the-usage-page-shows-the-risk-signals-next-to-the-risk-score */ ensureType(type) { if ( diff --git a/src/components/portfolio/ValueFitPlot.vue b/src/components/portfolio/ValueFitPlot.vue index 97316d036..31f0cdee7 100644 --- a/src/components/portfolio/ValueFitPlot.vue +++ b/src/components/portfolio/ValueFitPlot.vue @@ -118,7 +118,7 @@ export default { computed: { /** * @return {{points: Array, notScored: number}} The plotted points. - * @spec 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 + * @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 */ plotted() { return valueFitPoints(this.rows) @@ -126,7 +126,7 @@ export default { /** * @return {number} The plot width inside the padding. - * @spec 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 + * @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 */ plotWidth() { return this.width - this.pad.left - this.pad.right @@ -134,7 +134,7 @@ export default { /** * @return {number} The plot height inside the padding. - * @spec 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 + * @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 */ plotHeight() { return this.height - this.pad.top - this.pad.bottom @@ -144,7 +144,7 @@ export default { * The four TIME areas, split between score 2 and 3. * * @return {Array} The areas. - * @spec 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 + * @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 */ quadrants() { const left = this.scaleX(0.5) @@ -163,7 +163,7 @@ export default { /** * @return {string} A text alternative for the plot. - * @spec 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 + * @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 */ ariaLabel() { return n( @@ -183,7 +183,7 @@ export default { /** * @param {number} fit A technical fit value. * @return {number} The x coordinate. - * @spec 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 + * @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 */ scaleX(fit) { return this.pad.left + ((fit - 0.5) / 5) * this.plotWidth @@ -192,7 +192,7 @@ export default { /** * @param {number} value A business value. * @return {number} The y coordinate. - * @spec 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 + * @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 */ scaleY(value) { return this.pad.top + ((5.5 - value) / 5) * this.plotHeight @@ -201,7 +201,7 @@ export default { /** * @param {string} key A TIME class. * @return {string} Its label. - * @spec 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 + * @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 */ quadrantLabel(key) { const map = { @@ -216,7 +216,7 @@ export default { /** * @param {object} point A plotted point. * @return {string} The tooltip. - * @spec 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 + * @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 */ pointTitle(point) { return t( diff --git a/src/utils/valueAssessment.js b/src/utils/valueAssessment.js index 6988cfdee..03edca16b 100644 --- a/src/utils/valueAssessment.js +++ b/src/utils/valueAssessment.js @@ -6,7 +6,7 @@ * signals next to the risk score, and the value against fit plot and the * mismatch filter of the portfolio report. * - * @spec openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md + * @spec openspec/specs/application-value-assessment/spec.md */ import { endOfSupportState } from './lifecyclePhase.js' import { refId } from './maintenance.js' @@ -24,7 +24,7 @@ const SPREAD = 0.18 * @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/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 + * @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) @@ -48,7 +48,7 @@ export function riskSignals(version, vulnerabilities, moduleId, now = new Date() * * @param {object} row A portfolio report row. * @return {boolean} True when value and fit are set. - * @spec 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 + * @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) @@ -61,7 +61,7 @@ function isScored(row) { * * @param {Array|null} rows Portfolio report rows. * @return {{points: Array, notScored: number}} The points and the count of usages without both scores. - * @spec 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 + * @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 : [] @@ -95,7 +95,7 @@ export function valueFitPoints(rows) { * * @param {Array|null} rows Portfolio report rows. * @return {Array} The mismatching rows. - * @spec 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 + * @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 9a0c07cd4..fcafc94c6 100644 --- a/src/views/organisaties/PortfolioReport.vue +++ b/src/views/organisaties/PortfolioReport.vue @@ -470,7 +470,7 @@ 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/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 + * @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) { @@ -638,7 +638,7 @@ export default { * * @param {object} row A report row. * @return {string} The label. - * @spec 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 + * @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] diff --git a/tests/Unit/Service/PortfolioReportServiceTest.php b/tests/Unit/Service/PortfolioReportServiceTest.php index c13b8bfa8..52577a415 100644 --- a/tests/Unit/Service/PortfolioReportServiceTest.php +++ b/tests/Unit/Service/PortfolioReportServiceTest.php @@ -544,7 +544,7 @@ private function scoredUsages(): array { * * @return void * - * @spec 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 + * @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']; @@ -573,7 +573,7 @@ public function testRowsCarryTheScoresAndTheMismatch(): void { * * @return void * - * @spec 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 + * @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')))); diff --git a/tests/Unit/Settings/ValueAssessmentFragmentTest.php b/tests/Unit/Settings/ValueAssessmentFragmentTest.php index f498c58cb..c930d9507 100644 --- a/tests/Unit/Settings/ValueAssessmentFragmentTest.php +++ b/tests/Unit/Settings/ValueAssessmentFragmentTest.php @@ -10,7 +10,7 @@ * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://github.com/ConductionNL/stackiq * - * @spec openspec/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md#requirement-req-ava-001-an-organisation-scores-each-application-it-uses-on-value-fit-and-risk + * @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 diff --git a/tests/vitest/valueAssessment.spec.js b/tests/vitest/valueAssessment.spec.js index bc220e56d..ba206453b 100644 --- a/tests/vitest/valueAssessment.spec.js +++ b/tests/vitest/valueAssessment.spec.js @@ -6,7 +6,7 @@ * 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/changes/lifecycle-application-value-assessment/specs/application-value-assessment/spec.md + * @spec openspec/specs/application-value-assessment/spec.md */ import * as fs from 'fs' import * as path from 'path' From 90beb136376c40365dd9cbf352237eae952c8464 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 04:56:27 +0200 Subject: [PATCH 8/9] fix(portfolio): lint and prettier on the value assessment files --- src/components/portfolio/UsageRiskSignals.vue | 44 ++++++++++++----- src/components/portfolio/ValueFitPlot.vue | 38 +++++++++++++-- src/utils/valueAssessment.js | 18 +++++-- src/views/organisaties/PortfolioReport.vue | 12 +++-- tests/vitest/valueAssessment.spec.js | 48 +++++++++++++++++-- 5 files changed, 131 insertions(+), 29 deletions(-) diff --git a/src/components/portfolio/UsageRiskSignals.vue b/src/components/portfolio/UsageRiskSignals.vue index 86e3b1eba..424cf2586 100644 --- a/src/components/portfolio/UsageRiskSignals.vue +++ b/src/components/portfolio/UsageRiskSignals.vue @@ -22,20 +22,36 @@
{{ t('stackiq', 'Risk score') }}
- {{ riskScore === null ? t('stackiq', 'Not scored') : riskScore + ' / 5' }} + {{ + riskScore === null + ? t('stackiq', 'Not scored') + : riskScore + ' / 5' + }}
{{ t('stackiq', 'End of support') }}
- {{ t('stackiq', 'Passed on {date}', { date: signals.endOfSupportDate }) }} + {{ + t('stackiq', 'Passed on {date}', { + date: signals.endOfSupportDate, + }) + }} - {{ t('stackiq', 'Supported until {date}', { date: signals.endOfSupportDate }) }} + {{ + t('stackiq', 'Supported until {date}', { + date: signals.endOfSupportDate, + }) + }} - {{ t('stackiq', 'No end of support date known') }} - + {{ + t('stackiq', 'No end of support date known') + }} + {{ t('stackiq', 'This version was withdrawn') }}
@@ -43,7 +59,8 @@
{{ n( @@ -129,7 +146,9 @@ export default { this.ensureType('vulnerability') const usage = await objectStore.fetchObject('usage', this.objectId) const data = usage?.object || usage || {} - this.riskScore = Number.isInteger(data.riskScore) ? data.riskScore : null + this.riskScore = Number.isInteger(data.riskScore) + ? data.riskScore + : null const moduleId = refId(data.module) const versionId = refId(data.moduleVersion) const version = versionId @@ -137,9 +156,9 @@ export default { : null const vulnerabilities = moduleId ? await objectStore.fetchCollectionForOptions('vulnerability', { - modules: moduleId, - _limit: 200, - }) + modules: moduleId, + _limit: 200, + }) : [] this.signals = riskSignals(version, vulnerabilities, moduleId) } catch { @@ -163,7 +182,7 @@ export default { ) { return } - let config = null + let config try { config = objectStore.getSchemaConfig?.(type) } catch { @@ -191,7 +210,8 @@ export default { .usage-risk-signals__list { display: grid; grid-template-columns: max-content 1fr; - gap: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 4); + gap: calc(var(--default-grid-baseline) * 2) + calc(var(--default-grid-baseline) * 4); margin: 0; } diff --git a/src/components/portfolio/ValueFitPlot.vue b/src/components/portfolio/ValueFitPlot.vue index 31f0cdee7..de6b28eda 100644 --- a/src/components/portfolio/ValueFitPlot.vue +++ b/src/components/portfolio/ValueFitPlot.vue @@ -63,7 +63,11 @@ :cy="scaleY(point.value + point.offset[1])" :r="point.radius" :fill="quadrantColor(point.suggested || 'Unclassified')" - :stroke="point.recorded && point.recorded !== point.suggested ? 'var(--color-main-text)' : 'none'" + :stroke=" + point.recorded && point.recorded !== point.suggested + ? 'var(--color-main-text)' + : 'none' + " stroke-width="2" fill-opacity="0.75" data-testid="pr-value-fit-point"> @@ -154,10 +158,34 @@ export default { const middle = this.scaleY(2.5) const bottom = this.scaleY(0.5) return [ - { key: 'Migrate', x: left, y: top, width: mid - left, height: middle - top }, - { key: 'Invest', x: mid, y: top, width: right - mid, height: middle - top }, - { key: 'Eliminate', x: left, y: middle, width: mid - left, height: bottom - middle }, - { key: 'Tolerate', x: mid, y: middle, width: right - mid, height: bottom - middle }, + { + key: 'Migrate', + x: left, + y: top, + width: mid - left, + height: middle - top, + }, + { + key: 'Invest', + x: mid, + y: top, + width: right - mid, + height: middle - top, + }, + { + key: 'Eliminate', + x: left, + y: middle, + width: mid - left, + height: bottom - middle, + }, + { + key: 'Tolerate', + x: mid, + y: middle, + width: right - mid, + height: bottom - middle, + }, ] }, diff --git a/src/utils/valueAssessment.js b/src/utils/valueAssessment.js index 03edca16b..b0ea6c3fc 100644 --- a/src/utils/valueAssessment.js +++ b/src/utils/valueAssessment.js @@ -51,7 +51,9 @@ export function riskSignals(version, vulnerabilities, moduleId, now = new Date() * @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) + return ( + Number.isInteger(row?.businessValue) && Number.isInteger(row?.technicalFit) + ) } /** @@ -66,7 +68,10 @@ function isScored(row) { 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 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) @@ -82,7 +87,10 @@ export function valueFitPoints(rows) { 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], + offset: + index === 0 + ? [0, 0] + : [Math.cos(angle) * SPREAD, Math.sin(angle) * SPREAD], recorded: row.timeClassification || null, suggested: row.suggestedTimeClassification || null, } @@ -98,5 +106,7 @@ export function valueFitPoints(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) + 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 fcafc94c6..6482c4387 100644 --- a/src/views/organisaties/PortfolioReport.vue +++ b/src/views/organisaties/PortfolioReport.vue @@ -245,7 +245,11 @@ - {{ quadrantLabel(row.suggestedTimeClassification) }} + {{ + quadrantLabel( + row.suggestedTimeClassification, + ) + }} — @@ -301,9 +305,9 @@ 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 ValueFitPlot from '../../components/portfolio/ValueFitPlot.vue' import { resolveUuid } from '../../utils/lifecyclePhase.js' import { buildCsvExportUrl, @@ -646,7 +650,9 @@ export default { return '—' } return scores - .map((score) => (score === null || score === undefined ? '-' : score)) + .map((score) => + score === null || score === undefined ? '-' : score, + ) .join(' / ') }, diff --git a/tests/vitest/valueAssessment.spec.js b/tests/vitest/valueAssessment.spec.js index ba206453b..0cbbf50da 100644 --- a/tests/vitest/valueAssessment.spec.js +++ b/tests/vitest/valueAssessment.spec.js @@ -60,10 +60,46 @@ describe('riskSignals', () => { }) 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 }, + { + 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', () => { @@ -123,7 +159,9 @@ describe('the usage page', () => { 'suggestedTimeClassification', ]), ) - expect(page.config.layout.some((l) => l.widgetId === 'gb-assessment')).toBe(true) + expect(page.config.layout.some((l) => l.widgetId === 'gb-assessment')).toBe( + true, + ) }) it('places the risk signals after the data and registers the component', () => { From 6fd45fdb4efacabf610b484e34e764a6f616c61e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 30 Sep 2026 05:22:50 +0200 Subject: [PATCH 9/9] refactor(portfolio): the value assessment row fields live in PortfolioReportDerivation, keeping the service under the complexity limit --- lib/Service/PortfolioReportDerivation.php | 56 ++++++++++++++++++++- lib/Service/PortfolioReportService.php | 61 +++-------------------- 2 files changed, 61 insertions(+), 56 deletions(-) diff --git a/lib/Service/PortfolioReportDerivation.php b/lib/Service/PortfolioReportDerivation.php index c90efadcb..19b79d12d 100644 --- a/lib/Service/PortfolioReportDerivation.php +++ b/lib/Service/PortfolioReportDerivation.php @@ -261,7 +261,7 @@ static function ($object) { * * @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 score(mixed $value): ?int { + private function score(mixed $value): ?int { if (is_numeric($value) === false) { return null; } @@ -306,4 +306,58 @@ public function suggestTimeClassification(?int $businessValue, ?int $technicalFi 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 8d8b19958..18401a959 100644 --- a/lib/Service/PortfolioReportService.php +++ b/lib/Service/PortfolioReportService.php @@ -209,7 +209,7 @@ public function buildCsv(string $organisationUuid): string { (string)($row['riskScore'] ?? ''), $row['scoredOn'] ?? '', $row['suggestedTimeClassification'] ?? '', - $this->mismatchLabel(row: $row), + $this->derivation->mismatchLabel(row: $row), ] ); } @@ -321,7 +321,11 @@ private function buildRow(array $usage, array $cfg, array $contractsByGebruik, D } $classification = $this->normalizeClassification(value: $usage['timeClassification'] ?? null); - $scores = $this->buildScores(usage: $usage, classification: $classification); + $scores = $this->derivation->valueAssessment( + usage: $usage, + classification: $classification, + storedSuggestion: $this->normalizeClassification(value: $usage['suggestedTimeClassification'] ?? null) + ); return $scores + [ 'uuid' => $gebruikId, @@ -340,59 +344,6 @@ private function buildRow(array $usage, array $cfg, array $contractsByGebruik, D ]; }//end buildRow() - /** - * 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. - * - * @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 - */ - private function buildScores(array $usage, ?string $classification): array { - $value = $this->derivation->score(value: $usage['businessValue'] ?? null); - $fit = $this->derivation->score(value: $usage['technicalFit'] ?? null); - - $suggested = $this->normalizeClassification(value: $usage['suggestedTimeClassification'] ?? null); - if ($suggested === null) { - $suggested = $this->derivation->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->derivation->score(value: $usage['riskScore'] ?? null), - 'scoredOn' => $scoredOn, - 'suggestedTimeClassification' => $suggested, - 'timeMismatch' => $classification !== null && $suggested !== null && $classification !== $suggested, - ]; - }//end buildScores() - - /** - * 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 - */ - private function mismatchLabel(array $row): string { - if (($row['timeMismatch'] ?? false) === true) { - return 'yes'; - } - - return 'no'; - }//end mismatchLabel() - /** * Sum annualised + one-off cost across a set of contracts. *