12 Сделать добавление транзакций на фронте, добавить уже сгенерированые экраны в проект

This commit is contained in:
2026-05-30 10:30:26 +03:00
parent debb8e5974
commit 97d923142e
25 changed files with 2178 additions and 144 deletions
+41 -21
View File
@@ -5,9 +5,11 @@ import (
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
receiptServiceIntegration "FamilyHub/src/integrations/receiptProvider"
"context"
"database/sql"
"errors"
"log"
"net"
"net/http"
"runtime/debug"
@@ -38,27 +40,7 @@ func logInternalError(c *gin.Context, scope string, err error) {
}
func handleReceiptError(c *gin.Context, err error) {
var externalErr *receiptServiceIntegration.ExternalServiceError
switch {
case errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.As(err, &externalErr):
log.Printf(
"receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q",
c.Request.Method,
c.Request.URL.Path,
externalErr.StatusCode,
externalErr.Body,
)
logError(c, "receipt external service", err)
switch externalErr.StatusCode {
case http.StatusForbidden, http.StatusTooManyRequests:
c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service temporarily unavailable"})
default:
c.JSON(http.StatusBadGateway, dto.ErrorResponse{Message: "receipt service error"})
}
default:
if !handleReceiptProviderError(c, err) {
logInternalError(c, "receipt request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
@@ -96,3 +78,41 @@ func handleUserError(c *gin.Context, err error) {
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
}
}
func handleReceiptProviderError(c *gin.Context, err error) bool {
var externalErr *receiptServiceIntegration.ExternalServiceError
switch {
case errors.Is(err, services.ErrReceiptNotFound),
errors.Is(err, receiptServiceIntegration.ErrReceiptNotFound):
logError(c, "receipt request", err)
c.JSON(http.StatusUnprocessableEntity, dto.ErrorResponse{Message: err.Error()})
return true
case isTimeoutError(err):
logError(c, "receipt request", err)
c.JSON(http.StatusGatewayTimeout, dto.ErrorResponse{Message: "receipt service timeout"})
return true
case errors.As(err, &externalErr):
log.Printf(
"receipt external service error: method=%s path=%s upstream_status=%d upstream_body=%q",
c.Request.Method,
c.Request.URL.Path,
externalErr.StatusCode,
externalErr.Body,
)
logError(c, "receipt external service", err)
c.JSON(http.StatusServiceUnavailable, dto.ErrorResponse{Message: "receipt service unavailable"})
return true
default:
return false
}
}
func isTimeoutError(err error) bool {
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}
+10 -2
View File
@@ -65,12 +65,14 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
var req dto.CreateTransactionRequest
if err := c.ShouldBindJSON(&req); err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
input, err := requests.BuildCreateTransactionInput(req)
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
@@ -87,6 +89,7 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
fileHeader, err := c.FormFile("photo")
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"})
return
}
@@ -108,11 +111,13 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
familyID, err := parseOptionalInt64Form(c, "family_id")
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
createdBy, err := parseOptionalInt64Form(c, "created_by")
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
@@ -126,6 +131,7 @@ func (router *TransactionsRouter) createFromMultipart(c *gin.Context) {
Description: parseOptionalStringForm(c, "description"),
})
if err != nil {
logError(c, "transaction request validation", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return
}
@@ -335,6 +341,10 @@ func (router *TransactionsRouter) Delete(c *gin.Context) {
}
func handleTransactionError(c *gin.Context, err error) {
if handleReceiptProviderError(c, err) {
return
}
switch {
case errors.Is(err, services.ErrTransactionNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
@@ -352,8 +362,6 @@ func handleTransactionError(c *gin.Context, err error) {
errors.Is(err, services.ErrOCRNotConfigured),
errors.Is(err, services.ErrReceiptTransactionNotCreated):
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
default:
logInternalError(c, "transaction request", err)
c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "internal server error"})
@@ -5,6 +5,7 @@ import (
"FamilyHub/src/api/requests"
"FamilyHub/src/api/services"
"FamilyHub/src/domain"
receiptProvider "FamilyHub/src/integrations/receiptProvider"
"bytes"
"context"
"errors"
@@ -199,6 +200,54 @@ func TestTransactionsRouter_Create(t *testing.T) {
assert.Contains(t, w.Body.String(), `"id":21`)
})
t.Run("creates transaction from receipt with iso date", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
assert.Equal(t, validNumber, req.Number)
assert.Equal(t, "2025-11-09", req.Date)
require.NotNil(t, req.FamilyID)
require.NotNil(t, req.CreatedBy)
assert.Equal(t, int64(1), *req.FamilyID)
assert.Equal(t, int64(1), *req.CreatedBy)
return &domain.Receipt{ID: 9, ReceiptNumber: validNumber, IssuedAt: now, TransactionID: ptrInt64(23)}, nil
}}
service := &transactionServiceMock{getByIDFn: func(ctx context.Context, id int64) (*domain.Transaction, error) {
assert.Equal(t, int64(23), id)
return &domain.Transaction{
ID: 23,
FamilyID: 1,
Type: "expense",
DateTime: now,
Category: "receipt",
Amount: 89.4,
CreatedBy: 1,
CreatedAt: now,
ReceiptID: ptrInt64(9),
}, nil
}}
creationService := services.NewTransactionCreationService(service, receiptSvc, nil)
router := NewTransactionsRouter(service, creationService)
router.RegisterRoutes(apiV1)
req := httptest.NewRequest(http.MethodPost, "/api/v1/transactions", bytes.NewBufferString(`{
"family_id":1,
"created_by":1,
"receipt_number":"`+validNumber+`",
"receipt_date":"2025-11-09",
"type":"expense"
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code)
assert.Contains(t, w.Body.String(), `"id":23`)
})
t.Run("creates transaction from photo upload", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
@@ -290,6 +339,84 @@ func TestTransactionsRouter_Create(t *testing.T) {
require.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "family_id and created_by are required for receipt transaction")
})
t.Run("returns 422 when receipt is not found in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
return nil, receiptProvider.ErrReceiptNotFound
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusUnprocessableEntity, w.Code)
assert.Contains(t, w.Body.String(), "receipt not found")
})
t.Run("returns 503 when receipt provider is unavailable in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
return nil, &receiptProvider.ExternalServiceError{StatusCode: http.StatusBadGateway, Body: "upstream failed"}
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
assert.Contains(t, w.Body.String(), "receipt service unavailable")
})
t.Run("returns 504 when receipt provider times out in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
return nil, context.DeadlineExceeded
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusGatewayTimeout, w.Code)
assert.Contains(t, w.Body.String(), "receipt service timeout")
})
}
func ptrInt64(v int64) *int64 {
+1 -1
View File
@@ -129,7 +129,7 @@ func NewServer(cfg config.Config) *Server {
authRouter.RegisterRouter(apiV1)
// подключаем статику Vue — должно быть последним
registerStaticFiles(router)
registerStaticFiles(router, "src/api/dist")
return &Server{
httpServer: &http.Server{
Addr: cfg.APIHost + ":" + cfg.APIPort,
+1 -1
View File
@@ -119,7 +119,7 @@ func buildReceiptTransactionDescription(receipt *domain.Receipt, explicit *strin
return &value
}
if name := strings.TrimSpace(receipt.NameSPD); name != "" {
if name := strings.TrimSpace(receipt.NameTO); name != "" {
return &name
}
+10 -10
View File
@@ -1,26 +1,26 @@
package api
import (
"embed"
"io/fs"
"net/http"
"os"
"github.com/gin-gonic/gin"
)
//go:embed dist
var staticFiles embed.FS
func registerStaticFiles(router *gin.Engine, staticDir string) {
if _, err := os.Stat(staticDir); err != nil {
if os.IsNotExist(err) {
router.NoRoute(func(c *gin.Context) {
c.Status(http.StatusNotFound)
})
return
}
func registerStaticFiles(router *gin.Engine) {
// вырезаем префикс dist/ чтобы / отдавал index.html
distFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
panic(err)
}
fileServer := http.FileServer(http.FS(distFS))
fileServer := http.FileServer(http.Dir(staticDir))
// все маршруты которые не /api и не /openapi — отдаём Vue
router.NoRoute(func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
+45
View File
@@ -0,0 +1,45 @@
package api
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestRegisterStaticFilesReturns404WhenDirectoryIsMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
registerStaticFiles(router, filepath.Join(t.TempDir(), "missing-dist"))
req := httptest.NewRequest(http.MethodGet, "/some-route", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestRegisterStaticFilesServesExistingDirectory(t *testing.T) {
gin.SetMode(gin.TestMode)
staticDir := t.TempDir()
indexPath := filepath.Join(staticDir, "index.html")
require.NoError(t, os.WriteFile(indexPath, []byte("ok"), 0o644))
router := gin.New()
registerStaticFiles(router, staticDir)
req := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, "ok", recorder.Body.String())
}
@@ -172,7 +172,12 @@ func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("orig_date", date)
normalizedDate := strings.TrimSpace(date)
if isoDate, err := utils.NormalizeDateToISO(normalizedDate); err == nil {
normalizedDate = isoDate
}
_ = writer.WriteField("orig_date", normalizedDate)
_ = writer.WriteField("orig_ui", number)
_ = writer.Close()
+3 -3
View File
@@ -13,9 +13,9 @@ var knownDateFormats = []string{
"02.01.06", // 21.01.2026
"02-01-2006", // 21-01-2026
"02/01/2006", // 21/01/2026
//"2006/01/02", // 2026/01/21
//"2006-01-02", // 2026-01-21
//"2006.01.02", // 2026.01.21
"2006/01/02", // 2026/01/21
"2006-01-02", // 2026-01-21
"2006.01.02", // 2026.01.21
}
func NormalizeDateToISO(input string) (string, error) {
+21 -6
View File
@@ -1,6 +1,10 @@
package utils
import "regexp"
import (
"regexp"
"strings"
"time"
)
type ReceiptMeta struct {
Date string
@@ -10,17 +14,16 @@ type ReceiptMeta struct {
func ExtractReceiptMeta(text string) ReceiptMeta {
result := ReceiptMeta{}
// --- ДАТА ---
datePatterns := []string{
`(\d{2}[./-]\d{2}[./-]\d{4})`, // 25.01.2026
`(\d{2}[./-]\d{2}[./-]\d{2})`, // 25.01.26
`(\d{4}[./-]\d{2}[./-]\d{2})`, // 2026-01-25
`\b\d{2}[./:-]\d{2}[./:-]\d{4}\b`, // 25.01.2026, 25:01.2026
`\b\d{4}[./:-]\d{2}[./:-]\d{2}\b`, // 2026-01-25
`\b\d{2}[./-]\d{2}[./-]\d{2}\b`, // 25.01.26
}
for _, pattern := range datePatterns {
re := regexp.MustCompile(pattern)
if match := re.FindString(text); match != "" {
result.Date = match
result.Date = normalizeOCRDate(match)
break
}
}
@@ -31,3 +34,15 @@ func ExtractReceiptMeta(text string) ReceiptMeta {
return result
}
func normalizeOCRDate(value string) string {
sanitized := strings.NewReplacer(":", ".", "/", ".", "-", ".").Replace(strings.TrimSpace(value))
for _, layout := range knownDateFormats {
if t, err := time.Parse(layout, sanitized); err == nil {
return t.Format("02.01.2006")
}
}
return strings.TrimSpace(value)
}