// 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") }