Compare commits
3 Commits
mai/artemi
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c28009de | |||
| ff503ffc43 | |||
| 05f7ea2af5 |
@@ -1117,10 +1117,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Termin ge\u00e4ndert",
|
||||
"event.title.appointment_deleted": "Termin gel\u00f6scht",
|
||||
"event.title.appointment_project_changed": "Termin verschoben",
|
||||
// Umbrella audit kind + admin churn surfaced by the FilterBar
|
||||
// project_event_kind chip cluster (KnownProjectEventKinds).
|
||||
"event.title.approval_decided": "Genehmigung entschieden",
|
||||
"event.title.member_role_changed": "Teamrolle ge\u00e4ndert",
|
||||
// 4-eye approval lifecycle (t-paliad-138). Verlauf renders these as
|
||||
// a paired card with the original lifecycle event (e.g.
|
||||
// "Frist angelegt" + "Genehmigung erteilt von Bert").
|
||||
@@ -4147,10 +4143,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.appointment_updated": "Appointment updated",
|
||||
"event.title.appointment_deleted": "Appointment deleted",
|
||||
"event.title.appointment_project_changed": "Appointment moved",
|
||||
// Umbrella audit kind + admin churn surfaced by the FilterBar
|
||||
// project_event_kind chip cluster (KnownProjectEventKinds).
|
||||
"event.title.approval_decided": "Approval decided",
|
||||
"event.title.member_role_changed": "Team role changed",
|
||||
// 4-eye approval lifecycle (t-paliad-138).
|
||||
"event.title.deadline_approval_requested": "Approval requested",
|
||||
"event.title.deadline_approval_approved": "Approval granted",
|
||||
|
||||
@@ -1604,7 +1604,6 @@ export type I18nKey =
|
||||
| "event.title.appointment_deleted"
|
||||
| "event.title.appointment_project_changed"
|
||||
| "event.title.appointment_updated"
|
||||
| "event.title.approval_decided"
|
||||
| "event.title.checklist_created"
|
||||
| "event.title.checklist_deleted"
|
||||
| "event.title.checklist_linked"
|
||||
@@ -1623,7 +1622,6 @@ export type I18nKey =
|
||||
| "event.title.deadline_reopened"
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.member_role_changed"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
|
||||
146
internal/db/migrations/127_wave0_tier0_deadline_fixes.down.sql
Normal file
146
internal/db/migrations/127_wave0_tier0_deadline_fixes.down.sql
Normal file
@@ -0,0 +1,146 @@
|
||||
-- Revert t-paliad-263 Wave 0 + m/paliad#99.
|
||||
-- Restores each Tier 0 row to its pre-fix state per
|
||||
-- docs/research-deadlines-completeness-2026-05-25.md §10. T0.5 and
|
||||
-- T0.6 are NOT reverted here — they live in mig 124's down.
|
||||
--
|
||||
-- audit_reason set_config required for the mig 079 trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 127 revert: unwind Tier 0 deadline-rule corrections (Wave 0 + #99)',
|
||||
true);
|
||||
|
||||
-- T0.1 defence: 2mo + RoP.049.1 → 3mo + RoP.49.1
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 3,
|
||||
rule_code = 'RoP.49.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.defence'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.2 rejoin: 1mo + RoP.052/UPC.RoP.52 → 2mo + NULL/NULL
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 2,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.rejoin'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.3 response: 3mo + RoP.235.1 → 2mo + NULL
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 2,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.4 beruf_begr: parent_id NULL → de.inf.lg.berufung
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.berufung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
LIMIT 1
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.inf.lg.beruf_begr'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.7 reply: clear citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.reply'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.9 notice: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.220.1',
|
||||
legal_source = 'UPC.RoP.220.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.notice'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.10 grounds: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.220.1',
|
||||
legal_source = 'UPC.RoP.220.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.grounds'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.12 dpma.opp erwiderung: restore court-set=false + §59 citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
rule_code = '§ 59 PatG',
|
||||
legal_source = 'DE.PatG.59.3',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.13 dpma.appeal.bpatg begründung: restore court-set=false + §75 citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
rule_code = '§ 75 PatG',
|
||||
legal_source = 'DE.PatG.75.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.14 bpatg erwidg: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 82 PatG',
|
||||
legal_source = 'DE.PatG.82.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bpatg.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.15 bgh begründung: revert citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 111 PatG',
|
||||
legal_source = 'DE.PatG.111.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.16 bgh erwiderung: revert court-set + citation
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
rule_code = '§ 111 PatG',
|
||||
legal_source = 'DE.PatG.111.3',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- T0.17 epa.opp opd erwidg: revert court-set
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'epa.opp.opd.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
|
||||
-- #99 upc.inf.cfi.soc: clear citation backfill
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published';
|
||||
477
internal/db/migrations/127_wave0_tier0_deadline_fixes.up.sql
Normal file
477
internal/db/migrations/127_wave0_tier0_deadline_fixes.up.sql
Normal file
@@ -0,0 +1,477 @@
|
||||
-- t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections.
|
||||
--
|
||||
-- Source: docs/research-deadlines-completeness-2026-05-25.md §10 Tier 0
|
||||
-- (curie's bulletproof completeness audit, merged 2026-05-25 as commit
|
||||
-- 94a9e7e). 16 distinct single-row UPDATEs across UPC + DE-LG + DPMA +
|
||||
-- EPA proceedings; T0.5 + T0.6 were shipped separately as mig 124
|
||||
-- (m/paliad#95, de.inf.lg Replik/Duplik sequencing) and are not
|
||||
-- repeated here. T0.8 (covered by T0.2) and T0.11 (covered by T0.1)
|
||||
-- are dedup'd out per the audit's own note.
|
||||
--
|
||||
-- Also folds in m/paliad#99 (UPC Statement of Claim missing legal
|
||||
-- citation): upc.inf.cfi.soc.rule_code / legal_source backfilled to
|
||||
-- UPC RoP R.13(1). Same migration file, separate UPDATE block with
|
||||
-- its own guard.
|
||||
--
|
||||
-- All fixes within the existing schema (no new columns). Each UPDATE
|
||||
-- is guarded by a WHERE clause that matches only the pre-fix row
|
||||
-- state (per mig 095 convention) — re-applying against a DB that
|
||||
-- already carries the fix matches zero rows and no-ops, so there are
|
||||
-- no duplicate deadline_rule_audit entries on idempotent re-runs.
|
||||
--
|
||||
-- Verification DO block at the end RAISEs EXCEPTION if any of the
|
||||
-- patched rows is left in an inconsistent shape (mixing pre-fix and
|
||||
-- post-fix state).
|
||||
--
|
||||
-- audit_reason set_config required at the top — the mig 079 trigger
|
||||
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
|
||||
-- on any UPDATE without it.
|
||||
--
|
||||
-- Slot 127 reserved per paliadin: sequence is 124 brunel #95 (done),
|
||||
-- 125 hermes #97, 126 icarus #80, 127 brunel Wave 0 + #99, 128+ next.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 127: t-paliad-263 Wave 0 + m/paliad#99 — Tier 0 deadline-rule corrections from curie''s audit (docs/research-deadlines-completeness-2026-05-25.md §10) plus UPC SoC R.13 citation',
|
||||
true);
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.1 upc.rev.cfi.defence — duration 3mo → 2mo per RoP.049.1.
|
||||
-- Zero-pads the rule_code citation to canonical form. Audit §5
|
||||
-- (wrong period — every UPC_REV tracked in paliad today computes
|
||||
-- Defence at +3 months, statute says +2). Verbatim from
|
||||
-- UPCRoP.049.1: "The defendant shall lodge a Defence to revocation
|
||||
-- within two months of service of the Statement for revocation."
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 2,
|
||||
rule_code = 'RoP.049.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.defence'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 3
|
||||
AND rule_code = 'RoP.49.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.2 upc.rev.cfi.rejoin — duration 2mo → 1mo per RoP.052; add citation.
|
||||
-- Audit §5 (wrong period). Verbatim from UPCRoP.052: "Within one
|
||||
-- month of the service of the Reply the defendant may lodge a
|
||||
-- Rejoinder to the Reply to the Defence to revocation."
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 1,
|
||||
rule_code = 'RoP.052',
|
||||
legal_source = 'UPC.RoP.52',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.rejoin'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 2
|
||||
AND rule_code IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.3 upc.apl.merits.response — duration 2mo → 3mo per RoP.235.1.
|
||||
-- Audit §5 (wrong period — every main-track appellate respondent).
|
||||
-- Verbatim from UPCRoP.235.1: "Within three months of service of
|
||||
-- the Statement of grounds of appeal pursuant to Rule 224.2(a),
|
||||
-- any other party … may lodge a Statement of response."
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET duration_value = 3,
|
||||
rule_code = 'RoP.235.1',
|
||||
legal_source = 'UPC.RoP.235.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 2
|
||||
AND rule_code IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.4 de.inf.lg.beruf_begr — parent_id = NULL (was de.inf.lg.berufung).
|
||||
-- Audit §7.1 — every DE-LG-Verletzung appeal renders the
|
||||
-- Berufungsbegründung at trigger + 1mo (Berufung) + 2mo = 3 months
|
||||
-- from Urteil-service. Per ZPO §520(2) "die Frist für die
|
||||
-- Berufungsbegründung beträgt zwei Monate. Sie beginnt mit der
|
||||
-- Zustellung des in vollständiger Form abgefassten Urteils" → 2
|
||||
-- months from Urteil-service (parallel to, not chained off, the
|
||||
-- Berufungsfrist itself). NULL parent_id makes the rule anchor
|
||||
-- on the proceeding's trigger date — matches how the symmetric
|
||||
-- de.inf.olg.begruendung is modelled.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.inf.lg.beruf_begr'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND parent_id = (
|
||||
SELECT id FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.berufung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.5 / T0.6 de.inf.lg.replik + de.inf.lg.duplik — already shipped
|
||||
-- as mig 124 (m/paliad#95). Not repeated here. Idempotency of the
|
||||
-- audit's Tier 0 sweep against a fresh DB is preserved because mig
|
||||
-- 124 runs before this one and is itself guarded.
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.7 upc.rev.cfi.reply — backfill rule_code + legal_source per RoP.051.
|
||||
-- Audit §4.1 — duration (2mo) unchanged. Verbatim from UPCRoP.051:
|
||||
-- "Reply to Defence to revocation and Application to amend the
|
||||
-- patent. The claimant in the revocation action may, within two
|
||||
-- months of service of the Defence to revocation and the
|
||||
-- Application to amend the patent, if any, lodge a Reply…"
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.051',
|
||||
legal_source = 'UPC.RoP.51',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.rev.cfi.reply'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code IS NULL
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.9 upc.apl.merits.notice — citation drift RoP.220.1 → RoP.224.1.a.
|
||||
-- Audit §4.1 — duration unchanged. R.220.1 is the umbrella ("an
|
||||
-- appeal may be brought"); R.224.1(a) carries the Notice-of-appeal
|
||||
-- 2-month period explicitly.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.224.1.a',
|
||||
legal_source = 'UPC.RoP.224.1.a',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.notice'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.220.1'
|
||||
AND legal_source = 'UPC.RoP.220.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.10 upc.apl.merits.grounds — citation drift RoP.220.1 → RoP.224.2.a.
|
||||
-- Audit §4.1 — duration unchanged. R.224.2(a) sets the Grounds
|
||||
-- 4-month period for decisions referred to in R.220.1(a) and (b).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.224.2.a',
|
||||
legal_source = 'UPC.RoP.224.2.a',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.apl.merits.grounds'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.220.1'
|
||||
AND legal_source = 'UPC.RoP.220.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.12 dpma.opp.dpma.erwiderung — flip is_court_set = true; drop the
|
||||
-- § 59(3) PatG citation. Audit §4.3 + §9.1: §59(3) addresses
|
||||
-- Anhörung, not a 4-month response period. No statutory
|
||||
-- Erwiderungsfrist exists in §59 — the 4-month figure is DPMA
|
||||
-- practice (DPMA-Richtlinien D-IV 5.2). Modelled court-set, the
|
||||
-- 4-month value remains the default-display heuristic the
|
||||
-- lawyer overrides via "Datum setzen".
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'DE.PatG.59.3';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.13 dpma.appeal.bpatg.begruendung — flip is_court_set = true; drop
|
||||
-- the § 75 PatG citation. Audit §4.3 + §9.1: §75 PatG addresses
|
||||
-- aufschiebende Wirkung only, not a Begründungsfrist. No fixed
|
||||
-- Begründungsfrist for BPatG-Beschwerde exists in PatG §§73-80 —
|
||||
-- the BPatG sets it in the individual case. 1-month default
|
||||
-- retained as display heuristic.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
rule_code = NULL,
|
||||
legal_source = NULL,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'DE.PatG.75.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.14 de.null.bpatg.erwidg — citation DE.PatG.82.1 → DE.PatG.82.3.
|
||||
-- Audit §4.4 — duration (2 months) is correct. §82(1) carries the
|
||||
-- 1-month Erklärungsfrist ("sich darüber zu erklären"); the full
|
||||
-- Klageerwiderung 2-month period lives in §82(3).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 82 Abs. 3 PatG',
|
||||
legal_source = 'DE.PatG.82.3',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bpatg.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.PatG.82.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.15 de.null.bgh.begruendung — citation DE.PatG.111.1 →
|
||||
-- DE.ZPO.520.2 (via PatG §117). Audit §4.4 — duration (3 months)
|
||||
-- is correct. §111 PatG defines the Grounds of Berufung
|
||||
-- (Verletzung des Bundesrechts), not a Begründungsfrist; the
|
||||
-- 3-month figure is supplied by §117 PatG → ZPO §520(2).
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = '§ 520 Abs. 2 ZPO i.V.m. § 117 PatG',
|
||||
legal_source = 'DE.ZPO.520.2',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.PatG.111.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.16 de.null.bgh.erwiderung — flip is_court_set = true; recite as
|
||||
-- DE.ZPO.521.2 (via PatG §117). Audit §4.4 + §9.1 — §111 PatG
|
||||
-- has no Erwiderungsfrist clause. The actual Erwiderungsfrist
|
||||
-- for BGH-Nichtigkeitsberufung is set by the court per §117
|
||||
-- PatG → ZPO §521(2). 2-month default retained as display
|
||||
-- heuristic.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
rule_code = '§ 521 Abs. 2 ZPO i.V.m. § 117 PatG',
|
||||
legal_source = 'DE.ZPO.521.2',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'de.null.bgh.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'DE.PatG.111.3';
|
||||
|
||||
-- =============================================================================
|
||||
-- T0.17 epa.opp.opd.erwidg — flip is_court_set = true. Audit §4.5 +
|
||||
-- §9.1: R.79(1) EPÜ authorises the Opposition Division to set
|
||||
-- the period, but does not specify a fixed 4 months. The 4-month
|
||||
-- figure is administrative practice (EPO Guidelines D-IV 5.2).
|
||||
-- Citation retained as the rule-of-authority for the OD's
|
||||
-- discretion. 4-month default retained as display heuristic.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true,
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'epa.opp.opd.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = false
|
||||
AND legal_source = 'EU.EPC-R.79.1';
|
||||
|
||||
-- =============================================================================
|
||||
-- m/paliad#99 upc.inf.cfi.soc — backfill UPC RoP R.13(1) citation.
|
||||
-- The Statement of Claim is defined in UPC RoP R.13 (R.13.1
|
||||
-- lists the required contents). The row carries no statutory
|
||||
-- deadline (duration_value = 0, parent_id IS NULL — the SoC is
|
||||
-- the originating filing that anchors the proceeding's trigger
|
||||
-- date), but the catalog UI surfaces the rule citation in
|
||||
-- result cards and the Type=Statement-of-Claim / Rule=Auto
|
||||
-- resolution; both render blank today because rule_code +
|
||||
-- legal_source are NULL. Backfill leaves duration / anchor /
|
||||
-- party untouched.
|
||||
-- =============================================================================
|
||||
UPDATE paliad.deadline_rules
|
||||
SET rule_code = 'RoP.013.1',
|
||||
legal_source = 'UPC.RoP.13.1',
|
||||
updated_at = now()
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code IS NULL
|
||||
AND legal_source IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- Hard assertions. Each touched row must end up in its post-fix
|
||||
-- shape. Re-running the migration after a successful first run is a
|
||||
-- no-op for the data but the assertions still pass because they
|
||||
-- check the post-fix state.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_count integer;
|
||||
BEGIN
|
||||
-- T0.1 defence: dur=2 + canonical zero-padded rule_code
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.rev.cfi.defence'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 2
|
||||
AND rule_code = 'RoP.049.1';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.1: upc.rev.cfi.defence not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.2 rejoin: dur=1
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.rev.cfi.rejoin'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 1
|
||||
AND rule_code = 'RoP.052';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.2: upc.rev.cfi.rejoin not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.3 response: dur=3
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.merits.response'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND duration_value = 3
|
||||
AND rule_code = 'RoP.235.1';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.3: upc.apl.merits.response not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.4 beruf_begr: parent_id IS NULL
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.inf.lg.beruf_begr'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND parent_id IS NULL;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.4: de.inf.lg.beruf_begr not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.7 reply: citation backfilled
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.rev.cfi.reply'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.051'
|
||||
AND legal_source = 'UPC.RoP.51';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.7: upc.rev.cfi.reply not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.9 notice: citation RoP.224.1.a
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.merits.notice'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.224.1.a'
|
||||
AND legal_source = 'UPC.RoP.224.1.a';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.9: upc.apl.merits.notice not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.10 grounds: citation RoP.224.2.a
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.apl.merits.grounds'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.224.2.a'
|
||||
AND legal_source = 'UPC.RoP.224.2.a';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.10: upc.apl.merits.grounds not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.12 dpma.opp erwiderung: court-set, no citation
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'dpma.opp.dpma.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND legal_source IS NULL
|
||||
AND rule_code IS NULL;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.12: dpma.opp.dpma.erwiderung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.13 dpma.appeal.bpatg begründung: court-set, no citation
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'dpma.appeal.bpatg.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND legal_source IS NULL
|
||||
AND rule_code IS NULL;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.13: dpma.appeal.bpatg.begruendung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.14 bpatg erwidg: §82.3
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.null.bpatg.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.PatG.82.3';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.14: de.null.bpatg.erwidg not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.15 bgh begründung: ZPO §520.2
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.null.bgh.begruendung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND legal_source = 'DE.ZPO.520.2';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.15: de.null.bgh.begruendung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.16 bgh erwiderung: court-set, ZPO §521.2
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'de.null.bgh.erwiderung'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true
|
||||
AND legal_source = 'DE.ZPO.521.2';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.16: de.null.bgh.erwiderung not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- T0.17 epa.opp opd erwidg: court-set
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'epa.opp.opd.erwidg'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_court_set = true;
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 T0.17: epa.opp.opd.erwidg not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
|
||||
-- #99 upc.inf.cfi.soc: citation backfilled
|
||||
SELECT count(*) INTO v_count
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = 'upc.inf.cfi.soc'
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'
|
||||
AND rule_code = 'RoP.013.1'
|
||||
AND legal_source = 'UPC.RoP.13.1';
|
||||
IF v_count <> 1 THEN
|
||||
RAISE EXCEPTION 'mig 127 #99: upc.inf.cfi.soc not in post-fix shape (got % matches)', v_count;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Revert t-paliad-271 Wave 2 Tier-3 Slice A — drop duration_unit /
|
||||
-- alt_duration_unit CHECK constraints. Pre-mig-128 the columns accepted
|
||||
-- arbitrary text, so dropping the CHECKs restores that shape exactly.
|
||||
-- No data revert necessary — the constraint addition was purely
|
||||
-- additive and validated against live data before adding.
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
|
||||
36
internal/db/migrations/128_deadline_rules_unit_check.up.sql
Normal file
36
internal/db/migrations/128_deadline_rules_unit_check.up.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- t-paliad-271 Wave 2 Tier-3 Slice A — duration_unit CHECK constraint with
|
||||
-- 'working_days' added to the allowed set.
|
||||
--
|
||||
-- Per docs/research-deadlines-completeness-2026-05-25.md Tier 3 Primitive 1
|
||||
-- (T3.1) — the calculator gains a business-day arithmetic path for UPC RoP
|
||||
-- R.198 / R.213 (and downstream for any rule that needs the 31d-OR-20wd
|
||||
-- combine-max pattern). The schema currently accepts free-text on
|
||||
-- duration_unit (no CHECK), which is why 'working_days' rows already exist
|
||||
-- in the DB but were silently dropped by the calculator. Adding the CHECK
|
||||
-- pins the contract and prevents typos.
|
||||
--
|
||||
-- alt_duration_unit gets the same constraint (NULL-tolerant) so the alt
|
||||
-- path stays in lockstep with the primary path.
|
||||
--
|
||||
-- Idempotent: DROP CONSTRAINT IF EXISTS before ADD. Existing data was
|
||||
-- audited via `SELECT DISTINCT duration_unit FROM paliad.deadline_rules`
|
||||
-- on 2026-05-25 (returned only days/weeks/months) plus the two live
|
||||
-- alt-unit rows already at 'working_days' — both shapes pass.
|
||||
--
|
||||
-- audit_reason set_config is NOT needed for DDL (mig 079 trigger fires on
|
||||
-- INSERT/UPDATE/DELETE on the rows, not on ALTER TABLE).
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_duration_unit_check
|
||||
CHECK (duration_unit IN ('days', 'weeks', 'months', 'working_days'));
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP CONSTRAINT IF EXISTS deadline_rules_alt_duration_unit_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_alt_duration_unit_check
|
||||
CHECK (alt_duration_unit IS NULL
|
||||
OR alt_duration_unit IN ('days', 'weeks', 'months', 'working_days'));
|
||||
@@ -27,33 +27,119 @@ func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
|
||||
}
|
||||
|
||||
// CalculateEndDate applies a single rule's duration + timing to the event date,
|
||||
// then bumps forward off non-working days for the given (country, regime).
|
||||
// Returns (adjusted, original, didAdjust).
|
||||
// then bumps off non-working days for the given (country, regime). For
|
||||
// rules with both a primary and an alt duration (alt_duration_value/_unit)
|
||||
// and a combine_op of 'max' or 'min', both legs are computed independently
|
||||
// and combined per the operator — this implements RoP R.198 / R.213
|
||||
// ("31 days OR 20 working days, whichever is longer") and the equivalent
|
||||
// shape under EPC. Returns (adjusted, original, didAdjust).
|
||||
//
|
||||
// Snap direction follows timing: 'after' snaps forward to the next
|
||||
// working day (RoP R.300.b — period extends to the next working day),
|
||||
// 'before' snaps *backward* to the preceding working day so the
|
||||
// statutory cut-off is not pushed past its hard limit.
|
||||
//
|
||||
// duration_unit='working_days' walks day-by-day via the holiday service
|
||||
// (skipping weekends + court holidays), so its result is always already a
|
||||
// working day — no post-arithmetic snap needed for that leg.
|
||||
//
|
||||
// Per Tier 3 Primitives §10 of docs/research-deadlines-completeness-2026-05-25.md
|
||||
// (m's 2026-05-25 15:29 steer: build the full primitives, no workarounds).
|
||||
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule, country, regime string) (time.Time, time.Time, bool) {
|
||||
endDate := eventDate
|
||||
|
||||
timing := "after"
|
||||
if rule.Timing != nil {
|
||||
timing = *rule.Timing
|
||||
}
|
||||
|
||||
adjusted, raw, wasAdjusted := c.computeLeg(eventDate, rule.DurationValue, rule.DurationUnit, timing, country, regime)
|
||||
|
||||
// combine_op + alt_duration_*: compute the alt leg independently,
|
||||
// then pick the later (max) or earlier (min) of the two adjusted
|
||||
// end-dates. Live use case is UPC RoP R.198 / R.213 (31 calendar
|
||||
// days vs. 20 working days, whichever is longer).
|
||||
if rule.CombineOp != nil && rule.AltDurationValue != nil && rule.AltDurationUnit != nil {
|
||||
altAdj, altRaw, altWasAdj := c.computeLeg(eventDate, *rule.AltDurationValue, *rule.AltDurationUnit, timing, country, regime)
|
||||
switch *rule.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(adjusted) {
|
||||
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(adjusted) {
|
||||
adjusted, raw, wasAdjusted = altAdj, altRaw, altWasAdj
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return adjusted, raw, wasAdjusted
|
||||
}
|
||||
|
||||
// computeLeg evaluates a single (value, unit) duration against the event
|
||||
// date in the given timing direction and snap-adjusts the result. Returns
|
||||
// the snap-adjusted end-date, the pre-snap end-date, and whether a snap
|
||||
// occurred. working_days arithmetic never needs a snap (the walker lands
|
||||
// on a working day by construction).
|
||||
func (c *DeadlineCalculator) computeLeg(eventDate time.Time, value int, unit string, timing string, country, regime string) (adjusted, raw time.Time, wasAdjusted bool) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
}
|
||||
|
||||
switch rule.DurationUnit {
|
||||
case "days":
|
||||
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue)
|
||||
case "weeks":
|
||||
endDate = endDate.AddDate(0, 0, sign*rule.DurationValue*7)
|
||||
case "months":
|
||||
endDate = endDate.AddDate(0, sign*rule.DurationValue, 0)
|
||||
raw = c.addDuration(eventDate, value, unit, sign, country, regime)
|
||||
if unit == "working_days" {
|
||||
return raw, raw, false
|
||||
}
|
||||
if timing == "before" {
|
||||
return c.holidays.AdjustForNonWorkingDaysBackward(raw, country, regime)
|
||||
}
|
||||
return c.holidays.AdjustForNonWorkingDays(raw, country, regime)
|
||||
}
|
||||
|
||||
original := endDate
|
||||
adjusted, _, wasAdjusted := c.holidays.AdjustForNonWorkingDays(endDate, country, regime)
|
||||
return adjusted, original, wasAdjusted
|
||||
// addDuration adds `sign * value` of the given unit to eventDate. For
|
||||
// 'working_days' it walks day-by-day skipping weekends and court
|
||||
// holidays via the holiday service.
|
||||
func (c *DeadlineCalculator) addDuration(eventDate time.Time, value int, unit string, sign int, country, regime string) time.Time {
|
||||
switch unit {
|
||||
case "days":
|
||||
return eventDate.AddDate(0, 0, sign*value)
|
||||
case "weeks":
|
||||
return eventDate.AddDate(0, 0, sign*value*7)
|
||||
case "months":
|
||||
return eventDate.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
return c.addWorkingDays(eventDate, sign*value, country, regime)
|
||||
}
|
||||
return eventDate
|
||||
}
|
||||
|
||||
// addWorkingDays walks `n` business days from `date` (negative `n` walks
|
||||
// backward). The event day itself is never counted; we step first, then
|
||||
// skip past non-working days, repeated n times. Result is always a
|
||||
// working day for the given (country, regime). Matches UPC RoP R.300.b's
|
||||
// "the day on which the event happens shall not be counted" convention
|
||||
// applied to the business-day axis.
|
||||
//
|
||||
// Bound: each business-day step is bounded by a 60-day inner cap so a
|
||||
// misconfigured holiday table can never spin forever. The longest
|
||||
// real-world non-working run between adjacent business days is the
|
||||
// Christmas Eve → Neujahr window (~6 days), so 60 is over-provisioned.
|
||||
func (c *DeadlineCalculator) addWorkingDays(date time.Time, n int, country, regime string) time.Time {
|
||||
if n == 0 {
|
||||
return date
|
||||
}
|
||||
step := 1
|
||||
count := n
|
||||
if n < 0 {
|
||||
step = -1
|
||||
count = -n
|
||||
}
|
||||
cur := date
|
||||
for i := 0; i < count; i++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
for j := 0; j < 60 && c.holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// CalculateFromRules calculates deadlines for a slice of rules using the
|
||||
|
||||
@@ -93,7 +93,14 @@ func TestCalculateEndDate_Weeks_LandsOnHoliday(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateEndDate_BeforeTiming(t *testing.T) {
|
||||
// TestCalculateEndDate_BeforeTiming_SnapsBackward — Tier 3 Primitive 5
|
||||
// (m/paliad#103 Slice A). For timing='before' rules (R.109.1 / R.109.4
|
||||
// "no later than X before the oral hearing"), a computed cut-off that
|
||||
// lands on a weekend / holiday must snap *backward* to the preceding
|
||||
// working day. Forward snap would push the cut-off past the statutory
|
||||
// limit and miss the deadline. See
|
||||
// docs/research-deadlines-completeness-2026-05-25.md §10 T3.5.
|
||||
func TestCalculateEndDate_BeforeTiming_SnapsBackward(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
@@ -104,11 +111,322 @@ func TestCalculateEndDate_BeforeTiming(t *testing.T) {
|
||||
DurationUnit: "months",
|
||||
Timing: ptr("before"),
|
||||
}
|
||||
// "before" subtracts: 2026-04-15 - 1 month = 2026-03-15 (Sunday).
|
||||
// Adjust: Sunday → Monday 2026-03-16.
|
||||
// "before" subtracts: 2026-04-15 (Wed) - 1 month = 2026-03-15 (Sunday).
|
||||
// Backward snap: Sunday → Friday 2026-03-13 (Karfreitag is later
|
||||
// in 2026, so no extra holiday in this window).
|
||||
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
wantOrig := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
wantAdj := time.Date(2026, 3, 13, 0, 0, 0, 0, time.UTC)
|
||||
if !original.Equal(wantOrig) {
|
||||
t.Errorf("original: got %s, want %s", original, wantOrig)
|
||||
}
|
||||
if !adjusted.Equal(wantAdj) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, wantAdj)
|
||||
}
|
||||
if !wasAdjusted {
|
||||
t.Error("expected wasAdjusted=true (Sun → preceding Fri)")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 5 — backward snap across Karfreitag / Ostermontag.
|
||||
// 2026 Ostern: Karfreitag = 2026-04-03 (Fri), Ostermontag = 2026-04-06 (Mon).
|
||||
// Anchor Tue 2026-05-05 minus 1 month = Sun 2026-04-05 → backward through
|
||||
// Sat → Karfreitag → Thu 2026-04-02.
|
||||
func TestCalculateEndDate_BeforeTiming_BackwardSkipsHolidayCluster(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "1-month before, Ostern cluster",
|
||||
DurationValue: 1,
|
||||
DurationUnit: "months",
|
||||
Timing: ptr("before"),
|
||||
}
|
||||
in := time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
if !wasAdjusted {
|
||||
t.Error("expected wasAdjusted=true (Sun→Karfreitag→Thu)")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days arithmetic forward over a weekend.
|
||||
// Anchor Mon 2026-01-12 + 5 working days = Tue 13 (1), Wed 14 (2),
|
||||
// Thu 15 (3), Fri 16 (4), Mon 19 (5). Result = Mon 2026-01-19.
|
||||
func TestCalculateEndDate_WorkingDays_ForwardSkipsWeekend(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "5 working days",
|
||||
DurationValue: 5,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, original, wasAdjusted := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 19, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
// working_days arithmetic lands on a working day by construction, so the
|
||||
// "snap" reports no adjustment and original == adjusted.
|
||||
if !original.Equal(want) {
|
||||
t.Errorf("original: got %s, want %s", original, want)
|
||||
}
|
||||
if wasAdjusted {
|
||||
t.Error("working_days result should not report a snap adjustment")
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days arithmetic with anchor on Friday;
|
||||
// 20 working days lands on the Friday four weeks later. Anchor Fri
|
||||
// 2026-01-09 → +20wd → Fri 2026-02-06. No DE federal holiday in
|
||||
// window. This exercises the R.198 / R.213 "20 working days" leg.
|
||||
func TestCalculateEndDate_WorkingDays_TwentyDays(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "20 working days",
|
||||
DurationValue: 20,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 1, 9, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 3, 16, 0, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 2, 6, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days across Karfreitag/Ostermontag. Anchor
|
||||
// Thu 2026-04-02 + 3 working days: skip Karfreitag (Fri 04-03), weekend,
|
||||
// Ostermontag (Mon 04-06). Walk: Tue 04-07 (1), Wed 04-08 (2), Thu 04-09
|
||||
// (3). Result = Thu 2026-04-09.
|
||||
func TestCalculateEndDate_WorkingDays_AcrossEasterCluster(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "3 working days over Ostern",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 4, 2, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 9, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days across year boundary. Anchor Mon
|
||||
// 2025-12-29 + 5 working days. Calendar: Tue 30 (1), Wed 31 (2),
|
||||
// Thu 2026-01-01 = Neujahr (skip), Fri 2026-01-02 (3), Mon 05 (4),
|
||||
// Tue 06 (5). Result = Tue 2026-01-06.
|
||||
func TestCalculateEndDate_WorkingDays_AcrossYearBoundary(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "5 working days over year-end",
|
||||
DurationValue: 5,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2025, 12, 29, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 6, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days backward (timing='before'). Anchor
|
||||
// Fri 2026-04-17 - 5 working days: Thu 16 (1), Wed 15 (2), Tue 14 (3),
|
||||
// Mon 13 (4), Fri 10 (5 — Mon 13 - 3 days skipping Sun/Sat). Result =
|
||||
// Fri 2026-04-10.
|
||||
func TestCalculateEndDate_WorkingDays_BackwardSkipsWeekend(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "5 working days before",
|
||||
DurationValue: 5,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("before"),
|
||||
}
|
||||
in := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 1 — working_days anchored on a Saturday (rare but
|
||||
// must not loop). +3 working days from Sat 2026-01-10: Mon 12 (1), Tue
|
||||
// 13 (2), Wed 14 (3). Result = Wed 2026-01-14.
|
||||
func TestCalculateEndDate_WorkingDays_AnchorOnWeekend(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "3 working days from Saturday",
|
||||
DurationValue: 3,
|
||||
DurationUnit: "working_days",
|
||||
Timing: ptr("after"),
|
||||
}
|
||||
in := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 14, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op='max' picks the LATER of two adjusted
|
||||
// end-dates. Matches UPC RoP R.198 / R.213 "31 calendar days OR 20
|
||||
// working days, whichever is longer". Anchor Mon 2026-01-12.
|
||||
// - Primary: 31 cal days → Sun 2026-02-12... wait, Mon Jan 12 + 31 =
|
||||
// Thu 2026-02-12 (verify: Jan has 31 days; 12 + 31 = day-43 of year
|
||||
// = Feb 12). Feb 12 2026 is Thursday → no snap, +31d.
|
||||
// - Alt: 20 working_days → Mon Jan 12 + 20wd: Tue 13 (1) ... walk
|
||||
// gives Mon 2026-02-09 (20 business days later, no DE holiday).
|
||||
//
|
||||
// max(Feb 12 Thu, Feb 09 Mon) = Feb 12 → primary wins.
|
||||
func TestCalculateEndDate_CombineMax_PrimaryWins(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "31d OR 20wd, max",
|
||||
DurationValue: 31,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
AltDurationValue: ptr(20),
|
||||
AltDurationUnit: ptr("working_days"),
|
||||
CombineOp: ptr("max"),
|
||||
}
|
||||
in := time.Date(2026, 1, 12, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 2, 12, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op='max', alt wins. Anchor that makes the
|
||||
// 20-working-days leg longer than the 31-cal-day leg. Anchor Fri
|
||||
// 2026-01-09: +31 cal days = Mon 2026-02-09 (calendar weekday, no snap);
|
||||
// +20 working_days = Fri 2026-02-06 ... actually let's pick an anchor
|
||||
// where the working-days side overshoots. Anchor over a long-weekend
|
||||
// cluster: Wed 2026-12-23, +31cal = Sat 2027-01-23 → forward-snap to Mon
|
||||
// 2027-01-25 (DE has no holiday that day). +20wd = walk skipping Heilig
|
||||
// Abend, Christmas, Neujahr, weekends. Pick simpler: anchor where 31cal
|
||||
// + snap ≈ 20wd + cluster.
|
||||
//
|
||||
// Concrete: anchor Mon 2026-01-12, mock the 31d leg landing on Sun
|
||||
// 2026-02-15 (no — Jan 12 + 34 days = Feb 15, not 31). For deterministic
|
||||
// "alt wins", we use a configurable anchor and check the relative order
|
||||
// instead.
|
||||
func TestCalculateEndDate_CombineMax_AltWins(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
// Anchor Thu 2026-12-24 (Heilig Abend is not a DE federal holiday;
|
||||
// holiday service only has Neujahr/Easter/.../Weihnachtstag — Dec
|
||||
// 24 is a working day here). +14 calendar days = Thu 2027-01-07.
|
||||
// +20 working_days walks Fri 12-25 (1. Weihnachtstag — skip), ...
|
||||
// arrives much later. Use 14 days vs 20 working_days to make alt
|
||||
// reliably win on this stretch.
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "14d OR 20wd, max",
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
AltDurationValue: ptr(20),
|
||||
AltDurationUnit: ptr("working_days"),
|
||||
CombineOp: ptr("max"),
|
||||
}
|
||||
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
// Primary 14 cal days: Dec 24 (Thu) + 14 = Jan 7 2027 (Thu), working
|
||||
// day → no snap. Alt 20 working_days walks past Christmas + Neujahr:
|
||||
// Fri 12-25 (1.W) skip, Sat/Sun 12-26/27 skip (Sat counts as
|
||||
// non-working; 2.W on 26 also skips), Mon 12-28 (1), Tue 12-29 (2),
|
||||
// Wed 12-30 (3), Thu 12-31 (4), Fri 01-01-2027 Neujahr skip, Mon
|
||||
// 01-04 (5), Tue 01-05 (6), Wed 01-06 (7), Thu 01-07 (8), Fri 01-08
|
||||
// (9), Mon 01-11 (10), Tue 01-12 (11), Wed 01-13 (12), Thu 01-14
|
||||
// (13), Fri 01-15 (14), Mon 01-18 (15), Tue 01-19 (16), Wed 01-20
|
||||
// (17), Thu 01-21 (18), Fri 01-22 (19), Mon 01-25 (20). Result =
|
||||
// Mon 2027-01-25. After max(Jan 7, Jan 25) → Jan 25.
|
||||
want := time.Date(2027, 1, 25, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op='min' picks the EARLIER end-date.
|
||||
// Same shape as the max test but inverted. Same Dec 24 2026 anchor,
|
||||
// 14d vs 20wd: min = Jan 7 2027 (the primary leg).
|
||||
func TestCalculateEndDate_CombineMin_PrimaryWins(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "14d OR 20wd, min",
|
||||
DurationValue: 14,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
AltDurationValue: ptr(20),
|
||||
AltDurationUnit: ptr("working_days"),
|
||||
CombineOp: ptr("min"),
|
||||
}
|
||||
in := time.Date(2026, 12, 24, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2027, 1, 7, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 Primitive 2 — combine_op with NULL alt fields short-circuits to
|
||||
// the primary-only result (defensive: drift in seed data shouldn't crash
|
||||
// the calculator). Same as the basic days test but with combine_op set
|
||||
// and alt fields nil.
|
||||
func TestCalculateEndDate_CombineOp_AltNil_FallsBackToPrimary(t *testing.T) {
|
||||
holidays := NewHolidayService(nil)
|
||||
calc := NewDeadlineCalculator(holidays)
|
||||
|
||||
rule := models.DeadlineRule{
|
||||
ID: uuid.New(),
|
||||
Name: "Primary only, stray combine_op",
|
||||
DurationValue: 10,
|
||||
DurationUnit: "days",
|
||||
Timing: ptr("after"),
|
||||
CombineOp: ptr("max"),
|
||||
}
|
||||
in := time.Date(2026, 1, 13, 0, 0, 0, 0, time.UTC)
|
||||
adjusted, _, _ := calc.CalculateEndDate(in, rule, "DE", "UPC")
|
||||
want := time.Date(2026, 1, 23, 0, 0, 0, 0, time.UTC)
|
||||
if !adjusted.Equal(want) {
|
||||
t.Errorf("adjusted: got %s, want %s", adjusted, want)
|
||||
}
|
||||
@@ -168,4 +486,3 @@ func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) {
|
||||
// PR-3 ("SoD 3mo from 2026-04-30 → adjusted Mon 2026-08-31, not Sat
|
||||
// 2026-08-29") locks the live behaviour.
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,25 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time, country, regime string)
|
||||
return h != nil && h.IsClosure
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDaysBackward is the symmetric counterpart of
|
||||
// AdjustForNonWorkingDays: walks the date *backward* day-by-day until it
|
||||
// lands on a working day for the given (country, regime). Used for
|
||||
// timing='before' rules (e.g. UPC R.109.1 "no later than 1 month before
|
||||
// the oral hearing") — when the computed cut-off lands on a weekend or
|
||||
// public holiday, the lawyer must finish *earlier*, not later. Forward
|
||||
// snap would push the cut-off past the statutory limit and cause the
|
||||
// step to be filed too late. Bound by the same 60-iter cap as the
|
||||
// forward variant.
|
||||
func (s *HolidayService) AdjustForNonWorkingDaysBackward(date time.Time, country, regime string) (adjusted, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted, country, regime); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, -1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
return adjusted, original, wasAdjusted
|
||||
}
|
||||
|
||||
// AdjustForNonWorkingDays moves the date forward to the next working day for
|
||||
// the given (country, regime). Returns adjusted date, the original
|
||||
// (unmodified) date, and whether any adjustment was made.
|
||||
|
||||
Reference in New Issue
Block a user