Added uploading receipt photo to API

This commit is contained in:
2026-04-11 11:26:58 +03:00
parent 545b05d5a0
commit b66be96033
7 changed files with 455 additions and 4 deletions
+141 -2
View File
@@ -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
}
+101 -1
View File
@@ -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)