diff --git a/appinfo/info.xml b/appinfo/info.xml index 7c516b404..c842d443a 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.2.9-unstable.20260831013830 + 0.2.12-unstable.20260901050759 EUPL-1.2 Conduction Shillinq diff --git a/eslint.config.mjs b/eslint.config.mjs index 9bad6e2d4..6d6c921b5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -144,11 +144,32 @@ export default [ 'no-console': 'off', 'n/no-process-exit': 'off', 'n/hashbang': 'off', - // `_` / `__` as a deliberate throwaway binding β€” `catch (_)`, a - // discarded destructuring slot. Narrow on purpose: the pattern matches - // UNDERSCORES ONLY, so a real name that happens to start with `_` is - // still reported. v9 drives plain `.js` through the CORE rule (the - // `@typescript-eslint` swap is per-file-type), so it is set here. + // Tests import devDependencies by definition; this rule is about what + // ships in the published package, which tests/ never does. + 'n/no-unpublished-import': 'off', + }, + }, + + { + // `_` / `__` as a deliberate throwaway binding β€” `catch (_)`, a discarded + // destructuring slot. Narrow on purpose: the pattern matches UNDERSCORES + // ONLY, so a real name that happens to start with `_` is still reported. + // + // πŸ”΄ `.js` / `.mjs` ONLY, NOT `.ts`. The CORE rule is not TypeScript-aware: + // applied to a `.ts` file it reads the parameter NAMES inside a function + // TYPE as bindings and reports them unused. Measured on humaniq β€” + // + // t?: (app: string, key: string) => string + // + // produced four `no-unused-vars` errors for `app` and `key`, which are + // documentation, not variables. The same mis-scoping made every unused + // `catch (e)` in a `.ts` spec report TWICE, once per rule. + // + // v9 already turns the core rule off for `.ts` and drives + // `@typescript-eslint/no-unused-vars` instead; naming `.ts` here switched + // it back on. TypeScript files are handled by the block below. + files: ['tests/**/*.js', 'tests/**/*.mjs'], + rules: { 'no-unused-vars': [ 'error', { @@ -165,12 +186,116 @@ export default [ ignoreRestSiblings: true, }, ], - // Tests import devDependencies by definition; this rule is about what - // ships in the published package, which tests/ never does. - 'n/no-unpublished-import': 'off', }, }, + { + // The TypeScript half of the block above. Same intent, same patterns, on + // the rule that actually understands the language: it knows a name inside + // a function type is not a binding, so type annotations stay quiet while a + // genuinely dead local is still reported. + files: ['tests/**/*.ts', 'tests/**/*.tsx'], + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + varsIgnorePattern: '^_+$', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_+$', + argsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + }, + }, + + { + // πŸ”΄ Node-side CLI tooling under `scripts/`, which is COMMONJS. Flat + // config defaults every `.js` to ESM with browser-ish globals, so without + // this block eslint reports the CommonJS wrapper itself as undefined + // identifiers. Measured on this app: 52 of the 233 errors under + // `tests/` + `scripts/` were `no-undef`, ALL of them in `scripts/`, and + // all five names were the environment rather than a typo β€” `process` 23, + // `require` 20, `__dirname` 6, `__filename` 2, `module` 1. + // + // This is describing the environment, not relaxing a rule, and it is the + // same argument the test-globals block below makes: declaring them keeps + // `no-undef` able to do its real job, which is catching a genuinely + // misspelled identifier. Suppressing the rule instead would bury that. + // + // `no-console` is off because printing its report is what a CLI checker + // is FOR. + // + // πŸ”΄ NO `n/*` ENTRIES HERE, DELIBERATELY. `eslint-plugin-n` is NOT + // registered for these files under eslint 10 + @nextcloud/eslint-config + // 9, so `'n/no-process-exit': 'off'` would be dead config that reads as + // if it were doing something. Measured both ways on this app: 0 `n/` + // findings with the entries and 0 without. + // + // What DID report was the opposite β€” four `scripts/*.js` carried + // `/* eslint-disable n/no-process-exit */` and `/* eslint-disable + // n/shebang */` left over from the eslintrc era, and an inline disable + // naming an unregistered plugin is itself an error ("Definition for rule + // 'n/shebang' was not found"). Those 8 comments are removed; do not add + // `n/*` rules back to replace them. + // + // ⚠️ `.js` and `.cjs` ONLY. A `scripts/*.mjs` is genuinely ESM and must + // keep the default `sourceType`, or `import` stops parsing there. + files: ['scripts/**/*.js', 'scripts/**/*.cjs'], + languageOptions: { + sourceType: 'commonjs', + globals: { + require: 'readonly', + module: 'writable', + exports: 'writable', + process: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + console: 'readonly', + Buffer: 'readonly', + global: 'readonly', + URL: 'readonly', + TextEncoder: 'readonly', + TextDecoder: 'readonly', + }, + }, + rules: { + 'no-console': 'off', + }, + }, + + { + // The ESM half of the block above. A `scripts/*.mjs` is genuinely a module + // and must keep the default `sourceType`, so it gets Node's globals but + // none of the CommonJS wrapper. Measured: `process` reported undefined 2x + // in hermiq's generate-opengemeenten-icons.mjs and 4x in openregister's + // l10n/runtime-check.mjs, which the `.js`/`.cjs` block deliberately does + // not match. + files: ['scripts/**/*.mjs', 'tests/**/*.mjs'], + languageOptions: { + globals: { + process: 'readonly', + console: 'readonly', + Buffer: 'readonly', + global: 'readonly', + URL: 'readonly', + TextEncoder: 'readonly', + TextDecoder: 'readonly', + }, + }, + rules: { + 'no-console': 'off', + }, + }, + + { + // eslint must not try to PARSE a shell script. `tests/e2e/seed.test.sh` + // matches the `**/*.test.*` glob some presets use, and eslint then reads + // it as JavaScript and reports "Parsing error: Unexpected character" β€” + // a finding about a file it should never have opened. + ignores: ['**/*.sh', '**/*.bash'], + }, + { // Test globals. Several apps keep their spec files INSIDE `src/`, which the // lint script scans, and neither `@nextcloud/eslint-config` nor the runner diff --git a/lib/Repair/FoldExpensesAndHoursIntoProject.php b/lib/Repair/FoldExpensesAndHoursIntoProject.php index 68dbaedcd..81aac98c6 100644 --- a/lib/Repair/FoldExpensesAndHoursIntoProject.php +++ b/lib/Repair/FoldExpensesAndHoursIntoProject.php @@ -90,6 +90,8 @@ public function getName(): string { * @param IOutput $output The repair-step output (progress + warnings). * * @return void + * + * @spec openspec/specs/bookkeeping-consultancy-project-accounting/spec.md */ public function run(IOutput $output): void { try { @@ -99,7 +101,7 @@ public function run(IOutput $output): void { // Index every Project by every identifier a source object might // reference (id / uuid / projectNumber / code). Values are kept // as live mutable arrays so multiple lines fold into one save. - $projects = $this->readAllRows(objectService: $this->objectService, registerSlug: $registerSlug, schema: 'Project'); + $projects = $this->readAllRows(objectService: $this->objectService, registerSlug: $registerSlug, schema: 'engagement'); if ($projects === []) { $output->info('Shillinq: no Project records β€” expense/hours fold skipped.'); @@ -171,11 +173,11 @@ public function run(IOutput $output): void { try { $this->objectService ->setRegister($registerSlug) - ->setSchema('Project') + ->setSchema('engagement') ->saveObject( object: $record, register: $registerSlug, - schema: 'Project', + schema: 'engagement', _rbac: false, _multitenancy: false, ); diff --git a/lib/Repair/RematerialiseConvertedCalculations.php b/lib/Repair/RematerialiseConvertedCalculations.php index f9bafd1d4..cfe8348c7 100644 --- a/lib/Repair/RematerialiseConvertedCalculations.php +++ b/lib/Repair/RematerialiseConvertedCalculations.php @@ -101,7 +101,7 @@ class RematerialiseConvertedCalculations implements IRepairStep { 'ZzpDeduction', 'SisaReport', 'InventoryReorderRule', - 'Project', + 'engagement', 'ProjectAssignment', 'VatReturn', 'InnovatieboxElection', diff --git a/lib/Settings/register.d/000-register-declaration.json b/lib/Settings/register.d/000-register-declaration.json index 510fc0094..d5bc66c98 100644 --- a/lib/Settings/register.d/000-register-declaration.json +++ b/lib/Settings/register.d/000-register-declaration.json @@ -356,7 +356,7 @@ "PricingTier", "Programma", "Programmabegroting", - "Project", + "engagement", "ProjectAssignment", "ProjectBudget", "ProvincialeFondsPosting", diff --git a/lib/Settings/register.d/abstract-project-cost-lines.json b/lib/Settings/register.d/abstract-project-cost-lines.json index 53636dead..297b06940 100644 --- a/lib/Settings/register.d/abstract-project-cost-lines.json +++ b/lib/Settings/register.d/abstract-project-cost-lines.json @@ -2,7 +2,7 @@ "x-shillinq-fragment": "abstract-project-cost-lines", "components": { "schemas": { - "Project": { + "engagement": { "version": "0.2.0", "properties": { "costLines": { diff --git a/lib/Settings/register.d/add-shillinq-audit-trail.json b/lib/Settings/register.d/add-shillinq-audit-trail.json index d94caf7e3..b1ed91121 100644 --- a/lib/Settings/register.d/add-shillinq-audit-trail.json +++ b/lib/Settings/register.d/add-shillinq-audit-trail.json @@ -542,7 +542,7 @@ "description": "OR's audit-trail-immutable captures every create / update / lifecycle / delete event on PerDiemRate with actor, timestamp, action, before/after snapshot, and hash chain per ADR-022 + REQ-AT-001..AT-002. Retention is governed by OR per REQ-AT-005 (no shillinq cleanup job)." } }, - "Project": { + "engagement": { "x-openregister-audit-trail": { "enabled": true, "description": "OR's audit-trail-immutable captures every create / update / lifecycle / delete event on Project with actor, timestamp, action, before/after snapshot, and hash chain per ADR-022 + REQ-AT-001..AT-002. Retention is governed by OR per REQ-AT-005 (no shillinq cleanup job)." diff --git a/lib/Settings/register.d/bookings-deposit-to-invoice.json b/lib/Settings/register.d/bookings-deposit-to-invoice.json index 25db74eeb..d23ebe165 100644 --- a/lib/Settings/register.d/bookings-deposit-to-invoice.json +++ b/lib/Settings/register.d/bookings-deposit-to-invoice.json @@ -402,7 +402,7 @@ }, "Invoice": { "slug": "Invoice", - "icon": "FileDocumentOutline", + "icon": "ReceiptTextOutline", "version": "0.1.0", "title": "Invoice", "description": "A final customer invoice materialised in Shillinq when a booking order completes. Carries a service line and, when a deposit was authorised, a negative deposit-credit line. Bidirectionally linked to its Order via sourceDocumentUri and to the credited DepositPayment via depositPaymentId (REQ-DI-001). Integrates into Shillinq AR aging with no special handling (REQ-DI-009).", diff --git a/lib/Settings/register.d/bookkeeping-cost-centers-dimensions.json b/lib/Settings/register.d/bookkeeping-cost-centers-dimensions.json index 8c17ab701..da8502690 100644 --- a/lib/Settings/register.d/bookkeeping-cost-centers-dimensions.json +++ b/lib/Settings/register.d/bookkeeping-cost-centers-dimensions.json @@ -456,7 +456,7 @@ ], "filter": {}, "join": { - "through": "Project", + "through": "engagement", "on": "Project.code", "select": [ "Project.name", @@ -570,7 +570,7 @@ { "@self": { "register": "shillinq", - "schema": "Project", + "schema": "engagement", "slug": "proj-internal-platform" }, "code": "PROJ-INT-PLAT", @@ -584,7 +584,7 @@ { "@self": { "register": "shillinq", - "schema": "Project", + "schema": "engagement", "slug": "proj-grant-research" }, "code": "PROJ-GRANT-RND", diff --git a/lib/Settings/register.d/bookkeeping-detachering-payroll-administratie.json b/lib/Settings/register.d/bookkeeping-detachering-payroll-administratie.json index 9deb6617e..3207586da 100644 --- a/lib/Settings/register.d/bookkeeping-detachering-payroll-administratie.json +++ b/lib/Settings/register.d/bookkeeping-detachering-payroll-administratie.json @@ -9,7 +9,7 @@ "schemas": { "Employee": { "slug": "Employee", - "icon": "AccountTie", + "icon": "AccountTieOutline", "version": "0.1.0", "title": "Employee", "description": "Employee / detached worker / freelancer master record (REQ-PAY-001). Carries legal name, BSN (11-proef validated, masked on display per ADR-005), contract classification and onboarding/exit dates. A contact/person is a Nextcloud entity; this register holds the payroll-administration projection only.", @@ -211,7 +211,7 @@ }, "Payroll": { "slug": "Payroll", - "icon": "CashMultiple", + "icon": "CashSync", "version": "0.1.0", "title": "Payroll", "description": "Payroll period sub-ledger record (REQ-PAY-002). Captures gross input and aggregated deduction totals; on issue it materialises a balanced GLTransaction (REQ-PAY-008) per the T1 JournalEntry pattern. UBL Peppol BIS 30 field shape is declared for T4 passthrough but NOT computed here (REQ-PAY-010).", diff --git a/lib/Settings/register.d/zz-order-primitive.json b/lib/Settings/register.d/zz-order-primitive.json index 4a382ee8d..12fe2a0d5 100644 --- a/lib/Settings/register.d/zz-order-primitive.json +++ b/lib/Settings/register.d/zz-order-primitive.json @@ -10,7 +10,7 @@ "schemas": { "OrderPrimitive": { "slug": "OrderPrimitive", - "icon": "FileDocumentMultipleOutline", + "icon": "ClipboardListOutline", "version": "0.1.0", "title": "Order", "x-schema-org": "schema:Order", diff --git a/lib/Settings/register.d/zzz-mcp-tool-surface.json b/lib/Settings/register.d/zzz-mcp-tool-surface.json index 7dc84b1ba..90d3b3837 100644 --- a/lib/Settings/register.d/zzz-mcp-tool-surface.json +++ b/lib/Settings/register.d/zzz-mcp-tool-surface.json @@ -168,7 +168,7 @@ } } }, - "Project": { + "engagement": { "configuration": { "x-openregister-mcp": { "enabled": true, diff --git a/lib/Settings/shillinq_mock_register.json b/lib/Settings/shillinq_mock_register.json index 6ced9daa1..73782e59f 100644 --- a/lib/Settings/shillinq_mock_register.json +++ b/lib/Settings/shillinq_mock_register.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "title": "shillinq demo data", - "version": "1.0.0", + "version": "1.1.0", "description": "Demo data covering every schema this app supplies, offered as the first step of the app's setup walkthrough. Generated from the schemas themselves, so every object satisfies the schema that will validate it." }, "x-openregister": { @@ -23760,7 +23760,7 @@ { "@self": { "register": "shillinq", - "schema": "Project", + "schema": "engagement", "slug": "project-voorbeeld-name-1-1" }, "administrationId": "Voorbeeld Administrationid 1", @@ -23810,7 +23810,7 @@ { "@self": { "register": "shillinq", - "schema": "Project", + "schema": "engagement", "slug": "project-voorbeeld-name-2-2" }, "administrationId": "Voorbeeld Administrationid 2", @@ -23860,7 +23860,7 @@ { "@self": { "register": "shillinq", - "schema": "Project", + "schema": "engagement", "slug": "project-voorbeeld-name-3-3" }, "administrationId": "Voorbeeld Administrationid 3", @@ -33567,33 +33567,6 @@ "mkbProfitExemptionPercentage": 1.0, "source": "Voorbeeld Source 3" }, - { - "@self": { - "register": "shillinq", - "schema": "example", - "slug": "example-voorbeeld-title-1-1" - }, - "title": "Voorbeeld Title 1", - "description": "Voorbeeld Description 1" - }, - { - "@self": { - "register": "shillinq", - "schema": "example", - "slug": "example-voorbeeld-title-2-2" - }, - "title": "Voorbeeld Title 2", - "description": "Voorbeeld Description 2" - }, - { - "@self": { - "register": "shillinq", - "schema": "example", - "slug": "example-voorbeeld-title-3-3" - }, - "title": "Voorbeeld Title 3", - "description": "Voorbeeld Description 3" - }, { "@self": { "register": "shillinq", diff --git a/lib/Settings/shillinq_register.json b/lib/Settings/shillinq_register.json index f8f585a50..7b3c61573 100644 --- a/lib/Settings/shillinq_register.json +++ b/lib/Settings/shillinq_register.json @@ -2,8 +2,8 @@ "openapi": "3.0.0", "info": { "title": "Shillinq Register", - "description": "Register containing all schemas for the Shillinq application. v0.6.4 β€” re-authored 31 Dutch schema-level titles (entity display names) to English so manifest-driven UI labels are English-authored; Dutch display is now carried via l10n/nl.json per ADR i18n rule. Previous v0.6.3 β€” re-authored 254 Dutch schema-property titles (and none renamed keys) to English so manifest-driven UI labels are English-authored; Dutch display is now carried via l10n/nl.json per ADR i18n rule. Previous v0.6.2 β€” Register containing all schemas for the Shillinq application.", - "version": "0.6.4" + "description": "Shillinq register v0.7.0: retires the scaffold `example` schema and renames the `Project` slug to `engagement`. Planninq owns `project` as a container for tasks and kanban columns; this one is a consultancy engagement with RJ 270 percentage-of-completion revenue recognition, which is a different entity wearing the same word. Slugs are global on a shared OpenRegister. The schema KEY stays `Project`, so the register.d fragments that extend it by key are unaffected. PREVIOUSLY: Register containing all schemas for the Shillinq application. v0.6.4 β€” re-authored 31 Dutch schema-level titles (entity display names) to English so manifest-driven UI labels are English-authored; Dutch display is now carried via l10n/nl.json per ADR i18n rule. Previous v0.6.3 β€” re-authored 254 Dutch schema-property titles (and none renamed keys) to English so manifest-driven UI labels are English-authored; Dutch display is now carried via l10n/nl.json per ADR i18n rule. Previous v0.6.2 β€” Register containing all schemas for the Shillinq application.", + "version": "0.7.0" }, "x-openregister": { "type": "application", @@ -14,31 +14,7 @@ "paths": {}, "components": { "schemas": { - "example": { - "slug": "example", - "icon": "FileDocumentOutline", - "version": "0.1.0", - "title": "Example", - "description": "Example schema β€” replace with your app's actual schemas.", - "type": "object", - "required": [ - "title" - ], - "properties": { - "title": { - "type": "string", - "description": "The title of the example object", - "example": "My example", - "title": "Title" - }, - "description": { - "type": "string", - "description": "An optional description", - "example": "This is an example", - "title": "Description" - } - } - }, + "BankConnection": { "slug": "BankConnection", "icon": "BankOutline", @@ -5274,8 +5250,8 @@ } } }, - "Project": { - "slug": "Project", + "engagement": { + "slug": "engagement", "icon": "FolderOpenOutline", "version": "0.1.0", "title": "Project", @@ -7548,7 +7524,7 @@ ], "source": { "register": "shillinq", - "schema": "Project", + "schema": "engagement", "foreignKey": "projectId" }, "precondition": { @@ -7741,7 +7717,7 @@ "action": { "forEach": { "register": "@register", - "schema": "Project", + "schema": "engagement", "filter": { "state": "active" } @@ -8091,7 +8067,7 @@ "eventType": "nl.conduction.shillinq.fiscal-year.dimension-rollover", "targets": [ "AnalyticalDimension", - "Project" + "engagement" ], "filter": { "administrationId": "@self.administrationId", @@ -15021,7 +14997,7 @@ }, "Subsidie": { "slug": "Subsidie", - "icon": "CashMultiple", + "icon": "HandHeartOutline", "version": "0.1.0", "title": "Subsidy", "description": "Grant administration record per Awb afdeling 4.2 + VNG ASV-model 2022. Covers the full lifecycle for both outgoing grants (gemeente grants to beneficiary) and incoming grants (gemeente receives from granting body). Lifecycle declared via x-openregister-lifecycle per ADR-031; no SubsidieLifecycleService.", diff --git a/openspec/changes/hours-to-humaniq/.openspec.yaml b/openspec/changes/hours-to-humaniq/.openspec.yaml new file mode 100644 index 000000000..b4b3ece78 --- /dev/null +++ b/openspec/changes/hours-to-humaniq/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-01 diff --git a/openspec/changes/hours-to-humaniq/proposal.md b/openspec/changes/hours-to-humaniq/proposal.md new file mode 100644 index 000000000..bf0d8764d --- /dev/null +++ b/openspec/changes/hours-to-humaniq/proposal.md @@ -0,0 +1,129 @@ +# Change: hours-to-humaniq + +## Why + +ADR-107 decision 6 (`hydra/openspec/architecture/adr-107-money-and-effort-ownership.md`) +assigns booked hours to one app, and it is not this one: + +> **Effort is recorded against the domain object and costed by hrmq.** Hours +> logged on a case are hrmq time entries carrying the case reference. Hours Γ— +> the composed hourly cost becomes a cost allocation dispatched to Shillinq, +> taakveld taken from the case's `caseType`. The domain app supplies **context +> and classification**; hrmq supplies **the wage base**; Shillinq supplies +> **the ledger-derived additions and the booking**. + +Shillinq ships the opposite arrangement. `lib/Settings/register.d/uren-domain-subject-link.json` +adds `subjectApp` + `subjectId` to `UrenRegistratie` so a domain app can book +hours against a case here. Its own `_meta` cites the same ADR: + +> Per hydra ADR-081, the domain app CLASSIFIES and Shillinq AGGREGATES. + +Both readings cite ADR-081. That number was claimed by two documents until the +2026-08-26 renumbering, so the citation resolves to whichever the reader +assumed. Read against ADR-107 as it now stands, decision 6 is explicit about +where an hour lives, and this overlay contradicts it. + +Note the ADR's status is **Proposed**, not Accepted. This change proceeds on it +anyway, for the reason in the next section: the arrangement it replaces does +not work. + +## Investigation: nobody writes these fields, and nobody reads them + +Before designing a migration, we checked what would have to migrate. The answer +is nothing. + +**`UrenRegistratie.subjectApp` has no writer.** A fleet-wide grep across every +PHP, JS, Vue and JSON file in the workspace finds the field in exactly three +places: the schema overlay that declares it, a shillinq unit test asserting the +declaration, and one consumer. + +**That consumer is a dossiq KPI, and it can only ever read zero.** Dossiq's +`CaseDetail` manifest carries a `case-kpis-hours` tile summing +`shillinq.UrenRegistratie.hours` filtered on `subjectApp: "dossiq"` and +`subjectId: @objectId`. Nothing writes a row matching that filter, so the tile +reports 0 hours on every case in every install. It has done so since it +shipped. + +**Humaniq's side is unwired too.** `TimeEntry.domainObjectRef` and +`domainObjectType` are declared in `hr-cost-rate.json` and nothing in the fleet +writes or reads them either. The `x-notes` still give `procest:case` as the +example, an app id that was renamed to `dossiq`. + +So the fleet holds two competing designs for case hours, and neither has ever +carried a record. This is the same failure ADR-107 itself documents about +procest's IV3 report: an aggregator reading a field almost nothing filled. + +**Caveat.** We checked the source tree, not a production database. An operator +could have created rows by hand through the OpenRegister UI. Any install +running this change should count `UrenRegistratie` rows with a non-null +`subjectApp` before upgrading. The verification task below does that. + +## What changes + +`UrenRegistratie` stops being a place to book hours against a domain object. +Humaniq's `TimeEntry` becomes the only one, and it grows the case reference it +was already declared for. + +Shillinq keeps everything ADR-107 decision 1 gives it. It remains the only +general ledger and the only statutory reporter. What it stops doing is holding +the hour. + +### The design question this change must answer + +`UrenRegistratie` carries nine fields `TimeEntry` has no equivalent for: + +| Field | What it is for | +|---|---| +| `recognisedRate` | RateCard hourly rate snapshotted at booking time | +| `glTransactionId` | The GL transaction this hour posted to | +| `wbsoTagId`, `activityCodeId`, `tagSource`, `wbsoTaggedAt` | WBSO subsidy tagging | +| `projectAssignmentId`, `costProjectId` | Analytical dimensions | +| `utilizationPercent` | Derived utilization per REQ-CPA-109 | + +These are ledger and subsidy concerns, and ADR-107 decision 1 keeps them here. +So the answer is not "copy them to humaniq". Two shapes are open, and +`design.md` decides between them: + +1. **Shillinq derives a cost line per humaniq TimeEntry.** The hour lives in + humaniq. Shillinq holds a booking that references it and carries the rate, + the GL link and the WBSO tags. Matches decision 6 exactly. Costs a + cross-app read on every booking. +2. **Humaniq grows an opaque allocation payload.** `TimeEntry.allocationKey` + already exists as an opaque ledger dimension that humaniq refuses to + interpret. Extending that idea keeps the hour in one row. Risks smuggling + shillinq's model into humaniq under an opaque name, which is what + `allocationKey`'s own `x-notes` warn against. + +### Scope + +In scope, in this app: + +- Retire `subjectApp` / `subjectId` from `UrenRegistratie`. +- Repoint the four specs that read hours: `invoice-from-time-and-expense`, + `time-expense-invoice-intake`, `wbso-uren-tagging-and-export`, + `zzp-urencriterium-tracker`. +- Repoint the six code consumers: `TimeIntakeService` (10 references), + `WBSOExportValidationGuard` (5), `FinancialDashboardService` (3), + `InvoiceGenerationService` (2), `UrencriteriumGuard` (2), + `SubjectCostAggregator` (1). +- Keep the ledger, the WBSO export and the urencriterium guard working. These + are statutory. A Dutch self-employed person loses a tax deduction if the + 1225-hour count is wrong. + +Out of scope, tracked elsewhere: + +- The humaniq write path and the shared hours widget ship with the dossiq case + detail work. +- ADR-107's composed `hourlyCost` model is a separate programme. + +## Risks + +**The urencriterium and WBSO paths are statutory.** Both feed a tax position. +Neither may read a partial hour set during the migration. Tasks below keep the +old read path alive until the new one is proven against the same numbers. + +**Humaniq becomes a hard dependency for hours.** Today shillinq books hours +alone. After this it cannot, and no fleet app declares an `` dependency in +`info.xml`. An install without humaniq must degrade to a visible empty state, +never a silent zero. The dossiq KPI's current behaviour is exactly the failure +to avoid repeating. diff --git a/openspec/changes/hours-to-humaniq/specs/hours-to-humaniq/spec.md b/openspec/changes/hours-to-humaniq/specs/hours-to-humaniq/spec.md new file mode 100644 index 000000000..7d4dbc94a --- /dev/null +++ b/openspec/changes/hours-to-humaniq/specs/hours-to-humaniq/spec.md @@ -0,0 +1,70 @@ +# Spec: hours-to-humaniq (delta) + +## ADDED Requirements + +### Requirement: REQ-H2H-001 Β· Hours on a domain object live in humaniq + +An hour worked on a domain object (a dossiq case, any case or matter object) +MUST be recorded as a humaniq `TimeEntry` carrying `domainObjectRef` and +`domainObjectType`. Shillinq MUST NOT hold the hour. Per ADR-107 decision 6, +the domain app supplies context and classification, humaniq supplies the wage +base, and shillinq supplies the ledger-derived additions and the booking. + +#### Scenario: `UrenRegistratie` no longer declares a domain subject + +- **GIVEN** the shillinq register fragments +- **WHEN** `lib/Settings/register.d/uren-domain-subject-link.json` is looked up +- **THEN** the file does not exist +- **AND** no fragment declares `subjectApp` or `subjectId` on `UrenRegistratie` +- @e2e exclude verified by file-existence and a static grep over the register + fragments. A removed schema field has no browser surface of its own. + +#### Scenario: A case shows hours booked in humaniq + +- **GIVEN** a dossiq case with two humaniq `TimeEntry` records whose + `domainObjectType` is `dossiq:case` and whose `domainObjectRef` is the + case uuid +- **WHEN** a handler opens the case detail page +- **THEN** the hours tile shows the sum of those two entries + +### Requirement: REQ-H2H-002 Β· An hours surface never reports zero for a missing app + +Every surface that shows booked hours MUST distinguish "humaniq is absent" from +"no hours were booked". When humaniq is not installed or not reachable, the +surface MUST render a named empty state that says so. It MUST NOT render 0. + +This requirement exists because the behaviour it forbids already shipped. +Dossiq's `case-kpis-hours` tile summed a shillinq field no code ever wrote, so +it reported 0 hours on every case in every install and looked correct doing it. + +#### Scenario: Humaniq is not installed + +- **GIVEN** an install with humaniq disabled +- **WHEN** a handler opens a case detail page +- **THEN** the hours tile names humaniq as unavailable +- **AND** the tile does not show a numeric total + +#### Scenario: Humaniq is installed and the case has no hours + +- **GIVEN** an install with humaniq enabled and a case with no `TimeEntry` +- **WHEN** a handler opens the case detail page +- **THEN** the hours tile shows 0 + +### Requirement: REQ-H2H-003 Β· Statutory hour counts are proven before cutover + +The WBSO export guard and the urencriterium guard both feed a tax position. For +each, the humaniq-backed count MUST be proven equal to the `UrenRegistratie` +count over the same period and administration before the old read path is +removed. + +A wrong urencriterium count costs a self-employed person a real deduction, so +"the tests pass" is not the bar. The two counts must be compared directly. + +#### Scenario: Both guards agree across the cutover + +- **GIVEN** an administration with hours booked over a full calendar year +- **WHEN** the urencriterium total is computed from `UrenRegistratie` and from + humaniq `TimeEntry` over the same year +- **THEN** the two totals are equal +- @e2e exclude a numeric equivalence check between two service read paths, with + no UI of its own. Covered by an integration test. diff --git a/openspec/changes/hours-to-humaniq/tasks.md b/openspec/changes/hours-to-humaniq/tasks.md new file mode 100644 index 000000000..8c73361e2 --- /dev/null +++ b/openspec/changes/hours-to-humaniq/tasks.md @@ -0,0 +1,56 @@ +# Tasks: hours-to-humaniq + +## 1. Decide the split + +- [ ] 1.1 Write `design.md` choosing between the derived-cost-line shape and + the opaque-allocation shape described in `proposal.md`. Record why. +- [ ] 1.2 Confirm ADR-107 is promoted from Proposed to Accepted, or record + that this change proceeds on a Proposed ADR and why that is safe. + +## 2. Prove there is nothing to migrate + +- [ ] 2.1 Add an `occ` command that counts `UrenRegistratie` rows with a + non-null `subjectApp`. Report per administration. +- [ ] 2.2 Run it on the dev instance and on every reachable install. Record + the counts in `design.md`. A non-zero count reopens task 1. +- [ ] 2.3 Only when every count is zero: proceed. Otherwise write a data + migration first. + +## 3. Move the hour + +- [ ] 3.1 Humaniq writes case-scoped hours: `TimeEntry.domainObjectRef` + + `domainObjectType` are stamped on create from the logging app. +- [ ] 3.2 Correct humaniq's `x-notes` example from `procest:case` to + `dossiq:case`. The app id moved and nothing writes the field yet, so + this costs nothing now and is unrecoverable once hours exist. +- [ ] 3.3 Shillinq reads hours from humaniq for the ledger, in the shape + task 1.1 chose. + +## 4. Repoint each consumer, statutory ones last + +- [ ] 4.1 `SubjectCostAggregator` (1 reference). Smallest, and it proves the + read path. +- [ ] 4.2 `FinancialDashboardService` (3 references). Reporting only, no tax + position. +- [ ] 4.3 `InvoiceGenerationService` (2 references) and + `TimeIntakeService` (10). Invoicing. +- [ ] 4.4 `WBSOExportValidationGuard` (5 references). Subsidy. Run old and + new side by side over the same period and assert identical output + before cutting over. +- [ ] 4.5 `UrencriteriumGuard` (2 references). A wrong 1225-hour count costs + a real person a real deduction. Same side-by-side proof as 4.4. + +## 5. Retire the dead fields + +- [ ] 5.1 Delete `lib/Settings/register.d/uren-domain-subject-link.json`. +- [ ] 5.2 Delete `tests/Unit/Settings/UrenDomainSubjectLinkTest.php`. +- [ ] 5.3 Update the four affected specs: `invoice-from-time-and-expense`, + `time-expense-invoice-intake`, `wbso-uren-tagging-and-export`, + `zzp-urencriterium-tracker`. + +## 6. Degrade honestly without humaniq + +- [ ] 6.1 Every hours surface shows a named empty state when humaniq is + absent. Never a zero. The dossiq KPI reporting 0 hours on every case + is the bug this change exists to stop repeating. +- [ ] 6.2 Add a test that asserts the empty state, not the zero. diff --git a/package-lock.json b/package-lock.json index ce33a9b47..11b6c52b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.1", "license": "EUPL-1.2", "dependencies": { - "@conduction/nextcloud-vue": "^2.24.4", + "@conduction/nextcloud-vue": "^2.31.1", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "~2.5.2", "@nextcloud/capabilities": "^1.2.1", @@ -644,9 +644,9 @@ } }, "node_modules/@conduction/nextcloud-vue": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.27.2.tgz", - "integrity": "sha512-FhDF3FvM+ee0Jx7z70Lki7o7Mm4DeX/GsOqHo33hOcdylzXR63UXLRbxKMedodEiIqgOFUAI6GTox735iyL8Dw==", + "version": "2.31.1", + "resolved": "https://registry.npmjs.org/@conduction/nextcloud-vue/-/nextcloud-vue-2.31.1.tgz", + "integrity": "sha512-tF1/7yNaBgxj5iHhpNuxVdUrPugfy2ePQZILl7y+fFJTq68tR7WPs8EqPin8aaCBc6ZxgWp1ciyY+NW9rXBMYA==", "license": "EUPL-1.2", "dependencies": { "@ckpack/vue-color": "^1.6.0", diff --git a/package.json b/package.json index 05c96ddc5..e8bd9724d 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "postdev": "[ -d ../openregister/custom_apps/shillinq/js ] && cp -r js/. ../openregister/custom_apps/shillinq/js/ || true", "prewatch": "node scripts/generate-manifest-shell.js", "watch": "NODE_ENV=development webpack --config webpack.config.js --progress --watch", - "lint": "eslint src", + "lint": "eslint src tests scripts", "lint-fix": "npm run lint -- --fix", "test:l10n": "node tests/l10n/check-l10n.js", "test:l10n-parity": "L10N_REQUIRED_LOCALES=nl node tests/l10n/check-l10n-parity.js", @@ -50,7 +50,7 @@ "extends @nextcloud/browserslist-config" ], "dependencies": { - "@conduction/nextcloud-vue": "^2.24.4", + "@conduction/nextcloud-vue": "^2.31.1", "@nextcloud/auth": "^2.6.0", "@nextcloud/axios": "~2.5.2", "@nextcloud/capabilities": "^1.2.1", @@ -78,7 +78,7 @@ "overrides": { "libxmljs2": "^0.37.0", "apexcharts": "4.7.0", - "@nextcloud/axios": "~2.5.2" + "@nextcloud/axios": "$@nextcloud/axios" }, "devDependencies": { "@babel/core": "^7.22.9", diff --git a/scripts/build-l10n-js.js b/scripts/build-l10n-js.js index 176ef519c..335b08d2c 100644 --- a/scripts/build-l10n-js.js +++ b/scripts/build-l10n-js.js @@ -101,6 +101,9 @@ function renderJs(id, translations, pluralForm) { ].join('\n') } +/** + * + */ function main() { const check = process.argv.includes('--check') const id = appId() diff --git a/scripts/check-schema-l10n.js b/scripts/check-schema-l10n.js index 8a860b330..3c4b2626f 100644 --- a/scripts/check-schema-l10n.js +++ b/scripts/check-schema-l10n.js @@ -113,6 +113,9 @@ function collect(node, where, sink) { for (const value of Object.values(node)) collect(value, where, sink) } +/** + * + */ function main() { const update = process.argv.includes('--update') const list = process.argv.includes('--list') diff --git a/scripts/generate-manifest-shell.js b/scripts/generate-manifest-shell.js index 87f091b71..94d42408b 100644 --- a/scripts/generate-manifest-shell.js +++ b/scripts/generate-manifest-shell.js @@ -62,7 +62,7 @@ function slimPages(pages, fragment) { return pages.map((page) => { const slim = { _fragment: fragment } for (const key of SHELL_PAGE_KEYS) { - if (page && Object.prototype.hasOwnProperty.call(page, key)) { + if (page && Object.hasOwn(page, key)) { slim[key] = page[key] } } @@ -135,7 +135,7 @@ function generateShellDocument(dir = MANIFEST_D_DIR) { function main() { const shell = generateShellDocument() fs.writeFileSync(SHELL_OUTPUT_PATH, JSON.stringify(shell, null, '\t') + '\n') - // eslint-disable-next-line no-console + console.log( `[generate-manifest-shell] wrote ${SHELL_OUTPUT_PATH} ` + `(${shell.fragments.length} fragments, ` diff --git a/src/components/Dashboard/BBVComplianceDashboard.vue b/src/components/Dashboard/BBVComplianceDashboard.vue index 61167a79d..1da5b9ee0 100644 --- a/src/components/Dashboard/BBVComplianceDashboard.vue +++ b/src/components/Dashboard/BBVComplianceDashboard.vue @@ -44,7 +44,12 @@ :loading="loading" :cellHeight="80" :gridMargin="16" - :emptyLabel="t('shillinq', 'No widgets configured.')"> + :emptyLabel="t('shillinq', 'No widgets configured.')" + :refreshing="loading" + @refresh="loadProgrammes"> +