Init commit

This commit is contained in:
2026-01-27 15:31:58 +03:00
commit e6dc59c266
37 changed files with 1707 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
package dto
type HelloRequest struct {
Name string `form:"name" binding:"required,min=2,max=50"`
}
type AddReceiptRequest struct {
Number string `json:"number" binding:"required,min=24,max=24"`
Date string `json:"date" binding:"required"`
}
+17
View File
@@ -0,0 +1,17 @@
package dto
import "time"
type HelloResponse struct {
Message string `json:"message"`
}
type ErrorResponse struct {
Message string `json:"message"`
}
type AddReceiptResponse struct {
ID int32 `json:"id"`
Number string `json:"number"`
Date time.Time `json:"date"`
}
+37
View File
@@ -0,0 +1,37 @@
package handlers
import (
"net/http"
"GoFinanceManager/src/api/dto"
"github.com/gin-gonic/gin"
)
// Hello GoDoc
// @Summary Say hello
// @Description Returns greeting
// @Tags hello
// @Accept JSON
// @Produce JSON
// @Param name query string true "User name"
// @Success 200 {object} dto.HelloResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /hello [get]
func Hello(c *gin.Context) {
var req dto.HelloRequest
// биндинг + валидация
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{
Message: err.Error(),
})
return
}
resp := dto.HelloResponse{
Message: "Hello " + req.Name,
}
c.JSON(http.StatusOK, resp)
}
+56
View File
@@ -0,0 +1,56 @@
package handlers
import (
"GoFinanceManager/src/api/dto"
"GoFinanceManager/src/integrations/receiptService"
"GoFinanceManager/src/utils"
"context"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
type ReceiptHandler struct {
service *receiptService.ReceiptService
}
func NewReceiptHandler(s *receiptService.ReceiptService) *ReceiptHandler {
return &ReceiptHandler{service: s}
}
func (h *ReceiptHandler) AddReceipt(c *gin.Context) {
var req dto.AddReceiptRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Println("bind error:", err)
c.JSON(http.StatusBadRequest, dto.ErrorResponse{
Message: err.Error(),
})
return
}
isoDate, err := utils.NormalizeDateToISO(req.Date)
if err != nil {
c.JSON(400, gin.H{"error": "invalid date format"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
receipt, err := h.service.GetReceipt(
ctx,
isoDate,
req.Number,
)
if err != nil {
c.JSON(400, gin.H{"error": "Cant get receipt"})
log.Print(err.Error())
return
}
resp := dto.AddReceiptResponse{
ID: 1,
Number: receipt.ReceiptNumber,
Date: receipt.IssuedAt,
}
c.JSON(http.StatusOK, resp)
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"GoFinanceManager/src/api/handlers"
"GoFinanceManager/src/config"
"GoFinanceManager/src/database"
"GoFinanceManager/src/integrations/receiptService"
"GoFinanceManager/src/repositories"
"context"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
type Server struct {
httpServer *http.Server
}
func NewServer(cfg config.Config) *Server {
handler := gin.Default()
dbConn, err := database.Connect(cfg.DBConnectionString)
if err != nil {
log.Fatal(err)
}
if err := database.RunMigrations(dbConn); err != nil {
log.Fatal(err)
}
receiptRepo := repositories.NewReceiptSQLRepository(dbConn)
receiptService_ := receiptService.NewReceiptService(receiptRepo)
receiptHandler := handlers.NewReceiptHandler(receiptService_)
if cfg.OpenAPIEnabled {
handler.GET(cfg.OpenAPIEndpoint)
}
handler.POST("/receipts", receiptHandler.AddReceipt)
return &Server{
httpServer: &http.Server{
Addr: cfg.APIHost + ":" + cfg.APIPort,
Handler: handler,
},
}
}
func (s *Server) Start() error {
return s.httpServer.ListenAndServe()
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
+63
View File
@@ -0,0 +1,63 @@
package bot
import (
"GoFinanceManager/src/config"
"GoFinanceManager/src/integrations/ocr"
"GoFinanceManager/src/integrations/receiptApi"
"context"
"log"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
type Bot struct {
api *tgbotapi.BotAPI
ocr ocr.OCR
receiptApi receiptApi.Client
}
func NewBot(cfg config.Config) (*Bot, error) {
api, err := tgbotapi.NewBotAPI(cfg.BotToken)
if err != nil {
log.Fatal(err)
}
api.Debug = cfg.DebugMode
ctx := context.Background()
ocrSvc, err := ocr.NewGoogleOCR(ctx)
receiptApi_, err := receiptApi.NewHTTPClient("http://127.0.0.1:8000")
return &Bot{api: api, ocr: ocrSvc, receiptApi: receiptApi_}, nil
}
func (bot *Bot) Start(ctx context.Context) error {
u := tgbotapi.NewUpdate(0)
u.Timeout = 1
updates := bot.api.GetUpdatesChan(u)
for {
select {
case <-ctx.Done():
log.Println("Telegram bot stopping...")
bot.api.StopReceivingUpdates()
_ = bot.ocr.Close()
time.Sleep(500 * time.Millisecond)
return nil
case update, ok := <-updates:
if !ok {
return nil
}
if update.Message == nil {
continue
}
switch {
case update.Message.Photo != nil:
bot.handlePhoto(update.Message)
case update.Message.Text != "":
bot.handleMessage(update.Message)
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
package bot
import (
"GoFinanceManager/src/integrations/receiptApi"
"GoFinanceManager/src/utils"
"context"
"io"
"log"
"net/http"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
func (bot *Bot) handleMessage(msg *tgbotapi.Message) {
println(msg.Text)
switch msg.Text {
case "/start":
bot.handleStart(msg)
case "/help":
bot.handleHelp(msg)
default:
bot.handleUnknown(msg)
}
}
func (bot *Bot) handlePhoto(msg *tgbotapi.Message) {
// Берём самое большое фото
photo := msg.Photo[len(msg.Photo)-1]
file, err := bot.api.GetFile(tgbotapi.FileConfig{FileID: photo.FileID})
if err != nil {
bot.reply(msg.Chat.ID, "Не смог получить файл 😢")
return
}
url := file.Link(bot.api.Token)
resp, err := http.Get(url)
if err != nil {
bot.reply(msg.Chat.ID, "Ошибка загрузки изображения")
return
}
defer resp.Body.Close()
imageBytes, err := io.ReadAll(resp.Body)
if err != nil {
bot.reply(msg.Chat.ID, "Ошибка чтения изображения")
return
}
text, err := bot.ocr.Recognize(context.Background(), imageBytes)
if err != nil {
bot.reply(msg.Chat.ID, "Ошибка OCR 😢")
return
}
if text == "" {
bot.reply(msg.Chat.ID, "Текст не найден")
return
}
receiptMeta := utils.ExtractReceiptMeta(text)
payload := receiptApi.ReceiptPayload{
Number: receiptMeta.ReceiptID,
Date: receiptMeta.Date,
}
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
err = bot.receiptApi.SendReceipt(ctx, payload)
reply := "📄 *Результат распознавания*\n\n"
if receiptMeta.Date != "" {
reply += "📅 Дата: " + receiptMeta.Date + "\n"
} else {
reply += "📅 Дата: не найдена\n"
}
if receiptMeta.ReceiptID != "" {
reply += "🧾 Номер чека:\n`" + receiptMeta.ReceiptID + "`\n"
} else {
reply += "🧾 Номер чека: не найден\n"
}
if err != nil {
reply += "Не удалось отправить чек в API " + err.Error()
} else {
reply += "Чек добавлен в базу"
}
bot.replyMarkdown(msg.Chat.ID, reply)
}
func (bot *Bot) handleStart(msg *tgbotapi.Message) {
bot.reply(msg.Chat.ID, "Привет! Я Telegram-бот на Go ⚡")
}
func (bot *Bot) handleHelp(msg *tgbotapi.Message) {
bot.reply(msg.Chat.ID, "Доступные команды:\n/start\n/help")
}
func (bot *Bot) handleUnknown(msg *tgbotapi.Message) {
bot.reply(msg.Chat.ID, "Не знаю такой команды 😕")
}
func (bot *Bot) reply(chat int64, text string) {
m := tgbotapi.NewMessage(chat, text)
_, err := bot.api.Send(m)
if err != nil {
log.Fatal(err)
}
}
func (bot *Bot) replyMarkdown(chatID int64, text string) {
msg := tgbotapi.NewMessage(chatID, text)
msg.ParseMode = tgbotapi.ModeMarkdown
bot.api.Send(msg)
}
+88
View File
@@ -0,0 +1,88 @@
package config
import (
"errors"
"os"
"strings"
"github.com/joho/godotenv"
)
type Config struct {
RunMode RunMode
DebugMode bool
BotToken string
DBConnectionString string
OCRTokenPath string
APIPort string
APIHost string
APISecret string
OpenAPIEnabled bool
OpenAPIEndpoint string
}
func Load() (Config, error) {
_ = godotenv.Load()
var warnings []string
mode := os.Getenv("RUN_MODE")
debugMode := os.Getenv("DEBUG_MODE") == "true"
botToken := os.Getenv("BOT_TOKEN")
dbConnectionString := os.Getenv("DB_PATH")
ocrTokenPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
apiPort := os.Getenv("API_PORT")
apiHost := os.Getenv("API_HOST")
apiSecret := os.Getenv("API_SECRET")
openAPIEnabled := os.Getenv("OPEN_API_ENABLED") == "true"
openAPIEndpoint := os.Getenv("OPEN_API_ENDPOINT")
runMode, err := ParseRunMode(mode)
if err != nil {
warnings = append(warnings, err.Error())
}
if runMode == Bot || runMode == Standalone {
if ocrTokenPath == "" {
warnings = append(warnings, "Missing required environment variable: GOOGLE_APPLICATION_CREDENTIALS")
}
if botToken == "" {
warnings = append(warnings, "Missing required environment variable: BOT_TOKEN")
}
}
if runMode == API || runMode == Standalone {
if apiSecret == "" {
warnings = append(warnings, "Missing required environment variable: API_SECRET")
}
if dbConnectionString == "" {
dbConnectionString = "sqlite://data/app.db"
}
if apiHost == "" {
apiHost = "localhost"
}
if apiPort == "" {
apiPort = "8000"
}
if openAPIEndpoint == "" {
openAPIEndpoint = "/docs"
}
}
if len(warnings) > 0 {
return Config{}, errors.New(strings.Join(warnings, "\n"))
}
return Config{
BotToken: botToken,
DBConnectionString: dbConnectionString,
OCRTokenPath: ocrTokenPath,
DebugMode: debugMode,
RunMode: runMode,
APIPort: apiPort,
APIHost: apiHost,
APISecret: apiSecret,
OpenAPIEnabled: openAPIEnabled,
OpenAPIEndpoint: openAPIEndpoint,
}, nil
}
+106
View File
@@ -0,0 +1,106 @@
package config_test
import (
"GoFinanceManager/internal/config"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
type EnvFixture struct {
backup map[string]string
}
func NewEnvFixture() *EnvFixture {
env := make(map[string]string)
for _, e := range os.Environ() {
pair := split(e)
env[pair[0]] = pair[1]
}
// полностью чистое окружение
os.Clearenv()
return &EnvFixture{backup: env}
}
func (e *EnvFixture) Restore() {
os.Clearenv()
for key, val := range e.backup {
_ = os.Setenv(key, val)
}
}
func split(s string) [2]string {
var p [2]string
for i := 0; i < len(s); i++ {
if s[i] == '=' {
p[0] = s[:i]
p[1] = s[i+1:]
return p
}
}
return p
}
func MustSet(env map[string]string) {
for k, v := range env {
_ = os.Setenv(k, v)
}
}
func MustUnset(keys ...string) {
for _, k := range keys {
_ = os.Unsetenv(k)
}
}
func TestConfigLoad_Table(t *testing.T) {
env := NewEnvFixture()
defer env.Restore()
tests := []struct {
name string
env map[string]string
want config.Config
error bool
}{
{
name: "ok - values set",
env: map[string]string{"BOT_TOKEN": "abc", "DB_PATH": "db.sqlite"},
want: config.Config{BotToken: "abc", DBConnectionString: "db.sqlite"},
},
{
name: "fail - missing token",
env: map[string]string{}, // ничего нет
error: true,
},
{
name: "default DB path applied",
env: map[string]string{"BOT_TOKEN": "xyz"},
error: true,
},
}
for _, tc := range tests {
testCase := tc
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
os.Clearenv()
MustSet(testCase.env)
cfg, err := config.Load()
if testCase.error {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, testCase.want.BotToken, cfg.BotToken)
assert.Equal(t, testCase.want.DBConnectionString, cfg.DBConnectionString)
})
}
}
+28
View File
@@ -0,0 +1,28 @@
package config
import (
"fmt"
"strings"
)
type RunMode string
const (
Bot RunMode = "bot"
API RunMode = "api"
Standalone RunMode = "standalone"
Unknown RunMode = "unknown"
)
func ParseRunMode(s string) (RunMode, error) {
switch strings.ToLower(s) {
case "bot":
return Bot, nil
case "api":
return API, nil
case "standalone":
return Standalone, nil
default:
return Unknown, fmt.Errorf("invalid run mode: %s", s)
}
}
+23
View File
@@ -0,0 +1,23 @@
package database
import (
"database/sql"
"fmt"
"net/url"
_ "github.com/mattn/go-sqlite3"
)
func Connect(dsn string) (*sql.DB, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, err
}
switch u.Scheme {
case "sqlite":
return connectSQLite(u)
default:
return nil, fmt.Errorf("unsupported db scheme: %s", u.Scheme)
}
}
+32
View File
@@ -0,0 +1,32 @@
package database
import (
"database/sql"
"errors"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
func RunMigrations(db *sql.DB) error {
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithDatabaseInstance(
"file://migrations",
"sqlite",
driver,
)
if err != nil {
return err
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package database
import (
"database/sql"
"fmt"
"net/url"
"os"
"path/filepath"
)
func connectSQLite(u *url.URL) (*sql.DB, error) {
path := filepath.Join(u.Host, u.Path)
if path == "" {
return nil, fmt.Errorf("empty sqlite path")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite3", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // важно для sqlite
return db, nil
}
+64
View File
@@ -0,0 +1,64 @@
package models
import "time"
type Position struct {
SectionNumber string `json:"section_number"`
GTINCode string `json:"gtin_code"`
Tag string `json:"tag"`
MarkingCode string `json:"marking_code"`
UKZCode string `json:"ukz_code"`
ProductName string `json:"product_name"`
ProductCountRaw string `json:"product_count"`
ProductCount float64 `json:"-"`
AmountRaw string `json:"amount"`
Amount float64 `json:"-"`
DiscountRaw string `json:"discount"`
Discount float64 `json:"-"`
SurchargeRaw string `json:"surcharge"`
Surcharge float64 `json:"-"`
}
type Receipt struct {
ID int `json:"id"`
Status int `json:"STATUS"`
AnotherAmount float64 `json:"another_amount"`
CashAmount float64 `json:"cash_amount"`
CashboxNumber int64 `json:"cashbox_number"`
Cashier string `json:"cashier"`
ClearingAmount float64 `json:"clearing_amount"`
Currency string `json:"currency"`
DocNum string `json:"doc_num"`
HouseTo string `json:"house_to"`
KodSoato string `json:"kod_soato"`
Margin float64 `json:"margin"`
NameNP string `json:"name_np"`
NameSPD string `json:"name_spd"`
NameTO string `json:"name_to"`
OblastSoato *string `json:"oblast_soato"`
RayonSoato *string `json:"rayon_soato"`
SelsovetSoato *string `json:"selsovet_soato"`
PaymentAmount float64 `json:"payment_amount"`
PaymentType int `json:"payment_type"`
ReceiptNumber string `json:"receipt_number"`
SknoNumber string `json:"skno_number"`
StreetTo string `json:"street_to"`
Success string `json:"success"`
TotalAmount float64 `json:"total_amount"`
TypeNP string `json:"type_np"`
UI string `json:"ui"`
UNP string `json:"unp"`
IssuedAtRaw string `json:"issued_at"`
IssuedAt time.Time `json:"-"`
PositionsRaw string `json:"positions"`
Positions []Position `json:"-"`
}
+44
View File
@@ -0,0 +1,44 @@
package ocr
import (
"bytes"
"context"
"fmt"
vision "cloud.google.com/go/vision/apiv1"
)
type GoogleOCR struct {
client *vision.ImageAnnotatorClient
}
func NewGoogleOCR(ctx context.Context) (*GoogleOCR, error) {
client, err := vision.NewImageAnnotatorClient(ctx)
if err != nil {
return nil, err
}
return &GoogleOCR{client: client}, nil
}
func (g *GoogleOCR) Close() error {
return g.client.Close()
}
func (g *GoogleOCR) Recognize(ctx context.Context, image []byte) (string, error) {
img, err := vision.NewImageFromReader(bytes.NewReader(image))
if err != nil {
return "", fmt.Errorf("load image: %w", err)
}
annotations, err := g.client.DetectTexts(ctx, img, nil, 1)
if err != nil {
return "", fmt.Errorf("detect text: %w", err)
}
if len(annotations) == 0 {
return "", nil
}
return annotations[0].Description, nil
}
+9
View File
@@ -0,0 +1,9 @@
package ocr
import "context"
// OCR — контракт для любого OCR сервиса
type OCR interface {
Recognize(ctx context.Context, image []byte) (string, error)
Close() error
}
+7
View File
@@ -0,0 +1,7 @@
package receiptApi
import "context"
type Client interface {
SendReceipt(ctx context.Context, payload ReceiptPayload) error
}
+6
View File
@@ -0,0 +1,6 @@
package receiptApi
type ReceiptPayload struct {
Number string `json:"number"`
Date string `json:"date"`
}
@@ -0,0 +1,62 @@
package receiptApi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type HTTPClient struct {
baseURL string
client *http.Client
//apiKey string
}
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
return &HTTPClient{
baseURL: baseURL,
client: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
}
func (c *HTTPClient) SendReceipt(
ctx context.Context,
payload ReceiptPayload,
) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.baseURL+"/receipts",
bytes.NewReader(body),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
//if c.apiKey != "" {
// req.Header.Set("Authorization", "Bearer "+c.apiKey)
//}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,151 @@
package receiptService
import (
"GoFinanceManager/src/domain/models"
"GoFinanceManager/src/repositories"
"GoFinanceManager/src/utils"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log"
"mime/multipart"
"net/http"
"time"
)
type ReceiptService struct {
client *http.Client
repo repositories.ReceiptRepository
}
func NewReceiptService(repo repositories.ReceiptRepository) *ReceiptService {
return &ReceiptService{
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
},
repo: repo,
}
}
func (s *ReceiptService) GetReceipt(
ctx context.Context,
date string,
number string,
) (*models.Receipt, error) {
url := "https://ch.info-center.by/ajax/check1.php"
var receipt models.Receipt
body, contentType := buildMultipartBody(date, number)
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
url,
body,
)
if err != nil {
log.Println(err.Error())
return nil, err
}
req.Header.Set("Content-Type", contentType)
resp, err := s.client.Do(req)
if err != nil {
log.Println(err.Error())
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("external service returned %d\n", resp.StatusCode)
return nil, fmt.Errorf("external service returned %d", resp.StatusCode)
}
var raw struct {
Message map[string]interface{} `json:"message"`
}
log.Println(raw.Message)
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
log.Printf("external service returned %s\n", err.Error())
return nil, err
}
raw.Message["receipt_number"] = number
bytes_, _ := json.Marshal(raw.Message)
if err := json.Unmarshal(bytes_, &receipt); err != nil {
return nil, err
}
log.Println(receipt)
positions, err := parsePositions(receipt.PositionsRaw)
if err != nil {
log.Printf("failed to parse positions: %s", err.Error())
return nil, err
}
log.Println(receipt.IssuedAtRaw)
receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw)
if err != nil {
log.Printf("failed to parse issued at: %s", err.Error())
return nil, err
}
receipt.Positions = positions
for i := range receipt.Positions {
p := &receipt.Positions[i]
p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw)
if err != nil {
log.Printf("failed to parse product count: %s", err.Error())
return nil, err
}
p.Amount, err = utils.ParseFloat(p.AmountRaw)
if err != nil {
log.Printf("failed to parse amount: %s", err.Error())
return nil, err
}
p.Discount, _ = utils.ParseFloat(p.DiscountRaw)
p.Surcharge, _ = utils.ParseFloat(p.SurchargeRaw)
}
if _, err := s.repo.Create(ctx, &receipt); err != nil {
return nil, err
}
return &receipt, nil
}
func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
_ = writer.WriteField("orig_date", date)
_ = writer.WriteField("orig_ui", number)
_ = writer.Close()
return body, writer.FormDataContentType()
}
func parsePositions(raw string) ([]models.Position, error) {
var positions []models.Position
if raw == "" {
return positions, nil
}
if err := json.Unmarshal([]byte(raw), &positions); err != nil {
return nil, err
}
return positions, nil
}
+15
View File
@@ -0,0 +1,15 @@
package repositories
import (
"context"
"GoFinanceManager/src/domain/models"
)
type ReceiptRepository interface {
Create(ctx context.Context, receipt *models.Receipt) (int64, error)
GetByID(ctx context.Context, id int64) (*models.Receipt, error)
GetAll(ctx context.Context, limit, offset int) ([]*models.Receipt, error)
Update(ctx context.Context, receipt *models.Receipt) error
Delete(ctx context.Context, id int64) error
}
+327
View File
@@ -0,0 +1,327 @@
package repositories
import (
"context"
"database/sql"
"errors"
"GoFinanceManager/src/domain/models"
)
type ReceiptSQLRepository struct {
db *sql.DB
}
func NewReceiptSQLRepository(db *sql.DB) *ReceiptSQLRepository {
return &ReceiptSQLRepository{db: db}
}
func (r *ReceiptSQLRepository) Create(
ctx context.Context,
receipt *models.Receipt,
) (int64, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `
INSERT INTO receipts (
receipt_number, ui, status, issued_at,
total_amount, payment_amount, cash_amount,
another_amount, clearing_amount, margin,
currency, payment_type,
cashbox_number, cashier,
name_spd, name_to, name_np, type_np,
street_to, house_to,
kod_soato, oblast_soato, rayon_soato, selsovet_soato,
doc_num, skno_number, unp,
success
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
receipt.ReceiptNumber,
receipt.UI,
receipt.Status,
receipt.IssuedAt,
receipt.TotalAmount,
receipt.PaymentAmount,
receipt.CashAmount,
receipt.AnotherAmount,
receipt.ClearingAmount,
receipt.Margin,
receipt.Currency,
receipt.PaymentType,
receipt.CashboxNumber,
receipt.Cashier,
receipt.NameSPD,
receipt.NameTO,
receipt.NameNP,
receipt.TypeNP,
receipt.StreetTo,
receipt.HouseTo,
receipt.KodSoato,
receipt.OblastSoato,
receipt.RayonSoato,
receipt.SelsovetSoato,
receipt.DocNum,
receipt.SknoNumber,
receipt.UNP,
receipt.Success,
)
if err != nil {
return 0, err
}
receiptID, err := res.LastInsertId()
if err != nil {
return 0, err
}
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO positions (
receipt_id,
section_number,
gtin_code,
product_name,
product_count,
amount,
discount,
surcharge,
tag,
marking_code,
ukz_code
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return 0, err
}
defer stmt.Close()
for _, p := range receipt.Positions {
_, err = stmt.ExecContext(ctx,
receiptID,
p.SectionNumber,
p.GTINCode,
p.ProductName,
p.ProductCount,
p.Amount,
p.Discount,
p.Surcharge,
p.Tag,
p.MarkingCode,
p.UKZCode,
)
if err != nil {
return 0, err
}
}
return receiptID, tx.Commit()
}
func (r *ReceiptSQLRepository) GetByID(
ctx context.Context,
id int64,
) (*models.Receipt, error) {
var receipt models.Receipt
err := r.db.QueryRowContext(ctx, `
SELECT
id,
receipt_number, ui, status, issued_at,
total_amount, payment_amount, cash_amount,
another_amount, clearing_amount, margin,
currency, payment_type,
cashbox_number, cashier,
name_spd, name_to, name_np, type_np,
street_to, house_to,
kod_soato, oblast_soato, rayon_soato, selsovet_soato,
doc_num, skno_number, unp,
success, raw_json
FROM receipts
WHERE id = ?
`, id).Scan(
&receipt.ID,
&receipt.ReceiptNumber,
&receipt.UI,
&receipt.Status,
&receipt.IssuedAt,
&receipt.TotalAmount,
&receipt.PaymentAmount,
&receipt.CashAmount,
&receipt.AnotherAmount,
&receipt.ClearingAmount,
&receipt.Margin,
&receipt.Currency,
&receipt.PaymentType,
&receipt.CashboxNumber,
&receipt.Cashier,
&receipt.NameSPD,
&receipt.NameTO,
&receipt.NameNP,
&receipt.TypeNP,
&receipt.StreetTo,
&receipt.HouseTo,
&receipt.KodSoato,
&receipt.OblastSoato,
&receipt.RayonSoato,
&receipt.SelsovetSoato,
&receipt.DocNum,
&receipt.SknoNumber,
&receipt.UNP,
&receipt.Success,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT
section_number, gtin_code, product_name,
product_count, amount,
discount, surcharge,
tag, marking_code, ukz_code
FROM positions WHERE receipt_id = ?
`, id)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var p models.Position
if err := rows.Scan(
&p.SectionNumber,
&p.GTINCode,
&p.ProductName,
&p.ProductCount,
&p.Amount,
&p.Discount,
&p.Surcharge,
&p.Tag,
&p.MarkingCode,
&p.UKZCode,
); err != nil {
return nil, err
}
receipt.Positions = append(receipt.Positions, p)
}
return &receipt, nil
}
func (r *ReceiptSQLRepository) GetAll(
ctx context.Context,
limit, offset int,
) ([]*models.Receipt, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, receipt_number, issued_at, total_amount, currency
FROM receipts
ORDER BY issued_at DESC
LIMIT ? OFFSET ?
`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var receipts []*models.Receipt
for rows.Next() {
var rct models.Receipt
if err := rows.Scan(
&rct.ID,
&rct.ReceiptNumber,
&rct.IssuedAt,
&rct.TotalAmount,
&rct.Currency,
); err != nil {
return nil, err
}
receipts = append(receipts, &rct)
}
return receipts, nil
}
func (r *ReceiptSQLRepository) Update(
ctx context.Context,
receipt *models.Receipt,
) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `
UPDATE receipts SET
issued_at = ?,
total_amount = ?,
currency = ?
WHERE id = ?
`,
receipt.IssuedAt,
receipt.TotalAmount,
receipt.Currency,
receipt.ID,
)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `DELETE FROM positions WHERE receipt_id = ?`, receipt.ID)
if err != nil {
return err
}
for _, p := range receipt.Positions {
_, err = tx.ExecContext(ctx, `
INSERT INTO positions (
receipt_id, product_name, product_count, amount
) VALUES (?, ?, ?, ?)
`, receipt.ID, p.ProductName, p.ProductCount, p.Amount)
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *ReceiptSQLRepository) Delete(
ctx context.Context,
id int64,
) error {
_, err := r.db.ExecContext(ctx,
`DELETE FROM receipts WHERE id = ?`,
id,
)
return err
}
+36
View File
@@ -0,0 +1,36 @@
package utils
import (
"errors"
"log"
"strings"
"time"
)
const issuedAtLayout = "02/01/2006, 15:04:05"
var knownDateFormats = []string{
"02.01.2006", // 21.01.2026
"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
}
func NormalizeDateToISO(input string) (string, error) {
input = strings.TrimSpace(input)
log.Println(input)
for _, layout := range knownDateFormats {
if t, err := time.Parse(layout, input); err == nil {
return t.Format("2006-01-02"), nil
}
}
return "", errors.New("unsupported date format")
}
func ParseIssuedAt(value string) (time.Time, error) {
return time.Parse(issuedAtLayout, value)
}
+11
View File
@@ -0,0 +1,11 @@
package utils
import (
"strconv"
"strings"
)
func ParseFloat(value string) (float64, error) {
value = strings.ReplaceAll(value, ",", ".")
return strconv.ParseFloat(value, 64)
}
+33
View File
@@ -0,0 +1,33 @@
package utils
import "regexp"
type ReceiptMeta struct {
Date string
ReceiptID string
}
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
}
for _, pattern := range datePatterns {
re := regexp.MustCompile(pattern)
if match := re.FindString(text); match != "" {
result.Date = match
break
}
}
// --- НОМЕР ЧЕКА (24 символа) ---
receiptRe := regexp.MustCompile(`\b[A-Za-z0-9]{24}\b`)
result.ReceiptID = receiptRe.FindString(text)
return result
}