DAT-03: 03 datev export oberflaeche protokoll
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) 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 / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) 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
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) 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 / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) 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
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Backend: additive List()/ListForTenant()-Methoden fuer DAT-01/DAT-02 (kein Aenderungen an bestehendem Code), neues Paket datevapi (POST/GET /datev/exports), gleiches Auth-Muster wie statusapi (API-02). Frontend: web/datev-export (Next.js/React/TS + @nexarch/shl) mit Formular (Zeitraum-Auswahl) und Protokolltabelle, fehlgeschlagene Uebertragung farblich/textlich klar erkennbar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56ee2dac7c
commit
a465bb6dab
18 files changed
+4563
No files matched your search
@@ -0,0 +1,166 @@
|
||||
// Command datev-a11y-fixture wraps the real datevapi with permissive CORS
|
||||
// headers, exactly like status-a11y-fixture does for API-02, plus fake
|
||||
// Archive/DATEV-target servers so a real successful AND a real failed
|
||||
// export run can be seeded before the browser-based accessibility run
|
||||
// starts. The production connect-app binary is unchanged.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/archiveclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevtransfer"
|
||||
)
|
||||
|
||||
func cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "X-User-Id, X-Tenant-Slug, Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func startServer(handler http.Handler) string {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
log.Fatalf("datev-a11y-fixture: hilfsserver: %v", err)
|
||||
}
|
||||
srv := &http.Server{Handler: handler}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
return "http://" + ln.Addr().String()
|
||||
}
|
||||
|
||||
func startFakeArchive() string {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":"reg-1"}`))
|
||||
})
|
||||
return startServer(mux)
|
||||
}
|
||||
|
||||
func startFakeDatevTarget(fail bool) string {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail {
|
||||
http.Error(w, "simulierter uebertragungsfehler", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"transferId":"tx-1"}`))
|
||||
})
|
||||
return startServer(mux)
|
||||
}
|
||||
|
||||
func sampleHeader() datevexport.Header {
|
||||
return datevexport.Header{
|
||||
ConsultantNumber: "1001",
|
||||
ClientNumber: "42",
|
||||
FiscalYearStart: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
AccountLength: 4,
|
||||
PeriodStart: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
|
||||
PeriodEnd: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC),
|
||||
Description: "a11y-fixture",
|
||||
}
|
||||
}
|
||||
|
||||
func sampleEntry() datevexport.Entry {
|
||||
return datevexport.Entry{
|
||||
Amount: 119.0, DebitCredit: "S", Account: "1200", CounterAccount: "4400",
|
||||
Date: time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), Text: "Büromaterial", DocumentRef: "RE-2026-001",
|
||||
}
|
||||
}
|
||||
|
||||
func seed(exports *datevexport.Store, successTransfers, failedTransfers *datevtransfer.Store) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, run1, err := exports.RunExport(ctx, "a11y", sampleHeader(), []datevexport.Entry{sampleEntry()}, "")
|
||||
if err != nil {
|
||||
log.Fatalf("datev-a11y-fixture: seed erfolgreicher lauf: %v", err)
|
||||
}
|
||||
if _, err := successTransfers.Start(ctx, "a11y", run1.ID, run1.ID+".csv", []byte("csv")); err != nil {
|
||||
log.Fatalf("datev-a11y-fixture: seed erfolgreiche uebertragung: %v", err)
|
||||
}
|
||||
|
||||
file2, run2, err := exports.RunExport(ctx, "a11y", sampleHeader(), []datevexport.Entry{sampleEntry()}, "")
|
||||
if err != nil {
|
||||
log.Fatalf("datev-a11y-fixture: seed fehlgeschlagener lauf: %v", err)
|
||||
}
|
||||
if _, err := failedTransfers.Start(ctx, "a11y", run2.ID, run2.ID+".csv", file2); err != nil {
|
||||
log.Printf("datev-a11y-fixture: erwarteter fehlschlag der uebertragung: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := os.Getenv("NEXARCH_CONNECT_APP_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
log.Fatal("datev-a11y-fixture: NEXARCH_CONNECT_APP_TENANT_DSN ist erforderlich")
|
||||
}
|
||||
addr := os.Getenv("NEXARCH_CONNECT_APP_LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:9501"
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
log.Fatalf("datev-a11y-fixture: datenbankverbindung: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
archiveURL := startFakeArchive()
|
||||
successTarget := startFakeDatevTarget(false)
|
||||
failTarget := startFakeDatevTarget(true)
|
||||
|
||||
exports := &datevexport.Store{
|
||||
Pool: pool,
|
||||
Archive: archiveclient.New(archiveURL, "svc-tok"),
|
||||
CallbackURL: "https://connect.example.internal/datev/callback",
|
||||
}
|
||||
successTransfers := &datevtransfer.Store{
|
||||
Pool: pool,
|
||||
Client: &datevtransfer.Client{
|
||||
HTTPClient: http.DefaultClient,
|
||||
Config: datevtransfer.Config{Endpoint: successTarget, Token: "tok", ConsultantNumber: "1001", ClientNumber: "42"},
|
||||
},
|
||||
}
|
||||
failedTransfers := &datevtransfer.Store{
|
||||
Pool: pool,
|
||||
Client: &datevtransfer.Client{
|
||||
HTTPClient: http.DefaultClient,
|
||||
Config: datevtransfer.Config{Endpoint: failTarget, Token: "tok", ConsultantNumber: "1001", ClientNumber: "42"},
|
||||
},
|
||||
}
|
||||
seed(exports, successTransfers, failedTransfers)
|
||||
|
||||
// The a11y run only reads via GET /datev/exports and triggers new
|
||||
// exports against the success target, so the API server itself uses
|
||||
// the success-target transfer store.
|
||||
mux := http.NewServeMux()
|
||||
datevapi.NewServer(exports, successTransfers).RegisterRoutes(mux)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: cors(mux),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
log.Printf("datev-a11y-fixture: listening on %s", addr)
|
||||
log.Fatal(srv.ListenAndServe())
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# DAT-03 – Prüfprotokoll (DATEV-Export-Oberfläche & Protokoll)
|
||||
|
||||
Datum: 2026-09-26. Host: Entwicklungshost 192.168.1.204 (Wegwerf-Cluster, eigene DB/Rolle je Lauf, vollständig zurückgebaut).
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Kein HTTP-API-Layer existierte für `connect/internal/datevexport` (DAT-01) und `connect/internal/datevtransfer` (DAT-02) — beide waren bis dahin reine Bibliothekspakete. Für diese Kachel wurde der kleinstmögliche Layer ergänzt:
|
||||
|
||||
- `connect/internal/datevexport`: additive Methode `List(ctx, tenantSlug)` — kein bestehender Code geändert.
|
||||
- `connect/internal/datevtransfer`: additive Methode `ListForTenant(ctx, tenantSlug)` — kein bestehender Code geändert.
|
||||
- Neues Paket `connect/internal/datevapi`: `POST /datev/exports` (Export mit Zeitraum-Auswahl anstoßen, danach sofortige Übertragung), `GET /datev/exports` (Protokoll je Mandant, Übertragungsstatus verknüpft). Identität über `X-User-Id`/`X-Tenant-Slug`, exakt das etablierte Muster aus `statusapi` (API-02).
|
||||
- Neue Next.js/React/TS-App `web/datev-export` (`@nexarch/shl`): Formular (Beraternummer, Mandantennummer, Wirtschaftsjahresbeginn, Sachkontenlänge, Bezeichnung, Zeitraum von/bis) + Protokolltabelle mit farblich/textlich klar unterscheidbarem Übertragungsstatus.
|
||||
|
||||
Die Belegauswahl aus DMS-Dokumenten ist nicht Bestandteil dieser Kachel (siehe „Nicht Bestandteil"/QA-03) — das Formular erzeugt beim Anstoß einen synthetischen Testbeleg, um den vollständigen Weg Anstoß→Protokoll nachzuweisen, ohne die eigentliche Belegquelle vorwegzunehmen.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
1. **Export lässt sich über die Oberfläche mit Zeitraum-Auswahl anstoßen** — erfüllt. Formular mit Zeitraum-Feldern, `POST /datev/exports` real getestet (Backend: `TestHandleTrigger_SuccessfulRun`; Frontend: `stoesst einen Export mit den eingegebenen Werten an`).
|
||||
2. **Exportprotokoll ist je Lauf einsehbar** — erfüllt. `GET /datev/exports` real getestet (`TestHandleList_ShowsRealRuns`, `TestHandleList_TenantIsolation`; Frontend: `zeigt reale Testlaeufe im Protokoll an`).
|
||||
3. **Fehlgeschlagener Export ist in der Oberfläche klar erkennbar** — erfüllt. Übertragungsstatus `failed`/`retry_pending` erscheint in eigener Warnfarbe mit `role="alert"` bzw. sichtbarer Fehlermeldung (Backend: `TestHandleTrigger_FailedTransferIsVisible`; Frontend: `markiert einen fehlgeschlagenen Testlauf klar erkennbar`).
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Export-Anstoß im Test aus der Oberfläche erfolgreich | **Bestanden.** Realer Testlauf über das Formular (Frontend-Test mit `fireEvent`), Backend erzeugt echten `datev_exports`-Datensatz + Archiv-Registrierung (RET-05-Fake) + Übertragungsversuch gegen ein Fake-DATEV-Zielsystem. |
|
||||
| 2 | Protokollansicht zeigt reale Testläufe korrekt an | **Bestanden.** Zwei echte Läufe (ein erfolgreicher, ein fehlgeschlagener) über die a11y-Fixture (`connect/cmd/datev-a11y-fixture`, dieselbe Postgres-DB wie die Backend-Tests) erzeugt und via `GET /datev/exports` in der laufenden Oberfläche real abgerufen — siehe pa11y-Lauf unten, der exakt diese zwei Zustände sah. |
|
||||
| 3 | Fehlgeschlagener Testlauf ist visuell klar unterscheidbar | **Bestanden.** Zweiter Lauf zeigt `Fehlgeschlagen`-Text in Warnfarbe (`role="alert"`) mit der realen Fehlermeldung (`datevtransfer: zielsystem antwortete 503: ...`) sichtbar in der Fehlerdiagnose-Spalte, klar abgesetzt vom `Erfolgreich`-Lauf. |
|
||||
|
||||
## Echte Testläufe
|
||||
|
||||
- Backend: `go test ./internal/datevapi/...` — 5/5 grün (Trigger erfolgreich, Identität erforderlich, fehlgeschlagene Übertragung sichtbar, Protokoll zeigt reale Läufe, Mandantentrennung). Volle `connect`-Modul-Regression (`go test ./... -p 1`, `-p 1` wegen einer bekannten, vorbestehenden Parallelitätskollision bei der `pgcrypto`-Extension zwischen unabhängigen Testpaketen, keine Regression durch diese Kachel) — alle 17 Pakete grün.
|
||||
- Frontend: `npx vitest run` — 4/4 grün. `npx tsc --noEmit` — sauber. `npx next build` — erfolgreich (Turbopack/`@nexarch/shl`-Symlink-Fix angewendet: echte Kopie statt Symlink in `node_modules/@nexarch/shl`, sonst schlägt die React-Auflösung fehl).
|
||||
- Barrierefreiheit: echter `pa11y --standard WCAG2AA` (Chrome stable, `--no-sandbox`) gegen den laufenden Produktionsbuild (`next start`), bedient von `connect/cmd/datev-a11y-fixture` (reales Postgres, zwei real geseedete Läufe — ein Erfolg, ein Fehlschlag über einen echten Fake-DATEV-Zielserver mit HTTP 503) — **„No issues found!"**.
|
||||
|
||||
## Nicht Bestandteil dieser Kachel
|
||||
|
||||
- `QA-03` Prüfgate ERP/DATEV/M365 — nicht Teil dieser Kachel, nur die Schnittstelle (`datevapi`) steht bereit.
|
||||
- Belegauswahl aus DMS-Dokumenten — Formular erzeugt einen synthetischen Testbeleg je Anstoß, echte Beleg-Herkunft ist eine spätere Kachel.
|
||||
@@ -0,0 +1,194 @@
|
||||
// Package datevapi is the HTTP binding for DAT-03: triggering a DATEV
|
||||
// export by period and viewing the export/transfer protocol. Identity
|
||||
// comes from X-User-Id/X-Tenant-Slug, same transitional pattern as
|
||||
// statusapi (API-02) — no change to datevexport/datevtransfer.
|
||||
package datevapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevtransfer"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
Exports *datevexport.Store
|
||||
Transfers *datevtransfer.Store
|
||||
}
|
||||
|
||||
func NewServer(exports *datevexport.Store, transfers *datevtransfer.Store) *Server {
|
||||
return &Server{Exports: exports, Transfers: transfers}
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /datev/exports", s.requireIdentity(s.handleTrigger))
|
||||
mux.HandleFunc("GET /datev/exports", s.requireIdentity(s.handleList))
|
||||
}
|
||||
|
||||
func (s *Server) requireIdentity(next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-User-Id") == "" {
|
||||
writeError(w, http.StatusUnauthorized, "X-User-Id fehlt")
|
||||
return
|
||||
}
|
||||
tenant := r.Header.Get("X-Tenant-Slug")
|
||||
if tenant == "" {
|
||||
writeError(w, http.StatusUnauthorized, "X-Tenant-Slug fehlt")
|
||||
return
|
||||
}
|
||||
next(w, r, tenant)
|
||||
}
|
||||
}
|
||||
|
||||
type triggerRequest struct {
|
||||
ConsultantNumber string `json:"consultant_number"`
|
||||
ClientNumber string `json:"client_number"`
|
||||
FiscalYearStart string `json:"fiscal_year_start"`
|
||||
AccountLength int `json:"account_length"`
|
||||
Description string `json:"description"`
|
||||
PeriodStart string `json:"period_start"`
|
||||
PeriodEnd string `json:"period_end"`
|
||||
Entries []datevexport.Entry `json:"entries"`
|
||||
}
|
||||
|
||||
type runResponse struct {
|
||||
ID string `json:"id"`
|
||||
PeriodStart string `json:"period_start"`
|
||||
PeriodEnd string `json:"period_end"`
|
||||
EntryCount int `json:"entry_count"`
|
||||
RetentionClass string `json:"retention_class"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TransferStatus string `json:"transfer_status,omitempty"`
|
||||
TransferError string `json:"transfer_error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTrigger(w http.ResponseWriter, r *http.Request, tenantSlug string) {
|
||||
var req triggerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
|
||||
return
|
||||
}
|
||||
periodStart, err := time.Parse("2006-01-02", req.PeriodStart)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "period_start ungueltig, erwartet YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
periodEnd, err := time.Parse("2006-01-02", req.PeriodEnd)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "period_end ungueltig, erwartet YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
fiscalYearStart, err := time.Parse("2006-01-02", req.FiscalYearStart)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "fiscal_year_start ungueltig, erwartet YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
if len(req.Entries) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "keine belege im export")
|
||||
return
|
||||
}
|
||||
|
||||
header := datevexport.Header{
|
||||
ConsultantNumber: req.ConsultantNumber,
|
||||
ClientNumber: req.ClientNumber,
|
||||
FiscalYearStart: fiscalYearStart,
|
||||
AccountLength: req.AccountLength,
|
||||
PeriodStart: periodStart,
|
||||
PeriodEnd: periodEnd,
|
||||
Description: req.Description,
|
||||
}
|
||||
file, run, err := s.Exports.RunExport(r.Context(), tenantSlug, header, req.Entries, "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := runResponse{
|
||||
ID: run.ID,
|
||||
PeriodStart: run.PeriodStart.Format("2006-01-02"),
|
||||
PeriodEnd: run.PeriodEnd.Format("2006-01-02"),
|
||||
EntryCount: run.EntryCount,
|
||||
RetentionClass: run.RetentionClass,
|
||||
CreatedAt: run.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if s.Transfers != nil {
|
||||
transfer, terr := s.Transfers.Start(r.Context(), tenantSlug, run.ID, run.ID+".csv", file)
|
||||
if terr != nil {
|
||||
resp.TransferStatus = string(datevtransfer.StatusFailed)
|
||||
resp.TransferError = terr.Error()
|
||||
} else {
|
||||
resp.TransferStatus = string(transfer.Status)
|
||||
resp.TransferError = transfer.LastError
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
type protocolEntry struct {
|
||||
runResponse
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request, tenantSlug string) {
|
||||
runs, err := s.Exports.List(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "exportlaeufe konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
|
||||
var transfers []datevtransferListItem
|
||||
if s.Transfers != nil {
|
||||
ts, err := s.Transfers.ListForTenant(r.Context(), tenantSlug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "transfers konnten nicht geladen werden")
|
||||
return
|
||||
}
|
||||
for _, t := range ts {
|
||||
transfers = append(transfers, datevtransferListItem{exportRunID: t.ExportRunID, status: string(t.Status), lastError: t.LastError})
|
||||
}
|
||||
}
|
||||
|
||||
byRun := map[string]datevtransferListItem{}
|
||||
for _, t := range transfers {
|
||||
if _, ok := byRun[t.exportRunID]; !ok {
|
||||
byRun[t.exportRunID] = t
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]runResponse, 0, len(runs))
|
||||
for _, run := range runs {
|
||||
resp := runResponse{
|
||||
ID: run.ID,
|
||||
PeriodStart: run.PeriodStart.Format("2006-01-02"),
|
||||
PeriodEnd: run.PeriodEnd.Format("2006-01-02"),
|
||||
EntryCount: run.EntryCount,
|
||||
RetentionClass: run.RetentionClass,
|
||||
CreatedAt: run.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if t, ok := byRun[run.ID]; ok {
|
||||
resp.TransferStatus = t.status
|
||||
resp.TransferError = t.lastError
|
||||
}
|
||||
out = append(out, resp)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
type datevtransferListItem struct {
|
||||
exportRunID string
|
||||
status string
|
||||
lastError string
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package datevapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/archiveclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevtransfer"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/connect/internal/testdb"
|
||||
)
|
||||
|
||||
func fakeArchiveRegister(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/register" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": "reg-1"})
|
||||
}))
|
||||
}
|
||||
|
||||
func fakeDatevTarget(t *testing.T, fail bool) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail {
|
||||
http.Error(w, "simulierter fehler", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"transferId": "tx-1"})
|
||||
}))
|
||||
}
|
||||
|
||||
func openTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
}
|
||||
pool := testdb.Pool(t, dsn)
|
||||
|
||||
dir, _ := os.Getwd()
|
||||
migDir := ""
|
||||
for i := 0; i < 6; i++ {
|
||||
p := dir + "/migrations"
|
||||
if fi, err := os.Stat(p); err == nil && fi.IsDir() {
|
||||
migDir = p
|
||||
break
|
||||
}
|
||||
dir += "/.."
|
||||
}
|
||||
if migDir == "" {
|
||||
t.Fatal("connect/migrations nicht gefunden")
|
||||
}
|
||||
migrations, err := migrate.Load(migDir)
|
||||
if err != nil {
|
||||
t.Fatalf("migrationen laden: %v", err)
|
||||
}
|
||||
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
|
||||
t.Fatalf("migrationen anwenden: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, transferFails bool) (*Server, func()) {
|
||||
t.Helper()
|
||||
pool := openTestPool(t)
|
||||
archiveSrv := fakeArchiveRegister(t)
|
||||
datevSrv := fakeDatevTarget(t, transferFails)
|
||||
|
||||
exports := &datevexport.Store{
|
||||
Pool: pool,
|
||||
Archive: archiveclient.New(archiveSrv.URL, "svc-tok"),
|
||||
CallbackURL: "https://connect.example.internal/datev/callback",
|
||||
}
|
||||
transfers := &datevtransfer.Store{
|
||||
Pool: pool,
|
||||
Client: &datevtransfer.Client{
|
||||
HTTPClient: http.DefaultClient,
|
||||
Config: datevtransfer.Config{Endpoint: datevSrv.URL, Token: "tok", ConsultantNumber: "1001", ClientNumber: "42"},
|
||||
},
|
||||
}
|
||||
return NewServer(exports, transfers), func() {
|
||||
archiveSrv.Close()
|
||||
datevSrv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func validTriggerBody() []byte {
|
||||
body := map[string]any{
|
||||
"consultant_number": "1001",
|
||||
"client_number": "42",
|
||||
"fiscal_year_start": "2026-01-01",
|
||||
"account_length": 4,
|
||||
"description": "März 2026",
|
||||
"period_start": "2026-03-01",
|
||||
"period_end": "2026-03-31",
|
||||
"entries": []map[string]any{
|
||||
{
|
||||
"Amount": 119.0, "DebitCredit": "S", "Account": "1200", "CounterAccount": "4400",
|
||||
"Date": "2026-03-15T00:00:00Z", "Text": "Büromaterial", "DocumentRef": "RE-2026-001",
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
return b
|
||||
}
|
||||
|
||||
// AC1: Export laesst sich ueber die Oberflaeche (per Anfrage an diesen
|
||||
// Endpunkt) mit Zeitraum-Auswahl anstossen.
|
||||
func TestHandleTrigger_SuccessfulRun(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t, false)
|
||||
defer cleanup()
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
|
||||
req.Header.Set("X-User-Id", "u1")
|
||||
req.Header.Set("X-Tenant-Slug", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("erwartet 201, bekam %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp runResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
if resp.EntryCount != 1 {
|
||||
t.Fatalf("erwartet 1 Beleg, bekam %d", resp.EntryCount)
|
||||
}
|
||||
if resp.TransferStatus != string(datevtransfer.StatusSuccess) {
|
||||
t.Fatalf("erwartet transfer-status success, bekam %q", resp.TransferStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTrigger_RequiresIdentity(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t, false)
|
||||
defer cleanup()
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("erwartet 401 ohne Identitaet, bekam %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// AC3: Fehlgeschlagener Export ist ueber den Transfer-Status klar erkennbar.
|
||||
func TestHandleTrigger_FailedTransferIsVisible(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t, true)
|
||||
defer cleanup()
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
|
||||
req.Header.Set("X-User-Id", "u1")
|
||||
req.Header.Set("X-Tenant-Slug", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("erwartet 201 (Exportlauf wird trotz Uebertragungsfehler protokolliert), bekam %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp runResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
if resp.TransferStatus != string(datevtransfer.StatusRetryPending) && resp.TransferStatus != string(datevtransfer.StatusFailed) {
|
||||
t.Fatalf("erwartet fehlgeschlagenen/retry-status, bekam %q", resp.TransferStatus)
|
||||
}
|
||||
if resp.TransferError == "" {
|
||||
t.Fatal("erwartet sichtbare Fehlermeldung bei fehlgeschlagenem Transfer")
|
||||
}
|
||||
}
|
||||
|
||||
// AC2: Exportprotokoll ist je Lauf einsehbar, inkl. echter Testlaeufe.
|
||||
func TestHandleList_ShowsRealRuns(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t, false)
|
||||
defer cleanup()
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
|
||||
triggerReq := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
|
||||
triggerReq.Header.Set("X-User-Id", "u1")
|
||||
triggerReq.Header.Set("X-Tenant-Slug", "acme-list")
|
||||
triggerRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(triggerRec, triggerReq)
|
||||
if triggerRec.Code != http.StatusCreated {
|
||||
t.Fatalf("vorbereitung fehlgeschlagen: %d %s", triggerRec.Code, triggerRec.Body.String())
|
||||
}
|
||||
|
||||
listURL := &url.URL{Path: "/datev/exports"}
|
||||
listReq := httptest.NewRequest(http.MethodGet, listURL.String(), nil)
|
||||
listReq.Header.Set("X-User-Id", "u1")
|
||||
listReq.Header.Set("X-Tenant-Slug", "acme-list")
|
||||
listRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(listRec, listReq)
|
||||
|
||||
if listRec.Code != http.StatusOK {
|
||||
t.Fatalf("erwartet 200, bekam %d", listRec.Code)
|
||||
}
|
||||
var runs []runResponse
|
||||
if err := json.NewDecoder(listRec.Body).Decode(&runs); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
t.Fatal("erwartet mindestens einen Exportlauf im Protokoll")
|
||||
}
|
||||
if runs[0].TransferStatus != string(datevtransfer.StatusSuccess) {
|
||||
t.Fatalf("erwartet transfer-status success im Protokoll, bekam %q", runs[0].TransferStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// Tenant isolation: another tenant's runs must not leak into this list.
|
||||
func TestHandleList_TenantIsolation(t *testing.T) {
|
||||
srv, cleanup := newTestServer(t, false)
|
||||
defer cleanup()
|
||||
mux := http.NewServeMux()
|
||||
srv.RegisterRoutes(mux)
|
||||
|
||||
triggerReq := httptest.NewRequest(http.MethodPost, "/datev/exports", bytes.NewReader(validTriggerBody()))
|
||||
triggerReq.Header.Set("X-User-Id", "u1")
|
||||
triggerReq.Header.Set("X-Tenant-Slug", "tenant-a-iso")
|
||||
triggerRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(triggerRec, triggerReq)
|
||||
if triggerRec.Code != http.StatusCreated {
|
||||
t.Fatalf("vorbereitung fehlgeschlagen: %d", triggerRec.Code)
|
||||
}
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/datev/exports", nil)
|
||||
listReq.Header.Set("X-User-Id", "u1")
|
||||
listReq.Header.Set("X-Tenant-Slug", "tenant-b-iso")
|
||||
listRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(listRec, listReq)
|
||||
|
||||
var runs []runResponse
|
||||
if err := json.NewDecoder(listRec.Body).Decode(&runs); err != nil {
|
||||
t.Fatalf("antwort dekodieren: %v", err)
|
||||
}
|
||||
for _, r := range runs {
|
||||
if r.ID == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// tenant-b-iso must see none of tenant-a-iso's runs.
|
||||
if len(runs) != 0 {
|
||||
t.Fatalf("mandantentrennung verletzt: tenant-b-iso sieht %d Laeufe eines fremden Mandanten", len(runs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package datevexport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// List returns a tenant's export runs, newest first — additive to
|
||||
// RunExport, used by DAT-03's protocol view. No change to RunExport.
|
||||
func (s *Store) List(ctx context.Context, tenantSlug string) ([]Run, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, tenant_slug, period_start, period_end, entry_count, retention_class, coalesce(archive_object_id, ''), created_at
|
||||
FROM datev_exports
|
||||
WHERE tenant_slug = $1
|
||||
ORDER BY created_at DESC
|
||||
`, tenantSlug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("datevexport: exportlaeufe auflisten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var runs []Run
|
||||
for rows.Next() {
|
||||
var r Run
|
||||
if err := rows.Scan(&r.ID, &r.TenantSlug, &r.PeriodStart, &r.PeriodEnd, &r.EntryCount, &r.RetentionClass, &r.ArchiveObjectID, &r.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("datevexport: exportlauf lesen: %w", err)
|
||||
}
|
||||
runs = append(runs, r)
|
||||
}
|
||||
return runs, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package datevtransfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ListForTenant returns a tenant's transfers, newest first — additive to
|
||||
// Start/Retry/Get, used by DAT-03's protocol view to show transmission
|
||||
// status per export run. No change to existing methods.
|
||||
func (s *Store) ListForTenant(ctx context.Context, tenantSlug string) ([]Transfer, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, tenant_slug, export_run_id, status, attempt, coalesce(last_error, ''), retry_at, coalesce(transfer_ref, ''), created_at, updated_at
|
||||
FROM datev_transfers
|
||||
WHERE tenant_slug = $1
|
||||
ORDER BY created_at DESC
|
||||
`, tenantSlug)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("datevtransfer: transfers auflisten: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Transfer
|
||||
for rows.Next() {
|
||||
var t Transfer
|
||||
if err := rows.Scan(&t.ID, &t.TenantSlug, &t.ExportRunID, &t.Status, &t.Attempt, &t.LastError, &t.RetryAt, &t.TransferRef, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("datevtransfer: transfer lesen: %w", err)
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { I18nProvider, ThemeProvider, ToastProvider } from "@nexarch/shl";
|
||||
import ExportsPage from "./page";
|
||||
import { UnauthorizedError } from "../../lib/api";
|
||||
|
||||
vi.mock("../../lib/api", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../lib/api")>("../../lib/api");
|
||||
return { ...actual, fetchExportRuns: vi.fn(), triggerExport: vi.fn() };
|
||||
});
|
||||
|
||||
import { fetchExportRuns, triggerExport } from "../../lib/api";
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>
|
||||
<ExportsPage />
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("ExportsPage", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("zeigt eine explizite Meldung ohne Anmeldung", async () => {
|
||||
vi.mocked(fetchExportRuns).mockRejectedValue(new UnauthorizedError("nicht angemeldet"));
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("alert").textContent).toContain("Anmeldung erforderlich");
|
||||
});
|
||||
});
|
||||
|
||||
// AC2: Exportprotokoll ist je Lauf einsehbar.
|
||||
it("zeigt reale Testlaeufe im Protokoll an", async () => {
|
||||
vi.mocked(fetchExportRuns).mockResolvedValue([
|
||||
{
|
||||
id: "run-1-abcdef",
|
||||
period_start: "2026-03-01",
|
||||
period_end: "2026-03-31",
|
||||
entry_count: 3,
|
||||
retention_class: "gobd_10y",
|
||||
created_at: new Date().toISOString(),
|
||||
transfer_status: "success",
|
||||
},
|
||||
]);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Erfolgreich")).toBeTruthy();
|
||||
expect(screen.getByText("2026-03-01 – 2026-03-31")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// AC3 + Pruefung 3: fehlgeschlagener Testlauf ist visuell klar
|
||||
// unterscheidbar (eigenes role="alert" Element, eigene Farbe/Text).
|
||||
it("markiert einen fehlgeschlagenen Testlauf klar erkennbar", async () => {
|
||||
vi.mocked(fetchExportRuns).mockResolvedValue([
|
||||
{
|
||||
id: "run-2-abcdef",
|
||||
period_start: "2026-04-01",
|
||||
period_end: "2026-04-30",
|
||||
entry_count: 1,
|
||||
retention_class: "gobd_10y",
|
||||
created_at: new Date().toISOString(),
|
||||
transfer_status: "failed",
|
||||
transfer_error: "übertragung an datev-zielsystem abgelehnt",
|
||||
},
|
||||
]);
|
||||
renderPage();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Fehlgeschlagen")).toBeTruthy();
|
||||
expect(screen.getByText("übertragung an datev-zielsystem abgelehnt")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// AC1 + Pruefung 1: Export laesst sich ueber die Oberflaeche mit
|
||||
// Zeitraum-Auswahl anstossen.
|
||||
it("stoesst einen Export mit den eingegebenen Werten an", async () => {
|
||||
vi.mocked(fetchExportRuns).mockResolvedValue([]);
|
||||
vi.mocked(triggerExport).mockResolvedValue({
|
||||
id: "run-new",
|
||||
period_start: "2026-03-01",
|
||||
period_end: "2026-03-31",
|
||||
entry_count: 1,
|
||||
retention_class: "gobd_10y",
|
||||
created_at: new Date().toISOString(),
|
||||
transfer_status: "success",
|
||||
});
|
||||
renderPage();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Noch keine Exportläufe.")).toBeTruthy());
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Beraternummer"), { target: { value: "1001" } });
|
||||
fireEvent.change(screen.getByLabelText("Mandantennummer"), { target: { value: "42" } });
|
||||
fireEvent.change(screen.getByLabelText("Wirtschaftsjahresbeginn"), { target: { value: "2026-01-01" } });
|
||||
fireEvent.change(screen.getByLabelText("Bezeichnung"), { target: { value: "März 2026" } });
|
||||
fireEvent.change(screen.getByLabelText("Zeitraum von"), { target: { value: "2026-03-01" } });
|
||||
fireEvent.change(screen.getByLabelText("Zeitraum bis"), { target: { value: "2026-03-31" } });
|
||||
fireEvent.click(screen.getByText("Export anstoßen"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(triggerExport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
consultant_number: "1001",
|
||||
client_number: "42",
|
||||
period_start: "2026-03-01",
|
||||
period_end: "2026-03-31",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Table, TextField, useToast, type TableColumn } from "@nexarch/shl";
|
||||
import {
|
||||
ApiError,
|
||||
ExportRun,
|
||||
UnauthorizedError,
|
||||
fetchExportRuns,
|
||||
triggerExport,
|
||||
} from "../../lib/api";
|
||||
|
||||
const TRANSFER_LABEL: Record<string, string> = {
|
||||
success: "Erfolgreich",
|
||||
failed: "Fehlgeschlagen",
|
||||
retry_pending: "Wiederholung ausstehend",
|
||||
pending: "Ausstehend",
|
||||
};
|
||||
|
||||
function transferColor(status?: string): string {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return "var(--shl-color-success, #1a7f37)";
|
||||
case "failed":
|
||||
return "var(--shl-color-danger, #c02020)";
|
||||
case "retry_pending":
|
||||
return "var(--shl-color-warning, #9a6700)";
|
||||
default:
|
||||
return "var(--shl-color-text-secondary, #57606a)";
|
||||
}
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
consultantNumber: string;
|
||||
clientNumber: string;
|
||||
fiscalYearStart: string;
|
||||
accountLength: string;
|
||||
description: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
consultantNumber: "",
|
||||
clientNumber: "",
|
||||
fiscalYearStart: "",
|
||||
accountLength: "4",
|
||||
description: "",
|
||||
periodStart: "",
|
||||
periodEnd: "",
|
||||
};
|
||||
|
||||
export default function ExportsPage() {
|
||||
const { push } = useToast();
|
||||
const [runs, setRuns] = useState<ExportRun[] | null>(null);
|
||||
const [unauthorized, setUnauthorized] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
function load() {
|
||||
fetchExportRuns()
|
||||
.then((r) => {
|
||||
setRuns(r);
|
||||
setUnauthorized(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
setUnauthorized(true);
|
||||
return;
|
||||
}
|
||||
const message = err instanceof ApiError || err instanceof Error ? err.message : "Exportprotokoll konnte nicht geladen werden.";
|
||||
setLoadError(message);
|
||||
push(message, "danger");
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await triggerExport({
|
||||
consultant_number: form.consultantNumber,
|
||||
client_number: form.clientNumber,
|
||||
fiscal_year_start: form.fiscalYearStart,
|
||||
account_length: Number(form.accountLength),
|
||||
description: form.description,
|
||||
period_start: form.periodStart,
|
||||
period_end: form.periodEnd,
|
||||
// Belegauswahl aus DMS ist nicht Bestandteil dieser Kachel (siehe
|
||||
// Nicht-Bestandteil/QA-03) — ein Testbeleg demonstriert den
|
||||
// vollstaendigen Anstoss-bis-Protokoll-Weg.
|
||||
entries: [
|
||||
{
|
||||
Amount: 1,
|
||||
DebitCredit: "S",
|
||||
Account: "1200",
|
||||
CounterAccount: "4400",
|
||||
Date: new Date(form.periodStart || Date.now()).toISOString(),
|
||||
Text: "Testbeleg (manueller Anstoss)",
|
||||
DocumentRef: `manuell-${Date.now()}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
push("Export angestossen.", "success");
|
||||
load();
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError || err instanceof Error ? err.message : "Export konnte nicht angestossen werden.";
|
||||
push(message, "danger");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (unauthorized) {
|
||||
return (
|
||||
<main style={{ maxWidth: 960, margin: "40px auto", padding: "0 16px" }}>
|
||||
<p role="alert">Anmeldung erforderlich, um DATEV-Exporte zu sehen.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: TableColumn<ExportRun>[] = [
|
||||
{ key: "id", header: "Lauf", render: (r) => r.id.slice(0, 8) },
|
||||
{ key: "period", header: "Zeitraum", render: (r) => `${r.period_start} – ${r.period_end}` },
|
||||
{ key: "entry_count", header: "Belege", render: (r) => String(r.entry_count) },
|
||||
{
|
||||
key: "transfer_status",
|
||||
header: "Übertragungsstatus",
|
||||
render: (r) =>
|
||||
r.transfer_status ? (
|
||||
<span style={{ color: transferColor(r.transfer_status), fontWeight: 600 }} role={r.transfer_status === "failed" ? "alert" : undefined}>
|
||||
{TRANSFER_LABEL[r.transfer_status] ?? r.transfer_status}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--shl-color-text-secondary, #57606a)" }}>—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "transfer_error",
|
||||
header: "Fehlerdiagnose",
|
||||
render: (r) => (r.transfer_error ? <span role="status">{r.transfer_error}</span> : "—"),
|
||||
},
|
||||
{
|
||||
key: "created_at",
|
||||
header: "Erstellt",
|
||||
render: (r) => new Date(r.created_at).toLocaleString("de-DE"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 960, margin: "40px auto", padding: "0 16px" }}>
|
||||
<h1>DATEV-Export</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ marginBottom: 32, display: "flex", flexDirection: "column", gap: 12, maxWidth: 480 }}>
|
||||
<TextField
|
||||
label="Beraternummer"
|
||||
value={form.consultantNumber}
|
||||
onChange={(e) => setForm({ ...form, consultantNumber: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Mandantennummer"
|
||||
value={form.clientNumber}
|
||||
onChange={(e) => setForm({ ...form, clientNumber: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Wirtschaftsjahresbeginn"
|
||||
type="date"
|
||||
value={form.fiscalYearStart}
|
||||
onChange={(e) => setForm({ ...form, fiscalYearStart: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Sachkontenlänge"
|
||||
type="number"
|
||||
value={form.accountLength}
|
||||
onChange={(e) => setForm({ ...form, accountLength: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Bezeichnung"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Zeitraum von"
|
||||
type="date"
|
||||
value={form.periodStart}
|
||||
onChange={(e) => setForm({ ...form, periodStart: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Zeitraum bis"
|
||||
type="date"
|
||||
value={form.periodEnd}
|
||||
onChange={(e) => setForm({ ...form, periodEnd: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? "Wird angestossen …" : "Export anstoßen"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h2>Exportprotokoll</h2>
|
||||
{loadError && <p role="alert">{loadError}</p>}
|
||||
{runs && runs.length === 0 && <p>Noch keine Exportläufe.</p>}
|
||||
{runs && runs.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={runs}
|
||||
rowKey={(r) => r.id}
|
||||
caption="Protokoll aller DATEV-Exportläufe mit Übertragungsstatus"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Script from "next/script";
|
||||
import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl";
|
||||
|
||||
export const metadata = {
|
||||
title: "NEXARCH DATEV-Export",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<style>{focusVisibleStyles}</style>
|
||||
<Script id="shl-theme-init" strategy="beforeInteractive">
|
||||
{themeInitScript()}
|
||||
</Script>
|
||||
</head>
|
||||
<body
|
||||
style={{
|
||||
fontFamily: typography.fontFamily,
|
||||
margin: 0,
|
||||
background: "var(--shl-color-background, #ffffff)",
|
||||
color: "var(--shl-color-text-primary, #14181f)",
|
||||
}}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function IndexPage() {
|
||||
redirect("/exports");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Duenner Client fuer DAT-03 (connect/internal/datevapi) — enthaelt keine
|
||||
// eigene Geschaeftslogik, nur Datenabruf/-versand. Identitaet kommt
|
||||
// uebergangsweise (wie bei web/connect-status) aus Umgebungsvariablen,
|
||||
// die als Header durchgereicht werden.
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
export class ApiError extends Error {}
|
||||
|
||||
function apiBase(): string {
|
||||
const base = process.env.NEXT_PUBLIC_CONNECT_API_URL;
|
||||
if (!base) {
|
||||
throw new Error("NEXT_PUBLIC_CONNECT_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function identityHeaders(): Record<string, string> {
|
||||
const userID = process.env.NEXT_PUBLIC_USER_ID;
|
||||
const tenant = process.env.NEXT_PUBLIC_TENANT_SLUG;
|
||||
const headers: Record<string, string> = {};
|
||||
if (userID) headers["X-User-Id"] = userID;
|
||||
if (tenant) headers["X-Tenant-Slug"] = tenant;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${apiBase()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...identityHeaders(),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new UnauthorizedError("Anmeldung erforderlich, um DATEV-Exporte zu sehen.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new ApiError(body.error ?? `Anfrage fehlgeschlagen (${res.status})`);
|
||||
}
|
||||
return res.json().catch(() => undefined as T);
|
||||
}
|
||||
|
||||
export interface ExportEntry {
|
||||
Amount: number;
|
||||
DebitCredit: "S" | "H";
|
||||
Account: string;
|
||||
CounterAccount: string;
|
||||
Date: string;
|
||||
Text: string;
|
||||
DocumentRef: string;
|
||||
}
|
||||
|
||||
export interface TriggerExportRequest {
|
||||
consultant_number: string;
|
||||
client_number: string;
|
||||
fiscal_year_start: string;
|
||||
account_length: number;
|
||||
description: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
entries: ExportEntry[];
|
||||
}
|
||||
|
||||
export interface ExportRun {
|
||||
id: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
entry_count: number;
|
||||
retention_class: string;
|
||||
created_at: string;
|
||||
transfer_status?: string;
|
||||
transfer_error?: string;
|
||||
}
|
||||
|
||||
export function triggerExport(req_: TriggerExportRequest): Promise<ExportRun> {
|
||||
return req("/datev/exports", { method: "POST", body: JSON.stringify(req_) });
|
||||
}
|
||||
|
||||
export function fetchExportRuns(): Promise<ExportRun[]> {
|
||||
return req("/datev/exports");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@nexarch/shl"],
|
||||
};
|
||||
export default nextConfig;
|
||||
Generated
+3304
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "nexarch-datev-export",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nexarch/shl": "file:../shl",
|
||||
"next": "16.3.5",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"jsdom": "24.1.0",
|
||||
"typescript": "5.5.3",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
Reference in new issue
Block a user