Files
nexarch/workflow/internal/caseobj/caseobj.go
patrickandClaude Sonnet 5 f422d17120
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
VOR-01: Instance.State statt Status verwenden
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:57:33 +02:00

263 lines
8.6 KiB
Go

// Package caseobj ist das Akte/Vorgang-Objektmodell (VOR-01): eine Akte
// referenziert Objekte aus mehreren Modulen (Typ, ID, Herkunfts-Board)
// ueber den Modul-Adapter (ENG-02), ohne deren Rohdaten zu duplizieren.
// Status und dessen Historie kommen von der statemachine-Engine
// (ENG-01/ENG-04), wie bereits bei approval (APR-01) etabliert.
package caseobj
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/workflow/internal/statemachine"
)
// ProcessDefinition ist der Prozesstyp fuer Akten: offen, in Bearbeitung,
// geschlossen, mit den in der Papierakten-Tradition ueblichen Uebergaengen.
var ProcessDefinition = statemachine.ProcessDefinition{
Name: "case",
States: []string{"open", "in_progress", "closed"},
Initial: "open",
Transitions: []statemachine.Transition{
{From: "open", To: "in_progress"},
{From: "in_progress", To: "open"},
{From: "open", To: "closed"},
{From: "in_progress", To: "closed"},
},
}
// ErrNotFound wird zurueckgegeben, wenn keine Akte mit der angegebenen
// ID oder dem angegebenen Aktenzeichen existiert.
var ErrNotFound = errors.New("caseobj: akte nicht gefunden")
// ErrDuplicateCaseNumber wird zurueckgegeben, wenn ein Aktenzeichen
// bereits vergeben ist (Pruefung 3).
var ErrDuplicateCaseNumber = errors.New("caseobj: aktenzeichen bereits vergeben")
// Reference ist ein referenziertes Fremdobjekt (Akzeptanzkriterium 1).
type Reference struct {
ModuleName string
ObjectType string
ObjectID string
}
// Case ist eine Akte/ein Vorgang.
type Case struct {
ID string
InstanceID string
CaseNumber string
Responsible string
Status string
CreatedAt time.Time
References []Reference
}
// Service bindet das Aktenmodell an die statemachine-Engine und den
// Datenbank-Pool.
type Service struct {
pool *pgxpool.Pool
engine *statemachine.Engine
}
// NewService erzeugt den Dienst; die uebergebene Engine muss mit
// ProcessDefinition registriert sein (siehe NewEngineWithCase).
func NewService(pool *pgxpool.Pool, engine *statemachine.Engine) *Service {
return &Service{pool: pool, engine: engine}
}
// NewEngineWithCase registriert die Akten-Prozessdefinition zusammen mit
// optionalen weiteren Prozessdefinitionen des aufrufenden Dienstes auf
// einer gemeinsamen Engine.
func NewEngineWithCase(pool *pgxpool.Pool, extra ...statemachine.ProcessDefinition) (*statemachine.Engine, error) {
defs := append([]statemachine.ProcessDefinition{ProcessDefinition}, extra...)
return statemachine.NewEngine(pool, defs...)
}
// nextCaseNumber erzeugt das naechste Aktenzeichen nach dem
// konfigurierten Format (Akzeptanzkriterium 3: pro Tenant konfigurierbar
// -- eine DB je Mandant, Format liegt daher in dieser DB, nicht global).
// Platzhalter: {YYYY} = laufendes Jahr, {SEQ} = fortlaufende Nummer.
func nextCaseNumber(ctx context.Context, tx pgx.Tx) (string, error) {
var pattern string
var seq int64
if err := tx.QueryRow(ctx, `
UPDATE case_number_format SET next_seq = next_seq + 1
RETURNING pattern, next_seq - 1
`).Scan(&pattern, &seq); err != nil {
return "", fmt.Errorf("caseobj: aktenzeichen-format lesen: %w", err)
}
out := strings.ReplaceAll(pattern, "{YYYY}", strconv.Itoa(time.Now().Year()))
out = strings.ReplaceAll(out, "{SEQ}", strconv.FormatInt(seq, 10))
return out, nil
}
// SetCaseNumberFormat setzt das Aktenzeichen-Format dieses Mandanten
// (Akzeptanzkriterium 3: konfigurierbares Format). Der Zaehlerstand
// bleibt unveraendert.
func SetCaseNumberFormat(ctx context.Context, pool *pgxpool.Pool, pattern string) error {
if strings.TrimSpace(pattern) == "" {
return fmt.Errorf("caseobj: format darf nicht leer sein")
}
_, err := pool.Exec(ctx, `UPDATE case_number_format SET pattern = $1`, pattern)
if err != nil {
return fmt.Errorf("caseobj: format setzen: %w", err)
}
return nil
}
// Create legt eine neue Akte an. Ist caseNumber leer, wird es aus dem
// konfigurierten Format erzeugt; ein explizit uebergebenes Aktenzeichen
// wird respektiert und auf Eindeutigkeit geprueft (Pruefung 3).
// refs referenziert Objekte aus mehreren Modulen gleichzeitig
// (Akzeptanzkriterium 1), ohne deren Rohdaten zu duplizieren -- nur
// Modul, Typ und ID werden gespeichert.
func (s *Service) Create(ctx context.Context, caseNumber, responsible, createdBy string, refs []Reference) (Case, error) {
if responsible == "" {
return Case{}, fmt.Errorf("caseobj: verantwortlicher ist pflicht")
}
inst, err := s.engine.Start(ctx, ProcessDefinition.Name, createdBy)
if err != nil {
return Case{}, fmt.Errorf("caseobj: instanz starten: %w", err)
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Case{}, fmt.Errorf("caseobj: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if caseNumber == "" {
caseNumber, err = nextCaseNumber(ctx, tx)
if err != nil {
return Case{}, err
}
}
var id string
var createdAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO case_files (instance_id, case_number, responsible)
VALUES ($1, $2, $3)
RETURNING id, created_at
`, inst.ID, caseNumber, responsible).Scan(&id, &createdAt)
if err != nil {
if isUniqueViolation(err) {
return Case{}, ErrDuplicateCaseNumber
}
return Case{}, fmt.Errorf("caseobj: akte anlegen: %w", err)
}
for _, ref := range refs {
if ref.ModuleName == "" || ref.ObjectType == "" || ref.ObjectID == "" {
return Case{}, fmt.Errorf("caseobj: referenz benoetigt modul, typ und id")
}
if _, err := tx.Exec(ctx, `
INSERT INTO case_references (case_id, module_name, object_type, object_id)
VALUES ($1, $2, $3, $4)
`, id, ref.ModuleName, ref.ObjectType, ref.ObjectID); err != nil {
return Case{}, fmt.Errorf("caseobj: referenz anlegen: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return Case{}, fmt.Errorf("caseobj: transaktion abschliessen: %w", err)
}
return Case{
ID: id, InstanceID: inst.ID, CaseNumber: caseNumber, Responsible: responsible,
Status: ProcessDefinition.Initial, CreatedAt: createdAt, References: refs,
}, nil
}
// Get liefert eine Akte samt Referenzen und aktuellem Status.
func (s *Service) Get(ctx context.Context, id string) (Case, error) {
var c Case
err := s.pool.QueryRow(ctx, `
SELECT id, instance_id, case_number, responsible, created_at
FROM case_files WHERE id = $1
`, id).Scan(&c.ID, &c.InstanceID, &c.CaseNumber, &c.Responsible, &c.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Case{}, ErrNotFound
}
if err != nil {
return Case{}, fmt.Errorf("caseobj: akte lesen: %w", err)
}
inst, err := s.engine.Get(ctx, c.InstanceID)
if err != nil {
return Case{}, fmt.Errorf("caseobj: status lesen: %w", err)
}
c.Status = inst.State
rows, err := s.pool.Query(ctx, `
SELECT module_name, object_type, object_id FROM case_references
WHERE case_id = $1 ORDER BY created_at ASC
`, id)
if err != nil {
return Case{}, fmt.Errorf("caseobj: referenzen lesen: %w", err)
}
defer rows.Close()
for rows.Next() {
var r Reference
if err := rows.Scan(&r.ModuleName, &r.ObjectType, &r.ObjectID); err != nil {
return Case{}, fmt.Errorf("caseobj: referenz lesen: %w", err)
}
c.References = append(c.References, r)
}
return c, rows.Err()
}
// ByReference liefert die Akten, die ein bestimmtes Fremdobjekt
// referenzieren (Grundlage fuer die Rueckkanal-API aus VOR-04).
func (s *Service) ByReference(ctx context.Context, moduleName, objectType, objectID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `
SELECT case_id FROM case_references
WHERE module_name = $1 AND object_type = $2 AND object_id = $3
`, moduleName, objectType, objectID)
if err != nil {
return nil, fmt.Errorf("caseobj: referenzen suchen: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("caseobj: referenz lesen: %w", err)
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// Transition wechselt den Status einer Akte (Akzeptanzkriterium 2: der
// Wechsel wird ueber die Engine nachvollziehbar mit Person und
// Zeitpunkt protokolliert, Pruefung 2).
func (s *Service) Transition(ctx context.Context, id, to, actor, reason string) error {
c, err := s.Get(ctx, id)
if err != nil {
return err
}
return s.engine.Transition(ctx, c.InstanceID, to, actor, reason)
}
// History liefert die Statushistorie einer Akte.
func (s *Service) History(ctx context.Context, id string) ([]statemachine.HistoryEntry, error) {
c, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
return s.engine.History(ctx, c.InstanceID)
}
func isUniqueViolation(err error) bool {
return strings.Contains(err.Error(), "duplicate key value violates unique constraint")
}