fix(t-paliad-086): Tier 1 Fristenrechner bug fixes — PR-3
Implements the four audit recommendations from §6.1 of docs/audit-fristenrechner-completeness-2026-04-30.md plus a holiday- adjustment cap fix surfaced by PR-2's smoke test. (1) UPC_INF CCR-conditional rejoinder Public Fristenrechner now flips inf.reply (RoP.029.b → RoP.029.a) and inf.rejoin (1mo / RoP.029.c → 2mo / RoP.029.d) when the user ticks "Mit Widerklage auf Nichtigkeit." Implemented via a new `condition_flag` column on paliad.deadline_rules: when the rule names a flag and the API request's flags array contains it, the calculator substitutes alt_duration_value/unit and alt_rule_code. Independent of the existing `condition_rule_id` mechanism (which references a real rule in the same proceeding tree — only useful for matter-attached trees that already seed the CCR rule). (2) UPC_APP / internal APP grounds anchoring `app.grounds` is now anchored on the trigger date (the appealed decision) with a 4-month duration, not chained 2mo after `app.notice`. Per RoP 220.1 the legal rule is "4 months from notification of the decision," independent of when the notice itself was filed. The chain only happened to give the right answer when both legs landed on a working day; under holiday rollover (e.g. notice deadline pushed to Monday) the grounds deadline drifted off the 4mo legal target. (3) EP_GRANT publish anchor on priority date New `anchor_alt` column on paliad.deadline_rules. ep_grant.publish carries `anchor_alt='priority_date'`. The Fristenrechner UI surfaces an optional "Prioritätstag" input (visible only when EP_GRANT is selected) that, when populated, anchors the publish-A1 calculation on the priority date instead of the filing. Falls back to filing date when the priority field is empty (the case for purely-EP applications with no foreign priority claim). (4) Rule-code format normalisation Migration 029 normalises 'RoP 23' → 'RoP.023', 'RoP 29b' / 'RoP.029b' → 'RoP.029.b', 'RoP 220.1' → 'RoP.220.1', etc. across deadline_rules. Matches the canonical youpc format already used by the PR-1 imported event-deadline rule codes. (+) AdjustForNonWorkingDays cap bumped 30 → 60 Surfaced by the PR-2 smoke test: SoD on 2026-04-30 (3mo from trigger) landed on Sat 2026-08-29 instead of Mon 2026-08-31. The 30-iteration safety bound on AdjustForNonWorkingDays cannot walk past the 33-day UPC summer vacation plus flanking weekends. Bumped to 60. Pure-Go one-liner, locked by a follow-up production smoke (real paliad.holidays seed has the UPC vacation). Schema (migration 029): two new nullable text columns on paliad.deadline_rules — `condition_flag` and `anchor_alt`. Both ignored by every existing rule; only the rows updated above carry values. Models: DeadlineRule gains ConditionFlag + AnchorAlt (nilable strings). Service: FristenrechnerService.Calculate now takes a CalcOptions struct (PriorityDateStr, Flags). API handler accepts optional priorityDate and flags fields on POST /api/tools/fristenrechner. Frontend: TSX surfaces the priority-date row + CCR checkbox conditionally on selectedType (only EP_GRANT / UPC_INF respectively). Client TS reads them and threads through the API call. New i18n keys for both DE+EN. Migration 029 dry-run validated on prod Supabase (BEGIN/ROLLBACK): schema + UPDATEs apply cleanly, rule states match expected post-fix shape. Tests + go build/vet + bun build all clean.
This commit is contained in:
@@ -78,6 +78,15 @@ async function calculate() {
|
||||
const triggerDate = dateInput.value;
|
||||
if (!triggerDate || !selectedType) return;
|
||||
|
||||
// Priority date — only meaningful for EP_GRANT (Art. 93 EPÜ publish-anchor).
|
||||
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
|
||||
const priorityDate = selectedType === "EP_GRANT" && priorityInput?.value ? priorityInput.value : "";
|
||||
|
||||
// Flags — UPC_INF surfaces "Mit Widerklage auf Nichtigkeit" toggle.
|
||||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
const flags: string[] = [];
|
||||
if (selectedType === "UPC_INF" && ccrFlag?.checked) flags.push("with_ccr");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/tools/fristenrechner", {
|
||||
method: "POST",
|
||||
@@ -85,6 +94,8 @@ async function calculate() {
|
||||
body: JSON.stringify({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
priorityDate: priorityDate || undefined,
|
||||
flags: flags.length > 0 ? flags : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -366,6 +377,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const name = btn.querySelector("strong")?.textContent || "";
|
||||
document.getElementById("trigger-event")!.textContent = name;
|
||||
|
||||
// Conditional inputs: priority date for EP_GRANT, CCR toggle for UPC_INF.
|
||||
const priorityRow = document.getElementById("priority-date-row");
|
||||
if (priorityRow) priorityRow.style.display = selectedType === "EP_GRANT" ? "" : "none";
|
||||
const ccrRow = document.getElementById("ccr-flag-row");
|
||||
if (ccrRow) ccrRow.style.display = selectedType === "UPC_INF" ? "" : "none";
|
||||
|
||||
showStep(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,6 +206,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.trigger.event": "Ausl\u00f6sendes Ereignis:",
|
||||
"deadlines.trigger.date": "Datum:",
|
||||
"deadlines.trigger.label": "Ausgangsdatum",
|
||||
"deadlines.priority.date": "Priorit\u00e4tstag (optional):",
|
||||
"deadlines.flag.ccr": "Mit Widerklage auf Nichtigkeit",
|
||||
"deadlines.calculate": "Fristen berechnen",
|
||||
"deadlines.print": "Drucken",
|
||||
"deadlines.reset": "\u2190 Neu berechnen",
|
||||
@@ -1554,6 +1556,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.trigger.event": "Trigger event:",
|
||||
"deadlines.trigger.date": "Date:",
|
||||
"deadlines.trigger.label": "Trigger date",
|
||||
"deadlines.priority.date": "Priority date (optional):",
|
||||
"deadlines.flag.ccr": "Counterclaim for revocation filed",
|
||||
"deadlines.calculate": "Calculate Deadlines",
|
||||
"deadlines.print": "Print",
|
||||
"deadlines.reset": "\u2190 Start Over",
|
||||
|
||||
@@ -114,6 +114,16 @@ export function renderFristenrechner(): string {
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<div className="date-field-row" id="priority-date-row" style="display:none">
|
||||
<label htmlFor="priority-date" className="date-label" data-i18n="deadlines.priority.date">Prioritätstag (optional):</label>
|
||||
<input type="date" id="priority-date" className="date-input" />
|
||||
</div>
|
||||
<div className="date-field-row" id="ccr-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="ccr-flag" />
|
||||
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
|
||||
@@ -562,6 +562,7 @@ export type I18nKey =
|
||||
| "deadlines.filter.status"
|
||||
| "deadlines.filter.thisweek"
|
||||
| "deadlines.filter.upcoming"
|
||||
| "deadlines.flag.ccr"
|
||||
| "deadlines.heading"
|
||||
| "deadlines.kalender.empty"
|
||||
| "deadlines.kalender.heading"
|
||||
@@ -587,6 +588,7 @@ export type I18nKey =
|
||||
| "deadlines.party.court"
|
||||
| "deadlines.party.defendant"
|
||||
| "deadlines.print"
|
||||
| "deadlines.priority.date"
|
||||
| "deadlines.reset"
|
||||
| "deadlines.save.cta"
|
||||
| "deadlines.save.error"
|
||||
|
||||
37
internal/db/migrations/029_tier1_rule_fixes.down.sql
Normal file
37
internal/db/migrations/029_tier1_rule_fixes.down.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- Reverses 029_tier1_rule_fixes. Rule_code normalisation is intentionally
|
||||
-- one-way; the down-step only restores the parent_id chain on app.grounds
|
||||
-- so an out-of-band rollback can resume the prior shape if needed. The
|
||||
-- new columns (condition_flag, anchor_alt) stay on the table — harmless
|
||||
-- when null and removing them would force re-applying the data fixes.
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (SELECT id FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_APP')
|
||||
AND code = 'app.notice'),
|
||||
duration_value = 2,
|
||||
deadline_notes = NULL
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_APP')
|
||||
AND code = 'app.grounds';
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = (SELECT id FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'APP')
|
||||
AND code = 'app.notice'),
|
||||
duration_value = 2,
|
||||
deadline_notes = NULL
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'APP')
|
||||
AND code = 'app.grounds';
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET condition_flag = NULL,
|
||||
alt_duration_value = NULL,
|
||||
alt_duration_unit = NULL,
|
||||
alt_rule_code = NULL
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_INF')
|
||||
AND code IN ('inf.reply', 'inf.rejoin');
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET anchor_alt = NULL,
|
||||
deadline_notes = 'Ab Prioritätstag'
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'EP_GRANT')
|
||||
AND code = 'ep_grant.publish';
|
||||
128
internal/db/migrations/029_tier1_rule_fixes.up.sql
Normal file
128
internal/db/migrations/029_tier1_rule_fixes.up.sql
Normal file
@@ -0,0 +1,128 @@
|
||||
-- t-paliad-086 PR-3: Tier 1 bug fixes from the Fristenrechner audit.
|
||||
--
|
||||
-- Audit recommendations §6.1 (rec 1-4):
|
||||
--
|
||||
-- 1. UPC_INF: wire CCR-conditional adaptive rule on inf.reply / inf.rejoin
|
||||
-- so the public Fristenrechner can flip duration + rule code when the
|
||||
-- defendant counterclaims for revocation. Uses a new `condition_flag`
|
||||
-- column (string flag from the API request) instead of condition_rule_id
|
||||
-- which would require seeding a real CCR rule under UPC_INF.
|
||||
--
|
||||
-- 2. UPC_APP / internal APP: re-anchor app.grounds on the trigger date
|
||||
-- (decision being appealed) instead of chaining off app.notice. Per
|
||||
-- RoP 220.1, grounds is "4 months from the decision", not "2 months
|
||||
-- after notice." The chained shape happens to give the right calendar
|
||||
-- date when both legs land on a working day, but breaks under holiday
|
||||
-- rollover and is the wrong legal model.
|
||||
--
|
||||
-- 3. EP_GRANT.ep_grant.publish: support an alternate anchor on the
|
||||
-- priority date (Art. 93 EPÜ — "18 months ab Prioritätstag"). Existing
|
||||
-- parent_id=ep_grant.filing keeps the fallback when no priority date
|
||||
-- is supplied. New `anchor_alt` column tells the calculator to prefer
|
||||
-- a separately-supplied priority date when present.
|
||||
--
|
||||
-- 4. Normalise rule_code format: 'RoP 23' / 'RoP.029b' / 'RoP 220.1'
|
||||
-- → uniform 'RoP.023' / 'RoP.029.b' / 'RoP.220.1' (matching youpc and
|
||||
-- the PR-1 import).
|
||||
--
|
||||
-- Plus an in-passing fix not in the audit: the AdjustForNonWorkingDays
|
||||
-- 30-iteration safety cap is not enough to walk past the 33-day UPC
|
||||
-- summer vacation. The Go-side fix lives in internal/services/holidays.go;
|
||||
-- the migration just lets the data carry the correct bracket info.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Schema columns
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS condition_flag text,
|
||||
ADD COLUMN IF NOT EXISTS anchor_alt text;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.condition_flag IS
|
||||
'Flag-based conditional rule. When set (e.g. ''with_ccr''), the calculator '
|
||||
'uses alt_duration_value/alt_duration_unit/alt_rule_code if the API request '
|
||||
'flags include this value. Independent of condition_rule_id (which references '
|
||||
'a rule in the same proceeding tree — useful for matter-attached fristen but '
|
||||
'not for public Fristenrechner where the CCR variant has no separate rule).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.anchor_alt IS
|
||||
'Named alternate anchor. ''priority_date'' means: when the API request '
|
||||
'supplies priorityDate, use it as the base for this rule''s duration '
|
||||
'instead of the trigger date or the parent rule''s computed date.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. UPC_INF CCR-conditional adaptive rules
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET condition_flag = 'with_ccr',
|
||||
alt_duration_value = 2,
|
||||
alt_duration_unit = 'months',
|
||||
alt_rule_code = 'RoP.029.a'
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_INF')
|
||||
AND code = 'inf.reply';
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET condition_flag = 'with_ccr',
|
||||
alt_duration_value = 2,
|
||||
alt_duration_unit = 'months',
|
||||
alt_rule_code = 'RoP.029.d'
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_INF')
|
||||
AND code = 'inf.rejoin';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. UPC_APP and internal APP: re-anchor app.grounds on trigger (decision)
|
||||
--
|
||||
-- Public UPC_APP: app.notice (root, 2mo from decision), app.grounds (was
|
||||
-- chained 2mo after notice → now sibling, 4mo from decision).
|
||||
-- Internal APP: same fix.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
duration_value = 4,
|
||||
duration_unit = 'months',
|
||||
deadline_notes = 'Ab Zustellung der angegriffenen Entscheidung; nicht ab Berufungseinlegung'
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_APP')
|
||||
AND code = 'app.grounds';
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET parent_id = NULL,
|
||||
duration_value = 4,
|
||||
duration_unit = 'months',
|
||||
deadline_notes = 'Anchored on decision date (the trigger), not on filing of notice'
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'APP')
|
||||
AND code = 'app.grounds';
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. EP_GRANT.ep_grant.publish: alternate anchor on priority date
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET anchor_alt = 'priority_date',
|
||||
deadline_notes = 'Ab Prioritätstag (oder Anmeldetag, wenn keine Priorität beansprucht)'
|
||||
WHERE proceeding_type_id = (SELECT id FROM paliad.proceeding_types WHERE code = 'EP_GRANT')
|
||||
AND code = 'ep_grant.publish';
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. Normalise rule_code format → canonical 'RoP.NNN.x' (matches youpc and
|
||||
-- PR-1 imported event-deadline rule codes).
|
||||
--
|
||||
-- Done as targeted UPDATEs to avoid touching unrelated rows. Both spaced
|
||||
-- ('RoP 23') and dot-without-period-before-letter ('RoP.029b') variants
|
||||
-- are converted.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.023' WHERE rule_code IN ('RoP 23', 'RoP.023');
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.a' WHERE rule_code IN ('RoP 29a', 'RoP.029a');
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.b' WHERE rule_code IN ('RoP 29b', 'RoP.029b');
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.c' WHERE rule_code IN ('RoP 29c', 'RoP.029c');
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.d' WHERE rule_code IN ('RoP 29d', 'RoP.029d');
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.029.e' WHERE rule_code IN ('RoP 29e', 'RoP.029e');
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.220.1' WHERE rule_code IN ('RoP 220.1', 'RoP.220.1');
|
||||
UPDATE paliad.deadline_rules SET rule_code = 'RoP.050' WHERE rule_code IN ('RoP 50', 'RoP.050');
|
||||
|
||||
-- Same for alt_rule_code on UPC_INF rows above (we wrote canonical values
|
||||
-- but this protects against any prior data drift).
|
||||
UPDATE paliad.deadline_rules SET alt_rule_code = 'RoP.029.a' WHERE alt_rule_code IN ('RoP 29a', 'RoP.029a');
|
||||
UPDATE paliad.deadline_rules SET alt_rule_code = 'RoP.029.d' WHERE alt_rule_code IN ('RoP 29d', 'RoP.029d');
|
||||
@@ -28,6 +28,8 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ProceedingType string `json:"proceedingType"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
PriorityDate string `json:"priorityDate,omitempty"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -38,7 +40,10 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate)
|
||||
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannter Verfahrenstyp: " + req.ProceedingType})
|
||||
|
||||
@@ -332,9 +332,11 @@ type DeadlineRule struct {
|
||||
DeadlineNotes *string `db:"deadline_notes" json:"deadline_notes,omitempty"`
|
||||
SequenceOrder int `db:"sequence_order" json:"sequence_order"`
|
||||
ConditionRuleID *uuid.UUID `db:"condition_rule_id" json:"condition_rule_id,omitempty"`
|
||||
ConditionFlag *string `db:"condition_flag" json:"condition_flag,omitempty"`
|
||||
AltDurationValue *int `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit" json:"alt_duration_unit,omitempty"`
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
|
||||
@@ -140,3 +140,32 @@ func TestCalculateFromRules_BatchAndZeroDuration(t *testing.T) {
|
||||
t.Errorf("rule code: got %q, want inf.sod", results[1].RuleCode)
|
||||
}
|
||||
}
|
||||
|
||||
// PR-3 audit fix: AdjustForNonWorkingDays must walk past the full UPC summer
|
||||
// vacation (~33 weekdays) plus the flanking weekend without bailing on the
|
||||
// 30-iteration cap. Pre-PR-3 a SoD on 2026-04-30 (3mo from trigger) would
|
||||
// adjust to Sat 2026-08-29 (mid-walk, off-by-31). Correct landing is Mon
|
||||
// 2026-08-31 (UPC vacation ends Aug 28 Fri; weekend skipped).
|
||||
func TestAdjustForNonWorkingDays_WalksPastSummerVacation(t *testing.T) {
|
||||
holidays := NewHolidayService(nil) // pure German federal holidays — no UPC vacation
|
||||
// To reproduce the production case (which has UPC summer vacation seeded
|
||||
// in paliad.holidays), we shim the holiday service with a custom config
|
||||
// matching migration 010's UPC summer vacation.
|
||||
// Without the seed, a Thu 2026-07-30 + 0 days adjustment is a no-op
|
||||
// (working day), which doesn't exercise the cap. Skip if running without
|
||||
// the production seed.
|
||||
in := time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC)
|
||||
if holidays.IsNonWorkingDay(in) {
|
||||
t.Skip("Thu 2026-07-30 unexpectedly flagged as non-working without UPC seed")
|
||||
}
|
||||
// Sanity: with no UPC vacation, Thu 2026-07-30 + 0 → unchanged.
|
||||
adjusted, _, wasAdjusted := holidays.AdjustForNonWorkingDays(in)
|
||||
if wasAdjusted {
|
||||
t.Errorf("expected no adjustment without UPC seed; got %s", adjusted)
|
||||
}
|
||||
// The behaviour we actually rely on (cap=60) is only observable against
|
||||
// the seeded paliad.holidays. The production smoke test for t-paliad-086
|
||||
// PR-3 ("SoD 3mo from 2026-04-30 → adjusted Mon 2026-08-31, not Sat
|
||||
// 2026-08-29") locks the live behaviour.
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
|
||||
description, primary_party, event_type, is_mandatory, duration_value,
|
||||
duration_unit, timing, rule_code, deadline_notes, sequence_order,
|
||||
condition_rule_id, alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
is_spawn, spawn_label, is_active, created_at, updated_at`
|
||||
condition_rule_id, condition_flag, alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, is_spawn, spawn_label, is_active, created_at, updated_at`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
|
||||
@@ -55,6 +55,21 @@ type UIResponse struct {
|
||||
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
|
||||
var ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
||||
|
||||
// CalcOptions carries optional inputs for Calculate. Callers can leave fields
|
||||
// empty/nil for the legacy behaviour.
|
||||
//
|
||||
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with anchor_alt =
|
||||
// 'priority_date' (e.g. EP_GRANT.ep_grant.publish per Art. 93 EPÜ) use
|
||||
// this date as their base instead of the parent's adjusted date / the
|
||||
// trigger date.
|
||||
// - Flags: lowercase string flags from the UI (e.g. "with_ccr"). When a
|
||||
// rule's condition_flag is in this slice, the rule's alt_duration_* and
|
||||
// alt_rule_code take precedence over the default values.
|
||||
type CalcOptions struct {
|
||||
PriorityDateStr string
|
||||
Flags []string
|
||||
}
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
// Preserves the pre-Phase-C in-memory calculator's classification:
|
||||
//
|
||||
@@ -64,12 +79,32 @@ var ErrUnknownProceedingType = errors.New("unknown proceeding type")
|
||||
// (due date empty, UI shows "court-set" placeholder)
|
||||
// - All other rules → calculate from either the trigger date (no parent)
|
||||
// or the previously-computed date for their parent rule.
|
||||
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string) (*UIResponse, error) {
|
||||
//
|
||||
// Audit-driven extensions (PR-3 of t-paliad-086):
|
||||
//
|
||||
// - opts.Flags can flip flag-conditioned rules onto their alt_* values
|
||||
// (e.g. UPC_INF inf.reply / inf.rejoin under "with_ccr").
|
||||
// - opts.PriorityDateStr overrides the anchor for rules with anchor_alt
|
||||
// set (e.g. EP_GRANT publication date is 18mo from priority, not filing).
|
||||
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
}
|
||||
|
||||
var priorityDate *time.Time
|
||||
if opts.PriorityDateStr != "" {
|
||||
pd, err := time.Parse("2006-01-02", opts.PriorityDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err)
|
||||
}
|
||||
priorityDate = &pd
|
||||
}
|
||||
flagSet := make(map[string]struct{}, len(opts.Flags))
|
||||
for _, f := range opts.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
|
||||
// Look up proceeding type metadata.
|
||||
var pt struct {
|
||||
ID int `db:"id"`
|
||||
@@ -137,13 +172,13 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculated duration — anchor to parent's adjusted date if we
|
||||
// have it, else fall back to the trigger date.
|
||||
// Anchor: prefer alt-anchor (e.g. priority_date for EP_GRANT publish)
|
||||
// when supplied, then parent's computed date, then trigger date.
|
||||
baseDate := triggerDate
|
||||
if r.ParentID != nil {
|
||||
// Resolve parent's code from the rules slice so we can look up
|
||||
// its already-computed date. Linear scan is fine: rule trees
|
||||
// are small (< 20 entries).
|
||||
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
|
||||
baseDate = *priorityDate
|
||||
} else if r.ParentID != nil {
|
||||
// Linear scan is fine — rule trees are < 20 entries.
|
||||
for _, prev := range rules {
|
||||
if prev.ID == *r.ParentID {
|
||||
if prev.Code != nil {
|
||||
@@ -156,7 +191,25 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
}
|
||||
}
|
||||
|
||||
endDate := addDuration(baseDate, r.DurationValue, r.DurationUnit)
|
||||
// Flag-conditioned alt: if the rule names a condition_flag and the
|
||||
// caller passed it, swap in alt_duration_value/unit and alt_rule_code.
|
||||
durationValue := r.DurationValue
|
||||
durationUnit := r.DurationUnit
|
||||
if r.ConditionFlag != nil {
|
||||
if _, on := flagSet[*r.ConditionFlag]; on {
|
||||
if r.AltDurationValue != nil {
|
||||
durationValue = *r.AltDurationValue
|
||||
}
|
||||
if r.AltDurationUnit != nil {
|
||||
durationUnit = *r.AltDurationUnit
|
||||
}
|
||||
if r.AltRuleCode != nil {
|
||||
d.RuleRef = *r.AltRuleCode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endDate := addDuration(baseDate, durationValue, durationUnit)
|
||||
origDate := endDate
|
||||
adjusted, _, wasAdj := s.holidays.AdjustForNonWorkingDays(endDate)
|
||||
|
||||
|
||||
@@ -123,11 +123,18 @@ func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
|
||||
|
||||
// AdjustForNonWorkingDays moves the date forward to the next working day.
|
||||
// Returns adjusted date, the original (unmodified) date, and whether any
|
||||
// adjustment was made. Capped at 30 forward iterations as a safety bound.
|
||||
// adjustment was made.
|
||||
//
|
||||
// The 60-iteration safety bound has to span the longest run of consecutive
|
||||
// non-working days that paliad ever sees: UPC summer vacation (~33 days) +
|
||||
// flanking weekends + a German federal holiday on the trailing edge. Pre-
|
||||
// PR-3 the bound was 30, which silently bailed mid-vacation onto a Saturday
|
||||
// — caught by the t-paliad-086 PR-2 smoke test (Statement of Defence on
|
||||
// 2026-04-30 → adjusted incorrectly to 2026-08-29 instead of 2026-08-31).
|
||||
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) {
|
||||
original = date
|
||||
adjusted = date
|
||||
for i := 0; i < 30 && s.IsNonWorkingDay(adjusted); i++ {
|
||||
for i := 0; i < 60 && s.IsNonWorkingDay(adjusted); i++ {
|
||||
adjusted = adjusted.AddDate(0, 0, 1)
|
||||
wasAdjusted = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user