Init commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
*.iml
|
||||
.idea
|
||||
.env
|
||||
secret_key.json
|
||||
data
|
||||
+43
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE receipts;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE positions;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX idx_positions_receipt_id;
|
||||
DROP INDEX idx_receipts_issued_at;
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX idx_receipts_issued_at ON receipts(issued_at);
|
||||
CREATE INDEX idx_positions_receipt_id ON positions(receipt_id);
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ocr
|
||||
|
||||
import "context"
|
||||
|
||||
// OCR — контракт для любого OCR сервиса
|
||||
type OCR interface {
|
||||
Recognize(ctx context.Context, image []byte) (string, error)
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package receiptApi
|
||||
|
||||
import "context"
|
||||
|
||||
type Client interface {
|
||||
SendReceipt(ctx context.Context, payload ReceiptPayload) error
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseFloat(value string) (float64, error) {
|
||||
value = strings.ReplaceAll(value, ",", ".")
|
||||
return strconv.ParseFloat(value, 64)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user