commit e6dc59c2669f59e91927af8620d817decec97a47 Author: AlexBelyan Date: Tue Jan 27 15:31:58 2026 +0300 Init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e4c0f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.iml +.idea +.env +secret_key.json +data \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..4c9adc0 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "GoFinanceManager/src/api" + "GoFinanceManager/src/bot" + "GoFinanceManager/src/config" + + "context" + "log" +) + +func main() { + var runnable []Runnable + + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + + ctx := context.Background() + + if cfg.RunMode == config.API || cfg.RunMode == config.Standalone { + server := api.NewServer(cfg) + runnable = append(runnable, func(ctx context.Context) error { + log.Println("API started on", cfg.APIPort) + return server.Start() + }) + runnable = append(runnable, func(ctx context.Context) error { + <-ctx.Done() + return server.Shutdown(context.Background()) + }) + } + + if cfg.RunMode == config.Bot || cfg.RunMode == config.Standalone { + tgBot, _ := bot.NewBot(cfg) + log.Println("Bot started...") + runnable = append(runnable, func(ctx context.Context) error { + return tgBot.Start(ctx) + }) + } + + Run(ctx, runnable...) +} diff --git a/cmd/runtime.go b/cmd/runtime.go new file mode 100644 index 0000000..f274a6d --- /dev/null +++ b/cmd/runtime.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" +) + +type Runnable func(ctx context.Context) error + +func Run(ctx context.Context, runnable ...Runnable) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + errCh := make(chan error, len(runnable)) + + for _, r := range runnable { + go func(run Runnable) { + if err := run(ctx); err != nil { + errCh <- err + } + }(r) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + select { + case sig := <-sigCh: + log.Println("shutdown signal:", sig) + case err := <-errCh: + log.Println("runtime error:", err) + } + + cancel() + time.Sleep(2 * time.Second) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..34fd5b5 --- /dev/null +++ b/go.mod @@ -0,0 +1,75 @@ +module GoFinanceManager + +go 1.25 + +require ( + cloud.google.com/go/vision v1.2.0 + github.com/gin-gonic/gin v1.11.0 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/joho/godotenv v1.5.1 + github.com/mattn/go-sqlite3 v1.14.33 + github.com/stretchr/testify v1.11.1 +) + +require ( + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.16.4 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.8.0 // indirect + cloud.google.com/go/longrunning v0.6.7 // indirect + cloud.google.com/go/vision/v2 v2.9.5 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.org/x/tools v0.38.0 // indirect + google.golang.org/api v0.247.0 // indirect + google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect + google.golang.org/grpc v1.74.2 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/migrations/000001_create_receipts.down.sql b/migrations/000001_create_receipts.down.sql new file mode 100644 index 0000000..55f6cbd --- /dev/null +++ b/migrations/000001_create_receipts.down.sql @@ -0,0 +1 @@ +DROP TABLE receipts; diff --git a/migrations/000001_create_receipts.up.sql b/migrations/000001_create_receipts.up.sql new file mode 100644 index 0000000..c382c28 --- /dev/null +++ b/migrations/000001_create_receipts.up.sql @@ -0,0 +1,52 @@ +CREATE TABLE receipts +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + -- основные поля + receipt_number TEXT NOT NULL UNIQUE, + ui TEXT NOT NULL, + + status INTEGER NOT NULL, + + issued_at TIMESTAMP NOT NULL, + + -- суммы + total_amount REAL NOT NULL, + payment_amount REAL NOT NULL, + cash_amount REAL NOT NULL, + another_amount REAL NOT NULL, + clearing_amount REAL NOT NULL, + margin REAL NOT NULL, + + currency TEXT NOT NULL, + payment_type INTEGER NOT NULL, + + -- касса / продавец + cashbox_number INTEGER NOT NULL, + cashier TEXT, + + -- организация / адрес + name_spd TEXT, + name_to TEXT, + name_np TEXT, + type_np TEXT, + + street_to TEXT, + house_to TEXT, + + -- SOATO (nullable) + kod_soato TEXT, + oblast_soato TEXT, + rayon_soato TEXT, + selsovet_soato TEXT, + + -- прочее + doc_num TEXT, + skno_number TEXT, + unp TEXT, + + success TEXT, + + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/migrations/000002_create_positions.down.sql b/migrations/000002_create_positions.down.sql new file mode 100644 index 0000000..913ac66 --- /dev/null +++ b/migrations/000002_create_positions.down.sql @@ -0,0 +1 @@ +DROP TABLE positions; \ No newline at end of file diff --git a/migrations/000002_create_positions.up.sql b/migrations/000002_create_positions.up.sql new file mode 100644 index 0000000..da33535 --- /dev/null +++ b/migrations/000002_create_positions.up.sql @@ -0,0 +1,25 @@ +CREATE TABLE positions +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + receipt_id INTEGER NOT NULL, + + section_number TEXT, + gtin_code TEXT, + + product_name TEXT NOT NULL, + + product_count REAL NOT NULL, + amount REAL NOT NULL, + + discount REAL, + surcharge REAL, + + tag TEXT, + marking_code TEXT, + ukz_code TEXT, + + FOREIGN KEY (receipt_id) + REFERENCES receipts (id) + ON DELETE CASCADE +); diff --git a/migrations/000003_add_indexes.down.sql b/migrations/000003_add_indexes.down.sql new file mode 100644 index 0000000..e54f170 --- /dev/null +++ b/migrations/000003_add_indexes.down.sql @@ -0,0 +1,2 @@ +DROP INDEX idx_positions_receipt_id; +DROP INDEX idx_receipts_issued_at; diff --git a/migrations/000003_add_indexes.up.sql b/migrations/000003_add_indexes.up.sql new file mode 100644 index 0000000..555db8a --- /dev/null +++ b/migrations/000003_add_indexes.up.sql @@ -0,0 +1,2 @@ +CREATE INDEX idx_receipts_issued_at ON receipts(issued_at); +CREATE INDEX idx_positions_receipt_id ON positions(receipt_id); diff --git a/response.json b/response.json new file mode 100644 index 0000000..a969d77 --- /dev/null +++ b/response.json @@ -0,0 +1,31 @@ +{ + "STATUS": 1, + "another_amount": 0, + "cash_amount": 0, + "cashbox_number": 119066664, + "cashier": "Замена Магазин 3", + "clearing_amount": 13.54, + "currency": "BYN", + "doc_num": "153896", + "house_to": "11, 3 этаж", + "issued_at": "21/01/2026, 21:15:20", + "kod_soato": "5000000000", + "margin": 0, + "name_np": "Минск", + "name_spd": "Общество с ограниченной ответственностью \"СМАРТОН\"", + "name_to": "\"Офистон Маркет\", г.Минск, ул.Петра Мстиславца, 11", + "oblast_soato": null, + "payment_amount": 13.54, + "payment_type": 1, + "positions": "[{\"section_number\": \"0\", \"gtin_code\": \"8801068922011\", \"product_count\": \"1.000\", \"amount\": \"7.60\", \"discount\": \"0.00\", \"surcharge\": \"0.00\", \"tag\": \"0\", \"marking_code\": \"None\", \"ukz_code\": \"None\", \"product_name\": \"\\u041d\\u0430\\u043f\\u0438\\u0442\\u043e\\u043a \\\"Samlip\\\" 230 \\u043c\\u043b., \\u0441\\u043e \\u0432\\u043a\\u0443\\u0441\\u043e\\u043c \\u043b\\u0438\\u0447\\u0438\"}, {\"section_number\": \"0\", \"gtin_code\": \"4606008517920\", \"product_count\": \"1.000\", \"amount\": \"5.94\", \"discount\": \"0.00\", \"surcharge\": \"0.00\", \"tag\": \"0\", \"marking_code\": \"None\", \"ukz_code\": \"None\", \"product_name\": \"\\u0421\\u0442\\u0438\\u043a\\u0435\\u0440\\u044b \\u0434/\\u0437\\u0430\\u043c\\u0435\\u0442\\u043e\\u043a \\u0431\\u0443\\u043c\\u0430\\u0436\\u043d\\u044b\\u0435 \\\"\\u0421\\u0435\\u0440\\u0434\\u0446\\u0435\\\" 80 \\u0448\\u0442., \\u0444\\u0438\\u0433\\u0443\\u0440\\u043d\"}]", + "rayon_soato": null, + "receipt_number": "C0AD964BD53AC59A0718D028", + "selsovet_soato": null, + "skno_number": "AVQ24170087307", + "street_to": "УЛ. ПЕТРА МСТИСЛАВЦА", + "success": "A check is correct", + "total_amount": 13.54, + "type_np": "г.", + "ui": "C0AD964BD53AC59A0718D028", + "unp": "190635842" +} diff --git a/src/api/dto/requests.go b/src/api/dto/requests.go new file mode 100644 index 0000000..0065ff3 --- /dev/null +++ b/src/api/dto/requests.go @@ -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"` +} diff --git a/src/api/dto/responses.go b/src/api/dto/responses.go new file mode 100644 index 0000000..d217661 --- /dev/null +++ b/src/api/dto/responses.go @@ -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"` +} diff --git a/src/api/handlers/hello.go b/src/api/handlers/hello.go new file mode 100644 index 0000000..9c25bbe --- /dev/null +++ b/src/api/handlers/hello.go @@ -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) +} diff --git a/src/api/handlers/receipts.go b/src/api/handlers/receipts.go new file mode 100644 index 0000000..faf6d81 --- /dev/null +++ b/src/api/handlers/receipts.go @@ -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) +} diff --git a/src/api/server.go b/src/api/server.go new file mode 100644 index 0000000..9a36ddd --- /dev/null +++ b/src/api/server.go @@ -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) +} diff --git a/src/bot/bot.go b/src/bot/bot.go new file mode 100644 index 0000000..07e4134 --- /dev/null +++ b/src/bot/bot.go @@ -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) + } + } + } +} diff --git a/src/bot/handlers.go b/src/bot/handlers.go new file mode 100644 index 0000000..eace3a0 --- /dev/null +++ b/src/bot/handlers.go @@ -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) +} diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..1d2f13c --- /dev/null +++ b/src/config/config.go @@ -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 +} diff --git a/src/config/config_test.go b/src/config/config_test.go new file mode 100644 index 0000000..100665c --- /dev/null +++ b/src/config/config_test.go @@ -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) + }) + } +} diff --git a/src/config/runMode.go b/src/config/runMode.go new file mode 100644 index 0000000..854d7ed --- /dev/null +++ b/src/config/runMode.go @@ -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) + } +} diff --git a/src/database/database.go b/src/database/database.go new file mode 100644 index 0000000..a0af814 --- /dev/null +++ b/src/database/database.go @@ -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) + } +} diff --git a/src/database/migrations.go b/src/database/migrations.go new file mode 100644 index 0000000..f461b90 --- /dev/null +++ b/src/database/migrations.go @@ -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 +} diff --git a/src/database/sqlite.go b/src/database/sqlite.go new file mode 100644 index 0000000..17a95f2 --- /dev/null +++ b/src/database/sqlite.go @@ -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 +} diff --git a/src/domain/models/receipt.go b/src/domain/models/receipt.go new file mode 100644 index 0000000..fc8476c --- /dev/null +++ b/src/domain/models/receipt.go @@ -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:"-"` +} diff --git a/src/integrations/ocr/google.go b/src/integrations/ocr/google.go new file mode 100644 index 0000000..cbed58a --- /dev/null +++ b/src/integrations/ocr/google.go @@ -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 +} diff --git a/src/integrations/ocr/ocr.go b/src/integrations/ocr/ocr.go new file mode 100644 index 0000000..d34cfad --- /dev/null +++ b/src/integrations/ocr/ocr.go @@ -0,0 +1,9 @@ +package ocr + +import "context" + +// OCR — контракт для любого OCR сервиса +type OCR interface { + Recognize(ctx context.Context, image []byte) (string, error) + Close() error +} diff --git a/src/integrations/receiptApi/client.go b/src/integrations/receiptApi/client.go new file mode 100644 index 0000000..2b6443e --- /dev/null +++ b/src/integrations/receiptApi/client.go @@ -0,0 +1,7 @@ +package receiptApi + +import "context" + +type Client interface { + SendReceipt(ctx context.Context, payload ReceiptPayload) error +} diff --git a/src/integrations/receiptApi/dto.go b/src/integrations/receiptApi/dto.go new file mode 100644 index 0000000..ccd38a3 --- /dev/null +++ b/src/integrations/receiptApi/dto.go @@ -0,0 +1,6 @@ +package receiptApi + +type ReceiptPayload struct { + Number string `json:"number"` + Date string `json:"date"` +} diff --git a/src/integrations/receiptApi/http_client.go b/src/integrations/receiptApi/http_client.go new file mode 100644 index 0000000..f3cc3f1 --- /dev/null +++ b/src/integrations/receiptApi/http_client.go @@ -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 +} diff --git a/src/integrations/receiptService/receipt_service.go b/src/integrations/receiptService/receipt_service.go new file mode 100644 index 0000000..1befe87 --- /dev/null +++ b/src/integrations/receiptService/receipt_service.go @@ -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 +} diff --git a/src/repositories/receipt_repository.go b/src/repositories/receipt_repository.go new file mode 100644 index 0000000..2cd88b2 --- /dev/null +++ b/src/repositories/receipt_repository.go @@ -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 +} diff --git a/src/repositories/receipt_sql.go b/src/repositories/receipt_sql.go new file mode 100644 index 0000000..86fe65d --- /dev/null +++ b/src/repositories/receipt_sql.go @@ -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 +} diff --git a/src/utils/date.go b/src/utils/date.go new file mode 100644 index 0000000..0e4a8f4 --- /dev/null +++ b/src/utils/date.go @@ -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) +} diff --git a/src/utils/numbers.go b/src/utils/numbers.go new file mode 100644 index 0000000..0b0cf09 --- /dev/null +++ b/src/utils/numbers.go @@ -0,0 +1,11 @@ +package utils + +import ( + "strconv" + "strings" +) + +func ParseFloat(value string) (float64, error) { + value = strings.ReplaceAll(value, ",", ".") + return strconv.ParseFloat(value, 64) +} diff --git a/src/utils/parser.go b/src/utils/parser.go new file mode 100644 index 0000000..2705a85 --- /dev/null +++ b/src/utils/parser.go @@ -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 +}