12 Сделать добавление транзакций на фронте, добавить уже сгенерированые экраны в проект
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user