From b66be9603371dadbbd41e6a088e409849fda4f41 Mon Sep 17 00:00:00 2001 From: AlexBelyan Date: Sat, 11 Apr 2026 11:26:58 +0300 Subject: [PATCH] Added uploading receipt photo to API --- backend/src/api/docs/docs.go | 74 ++++++++++++ backend/src/api/docs/swagger.json | 74 ++++++++++++ backend/src/api/docs/swagger.yaml | 50 ++++++++ backend/src/api/routers/receipts.go | 143 ++++++++++++++++++++++- backend/src/api/routers/receipts_test.go | 102 +++++++++++++++- backend/src/api/server.go | 13 ++- backend/src/config/config.go | 3 + 7 files changed, 455 insertions(+), 4 deletions(-) diff --git a/backend/src/api/docs/docs.go b/backend/src/api/docs/docs.go index 3ee49d4..b716045 100644 --- a/backend/src/api/docs/docs.go +++ b/backend/src/api/docs/docs.go @@ -295,6 +295,80 @@ const docTemplate = `{ } } }, + "/receipts/photo": { + "post": { + "description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Receipts" + ], + "summary": "Загрузить чек по фото", + "parameters": [ + { + "type": "file", + "description": "Receipt photo", + "name": "photo", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Family ID for auto-created transaction", + "name": "family_id", + "in": "formData" + }, + { + "type": "integer", + "description": "User ID for auto-created transaction", + "name": "created_by", + "in": "formData" + }, + { + "type": "string", + "description": "Transaction type, default expense", + "name": "type", + "in": "formData" + }, + { + "type": "string", + "description": "Transaction category, default receipt", + "name": "category", + "in": "formData" + }, + { + "type": "string", + "description": "Transaction description override", + "name": "description", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.AddReceiptResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/transactions": { "get": { "description": "Возвращает список транзакций с фильтрами и пагинацией", diff --git a/backend/src/api/docs/swagger.json b/backend/src/api/docs/swagger.json index dcd4d65..e87e17b 100644 --- a/backend/src/api/docs/swagger.json +++ b/backend/src/api/docs/swagger.json @@ -284,6 +284,80 @@ } } }, + "/receipts/photo": { + "post": { + "description": "Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Receipts" + ], + "summary": "Загрузить чек по фото", + "parameters": [ + { + "type": "file", + "description": "Receipt photo", + "name": "photo", + "in": "formData", + "required": true + }, + { + "type": "integer", + "description": "Family ID for auto-created transaction", + "name": "family_id", + "in": "formData" + }, + { + "type": "integer", + "description": "User ID for auto-created transaction", + "name": "created_by", + "in": "formData" + }, + { + "type": "string", + "description": "Transaction type, default expense", + "name": "type", + "in": "formData" + }, + { + "type": "string", + "description": "Transaction category, default receipt", + "name": "category", + "in": "formData" + }, + { + "type": "string", + "description": "Transaction description override", + "name": "description", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/domain.AddReceiptResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/dto.ErrorResponse" + } + } + } + } + }, "/transactions": { "get": { "description": "Возвращает список транзакций с фильтрами и пагинацией", diff --git a/backend/src/api/docs/swagger.yaml b/backend/src/api/docs/swagger.yaml index ad9c251..6896929 100644 --- a/backend/src/api/docs/swagger.yaml +++ b/backend/src/api/docs/swagger.yaml @@ -387,6 +387,56 @@ paths: summary: Загрузить чек tags: - Receipts + /receipts/photo: + post: + consumes: + - multipart/form-data + description: Принимает фото, распознает текст через Google OCR и создает чек + с позициями; опционально создает связанную транзакцию + parameters: + - description: Receipt photo + in: formData + name: photo + required: true + type: file + - description: Family ID for auto-created transaction + in: formData + name: family_id + type: integer + - description: User ID for auto-created transaction + in: formData + name: created_by + type: integer + - description: Transaction type, default expense + in: formData + name: type + type: string + - description: Transaction category, default receipt + in: formData + name: category + type: string + - description: Transaction description override + in: formData + name: description + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/domain.AddReceiptResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/dto.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/dto.ErrorResponse' + summary: Загрузить чек по фото + tags: + - Receipts /transactions: get: consumes: diff --git a/backend/src/api/routers/receipts.go b/backend/src/api/routers/receipts.go index c157207..830d0cc 100644 --- a/backend/src/api/routers/receipts.go +++ b/backend/src/api/routers/receipts.go @@ -3,10 +3,15 @@ package routers import ( "FamilyHub/src/api/dto" "FamilyHub/src/domain" + "FamilyHub/src/integrations/ocr" "FamilyHub/src/utils" "context" + "errors" + "io" "log" "net/http" + "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -18,15 +23,17 @@ type receiptService interface { type ReceiptRouter struct { service receiptService + ocr ocr.OCR } -func NewReceiptRouter(s receiptService) *ReceiptRouter { - return &ReceiptRouter{service: s} +func NewReceiptRouter(s receiptService, ocrSvc ocr.OCR) *ReceiptRouter { + return &ReceiptRouter{service: s, ocr: ocrSvc} } func (router *ReceiptRouter) RegisterRoutes(r *gin.RouterGroup) { receipts := r.Group("/receipts") { receipts.POST("", router.AddReceipt) + receipts.POST("/photo", router.AddReceiptByPhoto) } } @@ -74,3 +81,135 @@ func (router *ReceiptRouter) AddReceipt(context_ *gin.Context) { } context_.JSON(http.StatusOK, resp) } + +// AddReceiptByPhoto GoDoc +// @Summary Загрузить чек по фото +// @Description Принимает фото, распознает текст через Google OCR и создает чек с позициями; опционально создает связанную транзакцию +// @Tags Receipts +// @Accept multipart/form-data +// @Produce json +// @Param photo formData file true "Receipt photo" +// @Param family_id formData int false "Family ID for auto-created transaction" +// @Param created_by formData int false "User ID for auto-created transaction" +// @Param type formData string false "Transaction type, default expense" +// @Param category formData string false "Transaction category, default receipt" +// @Param description formData string false "Transaction description override" +// @Success 200 {object} domain.AddReceiptResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /receipts/photo [post] +func (router *ReceiptRouter) AddReceiptByPhoto(c *gin.Context) { + if router.ocr == nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Message: "ocr is not configured"}) + return + } + + fileHeader, err := c.FormFile("photo") + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "photo is required"}) + return + } + + file, err := fileHeader.Open() + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "failed to open uploaded photo"}) + return + } + defer file.Close() + + imageBytes, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "failed to read uploaded photo"}) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + text, err := router.ocr.Recognize(ctx, imageBytes) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "ocr failed"}) + return + } + if strings.TrimSpace(text) == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "text not found"}) + return + } + + receiptMeta := utils.ExtractReceiptMeta(text) + if receiptMeta.ReceiptID == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "receipt number not found"}) + return + } + if receiptMeta.Date == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: "receipt date not found"}) + return + } + + req, err := buildPhotoReceiptRequest(c, receiptMeta) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + return + } + + receipt, err := router.service.GetReceipt(ctx, req) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) + log.Printf("photo receipt API error, %s", err.Error()) + return + } + + c.JSON(http.StatusOK, domain.AddReceiptResponse{ + ID: int32(receipt.ID), + Number: receipt.ReceiptNumber, + Date: receipt.IssuedAt, + TransactionID: receipt.TransactionID, + }) +} + +func buildPhotoReceiptRequest(c *gin.Context, meta utils.ReceiptMeta) (domain.AddReceiptRequest, error) { + req := domain.AddReceiptRequest{ + Number: meta.ReceiptID, + Date: meta.Date, + } + + familyID, err := parseOptionalInt64Form(c, "family_id") + if err != nil { + return domain.AddReceiptRequest{}, err + } + createdBy, err := parseOptionalInt64Form(c, "created_by") + if err != nil { + return domain.AddReceiptRequest{}, err + } + + req.FamilyID = familyID + req.CreatedBy = createdBy + req.Type = parseOptionalStringForm(c, "type") + req.Category = parseOptionalStringForm(c, "category") + req.Description = parseOptionalStringForm(c, "description") + + return req, nil +} + +func parseOptionalInt64Form(c *gin.Context, key string) (*int64, error) { + value := strings.TrimSpace(c.PostForm(key)) + if value == "" { + return nil, nil + } + + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return nil, errors.New(key + " must be int64") + } + + return &parsed, nil +} + +func parseOptionalStringForm(c *gin.Context, key string) *string { + value := strings.TrimSpace(c.PostForm(key)) + if value == "" { + return nil + } + + return &value +} diff --git a/backend/src/api/routers/receipts_test.go b/backend/src/api/routers/receipts_test.go index 0244a24..612bad0 100644 --- a/backend/src/api/routers/receipts_test.go +++ b/backend/src/api/routers/receipts_test.go @@ -2,10 +2,12 @@ package routers import ( "FamilyHub/src/domain" + "FamilyHub/src/integrations/ocr" "bytes" "context" "encoding/json" "errors" + "mime/multipart" "net/http" "net/http/httptest" "strings" @@ -28,6 +30,21 @@ func (m *receiptServiceMock) GetReceipt(ctx context.Context, req domain.AddRecei return nil, errors.New("mock is not configured") } +type ocrMock struct { + recognizeFn func(ctx context.Context, image []byte) (string, error) +} + +func (m *ocrMock) Recognize(ctx context.Context, image []byte) (string, error) { + if m.recognizeFn != nil { + return m.recognizeFn(ctx, image) + } + return "", errors.New("mock is not configured") +} + +func (m *ocrMock) Close() error { + return nil +} + func TestReceiptRouter_AddReceipt(t *testing.T) { gin.SetMode(gin.TestMode) @@ -88,7 +105,7 @@ func TestReceiptRouter_AddReceipt(t *testing.T) { r := gin.New() apiV1 := r.Group("/api/v1") - router := NewReceiptRouter(tc.mock) + router := NewReceiptRouter(tc.mock, nil) router.RegisterRoutes(apiV1) req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts", bytes.NewBufferString(tc.body)) @@ -115,3 +132,86 @@ func TestReceiptRouter_AddReceipt(t *testing.T) { }) } } + +func TestReceiptRouter_AddReceiptByPhoto(t *testing.T) { + gin.SetMode(gin.TestMode) + + validNumber := strings.Repeat("1", 24) + now := time.Date(2026, time.January, 21, 10, 11, 12, 0, time.UTC) + + newRequest := func(t *testing.T, extraFields map[string]string) (*http.Request, string) { + t.Helper() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("photo", "receipt.jpg") + require.NoError(t, err) + _, err = part.Write([]byte("fake-image")) + require.NoError(t, err) + + for key, value := range extraFields { + require.NoError(t, writer.WriteField(key, value)) + } + + require.NoError(t, writer.Close()) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts/photo", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req, writer.FormDataContentType() + } + + t.Run("bad request when photo missing", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + router := NewReceiptRouter(&receiptServiceMock{}, &ocrMock{}) + router.RegisterRoutes(apiV1) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/receipts/photo", bytes.NewBufferString("")) + req.Header.Set("Content-Type", "multipart/form-data") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "photo is required") + }) + + t.Run("ok", func(t *testing.T) { + r := gin.New() + apiV1 := r.Group("/api/v1") + + ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) { + assert.Equal(t, []byte("fake-image"), image) + return "21.01.2026 " + validNumber, nil + }} + service := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) { + assert.Equal(t, validNumber, req.Number) + assert.Equal(t, "21.01.2026", req.Date) + require.NotNil(t, req.FamilyID) + require.NotNil(t, req.CreatedBy) + assert.Equal(t, int64(1), *req.FamilyID) + assert.Equal(t, int64(2), *req.CreatedBy) + require.NotNil(t, req.Category) + assert.Equal(t, "groceries", *req.Category) + return &domain.Receipt{ID: 9, ReceiptNumber: validNumber, IssuedAt: now}, nil + }} + + router := NewReceiptRouter(service, ocrSvc) + router.RegisterRoutes(apiV1) + + req, _ := newRequest(t, map[string]string{ + "family_id": "1", + "created_by": "2", + "category": "groceries", + }) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), validNumber) + }) +} + +var _ ocr.OCR = (*ocrMock)(nil) diff --git a/backend/src/api/server.go b/backend/src/api/server.go index 88ae7e2..15f8473 100644 --- a/backend/src/api/server.go +++ b/backend/src/api/server.go @@ -6,6 +6,7 @@ import ( "FamilyHub/src/api/services" "FamilyHub/src/config" "FamilyHub/src/database" + "FamilyHub/src/integrations/ocr" "FamilyHub/src/integrations/receiptService" "FamilyHub/src/repositories" "context" @@ -22,6 +23,7 @@ import ( type Server struct { httpServer *http.Server + ocr ocr.OCR } func NewServer(cfg config.Config) *Server { @@ -89,9 +91,14 @@ func NewServer(cfg config.Config) *Server { transactionRepo := repositories.NewTransactionsSQLRepository(dbConn) + ocrSvc, err := ocr.NewGoogleOCR(context.Background()) + if err != nil { + log.Fatal(err) + } + receiptRepo := repositories.NewReceiptsSQLRepository(dbConn) receiptService_ := receiptService.NewReceiptService(receiptRepo, transactionRepo) - receiptRouter := routers.NewReceiptRouter(receiptService_) + receiptRouter := routers.NewReceiptRouter(receiptService_, ocrSvc) receiptRouter.RegisterRoutes(apiV1) transactionService := services.NewTransactionService(transactionRepo) @@ -118,6 +125,7 @@ func NewServer(cfg config.Config) *Server { Addr: cfg.APIHost + ":" + cfg.APIPort, Handler: router, }, + ocr: ocrSvc, } } @@ -126,5 +134,8 @@ func (s *Server) Start() error { } func (s *Server) Shutdown(ctx context.Context) error { + if s.ocr != nil { + _ = s.ocr.Close() + } return s.httpServer.Shutdown(ctx) } diff --git a/backend/src/config/config.go b/backend/src/config/config.go index da2ba56..6379e9a 100644 --- a/backend/src/config/config.go +++ b/backend/src/config/config.go @@ -55,6 +55,9 @@ func Load() (Config, error) { } } if runMode == API || runMode == Standalone { + if ocrTokenPath == "" { + warnings = append(warnings, "Missing required environment variable: GOOGLE_APPLICATION_CREDENTIALS") + } if apiSecret == "" { warnings = append(warnings, "Missing required environment variable: API_SECRET") }