From f4a32bf973621099ab16da3182a8f2d9c49d4b30 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 17 May 2026 09:29:49 +0200 Subject: [PATCH 1/7] chore(security): require 24h cooldown on npm packages (#450) Defense against supply-chain attacks (e.g. shai-hulud, nx-style compromised publishes) by blocking install of any package version published less than 24h ago. - .npmrc: `min-release-age=1` (npm 11.5+ native; older npm ignores it) - .github/dependabot.yml: `cooldown.default-days: 1`, with @conduction/* excluded so first-party releases reach our apps immediately For release-day consumption of fresh @conduction/* deps, use `npm install --min-release-age=0 @conduction/pkg@x.y.z`. --- .github/dependabot.yml | 13 +++++++++++++ .npmrc | 6 ++++++ 2 files changed, 19 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..3390c9e5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + cooldown: + default-days: 1 + include: + - "*" + exclude: + - "@conduction/*" diff --git a/.npmrc b/.npmrc index 521a9f7c..b9b774c8 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,7 @@ legacy-peer-deps=true + +# Supply-chain hardening: reject any npm package published less than +# 24h ago. Compromised first-party-Conduction packages are excluded via +# Dependabot cooldown (.github/dependabot.yml); for fresh @conduction/* +# releases, override per-install with `npm install --min-release-age=0`. +min-release-age=1 From 5293658c6dec1cdb4f2664cd724b5eec83985398 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 18 May 2026 07:00:54 +0200 Subject: [PATCH 2/7] spec(procurement): add procurement suite (8 specs consolidating 26 specter drafts) Consolidates 26 specter-discovered draft specs (moved from former budgetq during the bookkeeping pivot) into 8 coherent capability specs for procest's procurement domain: - procest-procurement-supplier-management (replaces 9 drafts: supplier-management base + 5 -other-tN + -ai + -misc + supplier-performance) - procest-procurement-contract-lifecycle (replaces 8 contract-lifecycle-* drafts) - procest-procurement-system-integration (replaces 5 procurement-integration-* drafts) - procest-procurement-tender-management (Aanbestedingswet 2012, ARW 2016) - procest-procurement-evaluation-award (Alcatel-termijn, gunningscriteria) - procest-procurement-compliance (drempelbedragen, UEA, EML-bestand) - procest-procurement-publication-platform (TED/OJEU, eForms F01-F25) - procest-procurement-spend-analytics-integration (cross-app contract w/ mydash) 56 RFC-2119 requirements + 80 Given/When/Then scenarios across 8 specs. All capabilities framed in procest's case-management domain (supplier-as-case, contract-as-case, tender-as-case). All integrations consumed from openconnector per ADR-022; documents from docudesk; workflow/RBAC/audit from openregister. Schema.org annotations on every register. financeq references kept as [future] until financeq repo exists. --- .../add-procest-procurement-suite/design.md | 215 ++++++++++++ .../add-procest-procurement-suite/proposal.md | 126 +++++++ .../procest-procurement-compliance/spec.md | 203 +++++++++++ .../spec.md | 263 ++++++++++++++ .../spec.md | 205 +++++++++++ .../spec.md | 194 +++++++++++ .../spec.md | 153 ++++++++ .../spec.md | 327 ++++++++++++++++++ .../spec.md | 157 +++++++++ .../spec.md | 288 +++++++++++++++ .../add-procest-procurement-suite/tasks.md | 132 +++++++ 11 files changed, 2263 insertions(+) create mode 100644 openspec/changes/add-procest-procurement-suite/design.md create mode 100644 openspec/changes/add-procest-procurement-suite/proposal.md create mode 100644 openspec/changes/add-procest-procurement-suite/specs/procest-procurement-compliance/spec.md create mode 100644 openspec/changes/add-procest-procurement-suite/specs/procest-procurement-contract-lifecycle/spec.md create mode 100644 openspec/changes/add-procest-procurement-suite/specs/procest-procurement-evaluation-award/spec.md create mode 100644 openspec/changes/add-procest-procurement-suite/specs/procest-procurement-publication-platform/spec.md create mode 100644 openspec/changes/add-procest-procurement-suite/specs/procest-procurement-spend-analytics-integration/spec.md create mode 100644 openspec/changes/add-procest-procurement-suite/specs/procest-procurement-supplier-management/spec.md create mode 100644 openspec/changes/add-procest-procurement-suite/specs/procest-procurement-system-integration/spec.md create mode 100644 openspec/changes/add-procest-procurement-suite/specs/procest-procurement-tender-management/spec.md create mode 100644 openspec/changes/add-procest-procurement-suite/tasks.md diff --git a/openspec/changes/add-procest-procurement-suite/design.md b/openspec/changes/add-procest-procurement-suite/design.md new file mode 100644 index 00000000..87551f86 --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/design.md @@ -0,0 +1,215 @@ +# Design: add-procest-procurement-suite + +## Domain framing — procurement as case management + +Procest models everything as a **case** (`schema:Project`, +`case-management` capability). The procurement suite preserves that +framing rather than introducing a new top-level domain object: + +| Procurement concept | Procest framing | Existing procest capability consumed | +|---|---|---| +| Supplier (vendor) | Supplier-as-record + Supplier-onboarding-as-case (one case per onboarding/qualification cycle) | `case-management`, `case-types`, `roles-decisions` | +| Contract | Contract-as-record + Contract-lifecycle-as-case (one case per material event: signature, renewal, amendment, termination) | `case-management`, `case-types`, `workflow-engine-abstraction`, `parafering-actions` (signatures) | +| Tender (aanbestedingsdossier) | Tender-as-case (`schema:Project`) with sub-cases per lot, per round, per RFI/RFP/RFQ phase | `case-management`, `case-types`, `deelzaak-support`, `process-step-configuration` | +| Evaluation | Evaluation-as-record attached to a tender case; scoring matrix as `propertyDefinition` data | `case-management`, `roles-decisions` | +| Award | Award-as-decision on a tender case — fits procest's existing `decision` register exactly | `case-management`, `roles-decisions`, `besluitvorming-workflow` | + +Three principles flow from this framing: + +1. **No new workflow engine.** ADR-022 forbids a parallel mechanism; + procest already wraps OR's workflow engine in + `workflow-engine-abstraction`. Every procurement lifecycle declared + in the suite is an `x-openregister-lifecycle` block on its schema + per ADR-031. No `SupplierOnboardingService::transition()` PHP class + gets written. +2. **No new audit/RBAC system.** All registers are OR-backed; RBAC + comes from OR's per-schema permissions; audit from + `audit-trail-immutable`. The "supplier user can edit their own + profile" pattern reuses the same RBAC model `case-management` + already uses. +3. **No CoA / GL in procest.** Spend, cost, GL postings, invoices — + procest emits domain events; consumers (mydash via GraphQL, + `[future]` financeq via OpenConnector source) compute the money + side. Procest never owns a chart-of-accounts or a posting table. + +## How the 8 specs fit together + +``` + ┌─────────────────────────────────────────────────┐ + │ procest case-management (existing) │ + │ case, caseType, statusType, role, decision │ + └────────────────┬────────────────────────────────┘ + │ all 8 specs consume + ┌────────────────┼────────────────┐ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ SUP supplier │ │ CLM contract │ │ TND tender │ + │ registers + │ │ registers + │ │ registers + │ + │ onboarding │ │ lifecycle │ │ deelzaak per │ + │ case-type │ │ case-type │ │ lot/round │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ EVA scoring │ │ PCC compliance│ │ PSI external │ + │ on tender │ │ thresholds + │ │ connectors │ + │ cases │ │ UEA/EML │ │ via openconn. │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + └────────┬────────┴─────────────────┘ + ▼ + ┌─────────────────────────┐ + │ PPP publication-platform│ + │ (TED/OJEU/national bekend-│ + │ makingen + amendment │ + │ re-publish flagging) │ + └─────────────────────────┘ + │ + ▼ + ┌─────────────────────────┐ + │ PSA spend-analytics │ + │ events → mydash │ + │ (cross-app contract) │ + └─────────────────────────┘ +``` + +Spec ordering for downstream code chains (per ADR-032): + +1. **First wave** (independent, can chain in parallel): SUP, CLM, TND + — each adds a `caseType` seed + a small set of new schemas. +2. **Second wave** (depends on first): EVA (needs TND), PCC (needs + SUP + TND + CLM for cross-register policy checks). +3. **Third wave** (depends on second): PSI (the connector slots are + declared once the data shapes are stable), PPP (depends on TND + + EVA + PSI). +4. **Fourth wave** (cross-app contract): PSA — emits events from all + of the above; ships when at least one of TND/CLM is in code. + +## OR abstraction usage table + +Per ADR-022, every spec declares which OR abstractions it consumes +and which it does NOT reimplement. + +| Abstraction | SUP | CLM | TND | EVA | PCC | PSI | PPP | PSA | +|---|---|---|---|---|---|---|---|---| +| Registers + schemas + objects | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| RBAC (authorization) | ✓ | ✓ | ✓ | ✓ | ✓ | – | – | – | +| Audit trail (immutable) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Archival + destruction (retention) | ✓ | ✓ | ✓ | – | – | – | – | – | +| `x-openregister-lifecycle` | ✓ | ✓ | ✓ | ✓ | – | – | ✓ | – | +| `x-openregister-aggregations` | ✓ | ✓ | ✓ | ✓ | ✓ | – | – | ✓ | +| `x-openregister-calculations` | ✓ | ✓ | ✓ | ✓ | ✓ | – | ✓ | – | +| `x-openregister-notifications` | ✓ | ✓ | ✓ | – | ✓ | – | ✓ | – | +| `x-openregister-relations` | ✓ | ✓ | ✓ | ✓ | ✓ | – | – | – | +| `x-openregister-widgets` | – | ✓ | ✓ | ✓ | ✓ | – | – | ✓ | +| Integration registry (ADR-019) — providers | – | – | – | – | – | ✓ | ✓ | – | +| OR `ScheduledWorkflow` + n8n | – | ✓ | – | – | ✓ | ✓ | ✓ | – | +| Deep link registry | ✓ | ✓ | ✓ | – | – | – | – | – | +| Events + webhooks (CloudEvents) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | + +## Declarative-vs-imperative decision (ADR-031) + +Every behaviour described in the 8 specs has been classified: + +- **Declarative (default)** — lifecycles, aggregations, calculations, + notifications, relations, widgets. Lands as JSON patches on + `lib/Settings/procest_register.json`. Reviewer should reject any + follow-up code chain that authors a `*Service::transition*`, + `*Service::getSummary*`, `*Service::compute*Field*`, or + `*Service::notifyOn*` for a register declared in this suite. +- **Imperative (justified)** — only: + - the OpenConnector source rows that talk to TenderNed, Mercell, + Negometrix, Peppol, RGS, TED, Digipoort SBR (PSI + PPP). These + are connector definitions, NOT services in procest's `lib/`. + - the lifecycle guards (`x-openregister-lifecycle.requires`) + called by the declarative engine for non-trivial preconditions + (e.g. "tender award requires standstill period elapsed", + "contract renewal requires no open termination case"). Each guard + is a short, single-method PHP class. +- **Schema engine gap** — none observed. Every behaviour fits an + existing `x-openregister-*` extension. If a future spec discovers a + gap, it opens an OR issue and adds a guard as a temporary bridge per + ADR-031 exception (1). + +## Source draft reconciliation (intelligence-db cleanup) + +After this change archives, the following 26 rows in +`app_specs` (where `app_slug = 'procest'`) MUST be marked +`status = 'superseded'` with `superseded_by = +'add-procest-procurement-suite'` to prevent Specter from re-emitting +them as fresh issues: + +| Draft slug | Consolidated into | +|---|---| +| `supplier-management` | `procest-procurement-supplier-management` | +| `supplier-management-ai` | `procest-procurement-supplier-management` | +| `supplier-management-misc` | `procest-procurement-supplier-management` | +| `supplier-management-other-t1` | `procest-procurement-supplier-management` | +| `supplier-management-other-t2` | `procest-procurement-supplier-management` | +| `supplier-management-other-t3` | `procest-procurement-supplier-management` | +| `supplier-management-other-t4` | `procest-procurement-supplier-management` | +| `supplier-management-other-t5` | `procest-procurement-supplier-management` | +| `supplier-performance-management` | `procest-procurement-supplier-management` | +| `contract-lifecycle-management` | `procest-procurement-contract-lifecycle` | +| `contract-lifecycle-management-ai` | `procest-procurement-contract-lifecycle` | +| `contract-lifecycle-management-analytics` | `procest-procurement-contract-lifecycle` | +| `contract-lifecycle-management-document-management` | `procest-procurement-contract-lifecycle` | +| `contract-lifecycle-management-other-t1` | `procest-procurement-contract-lifecycle` | +| `contract-lifecycle-management-other-t2` | `procest-procurement-contract-lifecycle` | +| `contract-lifecycle-management-other-t3` | `procest-procurement-contract-lifecycle` | +| `contract-lifecycle-management-other-t4` | `procest-procurement-contract-lifecycle` | +| `procurement-integration` | `procest-procurement-system-integration` | +| `procurement-integration-integration` | `procest-procurement-system-integration` | +| `procurement-integration-other-t1` | `procest-procurement-system-integration` | +| `procurement-integration-other-t2` | `procest-procurement-system-integration` | +| `procurement-integration-other-t3` | `procest-procurement-system-integration` | +| `tender-management` | `procest-procurement-tender-management` | +| `evaluation-award` | `procest-procurement-evaluation-award` | +| `procurement-compliance` | `procest-procurement-compliance` | +| `publication-platform-integration` | `procest-procurement-publication-platform` | + +## Judgement calls + +- **7 vs 8 split for publication-platform.** Kept as a standalone spec + (#7). TED/OJEU is *not* identical to TenderNed/Mercell transport + glue — it has its own statutory deadlines (rectification windows, + standard form codes F01..F25 + eForms), its own "material change" + flag triggering re-publication, and its own bidirectional flow + (publish → confirmation → indexing). Folding it into PSI would + obscure those constraints. PSI declares the *connector slot* (where + the OpenConnector source plugs in); PPP declares the *publication + workflow* (what gets published when, what re-publication means + domain-wise). +- **PSA is light by design.** Procest does not own analytics; mydash + does. PSA is a cross-app contract spec — it nails down the event + shape procest emits (CloudEvents per `events + webhooks`) and the + RBAC scope on the GraphQL query mydash will use. The actual widget + declarations belong to mydash's own fleet rollout. +- **No `procest-procurement-purchase-order` spec.** Purchase orders + (PO/Bestelling) are a procest case-type seed once CLM ships — they + are a "contract child" lifecycle, not a separate capability. If + market-intelligence later surfaces PO as its own surface, split + off then. +- **Supplier-performance folded into SUP.** Performance scorecards + are a `x-openregister-calculations` on Supplier + an aggregation; + a separate spec would just restate the same Supplier schema with a + scoreboard widget. One spec keeps the supplier surface coherent. + +## Risks + mitigations + +| Risk | Mitigation | +|---|---| +| Procest's `case-management` already declares a `decision` register; the EVA "award" spec must not duplicate it. | EVA reuses procest's existing `decision` schema; adds `awardType`, `evaluationRef`, `standstillUntil` fields via additive register patch, no new register. | +| OpenConnector sources for TenderNed/TED don't exist yet. | PSI + PPP describe the *slot*, not the transport. A separate `add-openconnector-eu-procurement-sources` change owns the connector definitions. PSI's manifest entry stays hidden until the sources register. | +| The 26 source drafts have weak NL-gov coverage; new specs add Aanbestedingswet 2012, ARW 2016, UEA, EML-bestand, Alcatel-termijn citations. | Citations are inline in each REQ's narrative; reviewer can verify against the cited articles. | +| financeq doesn't exist. | Every cross-app reference to financeq is prefixed `[future]`; manifests don't yet hard-depend on it. | + +## See also + +- ADR-022 — apps consume OR abstractions (the OR-side anti-pattern list). +- ADR-024 — app manifest (every spec ends with a manifest entry). +- ADR-031 — schema-declarative business logic (every lifecycle/aggregation/notification declared in the register, not coded as a service). +- ADR-032 — spec sizing + chained-spec routing (this change is `kind: config`; per-spec code chains will follow). +- Procest `case-management`, `case-types`, `workflow-engine-abstraction`, `roles-decisions`, `deelzaak-support`, `besluitvorming-workflow` — existing capabilities every new spec builds on. +- `feedback_mydash-no-or-dependency.md` — PSA contract shape. +- Intelligence-DB cleanup checklist in "Source draft reconciliation" above. diff --git a/openspec/changes/add-procest-procurement-suite/proposal.md b/openspec/changes/add-procest-procurement-suite/proposal.md new file mode 100644 index 00000000..71255973 --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/proposal.md @@ -0,0 +1,126 @@ +--- +kind: config +depends_on: [] +chain: [] +--- + +# Proposal: add-procest-procurement-suite + +**Status:** proposed +**Scope:** procest +**Owner:** Conduction BV — Procest team + +## Why + +Procest is the case-management foundation for Conduction (zaakgericht +werken on Nextcloud + OpenRegister). It already ships robust +public-sector case patterns (besluitvorming, bezwaar-beroep, +parafering, VTH, handhaving) but lacks an explicit, consolidated +description of the **procurement, contracting, supplier, and tender** +surface that municipal and SMB operators expect when they handle +public procurement as cases. + +Specter's intelligence pipeline (`specter_worker.py`, the +`app_specs` table) discovered 26 procurement-adjacent draft specs +under the procest namespace, originally drafted while the work was +parked under the now-deprecated `budgetq` app. Each spec carries +a misleading `— Shillinq` title suffix from that earlier shape; +their content however describes a public-procurement workflow that +fits procest's case-management framing, not shillinq's bookkeeping +engine. + +Left as 26 separate specs, the surface is: + +- impossible to review as a coherent product (each draft duplicates + the same Nextcloud/OpenRegister boilerplate), +- mis-titled with the Shillinq suffix, +- structurally inconsistent — some are pure feature lists, some are + near-empty stubs, none follow procest's case-centric framing. + +This change consolidates the 26 drafts into **8 capability specs** +under the `add-procest-procurement-suite` envelope. Each consolidated +spec frames its register(s) as `schema:Project` cases (supplier-as- +case, contract-as-case, tender-as-case) and is anchored to OR +abstractions per ADR-022 / ADR-031 — no parallel storage, no custom +state machines, no custom audit tables. + +## What changes + +1. **New capability specs (8)**, each shipped as a delta under this + change's `specs/` directory: + + | # | Slug | REQ prefix | Source drafts consolidated | + |---|---|---|---| + | 1 | `procest-procurement-supplier-management` | SUP | `supplier-management`, `supplier-management-ai`, `supplier-management-misc`, `supplier-management-other-t1..t5`, `supplier-performance-management` | + | 2 | `procest-procurement-contract-lifecycle` | CLM | `contract-lifecycle-management`, `-ai`, `-analytics`, `-document-management`, `-other-t1..t4` | + | 3 | `procest-procurement-system-integration` | PSI | `procurement-integration`, `procurement-integration-integration`, `procurement-integration-other-t1..t3` | + | 4 | `procest-procurement-tender-management` | TND | `tender-management` | + | 5 | `procest-procurement-evaluation-award` | EVA | `evaluation-award` | + | 6 | `procest-procurement-compliance` | PCC | `procurement-compliance` | + | 7 | `procest-procurement-publication-platform` | PPP | `publication-platform-integration` | + | 8 | `procest-procurement-spend-analytics-integration` | PSA | (cross-app contract, no consolidated drafts) | + +2. **Tier label**: every spec carries `Tier: procurement-suite` (procest + has no numeric tier roadmap; this label is the suite anchor). + +3. **No code, no UI, no controllers, no tests** are added by this + change. It is a *declarative* `kind: config` change per ADR-032 — + spec deltas + register-shape implications only. Implementation + lands in chained code specs once the suite specs merge. + +4. **Cross-app dependencies declared but not introduced**: + - `openconnector` for all external transport (TenderNed, Mercell, + Negometrix, Peppol/GHX, TED/OJEU, Digipoort SBR, RGS, etc.). + - `docudesk` for contract documents, signed PDFs, attachments. + - `openregister` for RBAC, audit, retention, lifecycle, + aggregations, scheduled workflows. + - `mydash` for the analytics surface — procest emits events, mydash + reads via runtime GraphQL (per ADR-024 §10 and + `feedback_mydash-no-or-dependency.md`). + - `financeq` — `[future]` reference only; the repo does not yet + exist. Spend / cost integration is documented as a placeholder. + +## Impact + +- **Specs added:** 8 new capability specs under + `procest/openspec/specs/` once this change archives. +- **Code changed:** none in this change. Each spec's implementation + is the work of a follow-up code chain (per ADR-032) — typically: + (1) register-patch landing the schema, (2) manifest entry landing + the navigation, (3) integration smoke test, (4) optional UI + decoration on top of the generic page renderer. +- **Drafts to archive (26)** in the `app_specs` intelligence table + once this change merges. See "Source draft reconciliation" in + `design.md`. +- **No breaking changes** — procest's existing case-management, + case-types, workflow-engine-abstraction specs continue to operate + unchanged. The new specs sit beside them as additional capability + surfaces consumed by the same case-management plumbing. + +## Out of scope + +- PHP / Vue implementation code (deferred to per-spec code chains). +- UI/component design beyond manifest navigation entries + (manifest-driven per ADR-024 — generic renderers). +- Tests, CI, fixtures. +- Deep `financeq` integration (no repo, marked `[future]`). +- Auto-merging of the 26 source drafts in Specter — that is an + intelligence-DB housekeeping step run after this change archives. +- Belgian Federal e-Procurement (Free Market) and Spanish PLACSP + source registrations — listed as connector slots in spec #3 + but their OpenConnector source rows land in a separate + `add-openconnector-eu-procurement-sources` change. + +## Reviewer gates this change should pass + +- ADR-022: no parallel storage scenarios — every spec includes the + "reviewer scans for `lib/Db/{*}_mapper.php`" pattern. +- ADR-031: no custom state-machine services — every lifecycle declared + as `x-openregister-lifecycle`. +- ADR-024: every spec ends with a manifest-navigation requirement. +- ADR-032: this is `kind: config` (specs only — no code surface). +- Procest case-centric framing: every register that represents work + (supplier-as-case, contract-as-case, tender-as-case) is reachable + from `case-management`'s `caseType` machinery so existing + dashboards, my-work, doorlooptijd, role-routing already work + without per-capability code. diff --git a/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-compliance/spec.md b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-compliance/spec.md new file mode 100644 index 00000000..897b92cd --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-compliance/spec.md @@ -0,0 +1,203 @@ +# Spec: procest-procurement-compliance + +**Status:** proposed +**Scope:** procest +**Tier:** procurement-suite +**Depends on:** case-management, procest-procurement-tender-management, procest-procurement-contract-lifecycle, procest-procurement-supplier-management, procest-procurement-evaluation-award, openregister (lifecycle + aggregations + audit + notifications + retention per ADR-022), docudesk (UEA/EML PDF rendering) + +## ADDED Requirements + +### REQ-PCC-001: Drempelbedragen SHALL be a `ProcurementThreshold` register, not hardcoded enums + +EU + nationale drempelbedragen (procurement thresholds) MUST be +seeded as a `ProcurementThreshold` register, not as constants in PHP. +This lets operators apply the European Commission's biannual revisions +without a code change (a fleet-wide lesson from ADR-031: rate-like +seed data belongs in registers). + +Schema.org annotation: `schema:MonetaryAmountDistribution`. + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `code` | string | Yes | e.g. `eu-werken`, `eu-leveringen`, `eu-diensten-klassiek`, `eu-diensten-speciale-sectoren`, `eu-concessies`, `nl-werken-sub`, `nl-leveringen-sub`, `nl-diensten-sub` | +| `amount` | number | Yes | Threshold in EUR (excl. BTW) | +| `regime` | enum | Yes | `klassiek`, `speciale-sectoren`, `concessies`, `nationaal` | +| `category` | enum | Yes | `werken`, `leveringen`, `diensten`, `sociale-en-andere-specifieke-diensten` | +| `effectiveFrom` | date | Yes | Start of period (typically `2026-01-01` / `2028-01-01`) | +| `effectiveTo` | date | No | End of period (null = current) | +| `sourceReference` | string | No | URL to the Commission Delegated Regulation or VNG/PIANOo announcement | + +Seed source: `lib/Settings/seeds/procurement-thresholds-2026-2027.json`. + +Statutory framing: Aw 2012 art. 2.1 + 3.4 (drempelbedragen); EU +Verordening 2019/1828 (and its biannual successors) sets the actual +values. + +#### Scenario: A threshold is editable without a deploy + +- **GIVEN** the European Commission publishes new thresholds for the + 2028-2029 period +- **WHEN** the operator adds a `ProcurementThreshold` record with + `effectiveFrom: 2028-01-01` +- **THEN** new tender procedure recommendations MUST consult the new + thresholds without a procest code change. + +### REQ-PCC-002: Procedure-type recommendation SHALL be a declarative calculation on the Tender register + +The `Tender` schema MUST declare an `x-openregister-calculations` +field `recommendedProcedureType` that, given `estimatedValue` + +`regime` + `category` and the matching `ProcurementThreshold` records, +returns the legally-mandated minimum procedure type (e.g. +`europees-openbaar`, `nationaal-meervoudig-onderhands`). + +Procest MUST NOT author `ProcurementProcedureService::recommend()` — +per ADR-031 this is the calculation anti-pattern. + +The operator MAY override; the override MUST be captured in audit +context with a justification field, and SHOULD trigger a notification +to the compliance officer. + +#### Scenario: A €250k werken tender is recommended onderhands + +- **GIVEN** the seed thresholds (`nl-werken-sub: 1.500.000` EUR + drempel for nationaal regime) +- **WHEN** an operator creates a tender with `estimatedValue: 250000`, + `regime: klassiek`, `category: werken` +- **THEN** `recommendedProcedureType` MUST resolve to + `meervoudig-onderhands` (national sub-threshold per ARW 2016). + +#### Scenario: An overridden recommendation notifies compliance + +- **GIVEN** a €5M leveringen tender (EU regime mandated) +- **WHEN** the operator sets `procedureType: enkelvoudig-onderhands` + with a justification +- **THEN** the save MUST succeed AND a notification MUST be + dispatched to the `procurement-compliance-officer` group with the + justification text. + +### REQ-PCC-003: UEA SHALL be modelled as a `UeaDeclaration` register, not a PDF blob + +The Uniform European Self-Declaration (UEA / ESPD, Annex 2 of EU +Verordening 2016/7) MUST be modelled as a structured `UeaDeclaration` +register. The PDF rendering for download/submission is a docudesk +artifact; the structured data is the canonical record. + +Schema.org annotation: `schema:DigitalDocument` with the structured +fields treated as `schema:Dataset` payload. + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `supplier` | string | Yes | FK to `Supplier` | +| `tender` | string | No | FK to `Tender` (NULL = standing declaration valid for multiple tenders within 6 months per UEA rules) | +| `partA` | object | Yes | Information concerning the procurement procedure and contracting authority | +| `partB` | object | Yes | Information about the economic operator | +| `partC` | object | Yes | Selection criteria (per Aw 2012 art. 2.86–2.94) | +| `partD` | object | Yes | Grounds for exclusion (uitsluitingsgronden) | +| `partE` | object | No | Information about subcontractors | +| `partF` | object | No | Reliance on capacities of other entities | +| `signedAt` | datetime | Yes | When the declaration was signed | +| `signedBy` | string | Yes | UID or external signature ref | +| `validUntil` | date | Yes | Auto-derived: `signedAt + 6 months` | +| `state` | enum | Yes | `draft`, `signed`, `submitted`, `verified`, `rejected`, `expired` | + +#### Scenario: A UEA is reusable across multiple tenders within validity + +- **GIVEN** a supplier signs a UEA with `tender: null`, + `validUntil: 2026-12-01` +- **WHEN** the supplier participates in three different tenders before + `2026-12-01` +- **THEN** all three tenders' EVA spec MUST be able to verify the + same UEA record — without a new declaration per tender. + +### REQ-PCC-004: EML-bestand (Eigen Verklaring) SHALL be modelled as a downloadable export from the UEA register + +For NL-domestic operators using the older `Eigen Verklaring` +mechanism (still accepted under Aw 2012 art. 2.86 for sub-threshold), +procest MUST expose an EML-bestand XML export derived from the +`UeaDeclaration` register. The export MUST be a declarative output +(OR's `x-openregister-export` or equivalent — a docudesk template +also acceptable), NOT a `EmlBestandExportService`. + +#### Scenario: EML export carries the structured fields + +- **GIVEN** a signed `UeaDeclaration` +- **WHEN** the operator triggers EML export +- **THEN** the resulting XML MUST contain the structured field + payload; the procest call path MUST contain no XML-templating PHP. + +### REQ-PCC-005: Compliance KPI dashboard SHALL be declarative widgets, not a `ComplianceReportService` + +The compliance officer dashboard MUST be declared as +`x-openregister-widgets` blocks on the relevant registers covering: + +- `tenders-on-or-above-eu-threshold` — count + ratio of tenders where + `estimatedValue >= matched ProcurementThreshold.amount`, + cross-referenced against actual `procedureType` (flags overrides). +- `contracts-exceeding-mantelovereenkomst-duration` — contracts + beyond 4 years (Aw 2012 art. 2.140); flags require operator-supplied + justification (lifecycle guard). +- `awards-without-publication` — `definitief-gegund` tenders missing + a publication of the gunningsbericht within 30 days (Aw 2012 art. + 2.130). +- `suppliers-excluded` — count + reasons; per OR `audit-trail-immutable` + the per-exclusion decision lineage is preserved. +- `maverick-spend` — `[future]` integration with financeq: contracts + in effect without a procest source tender, where applicable. + +Procest MUST NOT author `ComplianceReportService` — +per ADR-031 this is the aggregation + widget anti-pattern. + +#### Scenario: An award without timely publication surfaces in the dashboard + +- **GIVEN** a tender awarded 35 days ago without a publication ref + set on the gunningsbesluit +- **WHEN** the compliance dashboard renders +- **THEN** the tender MUST appear in `awards-without-publication` + with the days-overdue field calculated declaratively. + +### REQ-PCC-006: Compliance notifications SHALL be declarative per ADR-031 + +The relevant schemas MUST declare `x-openregister-notifications` +covering: + +- `procedure-override` — when an operator overrides + `recommendedProcedureType`; recipients: compliance-officer group. +- `mantelovereenkomst-aging` — at year 3 of a `mantelovereenkomst` + contract; recipients: contract owner + compliance-officer. +- `publication-missing` — at day 30 after `definitief-gegund` if no + publication; recipients: tender inkoper + compliance-officer. +- `uea-expiring` — 30 days before `UeaDeclaration.validUntil`; + recipients: supplier primaryContact + relevant tender inkopers. + +Procest MUST NOT author `ComplianceNotificationService`. + +#### Scenario: An overridden procedure recommendation fires a single notification + +- **GIVEN** the override REQ-PCC-002 scenario +- **WHEN** the save commits +- **THEN** exactly one `procedure-override` notification MUST be + dispatched (idempotency MUST prevent re-fire on subsequent edits + to unrelated fields). + +### REQ-PCC-007: Compliance pages SHALL be reachable through the procest manifest navigation + +`src/manifest.json` MUST declare: + +- a navigation entry `Procurement > Compliance` (`type: dashboard`) + rendering the widgets declared in REQ-PCC-005, restricted to the + `procurement-compliance-officer` and `procurement-admin` roles via + the manifest's visibility predicate; +- a navigation entry `Procurement > UEA declarations` (`type: index`) + binding to `UeaDeclaration`; +- a navigation entry `Procurement > Thresholds` (`type: index`) + binding to `ProcurementThreshold` (admin-only). + +All renderers MUST be the generic `@conduction/nextcloud-vue` page +renderers per ADR-024 Tier-4. Per-role visibility is the manifest's +job — no per-page controller. + +#### Scenario: The compliance dashboard is hidden for non-compliance roles + +- **GIVEN** a user with role `procurement-officer` only +- **WHEN** they open the procest main menu +- **THEN** the `Compliance` entry MUST NOT appear. diff --git a/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-contract-lifecycle/spec.md b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-contract-lifecycle/spec.md new file mode 100644 index 00000000..858c2c66 --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-contract-lifecycle/spec.md @@ -0,0 +1,263 @@ +# Spec: procest-procurement-contract-lifecycle + +**Status:** proposed +**Scope:** procest +**Tier:** procurement-suite +**Depends on:** case-management, case-types, workflow-engine-abstraction, roles-decisions, parafering-actions (for signature endorsement routes), procest-procurement-supplier-management (Supplier ref), openregister (lifecycle + aggregations + notifications + retention per ADR-022), docudesk (contract documents + signed PDFs), openconnector (e-signature provider + Peppol) + +## ADDED Requirements + +### REQ-CLM-001: The system SHALL store contracts as an OpenRegister-managed `Contract` register + +Contracts MUST be declared as a register in +`lib/Settings/procest_register.json` per ADR-024, with the `Contract` +schema as the canonical entity. No custom PHP model, no custom +database table, no parallel storage (ADR-022 anti-pattern list +applies). + +Schema.org annotation: `schema:Action` with `actionType: +schema:OrganizeAction` (a contract is the formalisation of a +multi-party action with obligations). The contract document itself is +`schema:DigitalDocument` and lives in docudesk; the `Contract` +register is the *case-side metadata*. + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `title` | string | Yes | Display name | +| `contractNumber` | string | Yes | Operator-assigned identifier, unique per administration | +| `supplier` | string | Yes | FK to `Supplier` UUID | +| `caseId` | string | Yes | FK to the contract-as-case (`caseType: contract-lifecycle`) | +| `contractType` | enum | Yes | `mantelovereenkomst`, `raamovereenkomst`, `nadere-overeenkomst`, `bestelovereenkomst`, `dienstverleningsovereenkomst`, `licentie`, `huur`, `sla`, `dpa` | +| `effectiveFrom` | date | Yes | Start of obligations | +| `effectiveUntil` | date | No | End of fixed term (null = indefinite) | +| `renewalPolicy` | enum | Yes | `none`, `auto`, `manual`, `tacit` (stilzwijgende verlenging — flagged for Wet van Dam compliance) | +| `noticePeriodDays` | integer | No | Required to declare for `auto` and `tacit` policies | +| `valueAmount` | number | No | Total contract value (informational only — `[future]` financeq owns the money side) | +| `currency` | string | No | ISO 4217 | +| `obligations` | array | No | Operator-declared key obligations (free-text) | +| `slaTargets` | array | No | Each: `metric`, `threshold`, `breachConsequence` | +| `signedDocumentRef` | string | No | docudesk URI of the signed PDF (set on `signed` transition) | +| `parafeerrouteId` | string | No | FK to a procest `parafeerroute` for the signature endorsement chain | +| `state` | enum | Yes | `draft`, `negotiation`, `awaiting-signature`, `signed`, `in-effect`, `pending-renewal`, `terminated`, `expired`, `archived` | +| `terminationReason` | string | No | Set on `terminated` transition | +| `renewalRemindersSentAt` | array | No | Audit-trail-readable list of reminder timestamps | + +Statutory framing: Wet van Dam (stilzwijgende verlenging) — `tacit` +renewal contracts MUST surface noticePeriod warnings; Aw 2012 art. +2.140 (looptijd raamovereenkomst) — public-sector mantelovereenkomsten +cap at 4 years unless justified. + +#### Scenario: A contract is created via OR's generic API + +- **GIVEN** procest is installed and the `Contract` schema is loaded +- **WHEN** an authenticated `contract-manager` POSTs a new contract to + `/index.php/apps/openregister/api/objects/procest/Contract` +- **THEN** the save MUST succeed via OR's generic endpoint, with no + procest-side controller in the call path. + +#### Scenario: Reviewer confirms no parallel storage + +- **GIVEN** the procest codebase +- **WHEN** scanned for `lib/Db/` Mapper classes naming `contract_`, + `overeenkomst_`, or `mantel_` +- **THEN** no such classes SHALL exist; all contract data flows + through the OR object API. + +### REQ-CLM-002: Each contract SHALL be governed by a `contract-lifecycle` case-type + +Procest MUST seed a `caseType` named `contract-lifecycle` (Schema.org +`schema:Project`). Every contract MUST have an associated case +(its `Contract.caseId`); the case is where workflow steps (intake, +risk review, legal review, signature collection, renewal review, +termination) play out using procest's existing +`workflow-engine-abstraction` and `process-step-configuration` +capabilities. No new workflow engine. + +The case type ships with a default `workflowTemplate` named +`standard-contract-flow` (declared as data in +`lib/Settings/procest_register.json` seeds — not as PHP). Operators +customise per organisation via the existing visual workflow editor. + +#### Scenario: A contract case opens with the seeded workflow + +- **GIVEN** the seeded `contract-lifecycle` case type and its default + workflow template +- **WHEN** a contract manager creates a new contract +- **THEN** an associated case MUST open in the `intake` status with + the workflow template bound; the contract's `caseId` MUST be set + before the contract save returns. + +### REQ-CLM-003: The `Contract` lifecycle SHALL be declarative per ADR-031 + +The `Contract` schema MUST declare an `x-openregister-lifecycle` +block: + +| From | To | Trigger | Guard | +|---|---|---|---| +| `draft` | `negotiation` | operator action | supplier MUST be in state `active` or `prospect` (warning) | +| `negotiation` | `awaiting-signature` | contract case reaches `awaiting-signature` status | `parafeerrouteId` MUST be set; supplier MUST be `active` | +| `awaiting-signature` | `signed` | OpenConnector event from `e-signature` source | `signedDocumentRef` MUST be set | +| `signed` | `in-effect` | scheduled — when `today >= effectiveFrom` | none (automatic) | +| `in-effect` | `pending-renewal` | scheduled — when `today >= effectiveUntil - noticePeriodDays` AND `renewalPolicy != none` | `renewalPolicy` MUST be set | +| `pending-renewal` | `in-effect` | operator action (renewal approved) | new `effectiveUntil` set | +| `pending-renewal` | `terminated` | operator action (renewal declined) | `terminationReason` MUST be set | +| `in-effect` | `terminated` | operator action (early termination) | `terminationReason` MUST be set | +| `in-effect` | `expired` | scheduled — when `today > effectiveUntil` AND no renewal triggered | none | +| `terminated` | `archived` | retention sweep | retention period elapsed | +| `expired` | `archived` | retention sweep | retention period elapsed | + +Per ADR-031, procest MUST NOT author `ContractService::transition*` +or `ContractRenewalService` methods. + +The scheduled transitions (`signed → in-effect`, `in-effect → +pending-renewal`, `in-effect → expired`) MUST be backed by OR's +`ScheduledWorkflow` per ADR-031 §"Background jobs that orchestrate +external systems" — not by a per-app `OverdueContractsJob`. + +#### Scenario: A direct write to `state: "signed"` is rejected + +- **GIVEN** any actor +- **WHEN** they attempt to save a contract with `state: "signed"` via + the generic OR API without going through the lifecycle +- **THEN** the save MUST fail with a "lifecycle transition required" + error. + +#### Scenario: An expired tacit contract is auto-renewed only if policy allows + +- **GIVEN** a contract with `renewalPolicy: "tacit"`, + `effectiveUntil: 2026-12-31`, `noticePeriodDays: 60` +- **WHEN** the date reaches `2026-11-01` +- **THEN** the contract MUST transition to `pending-renewal` AND + three notifications (REQ-CLM-006) MUST fire before `2026-12-31`. + +### REQ-CLM-004: Signature collection SHALL be delegated to OR's e-signature integration (ADR-019) + +The `awaiting-signature → signed` transition MUST consume an +OpenConnector source named `e-signature` (ADR-019 pluggable +integration). Concrete provider rows (DocuSign, ValidSign, KSeF, native +Nextcloud PDF sign, ...) land via a separate openconnector change. +Procest MUST NOT author a `DocuSignClient`, `ValidSignService`, or +any signature HTTP wrapper — that is the ADR-019 anti-pattern. + +The signing-package itself uses procest's existing +`parafering-actions` capability: the contract's `parafeerrouteId` +declares an endorsement route over internal signers (CFO, juridisch, +inkoper) before the supplier-side e-signature step. The external +e-signature event closes the route. + +#### Scenario: A signed event from OpenConnector closes the route + +- **GIVEN** a contract in `awaiting-signature` whose parafeerroute has + collected all internal signatures and the supplier has signed via + the configured e-signature provider +- **WHEN** the provider emits the `signed` CloudEvent through the + OpenConnector source +- **THEN** the contract MUST transition to `signed`, the + `signedDocumentRef` MUST be set from the event payload, and the + audit trail MUST record both the route closure and the external + signature event. + +#### Scenario: Reviewer scans for forbidden HTTP + +- **GIVEN** the procest codebase post-implementation +- **WHEN** scanned for `curl_init`, `GuzzleHttp\Client`, or hardcoded + `docusign.net` / `validsign.eu` URLs in `lib/` +- **THEN** no matches SHALL exist (the openconnector source is the + only path). + +### REQ-CLM-005: Contract documents SHALL live in docudesk, referenced by URI + +The signed PDF, the negotiated drafts, the supplier's countersigned +copy, and any annexes MUST be stored in docudesk and referenced from +the `Contract` register by URI (per ADR-022 — docudesk owns documents). + +Procest MUST NOT define a `lib/Service/ContractDocumentService.php` +that stores PDF bytes in its own table — that is the parallel-storage +anti-pattern. + +#### Scenario: The signed PDF is fetched via docudesk + +- **GIVEN** a `signed` contract +- **WHEN** an operator opens the contract detail page +- **THEN** the document MUST be fetched from `docudesk` via the URI in + `signedDocumentRef`; procest's code path MUST NOT contain the PDF + bytes. + +### REQ-CLM-006: Contract notifications SHALL be declarative per ADR-031 + +The `Contract` schema MUST declare `x-openregister-notifications` +covering at minimum: + +- `renewal.upcoming` — fires at `effectiveUntil - noticePeriodDays`, + again 30 days before, again 7 days before, again on the day; + recipients: contract owner + procurement-management group. +- `renewal.window-missed` — fires the day after `effectiveUntil` if + the contract has not transitioned out of `pending-renewal`; + recipients: contract owner + procurement-management group + (for + `tacit` policy) compliance-officer group. +- `sla-breach` — fires when an `slaTargets` threshold is breached + (calculated from delivery + customer-contact aggregations); + recipients: contract owner. +- `termination` — fires on `terminated`; recipients: supplier + primaryContact + contract owner + procurement-management group. + +Procest MUST NOT author `ContractNotificationService` — per ADR-031 +this is the exact notification anti-pattern. + +#### Scenario: A renewal-window-missed notification fires once + +- **GIVEN** a contract in `pending-renewal` with `effectiveUntil: + 2026-12-31` +- **WHEN** the date becomes `2027-01-01` and no renewal transition + has occurred +- **THEN** exactly one `renewal.window-missed` notification MUST be + dispatched (the engine's idempotency MUST prevent duplicates). + +### REQ-CLM-007: Contract analytics SHALL be derived via `x-openregister-aggregations` and exposed via widgets + +Common contract dashboards (open contracts by supplier, expiring in +next 90 days, value at risk, renewal-policy mix) MUST be expressed as +`x-openregister-aggregations` + `x-openregister-widgets` blocks on +the `Contract` schema. + +Procest MUST NOT author `ContractAnalyticsService` or +`ContractStatsService`. + +The widgets are consumed by procest's existing dashboard +capability — no per-widget Vue component is needed. + +#### Scenario: A dashboard widget reads aggregations directly + +- **GIVEN** the seeded `contracts-expiring-soon` widget +- **WHEN** the dashboard renders +- **THEN** the widget MUST display the count of contracts with + `effectiveUntil < today + 90` AND `state IN (in-effect, + pending-renewal)`, computed via aggregation — no per-app code path. + +### REQ-CLM-008: Contract registers SHALL be reachable through the procest manifest navigation + +`src/manifest.json` MUST declare: + +- a navigation entry `Procurement > Contracts` with `type: index` + binding to `Contract`; +- a `type: detail` page for individual contracts, including side + panels for: linked supplier, parafeerroute progress (reusing + existing parafering UI), linked obligations + SLAs, document + attachments (from docudesk via OR `object-interactions`); +- a navigation entry `Procurement > Renewals` filtered to + `state IN (pending-renewal)`; +- a navigation entry `Procurement > Contract dashboard` rendering the + widgets declared in REQ-CLM-007. + +All renderers MUST be the generic `@conduction/nextcloud-vue` page +renderers per ADR-024 Tier-4. + +#### Scenario: The renewals page lists pending-renewal contracts + +- **GIVEN** the manifest declares the renewals page with + `filter: { state: ["pending-renewal"] }` +- **WHEN** a contract manager opens + `/index.php/apps/procest/contracts/renewals` +- **THEN** the page MUST render via `CnIndexPage` showing only + contracts whose state matches the filter — no procest-side filter + controller is invoked. diff --git a/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-evaluation-award/spec.md b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-evaluation-award/spec.md new file mode 100644 index 00000000..7a3da27c --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-evaluation-award/spec.md @@ -0,0 +1,205 @@ +# Spec: procest-procurement-evaluation-award + +**Status:** proposed +**Scope:** procest +**Tier:** procurement-suite +**Depends on:** case-management, roles-decisions, besluitvorming-workflow, parafering-actions, procest-procurement-tender-management (Tender + Bid refs), openregister (lifecycle + aggregations + audit per ADR-022), docudesk (gunningsbericht, rejection letters, motivering) + +## ADDED Requirements + +### REQ-EVA-001: Scoring SHALL be an `Evaluation` register attached to a Bid; the existing procest `decision` register SHALL carry the award + +To avoid duplicating procest's existing `decision` register, this spec +splits the surface in two: + +1. **Evaluation work product** — modelled as a new `Evaluation` + register (Schema.org `schema:AssessAction`), one record per bid + per evaluator (so a 3-evaluator panel produces 3 records per bid). +2. **Award outcome** — reuses procest's *existing* `decision` register + with additive fields for procurement (no new "Award" register). + +The `Evaluation` schema: + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `bid` | string | Yes | FK to `Bid` UUID | +| `tender` | string | Yes | FK to `Tender` UUID (denormalised for query) | +| `evaluator` | string | Yes | UID of the evaluator | +| `criterionScores` | object | Yes | Per-`awardCriteria[i].key` score (numeric) + narrative | +| `weightedTotal` | number | No | Calculated via `x-openregister-calculations` from criterionScores + tender's awardCriteria weights | +| `state` | enum | Yes | `draft`, `submitted`, `calibrated`, `finalised`, `withdrawn` | +| `calibrationNotes` | string | No | Captured during panel calibration session | +| `attachments` | array | No | docudesk URIs of supporting evidence (e.g. evaluator's narrative report) | + +Procest MUST NOT define an `Award` register that mirrors `decision`. + +#### Scenario: A bid receives one Evaluation per panel member + +- **GIVEN** a tender with three evaluators in role `beoordelaar` +- **WHEN** evaluation begins on a bid +- **THEN** three `Evaluation` records MUST be created (one per + evaluator), each with state `draft`. + +#### Scenario: Reviewer confirms no duplicate Award register + +- **GIVEN** the procest register file +- **WHEN** inspected for a schema named `Award`, `Gunning`, or + `Gunningsbesluit` +- **THEN** no such schema SHALL exist; awards are recorded as + `decision` records with `decisionType: "gunningsbesluit"`. + +### REQ-EVA-002: Award decisions SHALL be procest `decision` records with seeded `decisionType`s + +Procest MUST seed three `decisionType` records for procurement: + +| decisionType | Purpose | +|---|---| +| `voorlopige-gunning` | Preliminary award — triggers tender's `beoordeling → voorlopige-gunning` transition | +| `gunningsbesluit` | Final award — triggers tender's `standstill → definitief-gegund` transition after standstill | +| `afwijzingsbesluit` | Rejection decision for a non-winning bid; carries motivering per Aw 2012 art. 2.130 | + +Additive fields on the `decision` register (via additive register +patch — see proposal "Impact" section, no rename): + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `awardedBid` | string | No | FK to the winning `Bid` (for voorlopige-gunning + gunningsbesluit) | +| `rejectedBid` | string | No | FK to the rejected `Bid` (for afwijzingsbesluit) | +| `motivering` | string | No | Motiveringsplicht text (Aw 2012 art. 2.130 / Awb art. 3:46) | +| `standstillEndDate` | date | No | Auto-computed on voorlopige-gunning; mirrors `Tender.standstillEndDate` | +| `lot` | string | No | FK to a lot child case if award is per-lot | + +#### Scenario: A voorlopige-gunning decision triggers the tender transition + +- **GIVEN** a tender in `beoordeling` with all bids evaluated +- **WHEN** the procurement officer creates a `decision` of type + `voorlopige-gunning` referencing the winning bid +- **THEN** the `Tender` lifecycle MUST advance to + `voorlopige-gunning`, `Tender.standstillEndDate` MUST be set, and + the audit trail MUST link the decision to the lifecycle event. + +#### Scenario: A rejection decision carries motivering + +- **GIVEN** a tender with five bids, one winner +- **WHEN** the procurement officer creates `afwijzingsbesluit` + records for the four non-winners +- **THEN** each rejection MUST carry a non-empty `motivering` field; + saves with empty motivering MUST fail validation. + +### REQ-EVA-003: Scoring formulas SHALL be data, not code + +The scoring formula per `awardCriteria[i]` (linear, relatieve +prijsformule, S-curve, pass/fail) MUST be declared as part of the +tender's `awardCriteria[i].scoringMethod` field — interpretable via +an OR `x-openregister-calculations` formula reference. + +Procest MUST NOT author a `ScoringFormulaService` switch statement +hardcoding formula behaviour. A formula registry register +(`ScoringFormula`) SHOULD be seeded with the common methods; operators +MAY add custom formulas via the register. + +#### Scenario: Switching a formula does not require a code change + +- **GIVEN** a procurement officer wants to add a new "weighted + geometric mean" formula +- **WHEN** they add a `ScoringFormula` record carrying the formula + expression +- **THEN** new tenders MUST be able to reference the new formula via + `awardCriteria[i].scoringMethod`; no procest PHP changes. + +### REQ-EVA-004: Calibration sessions SHALL be a child case under the tender, reusing existing procest case machinery + +A calibration session (where evaluators reconcile divergent scores) +MUST be a procest child case under the tender (`caseType: +tender-calibration`), inheriting all standard case behaviour +(meeting scheduling via NC Calendar, role assignment, minutes via +docudesk). The session's outcome MUST move associated `Evaluation` +records from `submitted` to `calibrated`. + +Procest MUST NOT author a `CalibrationSessionService` parallel to +`case`. + +#### Scenario: A calibration session updates linked evaluations + +- **GIVEN** a calibration child case is closed with `result: agreed` +- **WHEN** the closure transition fires +- **THEN** the `Evaluation` records referenced from the session MUST + transition `submitted → calibrated` via OR's lifecycle relations, + not via a per-app sync method. + +### REQ-EVA-005: Standstill (Alcatel-termijn) SHALL be enforced via the declarative lifecycle, not a guard service + +The `Tender` lifecycle's `standstill → definitief-gegund` transition +(per TND spec REQ-TND-002) is the only gate; this spec restates the +EVA-side requirement: the `gunningsbesluit` decision MUST NOT be +finalisable before `standstillEndDate`, and any bezwaar case opened +during standstill MUST further block the transition. + +Procest MUST NOT author an `AwardFinalisationGuard` PHP class beyond +the small `requires` guard called *by* the lifecycle engine (per +ADR-031 §"PHP guards remain a legitimate seam"). + +#### Scenario: A premature gunningsbesluit is blocked + +- **GIVEN** a tender with `standstillEndDate: 2026-04-21` +- **WHEN** an officer attempts to create `gunningsbesluit` on + `2026-04-15` +- **THEN** the save MUST fail with a guard violation citing the + remaining standstill days. + +#### Scenario: An open bezwaar blocks gunningsbesluit past standstill + +- **GIVEN** a tender past `standstillEndDate` with a linked bezwaar + case in state `behandeling` +- **WHEN** an officer attempts to create `gunningsbesluit` +- **THEN** the save MUST fail; the audit trail MUST cite the open + bezwaar reference. + +### REQ-EVA-006: Award documents SHALL be generated by docudesk, not by procest + +The voorlopig + definitief gunningsbericht, the afwijzingsbrieven met +motivering, and the publishable gunningsverslag MUST be generated by +docudesk (using docudesk's template engine — registered templates +seeded as part of the EVA implementation chain). Procest MUST NOT +author a `GunningPdfService`, `RejectionLetterService`, or +`MotiveringRenderer`. + +The decision register MUST carry a `documentRef` field (already +present in procest's existing `decision` schema as +`decisionDocument`) populated with the docudesk-generated URI. + +#### Scenario: Reviewer scans for forbidden PDF generation in procest + +- **GIVEN** the procest codebase +- **WHEN** scanned for `TCPDF`, `Mpdf`, `Dompdf`, or `wkhtmltopdf` in + `lib/` related to award documents +- **THEN** no matches SHALL exist; docudesk owns rendering. + +### REQ-EVA-007: Evaluation + award pages SHALL be reachable through the procest manifest navigation + +`src/manifest.json` MUST declare: + +- a navigation entry `Procurement > Evaluations` (`type: index`) + binding to `Evaluation`; +- a `type: detail` page for individual evaluations with the panel + view (all evaluators' scores side by side, calibration delta + surfaced); +- a navigation entry `Procurement > Awards` filtered to `decision` + records with `decisionType IN ("voorlopige-gunning", + "gunningsbesluit")`; +- a navigation entry `Procurement > Evaluation dashboard` rendering + `x-openregister-widgets` (in-progress evaluations, awaiting + calibration count, awarded-vs-rejected rate). + +All renderers MUST be the generic `@conduction/nextcloud-vue` page +renderers per ADR-024 Tier-4. Per-evaluator drill-down MUST be +gated by OR RBAC so an evaluator only sees their own draft scores +until calibration. + +#### Scenario: An evaluator sees only their own drafts + +- **GIVEN** an evaluator opens their dashboard while a panel is in + `submitted` state +- **WHEN** the evaluation index renders +- **THEN** only the evaluator's own evaluations MUST be visible; OR + RBAC enforces the scope. diff --git a/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-publication-platform/spec.md b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-publication-platform/spec.md new file mode 100644 index 00000000..3834c75e --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-publication-platform/spec.md @@ -0,0 +1,194 @@ +# Spec: procest-procurement-publication-platform + +**Status:** proposed +**Scope:** procest +**Tier:** procurement-suite +**Depends on:** procest-procurement-tender-management (Tender ref), procest-procurement-evaluation-award (award decision ref), procest-procurement-system-integration (PSI slots for transport), openregister (lifecycle + audit + retention per ADR-022), openconnector (TED, TenderNed, national platforms), docudesk (publication renderings) + +## ADDED Requirements + +### REQ-PPP-001: Publication notices SHALL be modelled as a `PublicationNotice` register, separate from `Tender` + +A publication on TED, TenderNed, or a national platform MUST be a +distinct `PublicationNotice` record (Schema.org `schema:PublicationEvent`) +rather than a field on `Tender`. A single tender produces multiple +notices over its lifetime: vooraankondiging (PIN), aankondiging +(prior + actual), wijzigingsbericht (rectification), gunningsbericht, +opdrachtgevingsbericht — each is a separate notice subject to its +own publication state. + +Schema.org annotation: `schema:PublicationEvent`. + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `tender` | string | Yes | FK to `Tender` UUID | +| `award` | string | No | FK to a `decision` of type `gunningsbesluit` (for award notices) | +| `noticeType` | enum | Yes | `vooraankondiging`, `aankondiging`, `rectificatie`, `gunningsbericht`, `concessieaankondiging`, `aankondiging-vrijwillige-transparantie`, `wijziging-opdracht` | +| `targetPlatform` | enum | Yes | `ted-ojeu`, `tenderned`, `mercell`, `negometrix`, `e-procurement-be`, `placsp-es`, `nationale-bekendmaking-overig` | +| `targetPlatformSlot` | string | Yes | PSI slot symbolic name (e.g. `tenderned-tenders`) | +| `eformsCode` | string | No | TED eForms standard form code (F01–F25 legacy, eForms 1..40 modern) | +| `payload` | object | Yes | The structured payload submitted (eForms XML or platform-native JSON) | +| `payloadDocumentRef` | string | No | docudesk URI of the human-readable rendering | +| `publishedAt` | datetime | No | Set on `confirmed` transition | +| `externalRef` | string | No | Platform's own ID (e.g. TED OJEU number, TenderNed publicatienummer) | +| `state` | enum | Yes | `draft`, `submitted`, `confirmed`, `rejected`, `superseded` | +| `supersededBy` | string | No | FK to a later notice that supersedes this one | + +Statutory framing: EU Directive 2014/24/EU art. 49–55 (notice +publication); Verordening 2019/1780 (eForms); national bekendmakingen +per Aw 2012 art. 2.108 + 2.130. + +#### Scenario: One tender carries multiple notices + +- **GIVEN** a tender that progresses publication → rectification → + award +- **WHEN** queried for its `PublicationNotice` records +- **THEN** three records MUST exist (`aankondiging`, `rectificatie`, + `gunningsbericht`), all referencing the same tender UUID. + +#### Scenario: Reviewer confirms no parallel storage + +- **GIVEN** the procest codebase +- **WHEN** scanned for `lib/Db/` Mapper classes naming `publication_`, + `notice_`, `bekendmaking_`, or `aankondiging_` +- **THEN** no such classes SHALL exist; all notice data flows through + the OR object API. + +### REQ-PPP-002: TED eForms standard-form codes SHALL be a `PublicationTemplate` register, not hardcoded enums + +The set of TED eForms / legacy F01–F25 standard form codes — each +with field schema, mandatory-field rules, allowed CPV scope, allowed +procedure types — MUST be seeded in a `PublicationTemplate` register. +Each notice's `payload` MUST validate against its matched template. + +Schema.org annotation: `schema:CreativeWorkSeries`. + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `code` | string | Yes | Standard form code (`F02`, `eForm-12`, `nationale-bekendmaking`, ...) | +| `name` | string | Yes | Human-readable label | +| `applicableNoticeTypes` | array | Yes | Enum values from `PublicationNotice.noticeType` that this template covers | +| `targetPlatform` | enum | Yes | Same enum as the notice — couples template to platform | +| `payloadSchema` | object | Yes | JSON Schema for the `payload` field, derived from the canonical eForms XSD or platform spec | +| `effectiveFrom` | date | Yes | Required because eForms supersedes legacy F01–F25 from `2026-10-25` per EU regulation | +| `effectiveTo` | date | No | Null = current | +| `sourceUrl` | string | No | URL to the eForms regulation or platform spec | + +#### Scenario: A submitted notice with payload not matching its template is rejected + +- **GIVEN** a `PublicationNotice` with `targetPlatform: ted-ojeu`, + `noticeType: aankondiging`, claiming `eformsCode: F02` but missing + the mandatory `procurement-object` block +- **WHEN** the notice transitions `draft → submitted` +- **THEN** OR's schema validation MUST reject the payload citing the + missing block. + +### REQ-PPP-003: The `PublicationNotice` lifecycle SHALL be declarative per ADR-031 + +The `PublicationNotice` schema MUST declare an +`x-openregister-lifecycle` block: + +| From | To | Trigger | Guard | +|---|---|---|---| +| `draft` | `submitted` | operator action | `payload` MUST validate against matched `PublicationTemplate`; `targetPlatformSlot` MUST resolve to an active openconnector source | +| `submitted` | `confirmed` | inbound event from openconnector source | `externalRef` MUST be set from event payload | +| `submitted` | `rejected` | inbound event from openconnector source | rejection reason MUST be captured in audit context | +| `rejected` | `draft` | operator action | none | +| `confirmed` | `superseded` | operator action (when issuing a rectificatie or wijziging) | `supersededBy` MUST be set | + +Per ADR-031, procest MUST NOT author `PublicationNoticeService:: +transition*` methods. + +#### Scenario: A confirmation event sets externalRef + +- **GIVEN** a notice in `submitted` state +- **WHEN** the openconnector source delivers a `publication.confirmed` + CloudEvent carrying the TED OJEU number +- **THEN** the lifecycle MUST transition to `confirmed` and + `externalRef` + `publishedAt` MUST be set from the event payload. + +### REQ-PPP-004: Material changes to a published tender SHALL be a declarative `wezenlijke-wijziging` calculation, surfacing a re-publication recommendation + +When fields on a published `Tender` change (CPV codes, estimated +value moving past a threshold, procedure type, award criteria +weights), a declarative `x-openregister-calculations` field +`isMaterialChange` on `Tender` MUST surface `true`, and procest MUST +recommend publishing a `rectificatie` (or `wijziging-opdracht` after +award) notice. + +Procest MUST NOT author a `MaterialChangeDetectorService` — per +ADR-031 this is the calculation anti-pattern. The threshold rules +(what counts as material) MUST be data — a `MaterialChangeRule` +register seed. + +Statutory framing: Aw 2012 art. 2.163 (wezenlijke wijziging gegunde +overeenkomst); CJEU jurisprudence on material changes during +procedure (case C-454/06 *pressetext*). + +#### Scenario: A CPV code change after publication recommends rectificatie + +- **GIVEN** a published tender with `cpvCodes: ["72200000"]` +- **WHEN** an operator edits cpvCodes to `["72200000", "72300000"]` +- **THEN** `isMaterialChange` MUST resolve to `true`, AND a + notification MUST recommend creating a `rectificatie` notice; + the operator MAY override (with justification captured in audit). + +#### Scenario: A whitespace edit on tender description is not material + +- **GIVEN** the same published tender +- **WHEN** an operator fixes a typo in `description` +- **THEN** `isMaterialChange` MUST resolve to `false`; no recommendation + fires. + +### REQ-PPP-005: Publication SHALL flow through the resolved PSI slot, never a hand-rolled TED or TenderNed client + +The `draft → submitted` transition MUST dispatch the payload via the +openconnector source resolved from `targetPlatformSlot`. Procest MUST +NOT author `TedSubmissionService`, `TenderNedClient`, or any +HTTP-bearing class for publication. Per ADR-019 this is the integration +registry anti-pattern. + +#### Scenario: Reviewer scans for forbidden HTTP + +- **GIVEN** the procest codebase post-implementation +- **WHEN** scanned for `curl_init`, `GuzzleHttp\Client`, or hardcoded + `ted.europa.eu`, `simap.europa.eu`, `tenderned.nl`, hostnames in + `lib/` related to publication +- **THEN** no matches SHALL exist; the openconnector source is the + only path. + +#### Scenario: A publication notification dispatch reaches the right slot + +- **GIVEN** a notice with `targetPlatformSlot: tenderned-tenders` in + `draft` +- **WHEN** the operator triggers submit +- **THEN** an OR `ScheduledWorkflow` MUST be dispatched targeting the + `tenderned-tenders` source with the payload; procest's code path + MUST not invoke any HTTP client directly. + +### REQ-PPP-006: Publication pages SHALL be reachable through the procest manifest navigation + +`src/manifest.json` MUST declare: + +- a navigation entry `Procurement > Publications` (`type: index`) + binding to `PublicationNotice`; +- a `type: detail` page for individual notices showing the payload + in human-readable form (rendered via docudesk template against + `payloadDocumentRef`) + the lifecycle state + the external ref; +- a navigation entry `Procurement > Publication templates` (admin- + only) binding to `PublicationTemplate`; +- a side-panel surface on tender detail pages showing all related + publications for the tender, with quick-action buttons to draft + the next applicable notice type. + +All renderers MUST be the generic `@conduction/nextcloud-vue` page +renderers per ADR-024 Tier-4. + +#### Scenario: The publications index filters by platform + +- **GIVEN** the manifest declares the publications page with a + platform-filter facet +- **WHEN** an inkoper opens + `/index.php/apps/procest/publications?targetPlatform=ted-ojeu` +- **THEN** the page MUST render via `CnIndexPage` showing only TED + notices — no procest-side filter controller invoked. diff --git a/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-spend-analytics-integration/spec.md b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-spend-analytics-integration/spec.md new file mode 100644 index 00000000..45558fe9 --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-spend-analytics-integration/spec.md @@ -0,0 +1,153 @@ +# Spec: procest-procurement-spend-analytics-integration + +**Status:** proposed +**Scope:** procest (event emitter), mydash (consumer — separate fleet rollout) +**Tier:** procurement-suite +**Depends on:** procest-procurement-supplier-management, procest-procurement-contract-lifecycle, procest-procurement-tender-management, procest-procurement-evaluation-award, openregister (events + webhooks per ADR-022), [future] financeq (referenced as `[future]`, no live dep) + +## ADDED Requirements + +### REQ-PSA-001: Procest SHALL emit procurement-domain CloudEvents; mydash SHALL consume them via runtime GraphQL + +This spec is a **cross-app contract** spec. Procest emits domain +events; mydash consumes them to render the spend-analytics surface. +Per ADR-024 §10 and `feedback_mydash-no-or-dependency.md`, mydash +MUST NOT declare procest, openregister, or financeq as install-time +dependencies — the consumption is runtime-only via OR's GraphQL +endpoint. + +Procest MUST NOT author analytics widgets, dashboards, or KPI +calculations beyond what's needed for the suite's own internal +dashboards (declared in spec-internal `x-openregister-widgets`). +The cross-app analytics surface is mydash's responsibility. + +Procest MUST emit CloudEvents (per OR's existing +`events + webhooks` abstraction) on these domain transitions: + +| Event type | Source register | Emitted when | +|---|---|---| +| `procurement.tender.published` | Tender | lifecycle `voorbereiding → gepubliceerd` | +| `procurement.tender.awarded` | Tender | lifecycle `standstill → definitief-gegund` | +| `procurement.contract.signed` | Contract | lifecycle `awaiting-signature → signed` | +| `procurement.contract.in-effect` | Contract | lifecycle `signed → in-effect` | +| `procurement.contract.expired` | Contract | lifecycle `in-effect → expired` OR `pending-renewal → terminated` | +| `procurement.contract.renewed` | Contract | lifecycle `pending-renewal → in-effect` with extended `effectiveUntil` | +| `procurement.supplier.qualified` | Supplier | lifecycle `onboarding → active` | +| `procurement.supplier.excluded` | Supplier | lifecycle `active|suspended → excluded` | + +Each event MUST carry the OR-canonical CloudEvent envelope (id, +source, specversion, type, subject, time, data) with `data` carrying +the changed object's id + the field delta. Procest MUST NOT author a +parallel event-emitter — the OR engine's notification/event pipeline +is the only path. + +#### Scenario: A signed contract emits an in-effect CloudEvent + +- **GIVEN** a contract in `signed` state with `effectiveFrom` reached +- **WHEN** the lifecycle transitions to `in-effect` +- **THEN** a `procurement.contract.in-effect` CloudEvent MUST appear + on the OR event bus carrying the contract id, supplier id, and + effectiveFrom/effectiveUntil in `data`. + +#### Scenario: Reviewer scans for parallel event mechanisms + +- **GIVEN** the procest codebase +- **WHEN** scanned for `class *EventEmitter*`, `class *EventDispatcher*` + in `lib/Service/` (excluding OR/symfony framework code) +- **THEN** no such procest-specific event-machinery classes SHALL + exist. + +### REQ-PSA-002: Procest SHALL expose a procurement GraphQL schema slice via OR's GraphQL abstraction + +Mydash's spend-analytics widgets MUST query procest data via OR's +GraphQL endpoint (per ADR-022 row "Schema declarative extensions" + +GraphQL exposure). Procest MUST NOT author a custom REST surface for +mydash — the existing OR GraphQL is the only consumer-side contract. + +The procest registers (`Tender`, `Contract`, `Supplier`, `Bid`, +`Evaluation`, `decision` with procurement decisionTypes, +`PublicationNotice`) MUST be GraphQL-queryable with declarative +filters declared in the schema metadata. No bespoke procest GraphQL +resolver code. + +#### Scenario: Mydash queries procest contracts via GraphQL + +- **GIVEN** mydash issues a GraphQL query for `contracts(state: + in-effect, supplier: $supplierId) { id, valueAmount, effectiveUntil }` +- **WHEN** the query resolves +- **THEN** the OR GraphQL endpoint MUST serve the response, gated by + OR RBAC; procest's code path MUST NOT contain a `GraphQLResolver` + class for these queries. + +### REQ-PSA-003: RBAC on the GraphQL contract SHALL be the OR-canonical scope, not a mydash-side bypass + +The roles that grant cross-app procurement read access via mydash +MUST be the same OR roles procest uses internally +(`procurement-officer`, `contract-manager`, +`procurement-compliance-officer`, `procurement-admin`). Mydash MUST +NOT bypass scope by injecting a service-account role. + +Per `feedback_mydash-no-or-dependency.md`, mydash MAY add a +*display-only* alias for these roles (e.g. show "Spend reader" in +mydash UI), but the underlying OR role check is canonical. + +#### Scenario: A user without procurement role gets empty results + +- **GIVEN** a mydash user who is not in any procurement role +- **WHEN** they load the spend-analytics widget querying procest + contracts +- **THEN** the OR GraphQL response MUST be empty (RBAC-filtered); + mydash MUST surface "no data" without a stack trace. + +### REQ-PSA-004: Aggregate spend calculations SHALL forward to `[future]` financeq, not be computed in procest + +Where the analytics widget needs actual posted spend (GL postings, +invoiced amounts, paid amounts), the data MUST come from financeq, +NOT from procest. Procest provides the *commitment* side (contract +valueAmount, tender estimatedValue, award value); financeq provides +the *posting* side. Until financeq exists: + +- procest MUST mark these analytics gaps as `[future]` in the + widget definitions emitted to mydash via the manifest; +- mydash MUST render the gap visibly ("Spend data unavailable — + financeq not yet deployed") rather than silently zero. + +This is the same forward-looking pattern shillinq specs use for +`[future]` financeq references. + +#### Scenario: A mydash widget renders a `[future]` gap + +- **GIVEN** a mydash spend-vs-commitment widget for an `in-effect` + contract with `valueAmount: 100000` +- **WHEN** the widget renders without financeq deployed +- **THEN** the commitment side MUST display `€100.000` and the spend + side MUST display the `[future]` gap label — not zero, not blank. + +### REQ-PSA-005: Procest MUST NOT ship a spend-analytics manifest entry; mydash owns the surface + +`procest/src/manifest.json` MUST NOT declare a `Procurement > +Spend analytics` navigation entry. The spend-analytics surface lives +in mydash (per ADR-024 §10 — mydash is the BI surface for the fleet). + +Procest MAY declare a deep-link convention (OR's `deep link registry`) +so mydash widgets can link back to individual procest objects +(contract detail, tender detail, supplier detail). The deep-link +metadata MUST be declared as schema metadata, not as a per-app deep +link controller. + +#### Scenario: Reviewer confirms no procest-side analytics page + +- **GIVEN** the procest manifest +- **WHEN** scanned for a navigation entry with title containing + "Analytics", "Spend", "Uitgaven", or "Inkoopdashboard" +- **THEN** no such entry SHALL exist in procest's manifest; cross-app + analytics is mydash's surface. + +#### Scenario: A mydash widget deep-links to a procest contract + +- **GIVEN** a mydash spend widget showing the top-10 contracts by + commitment value +- **WHEN** a user clicks one +- **THEN** the link MUST route to + `/index.php/apps/procest/contracts/` via the OR deep-link + registry; mydash MUST NOT hard-code the URL. diff --git a/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-supplier-management/spec.md b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-supplier-management/spec.md new file mode 100644 index 00000000..ff89f95e --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-supplier-management/spec.md @@ -0,0 +1,327 @@ +# Spec: procest-procurement-supplier-management + +**Status:** proposed +**Scope:** procest +**Tier:** procurement-suite +**Depends on:** case-management, case-types, roles-decisions, openregister (RBAC + audit + lifecycle + relations per ADR-022), docudesk (certificates + supplier-uploaded documents), openconnector (supplier portal + KvK/RGS/Peppol lookups) + +## ADDED Requirements + +### REQ-SUP-001: The system SHALL store suppliers as an OpenRegister-managed `Supplier` register + +Suppliers MUST be declared as a register in +`lib/Settings/procest_register.json` per ADR-024, with the `Supplier` +schema as the canonical entity. No custom PHP model, no custom +database table, no parallel storage (ADR-022 anti-pattern list +applies). The register is exposed through OpenRegister's generic CRUD +HTTP surface; procest adds no per-app `SupplierController` for +basic supplier CRUD. + +Schema.org annotation: `schema:Organization` (or +`schema:Person` for individual-trader suppliers — the schema's +`legalForm` field discriminates). + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `name` | string | Yes | Display name (statutaire of handelsnaam) | +| `legalForm` | enum | Yes | `nv`, `bv`, `vof`, `eenmanszaak`, `stichting`, `vereniging`, `cooperatie`, `overheid`, `buitenland`, `anders` | +| `kvkNumber` | string | No | Kamer van Koophandel registration (8 digits) | +| `rsin` | string | No | RSIN (9 digits) — required for NL organisations doing public-sector work | +| `vatNumber` | string | No | EU VAT identifier including country prefix | +| `addresses` | array | Yes | Operator-classified addresses (`registered`, `billing`, `delivery`, `correspondence`) | +| `primaryContact` | string | No | UUID reference to a `contact` (procest existing register) | +| `peppolParticipantId` | string | No | Peppol participant identifier for e-invoicing (PPP eForms — also feeds CLM) | +| `qualificationLevel` | enum | Yes | `unqualified`, `provisional`, `qualified`, `preferred`, `excluded` — operator-set via lifecycle transition | +| `qualificationValidUntil` | date | No | Set automatically when transitioning into `qualified` | +| `bankAccounts` | array | No | IBAN + BIC list, validated against IBAN format | +| `onboardingCaseId` | string | No | UUID of the `Case` (procest `caseType: supplier-onboarding`) currently progressing this supplier | +| `state` | enum | Yes | `prospect`, `onboarding`, `active`, `suspended`, `excluded`, `archived` (lifecycle field — see REQ-SUP-003) | + +Statutory framing: Aanbestedingswet 2012 art. 2.86 (uitsluitingsgronden) ++ EU Directive 2014/24/EU art. 57 (grounds for exclusion) require +suppliers to be qualifiable + auditable + excludable; the schema's +`qualificationLevel` + `state` fields are the data hooks. + +#### Scenario: A supplier is created via OR's generic API + +- **GIVEN** procest is installed and the `Supplier` schema is loaded +- **WHEN** an authenticated `procurement-officer` POSTs a new supplier + to `/index.php/apps/openregister/api/objects/procest/Supplier` +- **THEN** the save MUST succeed via OR's generic endpoint, with no + procest-side controller in the call path. + +#### Scenario: Reviewer confirms no parallel storage + +- **GIVEN** the procest codebase +- **WHEN** scanned for `lib/Db/` Mapper classes naming `supplier_`, + `vendor_`, or `crediteur_` +- **THEN** no such classes SHALL exist; all supplier data flows + through the OR object API. + +### REQ-SUP-002: Supplier onboarding SHALL be modelled as a procest case-type, reusing existing case-management machinery + +Procest MUST seed a `caseType` named `supplier-onboarding` (Schema.org +`schema:Project`) in `lib/Settings/procest_register.json`. The case +type inherits every behaviour from procest's existing +`case-management` and `case-types` capabilities — statusType +configuration, role assignment, deadline tracking, document +attachments, dashboard visibility, my-work integration. No new case +plumbing. + +The onboarding case carries: + +- `caseType: supplier-onboarding` +- `subject`: the prospect supplier's display name +- a `caseObject` link pointing back to the `Supplier` UUID +- standard procest fields (`assignee`, `priority`, `deadline`) + +Required statusType seed: `intake`, `kyc-screening`, +`qualification-review`, `awaiting-supplier-input`, `approved`, +`rejected`, `expired`. Lifecycle is declared via +`x-openregister-lifecycle` on the `Case` schema for `caseType = +supplier-onboarding` — see REQ-SUP-003. + +#### Scenario: A supplier-onboarding case shows up in my-work like any other case + +- **GIVEN** a procurement officer is assigned to a supplier-onboarding + case +- **WHEN** they open the procest my-work dashboard (existing + `my-work` capability) +- **THEN** the case MUST appear with the same columns + actions as + any other case; no per-supplier-onboarding controller is needed. + +#### Scenario: The supplier register is reachable from the onboarding case sidebar + +- **GIVEN** an onboarding case carries `caseObject` pointing at a + `Supplier` +- **WHEN** the operator opens the case detail page (rendered by + procest's existing case-detail renderer) +- **THEN** the linked supplier record MUST appear in the standard + related-objects sidebar (consumed from OR's `object-interactions` + per ADR-022). + +### REQ-SUP-003: The `Supplier` lifecycle SHALL be declarative per ADR-031 + +The `Supplier` schema MUST declare an `x-openregister-lifecycle` +block with these states and transitions: + +- `prospect` — newly entered, no qualification done +- `onboarding` — an `onboardingCaseId` is set and the case is open +- `active` — qualification approved; can be selected for procurement +- `suspended` — temporarily blocked (e.g. open dispute, missing + certificate renewal); CLM blocks new contracts; existing contracts + continue +- `excluded` — permanently blocked per Aw 2012 art. 2.86/2.87 + (uitsluitingsgrond); CLM blocks all new contracts +- `archived` — past retention; read-only + +| From | To | Trigger | Guard | +|---|---|---|---| +| `prospect` | `onboarding` | operator creates onboarding case | `onboardingCaseId` MUST resolve to an open case | +| `onboarding` | `active` | onboarding case reaches `approved` status | `qualificationLevel` MUST be ≥ `qualified` | +| `onboarding` | `prospect` | onboarding case `rejected` (recoverable) | none | +| `active` | `suspended` | operator action | reason MUST be captured in transition audit context | +| `suspended` | `active` | operator action | reason MUST be captured | +| `active` | `excluded` | operator action (after legal review case) | `Decision` of type `uitsluitingsbesluit` MUST exist | +| `suspended` | `excluded` | operator action (after legal review case) | same | +| `excluded` | `archived` | retention sweep | retention period elapsed | +| `active` | `archived` | retention sweep | `qualificationValidUntil` lapsed + no open contracts | + +Per ADR-031 anti-pattern list, procest MUST NOT author a +`SupplierService::transition*` or `SupplierLifecycleService` method. +The lifecycle is the only state machine. + +#### Scenario: A direct write to `state: "excluded"` is rejected + +- **GIVEN** any actor (operator, integration, API client) +- **WHEN** they attempt to save a supplier with `state: "excluded"` + via the generic OR API without going through the lifecycle +- **THEN** the save MUST fail with a "lifecycle transition required" + error. + +#### Scenario: Exclusion requires a documented decision + +- **GIVEN** an `active` supplier +- **WHEN** an operator triggers the `excluded` transition without + a referenced `Decision` of type `uitsluitingsbesluit` +- **THEN** the transition MUST fail with a guard violation; the + audit trail MUST record the failed attempt. + +### REQ-SUP-004: Supplier qualification SHALL be a `SupplierQualification` register backed by configurable questionnaires + +Qualification activities (KYC, financial-health, references, ISO, +SBB certificates, CO2-prestatieladder) MUST be modelled as a +`SupplierQualification` register, not as Supplier fields. Each +qualification record carries: + +Schema.org annotation: `schema:AssessAction`. + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `supplier` | string | Yes | FK to the `Supplier` UUID | +| `questionnaire` | string | Yes | FK to a `QualificationQuestionnaire` register record (operator-defined) | +| `responses` | object | Yes | Operator/supplier-supplied answers | +| `supportingDocuments` | array | No | docudesk URIs of evidentiary uploads (certificates, financial statements) | +| `scorecard` | object | No | Per-question score, computed via `x-openregister-calculations` | +| `outcome` | enum | Yes | `pending`, `passed`, `failed`, `conditional` | +| `validUntil` | date | No | Drives `Supplier.qualificationValidUntil` | +| `reviewedBy` | string | No | UID of the qualification reviewer | +| `reviewedAt` | datetime | No | Timestamp | + +Questionnaires are themselves a register +(`QualificationQuestionnaire`, Schema.org `schema:Questionnaire`) +with versioned question sets; rates and weights are seed data, not +hard-coded enums (ADR-031). + +#### Scenario: A qualification questionnaire is reused across suppliers + +- **GIVEN** a `QualificationQuestionnaire` named "ISO 27001 baseline" +- **WHEN** five new suppliers are onboarded +- **THEN** five `SupplierQualification` records MUST exist, all + pointing at the same questionnaire UUID — no questionnaire content + is duplicated per supplier. + +#### Scenario: An expiring certificate fires a renewal notification + +- **GIVEN** a supplier's qualification has `validUntil: 2026-08-01` + and today is `2026-05-01` +- **WHEN** OR's notification engine evaluates the schema's + `x-openregister-notifications` block (declared per REQ-SUP-007) +- **THEN** a renewal-reminder notification MUST be dispatched to the + supplier's primary contact AND to the procurement officer assigned + to any open contract with this supplier. + +### REQ-SUP-005: Supplier performance SHALL be derived via `x-openregister-aggregations`, not authored as a service + +Supplier performance scorecards (on-time delivery, defect rate, +SLA adherence) MUST be expressed as aggregations on existing OR +registers (`PurchaseOrder` deliveries from CLM, contract SLA +breaches, customer-contact complaints). + +Procest MUST NOT author a `SupplierPerformanceService` that loops +PurchaseOrder objects in PHP — per ADR-031 this is the exact +aggregation anti-pattern. The `Supplier` schema's `scorecard` +calculated field reads aggregations declared at the supplier-level: + +| Calculation | Source | +|---|---| +| `onTimeDeliveryRate` | aggregation over `PurchaseOrder` lines where `supplier == self.id`: `count(deliveredOnTime) / count(*)` over rolling 12 months | +| `defectRate` | aggregation over `PurchaseOrder.qualityIncidents`: `sum(qty_defect) / sum(qty_delivered)` | +| `slaAdherence` | aggregation over `Contract.slaBreaches` from CLM | +| `complaintCount` | aggregation over procest `customerContact` filtered by `supplier == self.id` | + +#### Scenario: A scorecard recomputes on the next delivery + +- **GIVEN** a supplier with `onTimeDeliveryRate: 0.95` +- **WHEN** a new `PurchaseOrder` delivery is recorded late +- **THEN** the next read of the supplier MUST surface the recomputed + rate (without a separate "rebuild scorecards" job). + +#### Scenario: Reviewer scans for the aggregation anti-pattern + +- **GIVEN** the procest codebase +- **WHEN** scanned for `class *SupplierPerformanceService*` or + `class *SupplierScorecardService*` +- **THEN** no such classes SHALL exist. + +### REQ-SUP-006: Supplier portal access SHALL flow through OR RBAC, not a parallel auth surface + +External supplier users (representatives logging in to update profile, +upload certificates, accept POs) MUST be modelled as Nextcloud user +accounts in a dedicated user-group (`procest-supplier-portal`) and +their per-supplier scope MUST be declared via OR's per-object RBAC +(ADR-022 row "Authorization RBAC"). Procest MUST NOT define a +`SupplierPortalAuthService` or store supplier passwords in any +procest table. + +The `Supplier` schema MUST declare an `x-openregister-authorization` +block restricting: + +- portal users to **read** their own `Supplier` record + write a + whitelisted field subset (addresses, primaryContact, + peppolParticipantId, bankAccounts), +- write access to `qualificationLevel`, `state`, `onboardingCaseId` + ONLY to internal `procurement-officer` role, +- read access to other suppliers — forbidden for portal users. + +#### Scenario: A portal user updates their own bank account + +- **GIVEN** a Nextcloud user in group `procest-supplier-portal` linked + to supplier `S1` +- **WHEN** they PATCH `S1.bankAccounts` via the generic OR API +- **THEN** the save MUST succeed. + +#### Scenario: A portal user attempts to read another supplier + +- **GIVEN** the same portal user +- **WHEN** they GET `Supplier/S2` +- **THEN** OR's RBAC MUST return 403; no procest-side guard runs. + +#### Scenario: A portal user attempts to set their own qualificationLevel + +- **GIVEN** the same portal user +- **WHEN** they PATCH `S1.qualificationLevel: "preferred"` +- **THEN** the save MUST fail with a per-field RBAC violation. + +### REQ-SUP-007: Supplier notifications SHALL be declarative per ADR-031 + +The `Supplier` and `SupplierQualification` schemas MUST declare +`x-openregister-notifications` blocks covering: + +- `qualification.expiring` — fires 90 / 30 / 7 days before + `qualificationValidUntil`; recipients: supplier primaryContact + + procurement officer + any officer assigned to open contracts. +- `qualification.expired` — fires on the day; same recipients; + triggers `Supplier.state` recommendation banner to suspend. +- `state.suspended` — recipients: supplier primaryContact + every + internal contract owner with an active contract for this supplier. +- `state.excluded` — recipients: supplier primaryContact + every + internal contract owner + procurement-management group. +- `qualification.outcome` — recipients: supplier primaryContact; + template differs by `outcome` enum. + +Procest MUST NOT author a `SupplierNotificationService` — per ADR-031 +this is the exact notification anti-pattern. + +#### Scenario: An expiring qualification fires three reminders + +- **GIVEN** a `SupplierQualification` with `validUntil: 2026-08-01` +- **WHEN** the engine ticks +- **THEN** notifications MUST be dispatched on `2026-05-03`, + `2026-07-02`, and `2026-07-25` (90/30/7 days prior), each carrying + the same template body with adjusted urgency. + +### REQ-SUP-008: Supplier registers SHALL be reachable through the procest manifest navigation + +`src/manifest.json` MUST declare: + +- a navigation entry `Procurement > Suppliers` with `type: index` + binding to `Supplier`; +- a `type: detail` page for individual suppliers, including a + side-panel listing the supplier's `SupplierQualification` records; +- a navigation entry `Procurement > Supplier onboarding` filtered by + `caseType: supplier-onboarding`, reusing procest's existing case + index renderer; +- a navigation entry `Procurement > Qualification questionnaires` + (admin-only via the manifest's visibility predicate) bound to + `QualificationQuestionnaire`. + +All renderers MUST be the generic `@conduction/nextcloud-vue` page +renderers per ADR-024 Tier-4. Procest MUST NOT author a per-page +Vue component for any of the above. + +#### Scenario: The supplier index lists active suppliers + +- **GIVEN** the manifest declares the supplier pages +- **WHEN** a `procurement-officer` opens `/index.php/apps/procest/ + suppliers` +- **THEN** the page MUST render via `CnIndexPage` showing the + organisation's suppliers with columns (name, qualificationLevel, + state, lastDelivery). + +#### Scenario: An admin-only menu entry is hidden for a non-admin + +- **GIVEN** a user without the `procurement-admin` role +- **WHEN** they open the procest main menu +- **THEN** the `Qualification questionnaires` entry MUST NOT appear + (per the manifest's visibility predicate). diff --git a/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-system-integration/spec.md b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-system-integration/spec.md new file mode 100644 index 00000000..d1c3e9a6 --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-system-integration/spec.md @@ -0,0 +1,157 @@ +# Spec: procest-procurement-system-integration + +**Status:** proposed +**Scope:** procest +**Tier:** procurement-suite +**Depends on:** openconnector (transport — ADR-019 source providers), openregister (integration registry — ADR-019), procest-procurement-supplier-management (Supplier ref), procest-procurement-contract-lifecycle (Contract ref), procest-procurement-tender-management (Tender ref) + +## ADDED Requirements + +### REQ-PSI-001: Procest SHALL declare logical connector slots; openconnector SHALL own transport + +Per ADR-019 + ADR-022, procest MUST NOT author transport code +(`*Client`, `*HttpService`, `curl_init`, `GuzzleHttp\Client`) for any +external procurement system. Procest declares **logical connector +slots** — symbolic names that downstream openconnector source rows +fulfil. The slot is a property of the relevant procest register +(e.g. `Tender.publicationSource: "tenderned-tenders"`); the actual +transport is configured by an operator in openconnector. + +The initial slot catalogue (each slot is a symbolic name; concrete +sources land in the separate `add-openconnector-eu-procurement-sources` +change): + +| Slot | Direction | Purpose | Consumer register | +|---|---|---|---| +| `tenderned-tenders` | out + in | Publish + retrieve TenderNed aankondigingen | Tender, Award | +| `mercell-rfx` | bidirectional | Mercell RFx events + responses | Tender | +| `negometrix-rfx` | bidirectional | Negometrix RFx events + responses | Tender | +| `e-procurement-be` | bidirectional | Belgian Federal Free Market | Tender, Award | +| `placsp-es` | bidirectional | Spanish Plataforma de Contratación del Sector Público | Tender, Award | +| `peppol-orders` | bidirectional | Peppol BIS 3.0 PO + invoice | Contract, [future] financeq | +| `ghx-orders` | bidirectional | GHX healthcare exchange | Contract | +| `kvk-companies` | in | KvK Handelsregister supplier lookup | Supplier | +| `rgs-coa` | in | Referentie Grootboekschema imports | [future] financeq | +| `e-signature` | bidirectional | Generic e-signature provider | Contract | + +#### Scenario: Reviewer scans for forbidden HTTP + +- **GIVEN** the procest codebase +- **WHEN** scanned for `curl_init`, `GuzzleHttp\Client`, + `Http\Client`, or hardcoded `tenderned.nl` / `mercell.com` / + `negometrix.com` / `peppol.eu` / `ghx.com` URLs in `lib/` +- **THEN** no matches SHALL exist; all transport flows through + openconnector sources. + +#### Scenario: A slot resolves at runtime + +- **GIVEN** an operator has registered an openconnector source named + `tenderned-tenders` of type `tender-publication-platform` +- **WHEN** procest dispatches a publish event for a Tender +- **THEN** the dispatch MUST resolve via OR's `ScheduledWorkflow` → + openconnector source lookup, with no per-app HTTP client. + +### REQ-PSI-002: Inbound integration events SHALL flow through OR's integration registry, not a procest webhook controller + +External systems that push to procest (Mercell bid received, TenderNed +publication confirmation, Peppol invoice forwarded, e-signature +completed, KvK record changed) MUST flow inbound via OR's integration +registry (ADR-019) — the openconnector source's inbound webhook +endpoint, OR's CloudEvent dispatcher, then procest's domain handlers. + +Procest MUST NOT define `lib/Controller/*WebhookController.php` for +any of the listed external systems. + +#### Scenario: A Mercell bid-received event updates the Tender + +- **GIVEN** an operator has configured the `mercell-rfx` openconnector + source with an inbound webhook +- **WHEN** Mercell POSTs a `bid.received` event to the openconnector + endpoint +- **THEN** openconnector MUST dispatch a `procurement.bid.received` + CloudEvent on the OR bus; procest's declarative `Bid` lifecycle + MUST consume it via `x-openregister-lifecycle.requires` — no + procest webhook controller is invoked. + +### REQ-PSI-003: Connector slot mapping SHALL be declared as schema metadata, not as code + +Each slot's mapping (which procest event triggers which slot, which +CloudEvent type returns) MUST be declared as `x-openregister-relations` +on the relevant register's schema, referencing the slot symbolic name. +Procest MUST NOT author a `ConnectorRegistryService` that hardcodes +the slot-to-source resolution. + +#### Scenario: A slot mapping is editable in the register file alone + +- **GIVEN** a new external system (e.g. Italian ANAC) needs to be + wired up +- **WHEN** the operator adds a new slot to the relevant register file + + registers an openconnector source +- **THEN** no procest PHP code MUST change. + +### REQ-PSI-004: KvK supplier lookup SHALL be a declarative source enrichment, not a hand-rolled service + +The `Supplier` register's `kvkNumber` field MUST declare an +`x-openregister-calculations` or `x-openregister-enrichment` block +(whichever OR extension currently fits — flag a gap per ADR-031 +exception (1) if the latter doesn't exist yet) consuming the +`kvk-companies` slot to populate `name`, `legalForm`, `addresses[type +== registered]`, and the rsin (where derivable) when a fresh +`kvkNumber` is entered. + +Procest MUST NOT author a `KvkLookupService` HTTP wrapper. + +#### Scenario: Entering a KvK number auto-populates the supplier + +- **GIVEN** an operator enters a new supplier with only `kvkNumber: + "12345678"` +- **WHEN** the save fires +- **THEN** the resulting supplier MUST carry the official `name`, + `legalForm`, and `addresses[registered]` from the KvK source, with + the audit trail recording the source and timestamp. + +### REQ-PSI-005: Outbound integration failures SHALL surface in procest as task signals, not as silent retries + +When an outbound dispatch via an openconnector slot fails terminally +(after openconnector's retry policy), the failure MUST surface as a +procest `Task` (reusing the existing procest `task` register) on the +relevant case (Supplier-onboarding, Contract case, or Tender case) +with title `"Integration failure: "` and the failure payload +in description. + +Procest MUST NOT author a parallel `IntegrationFailureLog` register +— OR's audit trail + the surfaced task carry the operator-visible +narrative. + +#### Scenario: A failed TenderNed publication surfaces as a task + +- **GIVEN** a Tender case where the operator triggered "publish to + TenderNed" and openconnector exhausted its retries +- **WHEN** the terminal failure event fires +- **THEN** a task MUST appear on the Tender case, assigned to the + case's `assignee`, with the failure payload in description. + +### REQ-PSI-006: Integration manifest entries SHALL be admin-only and declarative per ADR-024 + +`src/manifest.json` MUST declare an admin-only navigation entry +`Procurement > Integrations` of `type: custom` that points at OR's +existing integration-registry admin UI (consumed from +`@conduction/nextcloud-vue`'s `CnIntegrationsPage`). Procest MUST NOT +author its own integration-management UI. + +The entry's visibility predicate restricts it to the +`procurement-admin` role. + +#### Scenario: Non-admin users do not see the Integrations entry + +- **GIVEN** a user with role `procurement-officer` (no admin) +- **WHEN** they open the procest main menu +- **THEN** the `Integrations` entry MUST NOT appear. + +#### Scenario: Admin users land on the shared integration UI + +- **GIVEN** a user with role `procurement-admin` +- **WHEN** they click the `Integrations` entry +- **THEN** the page MUST render `CnIntegrationsPage` from + `@conduction/nextcloud-vue` filtered to slot types declared by + procest's procurement suite (no procest-side admin component). diff --git a/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-tender-management/spec.md b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-tender-management/spec.md new file mode 100644 index 00000000..c84aeb04 --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/specs/procest-procurement-tender-management/spec.md @@ -0,0 +1,288 @@ +# Spec: procest-procurement-tender-management + +**Status:** proposed +**Scope:** procest +**Tier:** procurement-suite +**Depends on:** case-management, case-types, deelzaak-support, process-step-configuration, workflow-engine-abstraction, procest-procurement-supplier-management (Supplier ref), procest-procurement-system-integration (PSI slots), openregister (lifecycle + aggregations + audit + retention per ADR-022), docudesk (tender documents, vragen + nota van inlichtingen, gunningsbericht), openconnector (TenderNed, Mercell, Negometrix transport) + +## ADDED Requirements + +### REQ-TND-001: Tenders SHALL be modelled as procest cases (`schema:Project`), not as a parallel domain object + +A tender (aanbesteding, aanbestedingsdossier) MUST be modelled as a +procest `Case` of a seeded `caseType: tender` (Schema.org +`schema:Project`). This reuses procest's existing case-management, +status-transition-engine, role-routing, deadline-tracking, my-work, +doorlooptijd-dashboard, and dashboard capabilities — no new +top-level domain object. + +The tender-specific metadata MUST be carried in a complementary +`Tender` register attached one-to-one to the case, holding fields +that don't belong on the generic `Case`: + +Schema.org annotation: `schema:Demand` (a structured procurement +solicitation is a `Demand` per Schema.org's commerce vocabulary). + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `caseId` | string | Yes | FK to the tender case (one-to-one) | +| `tenderNumber` | string | Yes | Operator-assigned identifier | +| `procedureType` | enum | Yes | `openbaar`, `niet-openbaar`, `mededingingsprocedure-met-onderhandeling`, `concurrentiegerichte-dialoog`, `innovatiepartnerschap`, `onderhandelingsprocedure-zonder-bekendmaking`, `meervoudig-onderhands`, `enkelvoudig-onderhands` (NL Aw 2012 + ARW 2016) | +| `regimeType` | enum | Yes | `europees`, `nationaal-boven-drempel`, `nationaal-onder-drempel`, `sociale-en-andere-specifieke-diensten`, `concessie-werken`, `concessie-diensten` | +| `cpvCodes` | array | Yes | EU Common Procurement Vocabulary codes (8-digit) | +| `nutsCodes` | array | No | NUTS regional codes for delivery location | +| `estimatedValue` | number | No | Excl. BTW; informational — used for drempelbedrag calc | +| `currency` | string | No | ISO 4217 | +| `publicationSource` | string | No | PSI slot (`tenderned-tenders`, `mercell-rfx`, ...) | +| `publicationRef` | string | No | External system reference after publication | +| `lots` | array | No | Per-lot metadata (lots become child cases — REQ-TND-005) | +| `selectionCriteria` | array | No | Per-criterion: label, type, weight, evidence-required (uitsluitingsgronden + geschiktheidseisen) | +| `awardCriteria` | array | No | Per-criterion: label, type (`prijs`, `kwaliteit`, `duurzaamheid`, `levenscycluskosten`), weight, scoringMethod — feeds EVA | +| `timeline` | object | Yes | `publicationDate`, `questionsDeadline`, `bidDeadline`, `awardTargetDate`, `standstillEndDate` (computed — see REQ-TND-006) | +| `state` | enum | Yes | `concept`, `marktconsultatie`, `voorbereiding`, `gepubliceerd`, `inschrijvingen-open`, `beoordeling`, `voorlopige-gunning`, `standstill`, `definitief-gegund`, `ingetrokken`, `mislukt`, `gesloten` | + +Statutory framing: Aanbestedingswet 2012 (Aw 2012), Aanbestedingsbesluit, +ARW 2016 (Aanbestedingsreglement Werken). EU Directives 2014/24/EU +(classieke sector), 2014/25/EU (sectoren), 2014/23/EU (concessies). + +#### Scenario: A tender is a case in my-work like any other + +- **GIVEN** a tender case is created with assignee `inkoper-a` +- **WHEN** that user opens the procest my-work dashboard +- **THEN** the tender case MUST appear with the standard columns; no + per-tender controller is invoked. + +#### Scenario: Reviewer confirms no parallel storage + +- **GIVEN** the procest codebase +- **WHEN** scanned for `lib/Db/` Mapper classes naming `tender_`, + `aanbesteding_`, or `aankondiging_` +- **THEN** no such classes SHALL exist; all tender data flows through + the OR object API. + +### REQ-TND-002: The `Tender` schema SHALL declare the procurement lifecycle declaratively per ADR-031 + +The `Tender` schema MUST declare an `x-openregister-lifecycle` block: + +| From | To | Trigger | Guard | +|---|---|---|---| +| `concept` | `marktconsultatie` | operator action | none | +| `concept` | `voorbereiding` | operator action | none | +| `marktconsultatie` | `voorbereiding` | operator action | none | +| `voorbereiding` | `gepubliceerd` | operator action | `selectionCriteria` non-empty AND `awardCriteria` non-empty AND `timeline.bidDeadline > today + minimumTermijn(procedureType)` (Aw 2012 art. 2.71 termijnen) AND `publicationSource` set | +| `gepubliceerd` | `inschrijvingen-open` | scheduled at `timeline.publicationDate` | none | +| `inschrijvingen-open` | `beoordeling` | scheduled at `timeline.bidDeadline` | none | +| `beoordeling` | `voorlopige-gunning` | operator action | EVA spec's award decision MUST exist | +| `voorlopige-gunning` | `standstill` | automatic on entry | none | +| `standstill` | `definitief-gegund` | scheduled at `timeline.standstillEndDate` AND no bezwaar pending | none | +| `voorlopige-gunning` | `mislukt` | operator action (after bezwaar succeeds) | bezwaar case MUST be referenced | +| any non-terminal | `ingetrokken` | operator action | reason MUST be captured in audit context | +| `definitief-gegund` | `gesloten` | retention sweep | retention period elapsed | + +Per ADR-031, procest MUST NOT author `TenderService::transition*` or +`TenderLifecycleService` methods. Scheduled transitions MUST be backed +by OR `ScheduledWorkflow`. + +#### Scenario: A direct write to `state: "definitief-gegund"` is rejected + +- **GIVEN** any actor +- **WHEN** they attempt to save a tender with + `state: "definitief-gegund"` via the generic OR API without going + through the lifecycle +- **THEN** the save MUST fail with a "lifecycle transition required" + error. + +#### Scenario: Minimum term enforcement on publication + +- **GIVEN** an openbaar EU tender with `timeline.bidDeadline = today + + 20 days` (below the 30-day minimum per Aw 2012 art. 2.71) +- **WHEN** the operator triggers `voorbereiding → gepubliceerd` +- **THEN** the transition MUST fail with a guard violation citing the + applicable Aw article. + +### REQ-TND-003: Publication SHALL flow through the PSI `publicationSource` slot, not a hand-rolled TenderNed client + +The `voorbereiding → gepubliceerd` transition MUST dispatch the +publication payload via the openconnector source resolved from +`Tender.publicationSource` (per spec +`procest-procurement-system-integration`). Procest MUST NOT author +a `TenderNedClient`, `MercellService`, or any HTTP wrapper. + +The publication payload composition (mapping `Tender` fields → eForms +notice XML / TenderNed JSON / Mercell payload) MUST be carried by an +OR mapping declared via `x-openregister-relations` to a +`PublicationPayloadMapping` register or equivalent, NOT by inline +PHP transformation code. + +#### Scenario: Reviewer scans for forbidden HTTP + +- **GIVEN** the procest codebase +- **WHEN** scanned for `curl_init`, `GuzzleHttp\Client`, hardcoded + `tenderned.nl`, `mercell.com`, `negometrix.com` URLs in `lib/` +- **THEN** no matches SHALL exist. + +### REQ-TND-004: Vragen + Nota van Inlichtingen SHALL be a `TenderQuestion` register, not a free-text field + +Operator-supplier Q+A on a tender MUST be modelled as a +`TenderQuestion` register (one record per question) with an +operator-authored `answer` field and a publish flag. + +Schema.org annotation: `schema:Question`. + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `tender` | string | Yes | FK to the `Tender` UUID | +| `lot` | string | No | Optional FK to a lot (child case) if the question is lot-specific | +| `submittedBy` | string | Yes | Supplier name or anonymised handle (per procedure type) | +| `submittedAt` | datetime | Yes | Auto-set | +| `question` | string | Yes | The supplier's question | +| `answer` | string | No | Operator-authored response | +| `publishedAt` | datetime | No | Set when the answer becomes part of the published Nota van Inlichtingen | +| `noi` | string | No | FK to the published `NotaVanInlichtingen` document URI (docudesk) | +| `state` | enum | Yes | `received`, `under-review`, `answered`, `published`, `rejected` | + +Nota van Inlichtingen documents themselves MUST live in docudesk and +be referenced by URI — not stored in procest tables. + +#### Scenario: A published NOI surfaces aggregated answers + +- **GIVEN** ten `TenderQuestion` records in `state: published` for a + tender +- **WHEN** the operator generates a Nota van Inlichtingen PDF +- **THEN** the document MUST be authored in docudesk (using docudesk's + template engine — not in procest's `lib/`), with each question's + `noi` field pointing back to the resulting URI. + +### REQ-TND-005: Multi-lot tenders SHALL reuse procest's existing `deelzaak-support` + +When a tender has lots, each lot MUST be modelled as a child case +(deelzaak) under the parent tender case, using procest's existing +`deelzaak-support` capability — `parentCase` references on the +`Case`, with the lot's caseType seeded as `tender-lot`. + +Lot-specific Bids, award decisions, and evaluation scoring MUST attach +to the lot's child case, not to the parent. Per-lot aggregations +(received bids, scoring averages) MUST be declared as +`x-openregister-aggregations` over child cases — not as a per-app +`TenderLotService`. + +#### Scenario: A bid is recorded against a lot's child case + +- **GIVEN** a tender with three lots (three child cases) +- **WHEN** a supplier submits a bid for lot 2 +- **THEN** the resulting `Bid` record MUST attach to lot 2's child + case via `case` ref; the parent tender case aggregations MUST + surface the new bid count without per-app code. + +### REQ-TND-006: Termijnen + standstill SHALL be declarative calculations per ADR-031 + +The `Tender` schema MUST declare `x-openregister-calculations` +deriving: + +- `standstillEndDate` — `voorlopige-gunning timestamp + 20 days` for + EU regime, `+ 15 days` for nationaal-boven-drempel, none for + meervoudig/enkelvoudig (Alcatel-termijn / wachttermijn per Aw 2012 + art. 2.127); +- `minimumPublicationTerm` — derived from `procedureType` per Aw 2012 + table; +- `daysUntilDeadline` — `bidDeadline - today` (for dashboard widgets); +- `bezwaarOpen` — boolean, true while any linked bezwaar case (procest + `bezwaar-lifecycle`) is in a non-terminal state. + +Procest MUST NOT author `TenderTermijnService` or +`StandstillCalculator` — calculations are declarative. + +#### Scenario: Standstill end is computed from preliminary award + +- **GIVEN** an EU openbaar tender, `voorlopige-gunning` set on + `2026-04-01` +- **WHEN** any read of the tender fires +- **THEN** `standstillEndDate` MUST resolve to `2026-04-21` (20 days + after, per Alcatel-termijn). + +### REQ-TND-007: Bids SHALL be modelled as a `Bid` register attached to the tender (or lot) case + +Bids (inschrijvingen) MUST be a `Bid` register; one record per +supplier-tender(-lot) submission. + +Schema.org annotation: `schema:Offer`. + +| Field | Type | Required | Purpose | +|---|---|---|---| +| `tender` | string | Yes | FK to the `Tender` UUID | +| `lot` | string | No | FK to a lot child case if the bid is lot-specific | +| `case` | string | Yes | FK to the case (parent or lot) — surfaces the bid in case views | +| `supplier` | string | Yes | FK to the `Supplier` UUID | +| `submittedAt` | datetime | Yes | Auto-set on receipt (may be set by openconnector inbound event) | +| `submissionRef` | string | No | External system reference (Mercell bid ID, Negometrix submission ID) | +| `priceAmount` | number | No | Bid price (excl. BTW) where the procedure exposes price | +| `responses` | object | Yes | Per-criterion supplier responses (free-form by criterion key) | +| `documents` | array | No | docudesk URIs of supplier-uploaded bid documents | +| `state` | enum | Yes | `received`, `admissible`, `inadmissible`, `excluded`, `evaluated`, `withdrawn` | +| `admissibilityNotes` | string | No | Operator narrative for the admissibility decision | +| `evaluationScore` | object | No | Per-criterion score from EVA spec (set during `beoordeling`) | + +`Bid.state` MUST follow a declarative `x-openregister-lifecycle`; +procest MUST NOT author a `BidLifecycleService`. + +#### Scenario: A bid arriving after the deadline is rejected + +- **GIVEN** a tender's `inschrijvingen-open` state has elapsed and + the lifecycle has transitioned to `beoordeling` +- **WHEN** a late `Bid` is POSTed +- **THEN** the lifecycle MUST set state directly to `inadmissible` + with audit context `"laat ingediend"`. + +### REQ-TND-008: Tender notifications SHALL be declarative per ADR-031 + +The `Tender` schema MUST declare `x-openregister-notifications` +covering: + +- `publication.confirmed` — fires on confirmation event from + `publicationSource`; recipients: inkoper + opdrachtgever. +- `questions.deadline.approaching` — 7d / 2d / on day before + `timeline.questionsDeadline`; recipients: inkoper. +- `bid.deadline.approaching` — 7d / 2d / on day before + `timeline.bidDeadline`; recipients: inkoper + opdrachtgever. +- `bid.received` — on each new `Bid`; recipients: inkoper. +- `standstill.elapsed` — at `standstillEndDate`; recipients: inkoper + + opdrachtgever + juridisch. + +Procest MUST NOT author `TenderNotificationService`. + +#### Scenario: A late-published NOI does not silence the deadline reminder + +- **GIVEN** an operator publishes the Nota van Inlichtingen 3 days + before `bidDeadline` +- **WHEN** the engine ticks +- **THEN** the `bid.deadline.approaching` 2d notification MUST still + fire (NOI publication and deadline notifications are independent). + +### REQ-TND-009: Tender registers SHALL be reachable through the procest manifest navigation + +`src/manifest.json` MUST declare: + +- a navigation entry `Procurement > Tenders` (`type: index`) binding + to the `tender` caseType filter on `Case`; +- a `type: detail` page for individual tender cases, including + side panels for: `Tender` metadata, lots (child cases), `Bid` + records, `TenderQuestion` records, timeline (computed dates); +- a navigation entry `Procurement > Bid responses` (`type: index`) + binding to `Bid`; +- a navigation entry `Procurement > Tender questions` (`type: index`) + binding to `TenderQuestion`; +- a navigation entry `Procurement > Tender dashboard` rendering + widgets declared via `x-openregister-widgets` (deadlines next 14 + days, tenders by procedureType, average bids per tender). + +All renderers MUST be the generic `@conduction/nextcloud-vue` page +renderers per ADR-024 Tier-4. + +#### Scenario: The tenders index lists case-type tenders only + +- **GIVEN** the manifest declares the tenders page with + `filter: { caseType: ["tender"] }` +- **WHEN** an inkoper opens `/index.php/apps/procest/tenders` +- **THEN** the page MUST render via `CnIndexPage` showing tender + cases — no other case types appear, no procest-side filter + controller is invoked. diff --git a/openspec/changes/add-procest-procurement-suite/tasks.md b/openspec/changes/add-procest-procurement-suite/tasks.md new file mode 100644 index 00000000..5295479d --- /dev/null +++ b/openspec/changes/add-procest-procurement-suite/tasks.md @@ -0,0 +1,132 @@ +# Tasks: add-procest-procurement-suite + +This is a `kind: config` change per ADR-032. Tasks here describe +**spec-authoring + reviewer verification** only. No PHP, no Vue, no +tests, no register-file patches. Implementation lives in follow-up +code chains (one per spec) opened after this change archives. + +## Spec authoring (this change) + +- [x] **T1** — Draft `proposal.md` with consolidation rationale and + source-draft → consolidated-spec mapping. + - files: `proposal.md` + - spec_ref: this change's `proposal.md` + +- [x] **T2** — Draft `design.md` with domain framing, OR abstraction + usage matrix, declarative-vs-imperative classification, 7-vs-8 split + rationale, and intelligence-DB cleanup checklist. + - files: `design.md` + - spec_ref: ADR-022, ADR-031, ADR-032 + +- [x] **T3** — Author `procest-procurement-supplier-management/spec.md` + consolidating 9 source drafts (`supplier-management`, + `supplier-management-ai`, `supplier-management-misc`, + `supplier-management-other-t1..t5`, `supplier-performance-management`). + - files: `specs/procest-procurement-supplier-management/spec.md` + - acceptance: 8 REQ-SUP-* requirements, each with ≥1 scenario, + Supplier register declared with Schema.org annotation, + no-parallel-storage reviewer-gate scenario present. + +- [x] **T4** — Author `procest-procurement-contract-lifecycle/spec.md` + consolidating 8 source drafts (`contract-lifecycle-management`, + `-ai`, `-analytics`, `-document-management`, `-other-t1..t4`). + - files: `specs/procest-procurement-contract-lifecycle/spec.md` + - acceptance: 8 REQ-CLM-* requirements, contract-as-case framing, + docudesk signing via OpenConnector source. + +- [x] **T5** — Author `procest-procurement-system-integration/spec.md` + consolidating 5 source drafts (`procurement-integration`, + `-integration`, `-other-t1..t3`). + - files: `specs/procest-procurement-system-integration/spec.md` + - acceptance: 6 REQ-PSI-* requirements, every external system + declared as an OpenConnector source slot (not as a procest + service), connector slot table present. + +- [x] **T6** — Author `procest-procurement-tender-management/spec.md` + from the `tender-management` draft. + - files: `specs/procest-procurement-tender-management/spec.md` + - acceptance: 9 REQ-TND-* requirements, tender-as-case framing, + Aanbestedingswet 2012 + ARW 2016 citations, sub-case (lot) + support via procest's `deelzaak-support`. + +- [x] **T7** — Author `procest-procurement-evaluation-award/spec.md` + from the `evaluation-award` draft. + - files: `specs/procest-procurement-evaluation-award/spec.md` + - acceptance: 7 REQ-EVA-* requirements, reuse of procest's existing + `decision` register (no new award register), Alcatel-termijn + documented, motiveringsplicht referenced. + +- [x] **T8** — Author `procest-procurement-compliance/spec.md` from + the `procurement-compliance` draft. + - files: `specs/procest-procurement-compliance/spec.md` + - acceptance: 7 REQ-PCC-* requirements, UEA + EML-bestand modelled + as registers (not as PHP enums), declarative threshold checks per + ADR-031. + +- [x] **T9** — Author `procest-procurement-publication-platform/spec.md` + from the `publication-platform-integration` draft. + - files: `specs/procest-procurement-publication-platform/spec.md` + - acceptance: 6 REQ-PPP-* requirements, TED eForms F01..F25 modelled + as a publication-template register, "material change → re-publish" + handled as a lifecycle transition, not as a PHP service. + +- [x] **T10** — Author + `procest-procurement-spend-analytics-integration/spec.md` as a + cross-app contract spec. + - files: `specs/procest-procurement-spend-analytics-integration/spec.md` + - acceptance: 5 REQ-PSA-* requirements, CloudEvent schemas for every + domain event emitted, mydash GraphQL query shape declared, ADR-024 + §10 (no OR dep on mydash) re-cited. + +## Reviewer verification (this change — pre-merge) + +- [ ] **T11** — Reviewer confirms every spec carries `Status`, `Scope`, + `Tier`, `Depends on` header per the shillinq reference style. + - files: all `specs/*/spec.md` + - acceptance: 8/8 headers present, all 4 fields populated. + +- [ ] **T12** — Reviewer confirms every register declared in any spec + has a Schema.org annotation on the schema row. + - files: all `specs/*/spec.md` field tables. + - acceptance: 100% of register definitions annotated. + +- [ ] **T13** — Reviewer confirms every lifecycle is declared as + `x-openregister-lifecycle` in the REQ prose, never as a PHP service. + ADR-031 anti-pattern scan. + - acceptance: zero references to `Service::transition`, + `Service::advance*`, `Service::setStatus*` in REQ prose. + +- [ ] **T14** — Reviewer confirms every spec ends with a manifest- + navigation requirement per ADR-024. + - acceptance: 8/8 specs have a final `REQ--NNN` describing + the manifest entries the suite contributes. + +- [ ] **T15** — Reviewer confirms every spec includes at least one + "no parallel storage" scenario (ADR-022 anti-pattern reviewer-gate). + - acceptance: 8/8 specs scan-clean for `lib/Db/{*}_mapper.php` + style scenarios. + +- [ ] **T16** — Deduplication check (ADR-012, per hydra/CLAUDE.md + design rules): verify no register declared in this suite duplicates + an existing procest register (`case`, `caseType`, `decision`, + `parafeerroute`, etc.). + - acceptance: only additive register patches; reused registers + explicitly cited as "extends procest's existing ``". + +## Post-merge follow-up (NOT this change) + +The following land as separate efforts and are listed here only so +the consolidation hand-off is unambiguous. **Do not author them as +tasks in this change — per `feedback_opsx-no-process-tasks.md`, +PR/merge/archive process tasks do not belong in opsx tasks.md.** + +- Per-spec code chains (one per spec, each a chain of `kind: config` + register patch → `kind: code` manifest wiring → `kind: code` guard + classes if any). +- Intelligence-DB cleanup script that flips the 26 source drafts to + `status: superseded` (see `design.md` "Source draft reconciliation" + table for the exact slug list). +- `add-openconnector-eu-procurement-sources` change in the + openconnector repo that lands the actual source rows referenced by + PSI + PPP. +- `[future]` financeq integration spec, once the financeq repo exists. From e561614add2b77e1d335a35b0b7326aafd756356 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 18 May 2026 18:51:01 +0200 Subject: [PATCH 3/7] spec(procest): bring all changes under ADR-032 20-task cap (Wave 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings procest's openspec/changes/ under Hydra's ADR-032 spec-sizing gate (cap: 20 unchecked tasks per change). Mix of CASE A (multi-spec bundle split into N per-capability changes) and CASE B (single-spec with too many tasks → trim tasks.md by collapsing nested sub-checkboxes). All semantic content preserved — only tasks.md restructured (Case B) or change-folder boundaries redrawn (Case A). Spec deltas, proposal motivations, and design decisions kept verbatim where untouched. Audit verified post-edit: zero change folders over 20 unchecked tasks (grep -cE '^\s*-\s+\[ \]' across openspec/changes/*/tasks.md = 0). Part of fleet-wide Wave 2 (8 apps, 60 changes) following the shillinq foundation pattern (#91) + Wave 1.2 (#94) precedent. --- .../changes/case-email-integration/tasks.md | 53 ++++------- .../docs-product-pages-conformance/tasks.md | 90 +++++-------------- .../procest-legacy-quality-cleanup/tasks.md | 65 ++++---------- .../changes/unit-test-coverage-75/tasks.md | 50 +++++------ 4 files changed, 73 insertions(+), 185 deletions(-) diff --git a/openspec/changes/case-email-integration/tasks.md b/openspec/changes/case-email-integration/tasks.md index d5c1fabc..b453c7c9 100644 --- a/openspec/changes/case-email-integration/tasks.md +++ b/openspec/changes/case-email-integration/tasks.md @@ -2,51 +2,32 @@ ## Implementation Tasks -### Schema & Configuration +### Schema, Configuration, Settings -- [ ] **T01**: Add `emailTemplate`, `emailMessage`, `emailThread` schemas to `lib/Settings/procest_register.json` with all fields from design (name/subject/body/caseType/variables/version/isActive for template; messageId/inReplyTo/direction/from/to/cc/bcc/subject/body/case/thread/pdfPath/pdfStatus/sentAt/templateId/templateVersion for message; subject/case/messageCount/firstMessageAt/lastMessageAt for thread). Include schema.org annotations (`schema:DigitalDocument`, `schema:EmailMessage`, `schema:Conversation`). Add config keys `email_template_schema`, `email_message_schema`, `email_thread_schema`, plus SMTP/IMAP keys (`email_smtp_host`, `email_smtp_port`, `email_smtp_encryption`, `email_smtp_username`, `email_smtp_password`, `email_from_address`, `email_imap_host`, `email_imap_port`, `email_imap_folder`, `email_transport`, `email_poll_interval`, `email_poll_batch_size`, `email_max_attachment_size`) to `SettingsService.php` CONFIG_KEYS and SLUG_TO_CONFIG_KEY arrays. +- [ ] **T01**: Add `emailTemplate`, `emailMessage`, `emailThread` schemas to `lib/Settings/procest_register.json` (schema.org annotations `schema:DigitalDocument`, `schema:EmailMessage`, `schema:Conversation`; fields per design). Add config keys (`email_template_schema`, `email_message_schema`, `email_thread_schema`, full SMTP/IMAP/transport/polling/attachment-size set) to `SettingsService.php` `CONFIG_KEYS` and `SLUG_TO_CONFIG_KEY`. +- [ ] **T02**: Create `lib/Settings/EmailSettings.php` + `src/views/settings/EmailSettings.vue` — Admin form with SMTP fields, IMAP fields, transport selector (Nextcloud Mail account picker or standalone SMTP), and a "Send test email" button hitting `POST /api/settings/email/test-smtp`. Passwords masked, stored as sensitive `IAppConfig` keys. Register settings section in `appinfo/info.xml`. ### Backend Services -- [ ] **T02**: Create `lib/Service/CaseEmailService.php` — Methods: `sendEmail(caseId, templateId, subject, body, recipients, cc, bcc, attachmentIds)` resolves template variables, generates `Message-ID`, dispatches via configured transport, stores `emailMessage`, triggers PDF conversion, appends `email_sent` activity entry; `processInboundEmail(rawMessage)` parses headers, auto-links by subject regex `\[([A-Z]+-\d{4}-\d{6})\]` or by `In-Reply-To`, stores `emailMessage` and updates/creates `emailThread`, queues unlinked messages; `resolveTemplateVariables(template, case)` returns rendered subject+body with unresolved names listed; `linkUnlinkedEmail(emailId, caseId)` moves an email from queue to case; `discardUnlinkedEmail(emailId, reason)` marks as discarded. Uses ObjectService from OpenRegister, Docudesk for PDF. +- [ ] **T03**: Create `lib/Service/CaseEmailService.php` — `sendEmail()` resolves template variables, generates `Message-ID`, dispatches via configured transport, stores `emailMessage`, triggers PDF conversion, appends `email_sent` activity entry. `processInboundEmail()` parses headers, auto-links by `\[([A-Z]+-\d{4}-\d{6})\]` subject regex or `In-Reply-To`, stores `emailMessage` + updates/creates `emailThread`, queues unlinked. Plus `resolveTemplateVariables()`, `linkUnlinkedEmail()`, `discardUnlinkedEmail()`. Uses OpenRegister ObjectService + Docudesk for PDF. +- [ ] **T04**: Create `lib/Service/EmailTemplateService.php` — `createTemplate()` saves with `version: 1`; `updateTemplate()` creates a new version object (no overwrite); `listTemplates()`, `getAvailableVariables()` grouped by source (case/contact/caseType), `seedDefaultTemplates()` creates `Ontvangstbevestiging`, `Informatieverzoek`, `Besluit`. -- [ ] **T03**: Create `lib/Service/EmailTemplateService.php` — Methods: `createTemplate(caseTypeId, data)` saves new template with `version: 1`; `updateTemplate(templateId, data)` creates a new version object rather than overwriting; `listTemplates(caseTypeId)` returns active templates for case type; `getAvailableVariables(caseTypeId)` returns the variable catalog grouped by source (case, contact, caseType); `seedDefaultTemplates(caseTypeId)` creates `Ontvangstbevestiging`, `Informatieverzoek`, `Besluit`. +### Controllers, Routes, Background Jobs -### Controllers & Routes - -- [ ] **T04**: Create `lib/Controller/CaseEmailController.php` — Authenticated controller with endpoints: `sendEmail(caseId)`, `listEmails(caseId)`, `listUnlinked()`, `linkEmail(emailId)`, `discardEmail(emailId)`, `listTemplates(caseTypeId)`, `createTemplate(caseTypeId)`, `updateTemplate(templateId)`. All methods `@NoAdminRequired`, returns `JSONResponse`. - -- [ ] **T05**: Add routes to `appinfo/routes.php` — `POST /api/cases/{caseId}/emails`, `GET /api/cases/{caseId}/emails`, `GET /api/emails/unlinked`, `POST /api/emails/unlinked/{id}/link`, `POST /api/emails/unlinked/{id}/discard`, template CRUD under `/api/casetypes/{caseTypeId}/email-templates` and `/api/email-templates/{templateId}`, settings under `/api/settings/email`. All before SPA catch-all. - -### Background Jobs - -- [ ] **T06**: Create `lib/BackgroundJob/InboundEmailJob.php` — `TimedJob` with default interval from `email_poll_interval` (default 300 s). Connects to IMAP via `imap_open()` or Nextcloud Mail's account API, fetches unread messages from configured folder (default INBOX), processes up to `email_poll_batch_size` per run (default 50), skips messages whose `Message-ID` already exists, moves processed messages to a "Processed" folder. Catches and logs exceptions without rethrowing. - -- [ ] **T07**: Create `lib/BackgroundJob/EmailPdfRetryJob.php` — `TimedJob` (every 15 min) that scans `emailMessage` objects with `pdfStatus: failed` and retries Docudesk conversion up to 3× with exponential backoff (15 min, 1 h, 4 h). Registers both jobs via `IJobList` in `appinfo/info.xml` background-jobs section or `Application::register()`. +- [ ] **T05**: Create `lib/Controller/CaseEmailController.php` (sendEmail/listEmails/listUnlinked/linkEmail/discardEmail/listTemplates/createTemplate/updateTemplate, all `@NoAdminRequired`) and add routes to `appinfo/routes.php` (`/api/cases/{caseId}/emails`, `/api/emails/unlinked[/{id}/{link,discard}]`, template CRUD under `/api/casetypes/{caseTypeId}/email-templates` + `/api/email-templates/{templateId}`, `/api/settings/email`). All before SPA catch-all. +- [ ] **T06**: Create `lib/BackgroundJob/InboundEmailJob.php` (`TimedJob`, interval from `email_poll_interval` default 300s; connects via IMAP or Nextcloud Mail account API, processes up to `email_poll_batch_size` unread messages, dedupes by `Message-ID`, moves processed to "Processed" folder, swallows + logs exceptions) and `lib/BackgroundJob/EmailPdfRetryJob.php` (every 15min, retries `pdfStatus: failed` up to 3× with 15m/1h/4h backoff). Register both via `IJobList` in `Application::register()` or `appinfo/info.xml`. ### Frontend Components -- [ ] **T08**: Create `src/views/cases/components/EmailComposer.vue` — Modal dialog with recipient (pre-filled from case contact), CC, BCC, subject (pre-filled with `[{{identifier}}]` prefix), rich text body editor, template selector dropdown, attachment picker (case documents), running attachment size, send confirmation step. Validates attachment size against `email_max_attachment_size`. Disabled when case status `isFinal`. - -- [ ] **T09**: Create `src/views/cases/components/EmailThread.vue` and `src/views/cases/components/EmailTab.vue` — Tab lists threads grouped collapsible (most recent first) with count badge in tab header; thread renders messages chronologically with direction-distinguished styling (inbound left, outbound right), inline body expand, PDF download link, and per-message Reply action that opens EmailComposer pre-populated. - -- [ ] **T10**: Create `src/views/casetypes/components/EmailTemplateAdmin.vue` and `src/views/emails/UnlinkedQueue.vue` — Template admin lists templates per case type with create/edit form, variable sidebar grouped by source (case/contact/caseType), live preview with red-highlighted unresolved variables. UnlinkedQueue lists sender/subject/date/body preview with search-and-link UI plus discard action. - -### Settings & Integration - -- [ ] **T11**: Create `src/views/settings/EmailSettings.vue` and `lib/Settings/EmailSettings.php` — Admin settings form with SMTP fields (host/port/encryption/username/password/from-address), IMAP fields (host/port/encryption/username/password/folder), transport selector (Nextcloud Mail account picker or standalone SMTP), and "Send test email" button calling `POST /api/settings/email/test-smtp`. Passwords masked and stored via sensitive `IAppConfig` keys. Register settings section in `appinfo/info.xml`. - -- [ ] **T12**: Update `src/views/cases/CaseDetail.vue` — Add `EmailTab` to the sidebar tabs in `sidebarProps`. Add "Verstuur email" header action that opens `EmailComposer` (disabled when status `isFinal`). Subscribe to email events to refresh `ActivityTimeline` after send. +- [ ] **T07**: Create `src/views/cases/components/EmailComposer.vue` — Modal with recipient prefilled from case contact, CC/BCC, subject prefilled with `[{{identifier}}]`, rich-text body, template dropdown, case-document attachment picker with running size + `email_max_attachment_size` validation, send confirmation. Disabled when case status `isFinal`. +- [ ] **T08**: Create `src/views/cases/components/EmailThread.vue` and `EmailTab.vue` — Tab lists threads grouped collapsible (most recent first) with count badge, messages chronological with inbound/outbound styling, inline body expand, PDF download link, per-message Reply opens prefilled EmailComposer. +- [ ] **T09**: Create `src/views/casetypes/components/EmailTemplateAdmin.vue` (per-case-type CRUD form, variable sidebar grouped by source, live preview with red-highlighted unresolved variables) and `src/views/emails/UnlinkedQueue.vue` (sender/subject/date/body preview with search-and-link + discard). +- [ ] **T10**: Update `src/views/cases/CaseDetail.vue` — Add `EmailTab` to sidebar tabs in `sidebarProps`. Add "Verstuur email" header action that opens `EmailComposer` (disabled when status `isFinal`). Subscribe to email events to refresh `ActivityTimeline`. ## Verification Tasks -- [ ] **V01**: All new schemas valid JSON in `procest_register.json`; load via `openregister:load-register` succeeds -- [ ] **V02**: Routes registered before SPA catch-all and resolve under `/index.php/apps/procest/api/` -- [ ] **V03**: Outbound email is sent, `emailMessage` stored, PDF generated, `email_sent` appears in activity timeline -- [ ] **V04**: Inbound email with `[ZAAK-2026-001234]` subject auto-links to the matching case -- [ ] **V05**: Inbound reply with `In-Reply-To` header is appended to the existing thread -- [ ] **V06**: Unlinked email appears in `/emails/unlinked` and can be manually linked -- [ ] **V07**: Template variables resolve from case data; unresolved variables highlighted red in preview -- [ ] **V08**: Template edit creates a new version; previously sent messages retain `templateVersion` -- [ ] **V09**: Docudesk failure marks `pdfStatus: failed` and retry job re-attempts up to 3× -- [ ] **V10**: SMTP/IMAP passwords stored sensitive; test-email button returns success/failure with specific error +- [ ] **V01**: Schemas + routes load: `procest_register.json` valid, `openregister:load-register` succeeds, all routes resolve under `/index.php/apps/procest/api/` before SPA catch-all. +- [ ] **V02**: Outbound flow end-to-end: send produces stored `emailMessage`, PDF generated, `email_sent` shown in activity timeline; Docudesk failure marks `pdfStatus: failed` + retry job re-attempts up to 3×. +- [ ] **V03**: Inbound + threading: subject-tagged inbound auto-links; `In-Reply-To` appends to existing thread; unrecognised mail surfaces in `/emails/unlinked` and can be manually linked. +- [ ] **V04**: Template variables resolve from case data with red-highlighted unresolved in preview; template edit creates a new version while previously sent messages retain their original `templateVersion`. +- [ ] **V05**: SMTP/IMAP credentials stored sensitive; "Send test email" returns success/failure with specific error message. diff --git a/openspec/changes/docs-product-pages-conformance/tasks.md b/openspec/changes/docs-product-pages-conformance/tasks.md index 8ad321ce..79853e58 100644 --- a/openspec/changes/docs-product-pages-conformance/tasks.md +++ b/openspec/changes/docs-product-pages-conformance/tasks.md @@ -1,85 +1,35 @@ -## 1. Folder renames (git mv — preserves history) +## 1. Folder renames + Technical folder (git mv preserves history) -- [ ] 1.1 `git mv docs/features docs/Features` — rename features folder to canonical casing (44 feature files + README.md) -- [ ] 1.2 `git mv docs/tutorials docs/user-guide` — rename tutorials folder to canonical name (11 MD files in admin/ + user/ subdirs, plus all screenshots) -- [ ] 1.3 Verify screenshot count in `docs/user-guide/` matches original `docs/tutorials/` (PNG files must all be present after mv) +- [ ] 1.1 `git mv docs/features docs/Features` (44 feature files + README.md) and `git mv docs/tutorials docs/user-guide` (admin/ + user/ subdirs + all screenshots); verify PNG counts match post-mv. +- [ ] 1.2 Create `docs/Technical/` with `_category_.json` (label "Technical", position 6), then `git mv` legacy root MDs in: `ARCHITECTURE.md` → `Technical/architecture.md`, `DESIGN-REFERENCES.md` → `Technical/design-decisions.md`, `development.md` → `Technical/development-guide.md`, `zgw-implementation.md` → `Technical/zgw-spec.md`, `GOVERNMENT-FEATURES.md` → `Technical/government-compliance.md`, `FEATURES.md` → `Technical/market-analysis.md`. -## 2. Create Technical/ folder and move legacy root MDs +## 2. Root index + installation + UseCases/Integrations stubs -- [ ] 2.1 Create `docs/Technical/` folder with `_category_.json` (label: "Technical", position: 6) -- [ ] 2.2 `git mv docs/ARCHITECTURE.md docs/Technical/architecture.md` -- [ ] 2.3 `git mv docs/DESIGN-REFERENCES.md docs/Technical/design-decisions.md` -- [ ] 2.4 `git mv docs/development.md docs/Technical/development-guide.md` -- [ ] 2.5 `git mv docs/zgw-implementation.md docs/Technical/zgw-spec.md` -- [ ] 2.6 `git mv docs/GOVERNMENT-FEATURES.md docs/Technical/government-compliance.md` -- [ ] 2.7 `git mv docs/FEATURES.md docs/Technical/market-analysis.md` +- [ ] 2.1 `git mv docs/README.md docs/index.md` and add frontmatter (`id: intro`, `title: Introduction`, `sidebar_position: 1`). +- [ ] 2.2 Create `docs/installation.md` (`sidebar_position: 2`) with Prerequisites (Nextcloud 28+, OpenRegister), App Store install + enable, post-install configuration (procest register auto-created via repair step, case-type config, ZGW endpoint mapping in Admin Settings), and Troubleshooting (register-not-found → re-run repair step, ZGW 400 → verify endpoint URLs). +- [ ] 2.3 Create `docs/UseCases/_category_.json` (label "Use Cases", position 4) + `docs/UseCases/index.md` stub (`draft: true`, note tracked in #440), and `docs/Integrations/_category_.json` (label "Integrations", position 5) + `docs/Integrations/index.md` stub (`draft: true`, #440). -## 3. Root README rename and index.md creation +## 3. _category_.json for canonical folders -- [ ] 3.1 `git mv docs/README.md docs/index.md` -- [ ] 3.2 Add Docusaurus frontmatter to `docs/index.md`: `id: intro`, `title: Introduction`, `sidebar_position: 1` +- [ ] 3.1 Create `docs/Features/_category_.json` (label "Features", position 3) and `docs/user-guide/_category_.json` (label "User Guide", position 2) if not already present. -## 4. Create installation.md +## 4. Redocusaurus + API route -- [ ] 4.1 Create `docs/installation.md` with: - - `sidebar_position: 2` frontmatter - - Prerequisites section (Nextcloud 28+, OpenRegister app) - - App Store installation steps (install from Nextcloud App Store, enable app) - - Post-install configuration section: register setup (procest register created automatically via repair step), case-type configuration, ZGW API endpoint mapping in Admin Settings - - Troubleshooting section (register not found: re-run repair step; ZGW 400 errors: verify endpoint URLs) +- [ ] 4.1 Add `"redocusaurus": "^2.0.0"` to `docs/package.json` dependencies, create `docs/static/oas/procest.json` with minimal valid OAS shim (`{"openapi":"3.0.0","info":{"title":"Procest","version":"0.0.0"},"paths":{}}`), wire Redocusaurus plugin (`specs: [{spec: 'static/oas/procest.json', route: '/api/'}]`) and add `{to: '/api/', label: 'API Documentation', position: 'left'}` navbar item in `docs/docusaurus.config.js`. -## 5. Create UseCases/ and Integrations/ stub folders +## 5. Re-enable nl locale (with escape hatch) -- [ ] 5.1 Create `docs/UseCases/_category_.json` (label: "Use Cases", position: 4) -- [ ] 5.2 Create `docs/UseCases/index.md` stub (frontmatter: `draft: true`, title: "Use Cases", note: content authoring tracked in issue #440) -- [ ] 5.3 Create `docs/Integrations/_category_.json` (label: "Integrations", position: 5) -- [ ] 5.4 Create `docs/Integrations/index.md` stub (frontmatter: `draft: true`, title: "Integrations", note: content authoring tracked in issue #440) +- [ ] 5.1 In `docs/docusaurus.config.js`, update `i18n.locales` to `['en', 'nl']` and add `nl: { label: 'Nederlands' }` to `localeConfigs`. If `npm run build` fails with SSR error on empty `i18n/nl/`, revert `locales` to `['en']` with comment `/* nl reverted: SSR error with empty i18n/nl/ — re-enable once translation backfill lands (issue #441) */`. -## 6. _category_.json files for canonical folders +## 6. Em-dash sweep (gate: `git grep -E '—' docs/` returns 0) -- [ ] 6.1 Create `docs/Features/_category_.json` (label: "Features", position: 3) -- [ ] 6.2 Create `docs/user-guide/_category_.json` (label: "User Guide", position: 2) — only if one does not already exist -- [ ] 6.3 Create `docs/Technical/_category_.json` if not already done in task 2.1 +- [ ] 6.1 Replace ` — ` with `, ` / `: ` (per context) across the em-dash hot spots in `docs/Technical/market-analysis.md` (11 hits incl. title), `docs/Features/README.md`, and per-feature pages: `administration.md`, `zgw-apis.md`, `bezwaar-beroep-workflow.md`, `workflow-engine-enhancement.md`, `vth-workflow-configuration.md`, `besluitvorming-workflow.md`, `doorlooptijd-dashboard.md`, `deelzaak-support.md`, `app-scaffold.md`, `gis-integration.md`. +- [ ] 6.2 Replace `—` with `-` inside `docs/user-guide/` HTML `` comments, then run `git grep -E '—' docs/` (excluding node_modules) and confirm 0 matches. -## 7. Add Redocusaurus and API Documentation route +## 7. Internal link updates -- [ ] 7.1 Add `"redocusaurus": "^2.0.0"` to `docs/package.json` under `dependencies` -- [ ] 7.2 Create `docs/static/oas/` directory and write `docs/static/oas/procest.json` with minimal valid OAS shim: `{"openapi":"3.0.0","info":{"title":"Procest","version":"0.0.0"},"paths":{}}` -- [ ] 7.3 Add Redocusaurus plugin config to `docs/docusaurus.config.js`: plugin `redocusaurus` with `specs: [{spec: 'static/oas/procest.json', route: '/api/'}]` -- [ ] 7.4 Add "API Documentation" navbar item to `docs/docusaurus.config.js`: `{to: '/api/', label: 'API Documentation', position: 'left'}` +- [ ] 7.1 Scan files moved into `docs/Technical/` for relative links to sibling root MDs (e.g. `../ARCHITECTURE.md`) and update to new paths. Scan `docs/Features/` for cross-links to root MDs, update. Verify `docs/index.md` references to `features/` are updated to `Features/`. -## 8. Re-enable nl locale +## 8. Build verification -- [ ] 8.1 In `docs/docusaurus.config.js`, update `i18n.locales` from `['en']` to `['en', 'nl']` -- [ ] 8.2 Add `nl: { label: 'Nederlands' }` to `i18n.localeConfigs` -- [ ] 8.3 Run `npm run build` in `docs/`; if SSR fails with nl locale, revert `locales` to `['en']` and add comment: `/* nl reverted: SSR error with empty i18n/nl/ — re-enable once translation backfill lands (issue #441) */` - -## 9. Em-dash sweep (gate: git grep -E '—' docs/ returns 0) - -- [ ] 9.1 Fix em-dashes in `docs/Technical/market-analysis.md` (11 hits from FEATURES.md): replace ` — ` with `, ` or `: ` per context; title `# Procest — Feature Analysis` → `# Procest: Feature Analysis` -- [ ] 9.2 Fix em-dashes in `docs/Features/README.md` (table cells and prose): replace ` — ` with `, ` or `: ` -- [ ] 9.3 Fix em-dashes in `docs/Features/administration.md` (bullet list items with ` — `): replace ` — ` with `: ` -- [ ] 9.4 Fix em-dashes in `docs/Features/zgw-apis.md`: replace ` — ` with `: ` in code-reference bullets -- [ ] 9.5 Fix em-dashes in `docs/Features/bezwaar-beroep-workflow.md`: replace ` — ` with `: ` -- [ ] 9.6 Fix em-dashes in `docs/Features/workflow-engine-enhancement.md`: replace ` — ` with `: ` -- [ ] 9.7 Fix em-dashes in `docs/Features/vth-workflow-configuration.md`: replace ` — ` with `: ` -- [ ] 9.8 Fix em-dashes in `docs/Features/besluitvorming-workflow.md`: replace ` — ` with `: ` -- [ ] 9.9 Fix em-dashes in `docs/Features/doorlooptijd-dashboard.md`: replace ` — ` with `: ` -- [ ] 9.10 Fix em-dashes in `docs/Features/deelzaak-support.md`: replace ` — ` with `: ` -- [ ] 9.11 Fix em-dashes in `docs/Features/app-scaffold.md`: replace ` — ` with `: ` -- [ ] 9.12 Fix em-dashes in `docs/Features/gis-integration.md`: replace ` — ` with `: ` -- [ ] 9.13 Fix em-dashes in `docs/user-guide/` HTML comments (tutorials): in ``, replace `—` with `-` -- [ ] 9.14 Verify em-dash gate: run `git grep -E '—' docs/` (excluding node_modules) — must return 0 matches - -## 10. Internal link updates in moved files - -- [ ] 10.1 Scan all files moved to `docs/Technical/` for relative links pointing to sibling root MDs (e.g., `../ARCHITECTURE.md`) and update to the new paths -- [ ] 10.2 Scan `docs/Features/` files for any cross-links to root MDs and update accordingly -- [ ] 10.3 Verify `docs/index.md` links to `features/` are updated to `Features/` (if any) - -## 11. Build verification - -- [ ] 11.1 Run `cd docs && npm install --legacy-peer-deps` -- [ ] 11.2 Run `npm run build` (with 10-minute timeout) — must exit 0 -- [ ] 11.3 If build fails due to nl SSR error, execute escape hatch from task 8.3 and re-run build -- [ ] 11.4 Confirm build output includes `Features/`, `user-guide/`, `Technical/`, `api/` routes +- [ ] 8.1 `cd docs && npm install --legacy-peer-deps && npm run build` (10-min timeout) must exit 0; apply escape hatch from 5.1 if nl SSR fails, then re-run. Confirm output includes `Features/`, `user-guide/`, `Technical/`, `api/` routes. diff --git a/openspec/changes/procest-legacy-quality-cleanup/tasks.md b/openspec/changes/procest-legacy-quality-cleanup/tasks.md index 4aac2064..7e24b033 100644 --- a/openspec/changes/procest-legacy-quality-cleanup/tasks.md +++ b/openspec/changes/procest-legacy-quality-cleanup/tasks.md @@ -2,70 +2,37 @@ ## Phase 1 — Inventory + planning -- [ ] Run `composer phpcs` and capture current baseline error count - (target: starting from 3 exclude-patterns in phpcs.xml) -- [ ] Run `composer phpmd` for the first time as a unified gate - and capture violation count + categories -- [ ] Run `composer phpstan` for the first time as a unified gate - and capture error count + categories -- [ ] Decide per gate: fix-outright (if <50 violations) or capture - a fresh baseline (if larger) -- [ ] Confirm CI runs `composer check:strict` on every PR before - starting burn-down work +- [ ] Run `composer phpcs`, `composer phpmd`, `composer phpstan` for the first time as unified gates; capture baseline counts + violation categories per gate (starting from 3 exclude-patterns in `phpcs.xml`). +- [ ] Per gate decide: fix-outright (if <50 violations) or capture a fresh baseline. Confirm CI runs `composer check:strict` on every PR before starting burn-down work. ## Phase 2 — PHPCS burn-down (per excluded file) -For each file: fix errors, remove the phpcs.xml `` -entry, verify gate stays green. +For each file: fix errors, remove the `phpcs.xml` `` entry, verify gate stays green. -- [ ] Excluded file 1 — fix sniffs + drop exclude -- [ ] Excluded file 2 — fix sniffs + drop exclude -- [ ] Excluded file 3 — fix sniffs + drop exclude -- [ ] Once all excludes are gone, drop the legacy-debt block from - phpcs.xml entirely +- [ ] Excluded file 1 — fix sniffs + drop exclude. +- [ ] Excluded file 2 — fix sniffs + drop exclude. +- [ ] Excluded file 3 — fix sniffs + drop exclude. +- [ ] Once all excludes are gone, drop the legacy-debt block from `phpcs.xml` entirely. ## Phase 3 — PHPMD burn-down -Contingent on Phase 1's first-run output. If volume is small, this -phase collapses to a single fix-outright PR. +Contingent on Phase 1 output. If volume is small, collapses to a single fix-outright PR. -- [ ] If baseline captured: ElseExpression — re-shape `if/else` to - early-return -- [ ] If baseline captured: CyclomaticComplexity / NPathComplexity — - extract methods -- [ ] If baseline captured: MissingImport — add `use` statements -- [ ] If baseline captured: StaticAccess — replace with DI -- [ ] If baseline captured: variable-naming sniffs (Long/Short/ - Undefined/UnusedFormalParameter) -- [ ] Once baseline reaches 0 lines: delete phpmd.baseline.xml and - drop `--baseline-file` from composer.json's phpmd script +- [ ] Resolve PHPMD findings by category as baseline dictates: ElseExpression (reshape `if/else` → early-return), CyclomaticComplexity / NPathComplexity (extract methods), MissingImport (`use` statements), StaticAccess (replace with DI), variable-naming (Long/Short/Undefined/UnusedFormalParameter). +- [ ] Once baseline reaches 0 lines: delete `phpmd.baseline.xml` and drop `--baseline-file` from composer.json's phpmd script. ## Phase 4 — PHPStan burn-down -Contingent on Phase 1's first-run output. If volume is small, this -phase collapses to a single fix-outright PR. +Contingent on Phase 1 output. If volume is small, collapses to a single fix-outright PR. -- [ ] Inventory phpstan errors by file/type -- [ ] Common patterns to fix: - - [ ] Missing return-type / param-type declarations - - [ ] Mixed types (specify generic / union) - - [ ] Possibly-null dereferences -- [ ] Once baseline reaches 0 lines (or never created): confirm - gate runs clean against current code +- [ ] Inventory phpstan errors by file/type and fix the common patterns: missing return-type / param-type declarations, mixed types (specify generic / union), possibly-null dereferences. +- [ ] Once baseline reaches 0 lines (or never created): confirm gate runs clean against current code. ## Phase 5 — CI integration -- [ ] Verify `composer check:strict` runs in CI on every PR -- [ ] Once all baselines are empty: - - [ ] Delete `phpmd.baseline.xml` (if it was created) - - [ ] Delete `phpstan-baseline.neon` (if it was created) - - [ ] Drop the legacy-debt section from `phpcs.xml` -- [ ] Add a smoke-test cron that runs `composer check:strict` - weekly on `development` +- [ ] Verify `composer check:strict` runs in CI on every PR; once all baselines are empty, delete `phpmd.baseline.xml` and `phpstan-baseline.neon` (if either was created) and drop the legacy-debt section from `phpcs.xml`. +- [ ] Add a smoke-test cron that runs `composer check:strict` weekly on `development`. ## Phase 6 — Documentation -- [ ] Update README quality-gates section -- [ ] Note in `app-config.json` that legacy quality cleanup is done -- [ ] Close the burn-down tracking issue once the last baseline - line is removed +- [ ] Update README quality-gates section, note in `app-config.json` that legacy quality cleanup is done, and close the burn-down tracking issue once the last baseline line is removed. diff --git a/openspec/changes/unit-test-coverage-75/tasks.md b/openspec/changes/unit-test-coverage-75/tasks.md index c39a7854..f4b10543 100644 --- a/openspec/changes/unit-test-coverage-75/tasks.md +++ b/openspec/changes/unit-test-coverage-75/tasks.md @@ -1,48 +1,38 @@ ## 1. Test Infrastructure Setup -- [ ] 1.1 Ensure tests/bootstrap.php supports OC::$server mocking for unit tests that need container access +- [ ] 1.1 Ensure `tests/bootstrap.php` supports `OC::$server` mocking for unit tests that need container access. -## 2. Service Unit Tests - Simple Services +## 2. Service Unit Tests — Simple Services -- [ ] 2.1 Create ZgwMappingServiceTest (getMapping, saveMapping, listMappings, deleteMapping) -- [ ] 2.2 Create ZgwPaginationHelperTest (wrapResults with various page/count combinations) -- [ ] 2.3 Create ZgwDocumentServiceTest (storeBase64, storeRaw, getContent, fileExists, deleteFiles) +- [ ] 2.1 `ZgwMappingServiceTest` — `getMapping`, `saveMapping`, `listMappings`, `deleteMapping`. +- [ ] 2.2 `ZgwPaginationHelperTest` — `wrapResults` with various page/count combinations. +- [ ] 2.3 `ZgwDocumentServiceTest` — `storeBase64`, `storeRaw`, `getContent`, `fileExists`, `deleteFiles`. -## 3. Service Unit Tests - Business Rules +## 3. Service Unit Tests — Business Rules -- [ ] 3.1 Create ZgwBrcRulesServiceTest (BRC rules: besluit create validation, uniqueness, immutability) -- [ ] 3.2 Create ZgwDrcRulesServiceTest (DRC rules: document create validation, lock checks) -- [ ] 3.3 Create ZgwZtcRulesServiceTest (ZTC rules: concept protection, afleidingswijze validation) -- [ ] 3.4 Create ZgwBusinessRulesServiceTest (dispatcher: delegates to correct register rules service) +- [ ] 3.1 Register rules services: `ZgwBrcRulesServiceTest` (besluit create validation, uniqueness, immutability), `ZgwDrcRulesServiceTest` (document create validation, lock checks), `ZgwZtcRulesServiceTest` (concept protection, afleidingswijze validation). +- [ ] 3.2 `ZgwBusinessRulesServiceTest` — dispatcher delegates to the correct per-register rules service. -## 4. Service Unit Tests - Complex Services +## 4. Service Unit Tests — Complex Services -- [ ] 4.1 Create NotificatieServiceTest (publish, deliver failure logging) -- [ ] 4.2 Create ZgwServiceTest (RESOURCE_MAP structure, constructor dependencies) +- [ ] 4.1 `NotificatieServiceTest` (`publish`, deliver-failure logging) and `ZgwServiceTest` (`RESOURCE_MAP` structure, constructor dependencies). -## 5. Controller Unit Tests - Simple Controllers +## 5. Controller Unit Tests — Simple Controllers -- [ ] 5.1 Create DashboardControllerTest (page returns TemplateResponse) -- [ ] 5.2 Create HealthControllerTest (health check returns JSONResponse with status) -- [ ] 5.3 Create MetricsControllerTest (index returns TextPlainResponse with Prometheus header) -- [ ] 5.4 Create SettingsControllerTest (getSettings, updateSettings delegation) -- [ ] 5.5 Create ZgwMappingControllerTest (index, show, update mapping operations) +- [ ] 5.1 `DashboardControllerTest` (`page` → `TemplateResponse`), `HealthControllerTest` (status JSON), `MetricsControllerTest` (`index` → `TextPlainResponse` with Prometheus header). +- [ ] 5.2 `SettingsControllerTest` (`getSettings`/`updateSettings` delegation) and `ZgwMappingControllerTest` (`index`, `show`, `update`). -## 6. Controller Unit Tests - ZGW Register Controllers +## 6. Controller Unit Tests — ZGW Register Controllers -- [ ] 6.1 Create ZrcControllerTest (index, create, show, update, destroy delegation to ZgwService) -- [ ] 6.2 Create ZtcControllerTest (index, create, show, update, destroy plus publish action) -- [ ] 6.3 Create DrcControllerTest (index, create, show, update, destroy plus download action) -- [ ] 6.4 Create BrcControllerTest (index, create, show, update, destroy delegation) -- [ ] 6.5 Create NrcControllerTest (index, create, show delegation) -- [ ] 6.6 Create AcControllerTest (index, create, show, update, destroy delegation) +- [ ] 6.1 `ZrcControllerTest` and `ZtcControllerTest` — index/create/show/update/destroy delegation to `ZgwService`; ZtcControllerTest also covers `publish`. +- [ ] 6.2 `DrcControllerTest` and `BrcControllerTest` — index/create/show/update/destroy delegation; DrcControllerTest also covers `download`. +- [ ] 6.3 `NrcControllerTest` (index/create/show delegation) and `AcControllerTest` (index/create/show/update/destroy delegation). ## 7. Newman API Tests -- [ ] 7.1 Create tests/newman/zgw-workflow.postman_collection.json with ZGW CRUD flow -- [ ] 7.2 Create tests/newman/procest-environment.json with local dev variables +- [ ] 7.1 Create `tests/newman/zgw-workflow.postman_collection.json` (ZGW CRUD flow) and `tests/newman/procest-environment.json` (local dev variables). ## 8. Verification -- [ ] 8.1 Run full PHPUnit suite and verify all tests pass -- [ ] 8.2 Verify file coverage count is 33+ of 42 (75%+) +- [ ] 8.1 Run full PHPUnit suite and verify all tests pass. +- [ ] 8.2 Verify file coverage count is 33+ of 42 (75%+). From 8fa8d14f716d9d4ffee41a6902e5cef6b8d3f791 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 18 May 2026 20:04:42 +0200 Subject: [PATCH 4/7] feat(docs): wire AI-crawler baseline (preset 3.4.0 + validator + llms.txt) (#472) --- docs/package.json | 4 +- docs/scripts/validate-ai-baseline.mjs | 177 ++++++++++++++++++++++++++ docs/static/llms.txt | 27 ++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100755 docs/scripts/validate-ai-baseline.mjs create mode 100644 docs/static/llms.txt diff --git a/docs/package.json b/docs/package.json index b5b9da0f..f34f78ae 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,6 +6,8 @@ "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", + "postbuild": "node scripts/validate-ai-baseline.mjs", + "validate:ai-baseline": "node scripts/validate-ai-baseline.mjs", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -15,7 +17,7 @@ "ci": "npm ci --legacy-peer-deps && npm run build" }, "dependencies": { - "@conduction/docusaurus-preset": "^2.6.1", + "@conduction/docusaurus-preset": "^3.4.0", "@docusaurus/core": "^3.7.0", "@docusaurus/preset-classic": "^3.7.0", "@docusaurus/theme-mermaid": "^3.7.0", diff --git a/docs/scripts/validate-ai-baseline.mjs b/docs/scripts/validate-ai-baseline.mjs new file mode 100755 index 00000000..83a127ed --- /dev/null +++ b/docs/scripts/validate-ai-baseline.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +/** + * scripts/validate-ai-baseline.mjs + * + * Generic AI-crawler baseline validator. Runs as a postbuild step on + * every Conduction Docusaurus site that consumes + * @conduction/docusaurus-preset >= 3.4.0. Asserts the SSG output + * carries the contract AI crawlers (GPTBot, ClaudeBot, PerplexityBot, + * OAI-SearchBot, Claude-SearchBot, Google AI Overviews) expect. + * + * Universal checks only - no site-specific routes. Sites that want + * additional gates (per-app SoftwareApplication, FAQPage on specific + * pages, etc.) extend this script in place. See conduction-website's + * version for an example of additional checks. + * + * Exit codes: + * 0 all checks passed + * 1 one or more checks failed (CI should block) + * 2 build directory not found (script invoked before build) + */ + +import {readFileSync, existsSync, statSync} from 'node:fs'; +import {join, resolve} from 'node:path'; + +const buildDir = resolve(process.argv[2] || 'build'); + +if (!existsSync(buildDir)) { + console.error(`✗ build directory not found: ${buildDir}`); + console.error(` Run \`npx docusaurus build\` first.`); + process.exit(2); +} + +const results = []; + +function check(name, fn) { + try { + const r = fn(); + results.push({name, ok: r.ok, msg: r.msg}); + } catch (e) { + results.push({name, ok: false, msg: `threw: ${e.message}`}); + } +} + +function readBuild(p) { + return readFileSync(join(buildDir, p), 'utf8'); +} + +/* robots.txt - shipped by the preset's ai-crawling plugin (or the + site's own static/robots.txt). Either way, the file must exist + and name at least one AI search bot so a `grep` audit can confirm + the posture at a glance. */ +check('robots.txt exists and is non-empty', () => { + const path = join(buildDir, 'robots.txt'); + if (!existsSync(path)) return {ok: false, msg: 'missing'}; + const size = statSync(path).size; + if (size < 50) return {ok: false, msg: `too small (${size} bytes)`}; + return {ok: true, msg: `${size} bytes`}; +}); + +check('robots.txt names at least one AI search bot', () => { + const body = readBuild('robots.txt'); + const candidates = ['OAI-SearchBot', 'Claude-SearchBot', 'PerplexityBot', 'ChatGPT-User', 'Claude-User']; + const found = candidates.filter(ua => body.includes(`User-agent: ${ua}`)); + if (found.length === 0) { + return {ok: false, msg: `none of [${candidates.join(', ')}] referenced`}; + } + return {ok: true, msg: `${found.length} bot(s): ${found.join(', ')}`}; +}); + +check('robots.txt has a Sitemap line', () => { + const body = readBuild('robots.txt'); + const matches = body.match(/^Sitemap:\s+https?:\/\//gm) || []; + if (matches.length === 0) return {ok: false, msg: 'no Sitemap: line'}; + return {ok: true, msg: `${matches.length} sitemap line(s)`}; +}); + +/* sitemap.xml - emitted by @docusaurus/plugin-sitemap (loaded via + the classic preset). Locale-specific sitemaps (e.g. /nl/sitemap.xml) + are present for i18n builds; we only check the canonical one + because some sites are single-locale. */ +check('sitemap.xml exists and has at least 1 URL', () => { + const path = join(buildDir, 'sitemap.xml'); + if (!existsSync(path)) return {ok: false, msg: 'missing'}; + const body = readBuild('sitemap.xml'); + const n = (body.match(//g) || []).length; + if (n < 1) return {ok: false, msg: 'no entries'}; + return {ok: true, msg: `${n} URLs`}; +}); + +/* Helper for the JSON-LD checks below. Docusaurus emits ld+json + tags via two paths with different attribute ordering: top-level + headTags renders