Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 42s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 2s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 2s
DB-Integrationstests / db-tests (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 / Web (pdf-viewer) (push) Failing after 36s
CI / Web (shl) (push) Failing after 7s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 43s
CI / Core-Modul (Build/Vet/Test) (pull_request) Successful in 46s
CI / Web (dms-browser) (pull_request) Failing after 58s
CI / Web (pdf-viewer) (pull_request) Failing after 43s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 18m38s
CI / Core-Modul (Build/Vet/Test) (push) Successful in 39s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 3s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 15s
CI / Web (dms-browser) (push) Failing after 51s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 14s
CI / Web (shl) (pull_request) Failing after 9s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m16s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 18m46s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 18m59s
/datev/exports braucht connect.datev.write (POST) bzw. connect.datev.read (GET) mit der Rolle aus X-User-Role. Registriert nur mit NEXARCH_CONNECT_APP_ARCHIVE_URL; die DATEV-Uebertragung bleibt aus. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
226 lines
7.1 KiB
Go
226 lines
7.1 KiB
Go
// 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/common/policyclient"
|
|
"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
|
|
policy *policyclient.Client
|
|
}
|
|
|
|
func (s *Server) WithPolicy(client *policyclient.Client) *Server {
|
|
s.policy = client
|
|
return s
|
|
}
|
|
|
|
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.requirePermission("connect.datev.write", s.handleTrigger)))
|
|
mux.HandleFunc("GET /datev/exports", s.requireIdentity(s.requirePermission("connect.datev.read", s.handleList)))
|
|
}
|
|
|
|
func (s *Server) requirePermission(permission string, next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) func(w http.ResponseWriter, r *http.Request, tenantSlug string) {
|
|
return func(w http.ResponseWriter, r *http.Request, tenantSlug string) {
|
|
if s.policy != nil {
|
|
allowed, err := s.policy.Allowed(r.Context(), tenantSlug, roleOf(r), permission)
|
|
if err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar")
|
|
return
|
|
}
|
|
if !allowed {
|
|
writeError(w, http.StatusForbidden, "keine berechtigung")
|
|
return
|
|
}
|
|
}
|
|
next(w, r, tenantSlug)
|
|
}
|
|
}
|
|
|
|
func roleOf(r *http.Request) string {
|
|
if role := r.Header.Get("X-User-Role"); role != "" {
|
|
return role
|
|
}
|
|
return "user"
|
|
}
|
|
|
|
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})
|
|
}
|