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
+74
View File
@@ -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": "Возвращает список транзакций с фильтрами и пагинацией",
+74
View File
@@ -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": "Возвращает список транзакций с фильтрами и пагинацией",
+50
View File
@@ -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:
+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)
+12 -1
View File
@@ -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)
}
+3
View File
@@ -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")
}