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.