Atomic extraction of the deadline-rule compute engine + types from
internal/services into a new pkg/litigationplanner package that paliad
+ youpc.org can both import. No behaviour change — every existing test
passes against the post-move shape.
Package contents (~1850 LoC):
- doc.go package docstring + reuse manifesto
- types.go Rule, ProceedingType, NullableJSON, AdjustmentReason,
HolidayDTO, CalcOptions, CalcRuleParams, Timeline,
TimelineEntry, RuleCalculation*, FristenrechnerType,
ProjectHint, sentinel errors
- catalog.go Catalog interface (proceeding + rule lookups)
- holidays.go HolidayCalendar interface
- courts.go CourtRegistry interface + DefaultsForJurisdiction +
country/regime constants
- expr.go EvalConditionExpr + HasConditionExpr +
ExtractFlagsFromExpr (jsonb gate evaluator)
- durations.go ApplyDuration + AddWorkingDays (pure compute)
- subtrack.go SubTrackRouting + LookupSubTrackRouting registry
- legal_source.go FormatLegalSourceDisplay + BuildLegalSourceURL
- proceeding_mapping.go MapLitigationToFristenrechner + code constants
(CodeUPCInfringement, CodeDEInfringementLG, ...)
- engine.go Calculate + CalculateRule + the trigger-event
branch + applyRuleOverrides (the big move)
paliad side (~1900 LoC net deletion):
- internal/services/fristenrechner.go shrinks from 1505 → ~290 lines
(thin paliad Catalog adapter + type aliases for back-compat).
- internal/models/models.go: DeadlineRule, ProceedingType, NullableJSON
become type aliases to litigationplanner.* — every sqlx scan and
every projection_service caller compiles unchanged.
- internal/services/holidays.go: AdjustmentReason + HolidayDTO become
aliases to lp.* (canonical definitions now in the package).
- internal/services/proceeding_mapping.go: rewritten as thin re-exports
of lp constants + helpers.
- internal/services/deadline_search_service.go: FormatLegalSourceDisplay
+ BuildLegalSourceURL replaced with delegating wrappers to lp.
Catalog interface satisfaction:
- DeadlineRuleService → paliadCatalog adapter (wraps the existing
service, replicates the original SELECT shapes).
- HolidayService → satisfies lp.HolidayCalendar directly (compile-
time assertion at end of fristenrechner.go).
- CourtService → satisfies lp.CourtRegistry directly.
Wire shape is byte-identical. JSON tags on Rule / ProceedingType /
Timeline / TimelineEntry / RuleCalculation match the historical
UIResponse / UIDeadline shape; the frontend reads the same bytes.
Slice B (Catalog interface + paliad loader cleanup) is folded into
this commit since Slice A already needs the interfaces to call
Calculate across the boundary. Slice C (embedded UPC snapshot +
generator) is the next coder shift; the Berufung unification m
called out lands in Slice B/C per head's brief.
Refs: docs/design-litigation-planner-2026-05-26.md
314 lines
12 KiB
Go
314 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
|
|
)
|
|
|
|
// FristenrechnerService renders the Paliad public Fristenrechner's
|
|
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
|
|
// is a thin adapter: the compute engine + types live in
|
|
// pkg/litigationplanner, and FristenrechnerService just wires the
|
|
// Postgres-backed Catalog + HolidayCalendar + CourtRegistry
|
|
// implementations and delegates Calculate / CalculateRule across the
|
|
// boundary.
|
|
//
|
|
// The package owns the wire shape (Timeline / TimelineEntry); paliad's
|
|
// historical aliases (UIResponse / UIDeadline) keep call-sites
|
|
// unchanged.
|
|
type FristenrechnerService struct {
|
|
rules *DeadlineRuleService
|
|
holidays *HolidayService
|
|
courts *CourtService
|
|
|
|
catalog lp.Catalog
|
|
}
|
|
|
|
// NewFristenrechnerService wires the service to its dependencies.
|
|
func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService {
|
|
s := &FristenrechnerService{rules: rules, holidays: holidays, courts: courts}
|
|
s.catalog = &paliadCatalog{rules: rules}
|
|
return s
|
|
}
|
|
|
|
// Type aliases keep call-sites byte-identical with the pre-Slice-A
|
|
// shape. The wire JSON tags are owned by the package.
|
|
// (AdjustmentReason + HolidayDTO are aliased in holidays.go.)
|
|
type (
|
|
UIResponse = lp.Timeline
|
|
UIDeadline = lp.TimelineEntry
|
|
CalcOptions = lp.CalcOptions
|
|
CalcRuleParams = lp.CalcRuleParams
|
|
RuleCalculation = lp.RuleCalculation
|
|
RuleCalculationRule = lp.RuleCalculationRule
|
|
RuleCalculationProceeding = lp.RuleCalculationProceeding
|
|
SubTrackRouting = lp.SubTrackRouting
|
|
)
|
|
|
|
// Sentinel errors. Re-exported as package-level vars so handlers that
|
|
// errors.Is(..., services.ErrUnknownProceedingType) continue to work.
|
|
var (
|
|
ErrUnknownProceedingType = lp.ErrUnknownProceedingType
|
|
ErrUnknownRule = lp.ErrUnknownRule
|
|
)
|
|
|
|
// Calculate delegates to litigationplanner.Calculate with paliad's
|
|
// Postgres-backed Catalog / HolidayCalendar / CourtRegistry implementations.
|
|
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
|
|
return lp.Calculate(ctx, proceedingCode, triggerDateStr, opts, s.catalog, s.holidays, s.courts)
|
|
}
|
|
|
|
// CalculateRule delegates to litigationplanner.CalculateRule. Distinct
|
|
// from Calculate: no parent-chain walk, no full-timeline rendering —
|
|
// just one date out.
|
|
func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*RuleCalculation, error) {
|
|
return lp.CalculateRule(ctx, params, s.catalog, s.holidays, s.courts)
|
|
}
|
|
|
|
// ListFristenrechnerTypes returns the proceeding types that populate
|
|
// the Fristenrechner UI (category='fristenrechner'), ordered by
|
|
// sort_order. Stays on the service because the response is a paliad-
|
|
// specific surface (the wire shape FristenrechnerType is owned by the
|
|
// package but the SQL filter is paliad-side).
|
|
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]lp.FristenrechnerType, error) {
|
|
rows, err := s.rules.db.QueryxContext(ctx, `
|
|
SELECT code, name, name_en, jurisdiction
|
|
FROM paliad.proceeding_types
|
|
WHERE category = 'fristenrechner' AND is_active = true
|
|
ORDER BY sort_order`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list fristenrechner types: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []lp.FristenrechnerType
|
|
for rows.Next() {
|
|
var t lp.FristenrechnerType
|
|
var juris sql.NullString
|
|
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
|
return nil, err
|
|
}
|
|
if juris.Valid {
|
|
t.Group = juris.String
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// FristenrechnerType is paliad's local alias for lp.FristenrechnerType
|
|
// so historical call-sites (services.FristenrechnerType) keep working.
|
|
type FristenrechnerType = lp.FristenrechnerType
|
|
|
|
// ---------------------------------------------------------------------
|
|
// paliadCatalog is the paliad-side litigationplanner.Catalog adapter.
|
|
// Wraps DeadlineRuleService to expose proceeding + rule lookups against
|
|
// paliad.proceeding_types + paliad.deadline_rules.
|
|
// ---------------------------------------------------------------------
|
|
|
|
type paliadCatalog struct {
|
|
rules *DeadlineRuleService
|
|
}
|
|
|
|
// proceedingTypeColumns is canonically defined in
|
|
// deadline_rule_service.go; the catalog adapter reuses it via the
|
|
// shared package-level const.
|
|
|
|
// LoadProceeding returns the proceeding-type metadata + rules. The
|
|
// ProjectHint is currently ignored on paliad's side (per m's 2026-05-26
|
|
// decision dropping the Slice E user-authored rules); kept on the
|
|
// interface for forward-compat.
|
|
func (c *paliadCatalog) LoadProceeding(ctx context.Context, code string, _ lp.ProjectHint) (*models.ProceedingType, []models.DeadlineRule, error) {
|
|
var pt models.ProceedingType
|
|
err := c.rules.db.GetContext(ctx, &pt,
|
|
`SELECT `+proceedingTypeColumns+`
|
|
FROM paliad.proceeding_types
|
|
WHERE code = $1 AND is_active = true`, code)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, lp.ErrUnknownProceedingType
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve proceeding %q: %w", code, err)
|
|
}
|
|
rules, err := c.rules.List(ctx, &pt.ID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &pt, rules, nil
|
|
}
|
|
|
|
// LoadProceedingByID is the resolver for a rule's parent proceeding.
|
|
func (c *paliadCatalog) LoadProceedingByID(ctx context.Context, id int) (*models.ProceedingType, error) {
|
|
var pt models.ProceedingType
|
|
err := c.rules.db.GetContext(ctx, &pt,
|
|
`SELECT `+proceedingTypeColumns+`
|
|
FROM paliad.proceeding_types
|
|
WHERE id = $1`, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, lp.ErrUnknownProceedingType
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve proceeding by id %d: %w", id, err)
|
|
}
|
|
return &pt, nil
|
|
}
|
|
|
|
// LoadRuleByID resolves a rule UUID to the rule row.
|
|
func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*models.DeadlineRule, error) {
|
|
var rule models.DeadlineRule
|
|
err := c.rules.db.GetContext(ctx, &rule,
|
|
`SELECT `+ruleColumns+`
|
|
FROM paliad.deadline_rules
|
|
WHERE id = $1 AND is_active = true`, ruleID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, lp.ErrUnknownRule
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve rule by id %q: %w", ruleID, err)
|
|
}
|
|
if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil {
|
|
return nil, err
|
|
}
|
|
return &rule, nil
|
|
}
|
|
|
|
// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode)
|
|
// + returns the parent proceeding for use in the response identity.
|
|
func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*models.DeadlineRule, *models.ProceedingType, error) {
|
|
var pt models.ProceedingType
|
|
err := c.rules.db.GetContext(ctx, &pt,
|
|
`SELECT `+proceedingTypeColumns+`
|
|
FROM paliad.proceeding_types
|
|
WHERE code = $1 AND is_active = true`, proceedingCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, lp.ErrUnknownProceedingType
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err)
|
|
}
|
|
var rule models.DeadlineRule
|
|
err = c.rules.db.GetContext(ctx, &rule,
|
|
`SELECT `+ruleColumns+`
|
|
FROM paliad.deadline_rules
|
|
WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
|
|
pt.ID, submissionCode)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil, lp.ErrUnknownRule
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", submissionCode, proceedingCode, err)
|
|
}
|
|
if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &rule, &pt, nil
|
|
}
|
|
|
|
// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules.
|
|
func (c *paliadCatalog) LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) {
|
|
return c.rules.ListByTriggerEvent(ctx, triggerEventID)
|
|
}
|
|
|
|
// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for
|
|
// the conditional-label override (t-paliad-294 / m/paliad#126).
|
|
func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
|
|
return c.rules.LoadTriggerEventsByIDs(ctx, ids)
|
|
}
|
|
|
|
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
|
|
var _ lp.Catalog = (*paliadCatalog)(nil)
|
|
|
|
// Ensure HolidayService satisfies lp.HolidayCalendar at compile time.
|
|
// HolidayService.AdjustForNonWorkingDaysWithReason returns the
|
|
// AdjustmentReason via paliad's internal type — since lp.AdjustmentReason
|
|
// is now the canonical definition and AdjustmentReason inside services
|
|
// is aliased to it, the signatures align verbatim.
|
|
var _ lp.HolidayCalendar = (*HolidayService)(nil)
|
|
|
|
// Ensure CourtService satisfies lp.CourtRegistry at compile time.
|
|
var _ lp.CourtRegistry = (*CourtService)(nil)
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Helpers used by sibling services (event_trigger_service,
|
|
// event_deadline_service). Re-exported as thin wrappers so the existing
|
|
// call-sites in those services continue to compile without an import
|
|
// rewrite. A future slice can collapse them onto direct lp.* imports.
|
|
// ---------------------------------------------------------------------
|
|
|
|
// applyDuration delegates to litigationplanner.ApplyDuration.
|
|
func applyDuration(base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
|
|
return lp.ApplyDuration(base, value, unit, timing, country, regime, holidays)
|
|
}
|
|
|
|
// addWorkingDays delegates to litigationplanner.AddWorkingDays.
|
|
//
|
|
//nolint:unused // referenced for forward-compat with sibling services
|
|
func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time {
|
|
return lp.AddWorkingDays(from, n, country, regime, holidays)
|
|
}
|
|
|
|
// evalConditionExpr delegates to litigationplanner.EvalConditionExpr.
|
|
func evalConditionExpr(expr []byte, flags map[string]struct{}) bool {
|
|
return lp.EvalConditionExpr(expr, flags)
|
|
}
|
|
|
|
// hasConditionExpr delegates to litigationplanner.HasConditionExpr.
|
|
func hasConditionExpr(expr models.NullableJSON) bool {
|
|
return lp.HasConditionExpr(expr)
|
|
}
|
|
|
|
// extractFlagsFromExpr delegates to litigationplanner.ExtractFlagsFromExpr.
|
|
//
|
|
//nolint:unused // retained for sibling services that may want it
|
|
func extractFlagsFromExpr(expr models.NullableJSON) []string {
|
|
return lp.ExtractFlagsFromExpr(expr)
|
|
}
|
|
|
|
// allFlagsSet delegates to litigationplanner.AllFlagsSet. Retained for
|
|
// the paliad-side test suite that asserts the helper's contract.
|
|
func allFlagsSet(required []string, set map[string]struct{}) bool {
|
|
return lp.AllFlagsSet(required, set)
|
|
}
|
|
|
|
// wireFlagsFromPriority delegates to
|
|
// litigationplanner.WireFlagsFromPriority. Retained for the paliad-side
|
|
// test suite that asserts the priority → (isMandatory, isOptional)
|
|
// mapping.
|
|
func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
|
return lp.WireFlagsFromPriority(priority)
|
|
}
|
|
|
|
// sortDeadlinesByDurationWithinTriggerGroup is the paliad-side wrapper
|
|
// retained for the t-paliad-296 sort tests. Delegates to the
|
|
// package-internal sort over the lp.TimelineEntry shape — which is
|
|
// just an alias for UIDeadline, so callers pass []UIDeadline directly.
|
|
func sortDeadlinesByDurationWithinTriggerGroup(
|
|
deadlines []UIDeadline,
|
|
ruleByID map[uuid.UUID]models.DeadlineRule,
|
|
) {
|
|
lp.SortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
|
|
}
|
|
|
|
// DefaultsForJurisdiction delegates to
|
|
// litigationplanner.DefaultsForJurisdiction. Public re-export so
|
|
// handlers (deadline_rules_db.go) can keep using
|
|
// services.DefaultsForJurisdiction without an import-rewrite.
|
|
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
|
|
return lp.DefaultsForJurisdiction(jurisdiction)
|
|
}
|
|
|
|
// applyRuleOverrides delegates to litigationplanner.ApplyRuleOverrides.
|
|
//
|
|
//nolint:unused // retained for sibling services that may want it
|
|
func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule {
|
|
return lp.ApplyRuleOverrides(src, overrides)
|
|
}
|