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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
263 lines
8.6 KiB
Go
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")
|
|
}
|