From 0cc531c7484f3cac494c0bb187c6f21d0a1685c5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 18:47:55 +0200 Subject: [PATCH 1/5] feat(contracts): a contract records its licence metric and the licences bought and in use --- .../register.d/contracts-licence-seats.json | 50 +++++ lib/Settings/stackiq_mock_register.json | 80 ++++++- src/utils/licensePosture.js | 110 ++++++++++ .../Settings/LicenceSeatsDeclarationTest.php | 105 +++++++++ tests/vitest/licenceSeats.spec.js | 201 ++++++++++++++++++ 5 files changed, 544 insertions(+), 2 deletions(-) create mode 100644 lib/Settings/register.d/contracts-licence-seats.json create mode 100644 tests/Unit/Settings/LicenceSeatsDeclarationTest.php create mode 100644 tests/vitest/licenceSeats.spec.js diff --git a/lib/Settings/register.d/contracts-licence-seats.json b/lib/Settings/register.d/contracts-licence-seats.json new file mode 100644 index 000000000..3f301991d --- /dev/null +++ b/lib/Settings/register.d/contracts-licence-seats.json @@ -0,0 +1,50 @@ +{ + "components": { + "schemas": { + "catalogContract": { + "version": "0.1.3", + "properties": { + "licenceMetric": { + "type": "string", + "enum": [ + "Per named user", + "Per concurrent user", + "Per device", + "Per inhabitant", + "Per organisation", + "Other" + ], + "x-enum-labels": { + "Per named user": "Per named user", + "Per concurrent user": "Per concurrent user", + "Per device": "Per device", + "Per inhabitant": "Per inhabitant", + "Per organisation": "Per organisation", + "Other": "Other" + }, + "title": "Licence metric", + "description": "What one licence covers, as the contract with the supplier states it.", + "facetable": true, + "order": 14 + }, + "licencesBought": { + "type": "integer", + "minimum": 0, + "title": "Licences bought", + "description": "The number of licences this contract buys.", + "facetable": false, + "order": 15 + }, + "licencesInUse": { + "type": "integer", + "minimum": 0, + "title": "Licences in use", + "description": "The number of licences in use today, as the application owner counted them.", + "facetable": false, + "order": 16 + } + } + } + } + } +} diff --git a/lib/Settings/stackiq_mock_register.json b/lib/Settings/stackiq_mock_register.json index 497666a6b..2fd933313 100644 --- a/lib/Settings/stackiq_mock_register.json +++ b/lib/Settings/stackiq_mock_register.json @@ -1334,6 +1334,45 @@ }, "referenceType": "decision", "x-allow-create": true + }, + "licenceMetric": { + "type": "string", + "enum": [ + "Per named user", + "Per concurrent user", + "Per device", + "Per inhabitant", + "Per organisation", + "Other" + ], + "x-enum-labels": { + "Per named user": "Per named user", + "Per concurrent user": "Per concurrent user", + "Per device": "Per device", + "Per inhabitant": "Per inhabitant", + "Per organisation": "Per organisation", + "Other": "Other" + }, + "title": "Licence metric", + "description": "What one licence covers, as the contract with the supplier states it.", + "facetable": true, + "order": 14 + }, + "licencesBought": { + "type": "integer", + "minimum": 0, + "title": "Licences bought", + "description": "The number of licences this contract buys.", + "facetable": false, + "order": 15 + }, + "licencesInUse": { + "type": "integer", + "minimum": 0, + "title": "Licences in use", + "description": "The number of licences in use today, as the application owner counted them.", + "facetable": false, + "order": 16 } }, "required": [ @@ -1387,7 +1426,7 @@ }, "title": "Contract", "description": "Een formele overeenkomst voor het inzetten van een Dienst op een Gebruik. Beschrijft de inkooprelatie achter een Gebruik (welke organisatie welke module onder welke voorwaarden gebruikt): looptijd, kosten, contracttype en status.", - "version": "0.1.2", + "version": "0.1.3", "omschrijving": "", "icon": "FileSign", "archive": [], @@ -7664,7 +7703,10 @@ "opmerkingen": "Voorbeeld Opmerkingen 2", "decisions": [ "00000000-0000-4000-8000-000000000001" - ] + ], + "licenceMetric": "Per named user", + "licencesBought": 400, + "licencesInUse": 460 }, { "@self": { @@ -7697,6 +7739,40 @@ "00000000-0000-4000-8000-000000000002" ] }, + { + "@self": { + "register": "stackiq", + "schema": "catalogContract", + "slug": "contract-seats-5" + }, + "service": {}, + "usage": {}, + "startDate": "2026-03-02", + "contractNumber": "Voorbeeld Contractnumber 5", + "contractType": "Licence", + "status": "Active", + "approvalDecisionId": "Voorbeeld Approvaldecisionid 2", + "approvalState": "none", + "endDate": "2026-03-02", + "cost": 2.0, + "costPeriod": "Annually", + "contactPersonProvider": { + "name": "Voorbeeld Name 2", + "email": "Voorbeeld Email 2" + }, + "contactPersonUser": { + "name": "Voorbeeld Name 2", + "email": "Voorbeeld Email 2" + }, + "documentReference": "Voorbeeld Documentreference 2", + "opmerkingen": "Voorbeeld Opmerkingen 2", + "decisions": [ + "00000000-0000-4000-8000-000000000001" + ], + "licenceMetric": "Per inhabitant", + "licencesBought": 58000, + "licencesInUse": 57120 + }, { "@self": { "register": "stackiq", diff --git a/src/utils/licensePosture.js b/src/utils/licensePosture.js index cec479d13..fc22b9665 100644 --- a/src/utils/licensePosture.js +++ b/src/utils/licensePosture.js @@ -286,3 +286,113 @@ export function perOrganisationPosture(orgId, modules, usages) { closedContributors: [...acc.closedContributors], } } + +/** + * Licence metrics that have no seat to count. A contract on one of these gets + * no seat comparison (contracts-licence-seats, design D3). + * + * @type {ReadonlyArray} + */ +export const UNCOUNTED_METRICS = Object.freeze(['Per organisation', 'Other']) + +/** + * Seat states a counted licence contract can be in. + * + * @type {{WITHIN: string, OVER: string, UNKNOWN: string, NOT_COUNTED: string}} + */ +export const SEAT_STATE = Object.freeze({ + WITHIN: 'within', + OVER: 'over', + UNKNOWN: 'unknown', + NOT_COUNTED: 'not-counted', +}) + +/** + * Read a licence count: a whole number of zero or more, or null when empty. + * + * @param {string|number|null|undefined} value The raw field value. + * @return {number|null} The count, or null. + */ +function seatCount(value) { + if (value === null || value === undefined || value === '') { + return null + } + const n = Number(value) + return Number.isInteger(n) && n >= 0 ? n : null +} + +/** + * Where one contract stands on its licences: in use against bought. + * + * @param {object} contract A catalogContract record (envelope or data bag). + * @return {{state: string, metric: string, bought: (number|null), inUse: (number|null), over: number}} + * The seat position; `over` is how many licences are in use above what was bought. + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + */ +export function seatPosition(contract) { + const data = dataOf(contract) + const metric = typeof data.licenceMetric === 'string' ? data.licenceMetric : '' + const bought = seatCount(data.licencesBought) + const inUse = seatCount(data.licencesInUse) + const position = { state: SEAT_STATE.UNKNOWN, metric, bought, inUse, over: 0 } + + if (UNCOUNTED_METRICS.includes(metric)) { + return { ...position, state: SEAT_STATE.NOT_COUNTED } + } + if (metric === '' || bought === null || inUse === null) { + return position + } + if (inUse > bought) { + return { ...position, state: SEAT_STATE.OVER, over: inUse - bought } + } + return { ...position, state: SEAT_STATE.WITHIN } +} + +/** + * One row per counted licence contract for the Seats section of the License + * posture page, over-licence rows first (most over first). A contract is left + * out when its metric is uncounted or empty, or when `licencesBought` is empty. + * + * @param {Array} contracts catalogContract records. + * @param {Array} usages Usage records, to find the application and the organisation. + * @return {Array<{contractId: string, contractNumber: string, moduleId: string, consumerId: string, metric: string, bought: number, inUse: (number|null), state: string, over: number}>} + * The seat rows. + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first + */ +export function seatRows(contracts, usages) { + const usageIndex = {} + for (const u of usages || []) { + const id = resolveUuid(u?.id ?? u?.uuid ?? u?.['@self']?.id ?? '') + if (id !== '') { + usageIndex[id] = dataOf(u) + } + } + + const rows = [] + for (const c of contracts || []) { + const position = seatPosition(c) + if ( + position.state === SEAT_STATE.NOT_COUNTED + || position.metric === '' + || position.bought === null + ) { + continue + } + const data = dataOf(c) + const usage = usageIndex[resolveUuid(data.usage)] || {} + rows.push({ + contractId: resolveUuid(c?.id ?? c?.uuid ?? c?.['@self']?.id ?? ''), + contractNumber: data.contractNumber || '', + moduleId: resolveUuid(usage.module), + consumerId: resolveUuid(usage.consumer), + metric: position.metric, + bought: position.bought, + inUse: position.inUse, + state: position.state, + over: position.over, + }) + } + + const rank = (row) => (row.state === SEAT_STATE.OVER ? 0 : 1) + return rows.sort((a, b) => rank(a) - rank(b) || b.over - a.over) +} diff --git a/tests/Unit/Settings/LicenceSeatsDeclarationTest.php b/tests/Unit/Settings/LicenceSeatsDeclarationTest.php new file mode 100644 index 000000000..06eec4743 --- /dev/null +++ b/tests/Unit/Settings/LicenceSeatsDeclarationTest.php @@ -0,0 +1,105 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use + * + * 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\SettingsService; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; + +/** + * Merges the register.d fragment into the monolith with SettingsService's own + * merge, the one loadSettings() runs before the import. + * + * @coversNothing + */ +class LicenceSeatsDeclarationTest extends TestCase { + + /** + * The catalogContract schema after the fragment is merged in. + * + * @return array The schema. + */ + private function contractSchema(): 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/contracts-licence-seats.json'), true); + + $merge = new ReflectionMethod(SettingsService::class, 'deepMergeConfig'); + $merged = $merge->invoke(null, $base, $fragment); + + return $merged['components']['schemas']['catalogContract']; + }//end contractSchema() + + /** + * The metric carries the six values of the spec. + * + * @return void + */ + public function testTheMetricCarriesSixValues(): void { + $metric = $this->contractSchema()['properties']['licenceMetric']; + + $this->assertSame('string', $metric['type']); + $this->assertSame( + ['Per named user', 'Per concurrent user', 'Per device', 'Per inhabitant', 'Per organisation', 'Other'], + $metric['enum'] + ); + $this->assertSame($metric['enum'], array_keys($metric['x-enum-labels'])); + }//end testTheMetricCarriesSixValues() + + /** + * Both counts are whole numbers of at least 0, and none of the three is required. + * + * @return void + */ + public function testBothCountsAreWholeNumbersOfAtLeastZero(): void { + $schema = $this->contractSchema(); + foreach (['licencesBought', 'licencesInUse'] as $field) { + $this->assertSame('integer', $schema['properties'][$field]['type'], $field); + $this->assertSame(0, $schema['properties'][$field]['minimum'], $field); + } + + $required = ($schema['required'] ?? []); + $this->assertSame([], array_values(array_intersect(['licenceMetric', 'licencesBought', 'licencesInUse'], $required))); + }//end testBothCountsAreWholeNumbersOfAtLeastZero() + + /** + * The merge keeps every existing contract property. + * + * @return void + */ + public function testTheMergeKeepsTheExistingProperties(): void { + $schema = $this->contractSchema(); + foreach (['contractType', 'cost', 'usage', 'status'] as $field) { + $this->assertArrayHasKey($field, $schema['properties'], $field); + } + }//end testTheMergeKeepsTheExistingProperties() + + /** + * The schema version moves above the version before this change. + * + * @return void + */ + public function testTheSchemaVersionMovesUp(): void { + $this->assertTrue(version_compare($this->contractSchema()['version'], '0.1.2', '>')); + }//end testTheSchemaVersionMovesUp() +}//end class diff --git a/tests/vitest/licenceSeats.spec.js b/tests/vitest/licenceSeats.spec.js new file mode 100644 index 000000000..c67461787 --- /dev/null +++ b/tests/vitest/licenceSeats.spec.js @@ -0,0 +1,201 @@ +/** + * Licence seats: the seat position of one contract, the Seats rows of the + * License posture page, and the seeded contracts validated against the real + * catalogContract schema (the monolith with the register.d fragment merged in, + * the way SettingsService::loadSettings() merges it). + * + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md + */ + +import Ajv2020 from 'ajv/dist/2020.js' +import { describe, expect, it } from 'vitest' +import fragment from '../../lib/Settings/register.d/contracts-licence-seats.json' +import register from '../../lib/Settings/softwarecatalogus_register.json' +import mock from '../../lib/Settings/stackiq_mock_register.json' +import { + SEAT_STATE, + seatPosition, + seatRows, +} from '../../src/utils/licensePosture.js' + +const SEAT_FIELDS = ['licenceMetric', 'licencesBought', 'licencesInUse'] + +/** + * The catalogContract properties as the import sees them: monolith plus fragment. + * + * @return {object} Property name to property schema. + */ +function contractProperties() { + return { + ...register.components.schemas.catalogContract.properties, + ...fragment.components.schemas.catalogContract.properties, + } +} + +/** + * A validator for the seat fields of a contract, built from the real properties. + * + * @return {Function} The compiled Ajv validator. + */ +function compileSeatFields() { + const props = contractProperties() + const ajv = new Ajv2020({ allErrors: true, strict: false }) + return ajv.compile({ + type: 'object', + properties: Object.fromEntries( + SEAT_FIELDS.map((f) => [ + f, + { + type: props[f].type, + enum: props[f].enum, + minimum: props[f].minimum, + }, + ]), + ), + }) +} + +function contract(id, fields, usage = '') { + return { + id, + contractNumber: id, + contractType: 'Licence', + usage, + ...fields, + } +} + +describe('seatPosition', () => { + it('flags 460 in use against 400 bought as over by 60', () => { + const p = seatPosition( + contract('c1', { + licenceMetric: 'Per named user', + licencesBought: 400, + licencesInUse: 460, + }), + ) + expect(p.state).toBe(SEAT_STATE.OVER) + expect(p.over).toBe(60) + }) + + it('reads use at or under what was bought as within', () => { + const p = seatPosition( + contract('c1', { + licenceMetric: 'Per inhabitant', + licencesBought: 58000, + licencesInUse: 58000, + }), + ) + expect(p.state).toBe(SEAT_STATE.WITHIN) + expect(p.over).toBe(0) + }) + + it('does not count Per organisation or Other', () => { + for (const metric of ['Per organisation', 'Other']) { + const p = seatPosition( + contract('c1', { + licenceMetric: metric, + licencesBought: 1, + licencesInUse: 5, + }), + ) + expect(p.state).toBe(SEAT_STATE.NOT_COUNTED) + } + }) + + it('is unknown when a count is empty', () => { + const p = seatPosition( + contract('c1', { licenceMetric: 'Per device', licencesBought: 10 }), + ) + expect(p.state).toBe(SEAT_STATE.UNKNOWN) + }) +}) + +describe('seatRows', () => { + const usages = [ + { id: 'u1', module: 'm1', consumer: 'o1' }, + { id: 'u2', module: 'm2', consumer: 'o2' }, + ] + + it('puts over-licence rows first, most over first, and leaves uncounted contracts out', () => { + const rows = seatRows( + [ + contract( + 'within', + { + licenceMetric: 'Per device', + licencesBought: 10, + licencesInUse: 5, + }, + 'u1', + ), + contract( + 'over10', + { + licenceMetric: 'Per device', + licencesBought: 10, + licencesInUse: 20, + }, + 'u1', + ), + contract( + 'over60', + { + licenceMetric: 'Per named user', + licencesBought: 400, + licencesInUse: 460, + }, + 'u2', + ), + contract( + 'site', + { + licenceMetric: 'Per organisation', + licencesBought: 1, + licencesInUse: 1, + }, + 'u1', + ), + contract('sla', {}, 'u1'), + contract( + 'noBought', + { licenceMetric: 'Per device', licencesInUse: 3 }, + 'u1', + ), + ], + usages, + ) + expect(rows.map((r) => r.contractId)).toEqual(['over60', 'over10', 'within']) + expect(rows[0]).toMatchObject({ moduleId: 'm2', consumerId: 'o2', over: 60 }) + }) +}) + +describe('the catalogContract schema and the seeded contracts', () => { + it('declares the metric with six values and two whole counts of at least 0', () => { + const props = contractProperties() + expect(props.licenceMetric.enum).toHaveLength(6) + for (const f of ['licencesBought', 'licencesInUse']) { + expect(props[f]).toMatchObject({ type: 'integer', minimum: 0 }) + } + }) + + it('refuses a negative count', () => { + const validate = compileSeatFields() + expect(validate({ licencesBought: -5 })).toBe(false) + }) + + it('seeds one over-licence and one within-licence contract that the real schema accepts', () => { + const validate = compileSeatFields() + const seeded = mock.components.objects.filter( + (o) => + o['@self'].register === 'stackiq' + && o['@self'].schema === 'catalogContract' + && o.licenceMetric !== undefined, + ) + for (const o of seeded) { + expect(validate(o), JSON.stringify(validate.errors)).toBe(true) + } + const states = seeded.map((o) => seatPosition(o).state).sort() + expect(states).toEqual([SEAT_STATE.OVER, SEAT_STATE.WITHIN]) + }) +}) From 01484fa0894ca93f1f4575ed5f9fc57a67a65e10 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 18:50:01 +0200 Subject: [PATCH 2/5] feat(contracts): see licences in use against licences bought on the contract and on the License posture page --- l10n/en.js | 22 ++ l10n/en.json | 22 ++ l10n/nl.js | 22 ++ l10n/nl.json | 22 ++ .../contracts/ContractSeatsPanel.vue | 254 ++++++++++++++++++ src/customComponents.js | 4 + src/manifest.json | 3 +- src/utils/seatLabels.js | 53 ++++ src/views/LicensePostureView.vue | 81 ++++++ tests/vitest/licenceSeatsLabels.spec.js | 73 +++++ 10 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 src/components/contracts/ContractSeatsPanel.vue create mode 100644 src/utils/seatLabels.js create mode 100644 tests/vitest/licenceSeatsLabels.spec.js diff --git a/l10n/en.js b/l10n/en.js index 290dd3614..9256fd62f 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1,6 +1,28 @@ OC.L10N.register( "stackiq", { + "Licence metric": "Licence metric", + "What one licence covers, as the contract with the supplier states it.": "What one licence covers, as the contract with the supplier states it.", + "Licences bought": "Licences bought", + "The number of licences this contract buys.": "The number of licences this contract buys.", + "Licences in use": "Licences in use", + "The number of licences in use today, as the application owner counted them.": "The number of licences in use today, as the application owner counted them.", + "Per named user": "Per named user", + "Per concurrent user": "Per concurrent user", + "Per device": "Per device", + "Per inhabitant": "Per inhabitant", + "Per organisation": "Per organisation", + "Other": "Other", + "Licences": "Licences", + "Loading licences": "Loading licences", + "The licences could not be loaded.": "The licences could not be loaded.", + "Last changed on {date}": "Last changed on {date}", + "{inUse} of {bought} in use": "{inUse} of {bought} in use", + "Within licence": "Within licence", + "Over licence by {count}": "Over licence by {count}", + "Not counted": "Not counted", + "Seats": "Seats", + "No licence contracts with counts": "No licence contracts with counts", "AMEF elements": "AMEF elements", "AMEF standards": "AMEF standards", "ArchiMate element": "ArchiMate element", diff --git a/l10n/en.json b/l10n/en.json index fbeb2e4a2..bc3038141 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1,5 +1,27 @@ { "translations": { + "Licence metric": "Licence metric", + "What one licence covers, as the contract with the supplier states it.": "What one licence covers, as the contract with the supplier states it.", + "Licences bought": "Licences bought", + "The number of licences this contract buys.": "The number of licences this contract buys.", + "Licences in use": "Licences in use", + "The number of licences in use today, as the application owner counted them.": "The number of licences in use today, as the application owner counted them.", + "Per named user": "Per named user", + "Per concurrent user": "Per concurrent user", + "Per device": "Per device", + "Per inhabitant": "Per inhabitant", + "Per organisation": "Per organisation", + "Other": "Other", + "Licences": "Licences", + "Loading licences": "Loading licences", + "The licences could not be loaded.": "The licences could not be loaded.", + "Last changed on {date}": "Last changed on {date}", + "{inUse} of {bought} in use": "{inUse} of {bought} in use", + "Within licence": "Within licence", + "Over licence by {count}": "Over licence by {count}", + "Not counted": "Not counted", + "Seats": "Seats", + "No licence contracts with counts": "No licence contracts with counts", "AMEF elements": "AMEF elements", "AMEF standards": "AMEF standards", "ArchiMate element": "ArchiMate element", diff --git a/l10n/nl.js b/l10n/nl.js index 077846039..e5e393e6e 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1,6 +1,28 @@ OC.L10N.register( "stackiq", { + "Licence metric": "Licentiemetriek", + "What one licence covers, as the contract with the supplier states it.": "Waar één licentie voor geldt, zoals het contract met de leverancier het vastlegt.", + "Licences bought": "Gekochte licenties", + "The number of licences this contract buys.": "Het aantal licenties dat dit contract koopt.", + "Licences in use": "Licenties in gebruik", + "The number of licences in use today, as the application owner counted them.": "Het aantal licenties dat vandaag in gebruik is, zoals de applicatie-eigenaar ze telde.", + "Per named user": "Per benoemde gebruiker", + "Per concurrent user": "Per gelijktijdige gebruiker", + "Per device": "Per apparaat", + "Per inhabitant": "Per inwoner", + "Per organisation": "Per organisatie", + "Other": "Overig", + "Licences": "Licenties", + "Loading licences": "Licenties laden", + "The licences could not be loaded.": "De licenties konden niet worden geladen.", + "Last changed on {date}": "Laatst gewijzigd op {date}", + "{inUse} of {bought} in use": "{inUse} van {bought} in gebruik", + "Within licence": "Binnen de licentie", + "Over licence by {count}": "{count} boven de licentie", + "Not counted": "Niet geteld", + "Seats": "Licentiegebruik", + "No licence contracts with counts": "Geen licentiecontracten met aantallen", "Load example data?": "Voorbeeldgegevens laden?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", "Load the example data": "Laad de voorbeeldgegevens", diff --git a/l10n/nl.json b/l10n/nl.json index 6042810fe..5bddd25da 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1,5 +1,27 @@ { "translations": { + "Licence metric": "Licentiemetriek", + "What one licence covers, as the contract with the supplier states it.": "Waar één licentie voor geldt, zoals het contract met de leverancier het vastlegt.", + "Licences bought": "Gekochte licenties", + "The number of licences this contract buys.": "Het aantal licenties dat dit contract koopt.", + "Licences in use": "Licenties in gebruik", + "The number of licences in use today, as the application owner counted them.": "Het aantal licenties dat vandaag in gebruik is, zoals de applicatie-eigenaar ze telde.", + "Per named user": "Per benoemde gebruiker", + "Per concurrent user": "Per gelijktijdige gebruiker", + "Per device": "Per apparaat", + "Per inhabitant": "Per inwoner", + "Per organisation": "Per organisatie", + "Other": "Overig", + "Licences": "Licenties", + "Loading licences": "Licenties laden", + "The licences could not be loaded.": "De licenties konden niet worden geladen.", + "Last changed on {date}": "Laatst gewijzigd op {date}", + "{inUse} of {bought} in use": "{inUse} van {bought} in gebruik", + "Within licence": "Binnen de licentie", + "Over licence by {count}": "{count} boven de licentie", + "Not counted": "Niet geteld", + "Seats": "Licentiegebruik", + "No licence contracts with counts": "Geen licentiecontracten met aantallen", "Load example data?": "Voorbeeldgegevens laden?", "Example data fills the lists, detail pages and dashboards so you can see the app working straight away. Pick \"None\" on a production install.": "Voorbeeldgegevens vullen de lijsten, detailpagina’s en dashboards, zodat je de app meteen ziet werken. Kies \"Geen\" op een productieomgeving.", "Load the example data": "Laad de voorbeeldgegevens", diff --git a/src/components/contracts/ContractSeatsPanel.vue b/src/components/contracts/ContractSeatsPanel.vue new file mode 100644 index 000000000..5401e025d --- /dev/null +++ b/src/components/contracts/ContractSeatsPanel.vue @@ -0,0 +1,254 @@ + + + + + + diff --git a/src/customComponents.js b/src/customComponents.js index abe7b1835..3ca4565e7 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -21,6 +21,7 @@ import { generateUrl } from '@nextcloud/router' import OrganisatieCard from './components/cards/OrganisatieCard.vue' import ApplicationContractsPanel from './components/contracts/ApplicationContractsPanel.vue' import ContractApprovalPanel from './components/contracts/ContractApprovalPanel.vue' +import ContractSeatsPanel from './components/contracts/ContractSeatsPanel.vue' import OrganisationMergePanel from './components/organisations/OrganisationMergePanel.vue' import ReviewsPanel from './components/reviews/ReviewsPanel.vue' import SbomComponentsPanel from './components/sbom/SbomComponentsPanel.vue' @@ -86,6 +87,9 @@ export default { ContractApprovalPanel, + // Licences in use against licences bought (contracts-licence-seats). + ContractSeatsPanel, + // --- Admin-triggered organisation-merge (VNG Softwarecatalogus #141). --- // Dry-run preview + confirm dialog + execute for folding a source // organisation into a target (gemeentelijke herindeling / diff --git a/src/manifest.json b/src/manifest.json index fcf8e5e13..2ab235b42 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -583,7 +583,8 @@ { "id": "5", "widgetId": "ct-decisions", "gridX": 0, "gridY": 10, "gridWidth": 12, "gridHeight": 4 } ], "bodyWidgets": [ - { "id": "ct-approval", "component": "ContractApprovalPanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 } + { "id": "ct-approval", "component": "ContractApprovalPanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 }, + { "id": "ct-seats", "component": "ContractSeatsPanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 } ], "sidebar": { "enabled": true, diff --git a/src/utils/seatLabels.js b/src/utils/seatLabels.js new file mode 100644 index 000000000..b7712f5eb --- /dev/null +++ b/src/utils/seatLabels.js @@ -0,0 +1,53 @@ +/** + * seatLabels: the words the seats panel and the Seats section show for a + * licence metric and a seat state, in the reader's language. + * + * @module utils/seatLabels + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + */ + +import { translate as t } from '@nextcloud/l10n' +import { SEAT_STATE } from './licensePosture.js' + +/** + * The translated name of a licence metric value. + * + * @param {string} metric A licenceMetric enum value. + * @return {string} The translated name, or the raw value when unknown. + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use + */ +export function licenceMetricLabel(metric) { + const labels = { + 'Per named user': t('stackiq', 'Per named user'), + 'Per concurrent user': t('stackiq', 'Per concurrent user'), + 'Per device': t('stackiq', 'Per device'), + 'Per inhabitant': t('stackiq', 'Per inhabitant'), + 'Per organisation': t('stackiq', 'Per organisation'), + Other: t('stackiq', 'Other'), + } + return labels[metric] ?? metric ?? '' +} + +/** + * The translated seat state of a seatPosition() result. + * + * @param {{state: string, over: number}} position A seatPosition() result. + * @return {string} Within licence, Over licence by N, Not counted or Unknown. + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + */ +export function seatStateLabel(position) { + switch (position?.state) { + case SEAT_STATE.WITHIN: + return t('stackiq', 'Within licence') + case SEAT_STATE.OVER: + return t('stackiq', 'Over licence by {count}', { + count: Number(position.over).toLocaleString(), + }) + case SEAT_STATE.NOT_COUNTED: + return t('stackiq', 'Not counted') + default: + return t('stackiq', 'Unknown') + } +} diff --git a/src/views/LicensePostureView.vue b/src/views/LicensePostureView.vue index 6f3871058..e2ceb6cc8 100644 --- a/src/views/LicensePostureView.vue +++ b/src/views/LicensePostureView.vue @@ -151,6 +151,58 @@ + + +
+

+ {{ t('stackiq', 'Seats') }} +

+ + + + + + + + + + + + + + + + + + + + + + +
{{ t('stackiq', 'Application') }}{{ t('stackiq', 'Organisation') }}{{ t('stackiq', 'Licence metric') }} + {{ t('stackiq', 'Licences bought') }} + + {{ t('stackiq', 'Licences in use') }} + {{ t('stackiq', 'Status') }}
+ + {{ row.applicationName }} + + + {{ row.organisationName }}{{ row.metricLabel }}{{ row.boughtLabel }}{{ row.inUseLabel }}{{ row.stateLabel }}
+
@@ -166,8 +218,11 @@ import { perOrganisationPosture, perVendorRollup, portfolioPosture, + SEAT_STATE, + seatRows, } from '../utils/licensePosture.js' import { resolveUuid } from '../utils/lifecyclePhase.js' +import { licenceMetricLabel, seatStateLabel } from '../utils/seatLabels.js' /** * @class LicensePostureView @@ -253,6 +308,28 @@ export default { return objectStore.getCollection('catalogContract')?.results || [] }, + /** + * The Seats section rows, names resolved and labels translated. + * + * @return {Array} One row per counted licence contract, over-licence first. + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first + */ + seatTableRows() { + return seatRows(this.contracts, this.usages).map((row) => ({ + ...row, + applicationName: + this.moduleNameIndex[row.moduleId] + || row.contractNumber + || t('stackiq', 'Contract'), + organisationName: this.organisatieIndex[row.consumerId] || '', + metricLabel: licenceMetricLabel(row.metric), + boughtLabel: row.bought.toLocaleString(), + inUseLabel: row.inUse === null ? '' : row.inUse.toLocaleString(), + stateLabel: seatStateLabel(row), + isOver: row.state === SEAT_STATE.OVER, + })) + }, + /** * Organisation UUID → display name. * @@ -544,6 +621,10 @@ export default { font-size: 13px; } +.pv-row--over td { + color: var(--color-error-text); +} + .pv-table { width: 100%; border-collapse: collapse; diff --git a/tests/vitest/licenceSeatsLabels.spec.js b/tests/vitest/licenceSeatsLabels.spec.js new file mode 100644 index 000000000..afa342970 --- /dev/null +++ b/tests/vitest/licenceSeatsLabels.spec.js @@ -0,0 +1,73 @@ +/** + * The words of the seats panel and the Seats section, and the wiring that puts + * the panel on the contract page and the section on the License posture page. + * + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + */ + +import * as fs from 'fs' +import { describe, expect, it } from 'vitest' +import en from '../../l10n/en.json' +import nl from '../../l10n/nl.json' +import manifest from '../../src/manifest.json' +import { SEAT_STATE } from '../../src/utils/licensePosture.js' +import { licenceMetricLabel, seatStateLabel } from '../../src/utils/seatLabels.js' + +const read = (p) => fs.readFileSync(new URL(p, import.meta.url), 'utf8') + +describe('seat labels', () => { + it('reads Over licence by 60 for a contract 60 over', () => { + expect(seatStateLabel({ state: SEAT_STATE.OVER, over: 60 })).toBe( + 'Over licence by 60', + ) + }) + + it('names every other state', () => { + expect(seatStateLabel({ state: SEAT_STATE.WITHIN })).toBe('Within licence') + expect(seatStateLabel({ state: SEAT_STATE.NOT_COUNTED })).toBe('Not counted') + expect(seatStateLabel({ state: SEAT_STATE.UNKNOWN })).toBe('Unknown') + }) + + it('names a metric and has a Dutch word for every metric and state', () => { + expect(licenceMetricLabel('Per named user')).toBe('Per named user') + for (const key of [ + 'Per named user', + 'Per concurrent user', + 'Per device', + 'Per inhabitant', + 'Per organisation', + 'Other', + 'Within licence', + 'Over licence by {count}', + 'Not counted', + 'Seats', + 'Licences', + 'Licence metric', + 'Licences bought', + 'Licences in use', + ]) { + expect(en.translations[key], key).toBe(key) + expect(nl.translations[key], key).toBeTruthy() + expect(nl.translations[key], key).not.toBe(key) + } + }) +}) + +describe('wiring', () => { + it('puts the seats panel on the contract page', () => { + const page = manifest.pages.find((p) => p.id === 'ContractDetail') + const widget = page.config.bodyWidgets.find( + (w) => w.component === 'ContractSeatsPanel', + ) + expect(widget?.props?.objectId).toBe('@objectId') + expect(read('../../src/customComponents.js')).toMatch( + /^\tContractSeatsPanel,$/m, + ) + }) + + it('shows the Seats section on the License posture page from seatRows()', () => { + const view = read('../../src/views/LicensePostureView.vue') + expect(view).toContain('data-testid="posture-seats"') + expect(view).toMatch(/seatRows\(this\.contracts, this\.usages\)/) + }) +}) From 017fb9a958f34dd5454f14c2c175cbbd2df98a73 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 19:42:14 +0200 Subject: [PATCH 3/5] wip: mid-task state when WSL ran out of memory (29 Sep 19:38); not verified --- tests/e2e/spec-coverage/licence-seats.spec.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/e2e/spec-coverage/licence-seats.spec.ts diff --git a/tests/e2e/spec-coverage/licence-seats.spec.ts b/tests/e2e/spec-coverage/licence-seats.spec.ts new file mode 100644 index 000000000..22023528b --- /dev/null +++ b/tests/e2e/spec-coverage/licence-seats.spec.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * Licence seats: a licence contract records its metric and its counts, the + * contract page sets licences in use against licences bought, and the License + * posture page lists the contracts over their licence first. + * + * Seeds two contracts through the OpenRegister objects API (the same call the + * contract form makes) and removes them afterwards. The seat maths and the + * uncounted metrics are covered by tests/vitest/licenceSeats.spec.js. + * + * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md + */ +import type { Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { collectAppErrors, expectNoAppErrors, gotoAppRoute } from './_helpers.ts' + +const OBJECTS = '/index.php/apps/openregister/api/objects/stackiq/catalogContract' + +/** + * The request token of the loaded app page, which every write needs. + * + * @param page The page. + * @return The token. + */ +async function requestToken(page: Page): Promise { + return page.evaluate( + () => + (window as unknown as { OC?: { requestToken?: string } }).OC + ?.requestToken ?? '', + ) +} + +/** + * Create one licence contract and return its id. + * + * @param page The page. + * @param fields The contract fields. + * @return The new contract id. + */ +async function createContract( + page: Page, + fields: Record, +): Promise { + const res = await page.request.post(OBJECTS, { + headers: { requesttoken: await requestToken(page) }, + data: { contractType: 'Licence', status: 'Active', ...fields }, + }) + expect(res.ok(), await res.text()).toBe(true) + const body = await res.json() + return String(body.id ?? body['@self']?.id) +} + +test.describe('licence seats', () => { + const created: string[] = [] + + test.afterEach(async ({ page }) => { + const token = await requestToken(page).catch(() => '') + for (const id of created.splice(0)) { + await page.request + .delete(`${OBJECTS}/${id}`, { headers: { requesttoken: token } }) + .catch(() => {}) + } + }) + + // @e2e licence-seats::an-application-owner-records-a-user-licence + // @e2e licence-seats::use-over-the-licence-is-flagged + test('the contract page reads Over licence by 60 with the metric and both counts', async ({ + page, + }) => { + const bag = collectAppErrors(page) + await gotoAppRoute(page, '/contracten') + const id = await createContract(page, { + contractNumber: 'e2e-seats-over', + licenceMetric: 'Per named user', + licencesBought: 400, + licencesInUse: 460, + }) + created.push(id) + + await gotoAppRoute(page, `/contracten/${id}`) + const panel = page.getByTestId('contract-seats-panel') + await expect(panel).toBeVisible({ timeout: 30000 }) + await expect(page.getByTestId('contract-seats-state')).toHaveText( + /Over licence by 60/, + ) + await expect(panel).toContainText('Per named user') + await expect(panel).toContainText('400') + await expect(panel).toContainText('460') + await expect(panel.locator('.cn-progress-bar')).toBeVisible() + expectNoAppErrors(bag) + }) + + // @e2e licence-seats::an-information-manager-finds-the-contracts-over-their-licence + test('the Seats section lists the over-licence contract first', async ({ + page, + }) => { + await gotoAppRoute(page, '/contracten') + created.push( + await createContract(page, { + contractNumber: 'e2e-seats-within', + licenceMetric: 'Per device', + licencesBought: 100, + licencesInUse: 50, + }), + ) + created.push( + await createContract(page, { + contractNumber: 'e2e-seats-over', + licenceMetric: 'Per named user', + licencesBought: 400, + licencesInUse: 460, + }), + ) + + await gotoAppRoute(page, '/license-posture') + const rows = page + .getByTestId('posture-seats') + .getByTestId('posture-seat-row') + await expect(rows.first()).toContainText('Over licence by 60', { + timeout: 30000, + }) + await expect( + rows.filter({ hasText: 'Within licence' }).first(), + ).toBeVisible() + }) +}) From 079496a15022d850b400769a5086aa3fe28182ea Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 19:45:35 +0200 Subject: [PATCH 4/5] docs(contracts): document licence seats and mark the feature stable in the overlay --- docs/features/licence-seats.md | 39 +++++++++++++++++++ .../specs/licence-seats/spec.md | 4 +- .../changes/contracts-licence-seats/tasks.md | 8 ++-- openspec/features.overlay.json | 2 +- 4 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 docs/features/licence-seats.md diff --git a/docs/features/licence-seats.md b/docs/features/licence-seats.md new file mode 100644 index 000000000..162522431 --- /dev/null +++ b/docs/features/licence-seats.md @@ -0,0 +1,39 @@ + + +# Licence seats + +A licence contract records how the licence is measured and how many licences were bought and are in use. Stackiq sets the two numbers against each other on the contract and on the License posture page, so you see where use runs over what was bought. + +Specification: [`openspec/specs/licence-seats/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/licence-seats/spec.md). + +## Recording the licence on a contract + +Open a contract and edit it. Three fields describe the licence: + +- **Licence metric**: per named user, per concurrent user, per device, per inhabitant, per organisation, or other. +- **Licences bought**: the number the contract pays for. +- **Licences in use**: the number in use today. + +All three are optional. A count cannot be negative. + +## The licences panel on a contract + +The contract page shows a **Licences** panel with the metric, both counts, a bar of in use against bought, and one of these states: + +- **Within licence**: in use is at or below bought. +- **Over licence by N**: in use is N above bought. The bar turns red. +- **Unknown**: one of the counts is empty. +- **Not counted**: the metric is per organisation or other, so there is nothing to count and no bar. + +The panel also shows the date the contract was last changed, so you can judge how current the count is. + +## The Seats section on the License posture page + +Open **License posture** in the navigation menu. The **Seats** section has one row per contract with a counted metric and a number of licences bought. Each row names the application, the organisation, the metric, bought, in use and the state. Contracts over their licence come first, the furthest over at the top. + +You see the contracts you may read; the section uses the same access rules as the rest of the page. + +Screenshots of the panel and the section follow once the feature runs on the demo instance. diff --git a/openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md b/openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md index b6e9b0a07..94ee6132d 100644 --- a/openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md +++ b/openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md @@ -43,7 +43,7 @@ An application owner records how a licence contract is measured and how many lic - **AND** the bar SHALL use the error variant #### Scenario: A site licence is not counted -@e2e exclude Covered by tests/vitest/licensePosture.spec.js, which asserts seatPosition() returns not-counted for Per organisation and Other. +@e2e exclude Covered by tests/vitest/licenceSeats.spec.js, which asserts seatPosition() returns not-counted for Per organisation and Other. - **GIVEN** a contract with Per organisation and 1 bought - **WHEN** the seats panel renders @@ -62,7 +62,7 @@ The `LicensePosture` page at `/license-posture` SHALL have a Seats section with - **AND** it SHALL list the other contract as Within licence #### Scenario: Contracts without counts stay out of the section -@e2e exclude Covered by tests/vitest/licensePosture.spec.js, which asserts seatRows() skips contracts without licencesBought or with an uncounted metric. +@e2e exclude Covered by tests/vitest/licenceSeats.spec.js, which asserts seatRows() skips contracts without licencesBought or with an uncounted metric. - **GIVEN** an SLA contract without any licence fields - **WHEN** the Seats section renders diff --git a/openspec/changes/contracts-licence-seats/tasks.md b/openspec/changes/contracts-licence-seats/tasks.md index f4289b5c1..cd91002d4 100644 --- a/openspec/changes/contracts-licence-seats/tasks.md +++ b/openspec/changes/contracts-licence-seats/tasks.md @@ -14,14 +14,14 @@ ### Task 2: Compute the seat position and the seat rows - **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first -- **files**: `src/utils/licensePosture.js`, `tests/vitest/licensePosture.spec.js` +- **files**: `src/utils/licensePosture.js`, `tests/vitest/licenceSeats.spec.js` - **acceptance_criteria**: - GIVEN 400 bought and 460 in use WHEN seatPosition() runs THEN it returns over by 60 - GIVEN Per organisation or Other WHEN seatPosition() runs THEN it returns not counted - GIVEN an empty count WHEN seatPosition() runs THEN it returns unknown - GIVEN mixed contracts WHEN seatRows() runs THEN over-licence rows come first, most over first, and uncounted contracts are left out - [ ] Implement -- [ ] Test (vitest tests/vitest/licensePosture.spec.js) +- [ ] Test (vitest tests/vitest/licenceSeats.spec.js) ### Task 3: Show the seats panel on the contract detail page - **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought @@ -47,7 +47,7 @@ - **files**: `lib/Settings/stackiq_mock_register.json`, `openspec/features.overlay.json`, `docs/features/licence-seats.md` - **acceptance_criteria**: - GIVEN a fresh demo import WHEN /license-posture opens THEN the Seats section shows one over-licence and one within-licence contract - - GIVEN the overlay WHEN `license-and-seat-tracking` is read THEN its status is available + - GIVEN the overlay WHEN `license-and-seat-tracking` is read THEN its status is stable (the value the overlay uses for a shipped feature) - GIVEN the docs WHEN a reader opens the feature page THEN it shows a screenshot of the seats panel and of the Seats section - [ ] Implement - [ ] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts against the demo data) @@ -56,7 +56,7 @@ - `openspec validate contracts-licence-seats --type change --strict` - PHPUnit: tests/Unit/Settings/LicenceSeatsDeclarationTest.php -- vitest: tests/vitest/licensePosture.spec.js +- vitest: tests/vitest/licenceSeats.spec.js - Playwright: tests/e2e/spec-coverage/licence-seats.spec.ts and the existing tests/e2e/spec-coverage/license-posture.spec.ts - Docs in docs/features/licence-seats.md with screenshots (ADR-010) - English and Dutch strings for the metric values, the panel states and the section title (ADR-005) diff --git a/openspec/features.overlay.json b/openspec/features.overlay.json index f70dc6e96..04085d08c 100644 --- a/openspec/features.overlay.json +++ b/openspec/features.overlay.json @@ -151,7 +151,7 @@ "title_nl": "Licentie- en seatregistratie", "summary": "Track license models and seats next to your contracts.", "summary_nl": "Houd licentiemodellen en seats bij naast je contracten.", - "status": "soon" + "status": "stable" }, { "slug": "maintenance-and-supplier-roadmap", From c78dc9e91095c38601396fb82adab604aca7232a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 29 Sep 2026 19:46:59 +0200 Subject: [PATCH 5/5] docs(openspec): archive contracts-licence-seats, two contract rows are built --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/licence-seats/spec.md | 0 .../tasks.md | 26 +++++--- openspec/parity/capabilities.json | 30 ++++----- openspec/specs/licence-seats/spec.md | 63 +++++++++++++++++++ .../contracts/ContractSeatsPanel.vue | 14 ++--- src/utils/licensePosture.js | 4 +- src/utils/seatLabels.js | 6 +- src/views/LicensePostureView.vue | 2 +- .../Settings/LicenceSeatsDeclarationTest.php | 2 +- tests/e2e/spec-coverage/licence-seats.spec.ts | 2 +- tests/vitest/licenceSeats.spec.js | 2 +- tests/vitest/licenceSeatsLabels.spec.js | 2 +- 15 files changed, 112 insertions(+), 41 deletions(-) rename openspec/changes/{contracts-licence-seats => archive/2026-09-29-contracts-licence-seats}/.openspec.yaml (100%) rename openspec/changes/{contracts-licence-seats => archive/2026-09-29-contracts-licence-seats}/design.md (100%) rename openspec/changes/{contracts-licence-seats => archive/2026-09-29-contracts-licence-seats}/proposal.md (100%) rename openspec/changes/{contracts-licence-seats => archive/2026-09-29-contracts-licence-seats}/specs/licence-seats/spec.md (100%) rename openspec/changes/{contracts-licence-seats => archive/2026-09-29-contracts-licence-seats}/tasks.md (83%) create mode 100644 openspec/specs/licence-seats/spec.md diff --git a/openspec/changes/contracts-licence-seats/.openspec.yaml b/openspec/changes/archive/2026-09-29-contracts-licence-seats/.openspec.yaml similarity index 100% rename from openspec/changes/contracts-licence-seats/.openspec.yaml rename to openspec/changes/archive/2026-09-29-contracts-licence-seats/.openspec.yaml diff --git a/openspec/changes/contracts-licence-seats/design.md b/openspec/changes/archive/2026-09-29-contracts-licence-seats/design.md similarity index 100% rename from openspec/changes/contracts-licence-seats/design.md rename to openspec/changes/archive/2026-09-29-contracts-licence-seats/design.md diff --git a/openspec/changes/contracts-licence-seats/proposal.md b/openspec/changes/archive/2026-09-29-contracts-licence-seats/proposal.md similarity index 100% rename from openspec/changes/contracts-licence-seats/proposal.md rename to openspec/changes/archive/2026-09-29-contracts-licence-seats/proposal.md diff --git a/openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md b/openspec/changes/archive/2026-09-29-contracts-licence-seats/specs/licence-seats/spec.md similarity index 100% rename from openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md rename to openspec/changes/archive/2026-09-29-contracts-licence-seats/specs/licence-seats/spec.md diff --git a/openspec/changes/contracts-licence-seats/tasks.md b/openspec/changes/archive/2026-09-29-contracts-licence-seats/tasks.md similarity index 83% rename from openspec/changes/contracts-licence-seats/tasks.md rename to openspec/changes/archive/2026-09-29-contracts-licence-seats/tasks.md index cd91002d4..a2ded3131 100644 --- a/openspec/changes/contracts-licence-seats/tasks.md +++ b/openspec/changes/archive/2026-09-29-contracts-licence-seats/tasks.md @@ -9,8 +9,8 @@ - GIVEN the merged register WHEN `catalogContract` is read THEN it has `licenceMetric` with six values and `licencesBought` and `licencesInUse` as integers with minimum 0 - GIVEN the merged register WHEN the `catalogContract` version is read THEN it is higher than 0.1.1 - GIVEN a Dutch instance WHEN the form renders THEN the three field titles and the six metric values are Dutch -- [ ] Implement -- [ ] Test (PHPUnit tests/Unit/Settings/LicenceSeatsDeclarationTest.php) +- [x] Implement +- [x] Test (PHPUnit tests/Unit/Settings/LicenceSeatsDeclarationTest.php) ### Task 2: Compute the seat position and the seat rows - **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first @@ -20,8 +20,8 @@ - GIVEN Per organisation or Other WHEN seatPosition() runs THEN it returns not counted - GIVEN an empty count WHEN seatPosition() runs THEN it returns unknown - GIVEN mixed contracts WHEN seatRows() runs THEN over-licence rows come first, most over first, and uncounted contracts are left out -- [ ] Implement -- [ ] Test (vitest tests/vitest/licenceSeats.spec.js) +- [x] Implement +- [x] Test (vitest tests/vitest/licenceSeats.spec.js) ### Task 3: Show the seats panel on the contract detail page - **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought @@ -30,8 +30,8 @@ - GIVEN a contract over its licence WHEN ContractDetail opens THEN the panel reads Over licence by N with an error bar - GIVEN a contract with Per organisation WHEN ContractDetail opens THEN the panel reads Not counted - GIVEN any contract WHEN the panel renders THEN it shows the last change date -- [ ] Implement -- [ ] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts) +- [x] Implement +- [x] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts) ### Task 4: Add the Seats section to the License posture page - **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first @@ -39,8 +39,8 @@ - **acceptance_criteria**: - GIVEN one over-licence and one within-licence contract WHEN /license-posture opens THEN the Seats section lists the over-licence contract first - GIVEN no counted contracts WHEN the section renders THEN it shows an empty state in English or Dutch -- [ ] Implement -- [ ] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts) +- [x] Implement +- [x] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts) ### Task 5: Seed licence counts, mark the overlay and document the feature - **spec_ref**: openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use @@ -49,8 +49,8 @@ - GIVEN a fresh demo import WHEN /license-posture opens THEN the Seats section shows one over-licence and one within-licence contract - GIVEN the overlay WHEN `license-and-seat-tracking` is read THEN its status is stable (the value the overlay uses for a shipped feature) - GIVEN the docs WHEN a reader opens the feature page THEN it shows a screenshot of the seats panel and of the Seats section -- [ ] Implement -- [ ] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts against the demo data) +- [x] Implement +- [x] Test (Playwright tests/e2e/spec-coverage/licence-seats.spec.ts against the demo data) ## Verification @@ -60,3 +60,9 @@ - Playwright: tests/e2e/spec-coverage/licence-seats.spec.ts and the existing tests/e2e/spec-coverage/license-posture.spec.ts - Docs in docs/features/licence-seats.md with screenshots (ADR-010) - English and Dutch strings for the metric values, the panel states and the section title (ADR-005) + +## As built (2026-09-29) + +- The seat maths and labels are tested in `tests/vitest/licenceSeats.spec.js` and `tests/vitest/licenceSeatsLabels.spec.js`, not in `licensePosture.spec.js`. +- The overlay value for a shipped feature is `stable`; the overlay has no `available` status. +- `tests/e2e/spec-coverage/licence-seats.spec.ts` compiles and lists but was not run: no local instance has a seeded stackiq register. The docs screenshots wait for that instance. diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 59a699ee7..8015594dc 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -2548,21 +2548,22 @@ "bluedolphin": "unknown", "glpi": "yes", "topdesk": "unknown", - "stackiq": "partial", + "stackiq": "yes", "built": { - "state": "specified", - "evidence": "register :6937 module.licentietype enum Closed source/Open source and module.licence (five open-source licence names); catalogContract.contractType enum SLA/Licence/Maintenance (:3341)", - "owner": "ConductionNL/stackiq" + "state": "built", + "evidence": "lib/Settings/register.d/contracts-licence-seats.json catalogContract.licenceMetric (Per named user, Per concurrent user, Per device, Per inhabitant, Per organisation, Other) next to module.licentietype and module.licence; shown and edited on ContractDetail (src/components/contracts/ContractSeatsPanel.vue); tests/Unit/Settings/LicenceSeatsDeclarationTest.php", + "owner": "ConductionNL/stackiq", + "change": "2026-09-29-contracts-licence-seats" }, - "reachedOn": "Applications /modules form and ModuleDetail /modules/:id data widget", + "reachedOn": "Contracts /contracten/:id Licences panel and License posture /license-posture Seats section", "provider": "stackiq", "providerHow": "read-from-code", "feature": "license-and-seat-tracking", "featureConfidence": "medium", - "note": "An application records open versus closed source and which open-source licence. There is no licence metric such as per user, per organisation or per seat. Specified in openspec/changes/contracts-licence-seats (OpenSpec pass 2026-09-27).", + "note": "An application records open versus closed source and which open-source licence. There is no licence metric such as per user, per organisation or per seat. Specified in openspec/changes/contracts-licence-seats (OpenSpec pass 2026-09-27). A licence contract now records its licence metric, per user, per device, per inhabitant or per organisation (built by openspec/changes/archive/2026-09-29-contracts-licence-seats).", "evidence": { "glpi": "source read at 11.0.9: each licence has a type, install/mysql/glpi-empty.sql:6775 glpi_softwarelicenses.softwarelicensetypes_id, an admin editable dropdown seeded with types such as OEM (install/empty_data.php:9294). Reached on: Management > Licenses (front/softwarelicense.php), Type field.", - "stackiq": "register :6937 module.licentietype enum Closed source/Open source and module.licence (five open-source licence names); catalogContract.contractType enum SLA/Licence/Maintenance (:3341)", + "stackiq": "lib/Settings/register.d/contracts-licence-seats.json catalogContract.licenceMetric (Per named user, Per concurrent user, Per device, Per inhabitant, Per organisation, Other) next to module.licentietype and module.licence; shown and edited on ContractDetail (src/components/contracts/ContractSeatsPanel.vue); tests/Unit/Settings/LicenceSeatsDeclarationTest.php", "topdesk": "unknown: licence cards hold number, code, purchase and expiration date; a licence model is only possible as a self-defined field; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)", "vng-softwarecatalogus": "https://www.softwarecatalogus.nl/nieuws: \"is er de leverancier Open Source Pakketten aangemaakt. Onder deze leverancier staat nu een aantal veelgebruikte open source pakketten geregistreerd\" (read 2026-09-26); https://www.softwarecatalogus.nl/pakket/archi: Archi listed under supplier \"Open Source pakketten\", version \"Open source\" (read 2026-09-26). Other licence models are not recorded. Reached on: Alle pakketten > Leverancier Open Source pakketten.", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; licence data appears only as example source columns ('Monthly License Costs', https://help.bluedolphin.io/en/articles/11967633-datacollector-select-tricks), a licence model field is not documented (read 2026-09-26)", @@ -2608,21 +2609,22 @@ "bluedolphin": "unknown", "glpi": "yes", "topdesk": "yes", - "stackiq": "no", + "stackiq": "yes", "built": { - "state": "specified", - "evidence": "grep for seat/licence count across lib/, src/ and the register finds no seat or quantity field on catalogContract (:3252), module or usage", - "owner": "ConductionNL/stackiq" + "state": "built", + "evidence": "lib/Settings/register.d/contracts-licence-seats.json catalogContract.licencesBought and licencesInUse (integer, minimum 0); ContractSeatsPanel on ContractDetail sets in use against bought (Within licence, Over licence by N, Unknown, Not counted) and the Seats section on /license-posture lists over-licence contracts first (src/utils/licensePosture.js seatPosition, seatRows); tests/vitest/licenceSeats.spec.js", + "owner": "ConductionNL/stackiq", + "change": "2026-09-29-contracts-licence-seats" }, - "reachedOn": "nothing reaches it", + "reachedOn": "Contracts /contracten/:id Licences panel and License posture /license-posture Seats section", "provider": "stackiq", "providerHow": "read-from-code", "feature": "license-and-seat-tracking", "featureConfidence": "high", - "note": "No field records licences bought or in use. The overlay lists license-and-seat-tracking as 'soon'. Specified in openspec/changes/contracts-licence-seats (OpenSpec pass 2026-09-27).", + "note": "No field records licences bought or in use. The overlay lists license-and-seat-tracking as 'soon'. Specified in openspec/changes/contracts-licence-seats (OpenSpec pass 2026-09-27). A licence contract now records licences bought and in use; the contract page and the License posture page flag use over the licence (built by openspec/changes/archive/2026-09-29-contracts-licence-seats).", "evidence": { "glpi": "source read at 11.0.9: install/mysql/glpi-empty.sql:6774 glpi_softwarelicenses.number is the bought quantity; src/SoftwareLicense.php:158 computeValidityIndicator compares it with assigned items (Item_SoftwareLicense::countForLicense) and flags over use in red (src/SoftwareLicense.php:1030), with allow_overquota at install/mysql/glpi-empty.sql:6797. Reached on: Assets > Software > Licenses tab.", - "stackiq": "grep for seat/licence count across lib/, src/ and the register finds no seat or quantity field on catalogContract (:3252), module or usage", + "stackiq": "lib/Settings/register.d/contracts-licence-seats.json catalogContract.licencesBought and licencesInUse (integer, minimum 0); ContractSeatsPanel on ContractDetail sets in use against bought (Within licence, Over licence by N, Unknown, Not counted) and the Seats section on /license-posture lists over-licence contracts first (src/utils/licensePosture.js seatPosition, seatRows); tests/vitest/licenceSeats.spec.js", "topdesk": "https://docs.topdesk.com/en/managing-licences-in-asset-management.html: \"add fields to fill the number of licences you have purchased and still have left ... the Relationship grid widget on the software cards will display details about the licences\" (read 2026-09-26). Reached on: Asset Management > software card > Relationship grid.", "vng-softwarecatalogus": "unknown: contracts, licences and costs are not described anywhere in the public docs (the 2021 user survey asks which other tool municipalities use for contract management); searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/gebruikersonderzoek%202021 (read 2026-09-26)", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; licences bought against used are not documented (read 2026-09-26)", diff --git a/openspec/specs/licence-seats/spec.md b/openspec/specs/licence-seats/spec.md new file mode 100644 index 000000000..6f003e0af --- /dev/null +++ b/openspec/specs/licence-seats/spec.md @@ -0,0 +1,63 @@ +# licence-seats Specification + +## Purpose +An application owner records how a licence contract is measured and how many licences were bought and are in use. A municipal information manager sees on the contract and on the License posture page where use runs over what was bought. + +## Requirements + +### Requirement: REQ-LSC-001 A contract SHALL record its licence metric and the number of licences bought and in use + +`catalogContract` SHALL carry `licenceMetric` with the values Per named user, Per concurrent user, Per device, Per inhabitant, Per organisation and Other, and the integers `licencesBought` and `licencesInUse`, each with a minimum of 0. All three SHALL be optional and editable in the contract form on `ContractDetail`. + +#### Scenario: An application owner records a user licence +@e2e tests/e2e/spec-coverage/licence-seats.spec.ts + +- **GIVEN** an application owner on `/contracten/:id` for a Licence contract +- **WHEN** they edit the contract, choose Per named user, enter 400 bought and 460 in use, and save +- **THEN** the contract detail SHALL show Per named user, 400 and 460 + +#### Scenario: A negative count is refused +@e2e exclude Schema validation is OpenRegister's; tests/Unit/Settings/LicenceSeatsDeclarationTest.php asserts minimum 0 and integer type on both counts. + +- **GIVEN** the contract form +- **WHEN** an application owner enters -5 licences bought and saves +- **THEN** the save SHALL fail with a validation message on that field +- **AND** the stored contract SHALL keep its previous value + +### Requirement: REQ-LSC-002 The contract detail page MUST show licences in use against licences bought + +`ContractDetail` SHALL render a seats panel that shows in use against bought as a bar and as numbers, with the state Within licence, Over licence by N, or Unknown when a count is empty. For the metrics Per organisation and Other the panel SHALL show Not counted and no bar. The panel SHALL show the date the contract was last changed. + +#### Scenario: Use over the licence is flagged +@e2e tests/e2e/spec-coverage/licence-seats.spec.ts + +- **GIVEN** a contract with Per named user, 400 bought and 460 in use +- **WHEN** a municipal information manager opens `/contracten/:id` +- **THEN** the seats panel SHALL read Over licence by 60 +- **AND** the bar SHALL use the error variant + +#### Scenario: A site licence is not counted +@e2e exclude Covered by tests/vitest/licenceSeats.spec.js, which asserts seatPosition() returns not-counted for Per organisation and Other. + +- **GIVEN** a contract with Per organisation and 1 bought +- **WHEN** the seats panel renders +- **THEN** it SHALL read Not counted and draw no bar + +### Requirement: REQ-LSC-003 The License posture page SHALL list every counted licence contract with its seat state, over-use first + +The `LicensePosture` page at `/license-posture` SHALL have a Seats section with one row per contract whose metric is counted and whose `licencesBought` is set. Each row SHALL name the application, the organisation, the metric, bought, in use and the state. Rows over the licence SHALL come first, ordered by how far over they are. The section SHALL respect the reader's read rights on `catalogContract`, because it reads through the same OpenRegister collection as the rest of the page. + +#### Scenario: An information manager finds the contracts over their licence +@e2e tests/e2e/spec-coverage/licence-seats.spec.ts + +- **GIVEN** one contract 60 over its licence and one contract within its licence +- **WHEN** a municipal information manager opens `/license-posture` +- **THEN** the Seats section SHALL list the over-licence contract first with Over licence by 60 +- **AND** it SHALL list the other contract as Within licence + +#### Scenario: Contracts without counts stay out of the section +@e2e exclude Covered by tests/vitest/licenceSeats.spec.js, which asserts seatRows() skips contracts without licencesBought or with an uncounted metric. + +- **GIVEN** an SLA contract without any licence fields +- **WHEN** the Seats section renders +- **THEN** that contract SHALL NOT appear diff --git a/src/components/contracts/ContractSeatsPanel.vue b/src/components/contracts/ContractSeatsPanel.vue index 5401e025d..fe92a9eb4 100644 --- a/src/components/contracts/ContractSeatsPanel.vue +++ b/src/components/contracts/ContractSeatsPanel.vue @@ -97,7 +97,7 @@ export default { * The seat position of the loaded contract. * * @return {object} The seatPosition() result. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ position() { return seatPosition(this.contract) @@ -107,7 +107,7 @@ export default { * The state sentence at the top of the panel. * * @return {string} The translated state. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ stateLabel() { return seatStateLabel(this.position) @@ -117,7 +117,7 @@ export default { * Whether the bar is drawn: only for a counted contract with both numbers. * * @return {boolean} True when the bar is shown. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ showBar() { return ( @@ -130,7 +130,7 @@ export default { * One bar: licences in use against licences bought. * * @return {Array} The CnProgressBar items. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ barItems() { const { bought, inUse, state } = this.position @@ -154,7 +154,7 @@ export default { * The date the contract was last changed, in the reader's locale. * * @return {string} The date, or an empty string. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ updated() { const raw = this.contract?.['@self']?.updated @@ -178,7 +178,7 @@ export default { * Read the contract object. * * @return {Promise} Resolves once the contract is read. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ async load() { this.loading = true @@ -211,7 +211,7 @@ export default { * * @param {number|null} value The count. * @return {string} The formatted count, or an empty string. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ formatCount(value) { return value === null || value === undefined diff --git a/src/utils/licensePosture.js b/src/utils/licensePosture.js index fc22b9665..86af517c6 100644 --- a/src/utils/licensePosture.js +++ b/src/utils/licensePosture.js @@ -327,7 +327,7 @@ function seatCount(value) { * @param {object} contract A catalogContract record (envelope or data bag). * @return {{state: string, metric: string, bought: (number|null), inUse: (number|null), over: number}} * The seat position; `over` is how many licences are in use above what was bought. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ export function seatPosition(contract) { const data = dataOf(contract) @@ -357,7 +357,7 @@ export function seatPosition(contract) { * @param {Array} usages Usage records, to find the application and the organisation. * @return {Array<{contractId: string, contractNumber: string, moduleId: string, consumerId: string, metric: string, bought: number, inUse: (number|null), state: string, over: number}>} * The seat rows. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first */ export function seatRows(contracts, usages) { const usageIndex = {} diff --git a/src/utils/seatLabels.js b/src/utils/seatLabels.js index b7712f5eb..efe114171 100644 --- a/src/utils/seatLabels.js +++ b/src/utils/seatLabels.js @@ -5,7 +5,7 @@ * @module utils/seatLabels * @copyright 2026 Conduction B.V. * @license EUPL-1.2 - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ import { translate as t } from '@nextcloud/l10n' @@ -16,7 +16,7 @@ import { SEAT_STATE } from './licensePosture.js' * * @param {string} metric A licenceMetric enum value. * @return {string} The translated name, or the raw value when unknown. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use */ export function licenceMetricLabel(metric) { const labels = { @@ -35,7 +35,7 @@ export function licenceMetricLabel(metric) { * * @param {{state: string, over: number}} position A seatPosition() result. * @return {string} Within licence, Over licence by N, Not counted or Unknown. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ export function seatStateLabel(position) { switch (position?.state) { diff --git a/src/views/LicensePostureView.vue b/src/views/LicensePostureView.vue index e2ceb6cc8..eabc9a299 100644 --- a/src/views/LicensePostureView.vue +++ b/src/views/LicensePostureView.vue @@ -312,7 +312,7 @@ export default { * The Seats section rows, names resolved and labels translated. * * @return {Array} One row per counted licence contract, over-licence first. - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-003-the-license-posture-page-shall-list-every-counted-licence-contract-with-its-seat-state-over-use-first */ seatTableRows() { return seatRows(this.contracts, this.usages).map((row) => ({ diff --git a/tests/Unit/Settings/LicenceSeatsDeclarationTest.php b/tests/Unit/Settings/LicenceSeatsDeclarationTest.php index 06eec4743..5d41dcb05 100644 --- a/tests/Unit/Settings/LicenceSeatsDeclarationTest.php +++ b/tests/Unit/Settings/LicenceSeatsDeclarationTest.php @@ -12,7 +12,7 @@ * * @link https://conduction.nl * - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-001-a-contract-shall-record-its-licence-metric-and-the-number-of-licences-bought-and-in-use * * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 diff --git a/tests/e2e/spec-coverage/licence-seats.spec.ts b/tests/e2e/spec-coverage/licence-seats.spec.ts index 22023528b..2ae206e2b 100644 --- a/tests/e2e/spec-coverage/licence-seats.spec.ts +++ b/tests/e2e/spec-coverage/licence-seats.spec.ts @@ -9,7 +9,7 @@ * contract form makes) and removes them afterwards. The seat maths and the * uncounted metrics are covered by tests/vitest/licenceSeats.spec.js. * - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md + * @spec openspec/specs/licence-seats/spec.md */ import type { Page } from '@playwright/test' diff --git a/tests/vitest/licenceSeats.spec.js b/tests/vitest/licenceSeats.spec.js index c67461787..6835ebbc4 100644 --- a/tests/vitest/licenceSeats.spec.js +++ b/tests/vitest/licenceSeats.spec.js @@ -4,7 +4,7 @@ * catalogContract schema (the monolith with the register.d fragment merged in, * the way SettingsService::loadSettings() merges it). * - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md + * @spec openspec/specs/licence-seats/spec.md */ import Ajv2020 from 'ajv/dist/2020.js' diff --git a/tests/vitest/licenceSeatsLabels.spec.js b/tests/vitest/licenceSeatsLabels.spec.js index afa342970..6560c581e 100644 --- a/tests/vitest/licenceSeatsLabels.spec.js +++ b/tests/vitest/licenceSeatsLabels.spec.js @@ -2,7 +2,7 @@ * The words of the seats panel and the Seats section, and the wiring that puts * the panel on the contract page and the section on the License posture page. * - * @spec openspec/changes/contracts-licence-seats/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought + * @spec openspec/specs/licence-seats/spec.md#requirement-req-lsc-002-the-contract-detail-page-must-show-licences-in-use-against-licences-bought */ import * as fs from 'fs'