mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Перевел на multi-tenant
Добавил поставщиков Накладные успешно создаются из фронта
This commit is contained in:
258
cmd/main.go
258
cmd/main.go
@@ -1,24 +1,23 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"rmser/config"
|
"rmser/config"
|
||||||
"rmser/internal/domain/catalog"
|
|
||||||
"rmser/internal/domain/invoices"
|
|
||||||
"rmser/internal/infrastructure/db"
|
"rmser/internal/infrastructure/db"
|
||||||
"rmser/internal/infrastructure/ocr_client"
|
"rmser/internal/infrastructure/ocr_client"
|
||||||
|
|
||||||
|
"rmser/internal/transport/http/middleware"
|
||||||
tgBot "rmser/internal/transport/telegram"
|
tgBot "rmser/internal/transport/telegram"
|
||||||
|
|
||||||
// Репозитории (инфраструктура)
|
// Repositories
|
||||||
|
accountPkg "rmser/internal/infrastructure/repository/account"
|
||||||
catalogPkg "rmser/internal/infrastructure/repository/catalog"
|
catalogPkg "rmser/internal/infrastructure/repository/catalog"
|
||||||
draftsPkg "rmser/internal/infrastructure/repository/drafts"
|
draftsPkg "rmser/internal/infrastructure/repository/drafts"
|
||||||
invoicesPkg "rmser/internal/infrastructure/repository/invoices"
|
invoicesPkg "rmser/internal/infrastructure/repository/invoices"
|
||||||
@@ -26,43 +25,45 @@ import (
|
|||||||
opsRepoPkg "rmser/internal/infrastructure/repository/operations"
|
opsRepoPkg "rmser/internal/infrastructure/repository/operations"
|
||||||
recipesPkg "rmser/internal/infrastructure/repository/recipes"
|
recipesPkg "rmser/internal/infrastructure/repository/recipes"
|
||||||
recRepoPkg "rmser/internal/infrastructure/repository/recommendations"
|
recRepoPkg "rmser/internal/infrastructure/repository/recommendations"
|
||||||
|
suppliersPkg "rmser/internal/infrastructure/repository/suppliers"
|
||||||
|
|
||||||
"rmser/internal/infrastructure/rms"
|
"rmser/internal/infrastructure/rms"
|
||||||
|
|
||||||
|
// Services
|
||||||
draftsServicePkg "rmser/internal/services/drafts"
|
draftsServicePkg "rmser/internal/services/drafts"
|
||||||
invServicePkg "rmser/internal/services/invoices" // Сервис накладных
|
|
||||||
ocrServicePkg "rmser/internal/services/ocr"
|
ocrServicePkg "rmser/internal/services/ocr"
|
||||||
recServicePkg "rmser/internal/services/recommend"
|
recServicePkg "rmser/internal/services/recommend"
|
||||||
"rmser/internal/services/sync"
|
"rmser/internal/services/sync"
|
||||||
"rmser/internal/transport/http/handlers" // Хендлеры
|
|
||||||
|
// Handlers
|
||||||
|
"rmser/internal/transport/http/handlers"
|
||||||
|
|
||||||
|
"rmser/pkg/crypto"
|
||||||
"rmser/pkg/logger"
|
"rmser/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 1. Загрузка конфигурации
|
// 1. Config
|
||||||
cfg, err := config.LoadConfig(".")
|
cfg, err := config.LoadConfig(".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Ошибка загрузки конфига: %v", err)
|
log.Fatalf("Ошибка загрузки конфига: %v", err)
|
||||||
}
|
}
|
||||||
// OCR Client
|
|
||||||
pyClient := ocr_client.NewClient(cfg.OCR.ServiceURL)
|
|
||||||
|
|
||||||
// 2. Инициализация логгера
|
// 2. Logger
|
||||||
logger.Init(cfg.App.Mode)
|
logger.Init(cfg.App.Mode)
|
||||||
defer logger.Log.Sync()
|
defer logger.Log.Sync()
|
||||||
|
|
||||||
logger.Log.Info("Запуск приложения rmser", zap.String("mode", cfg.App.Mode))
|
logger.Log.Info("Запуск приложения rmser", zap.String("mode", cfg.App.Mode))
|
||||||
|
|
||||||
// 3a. Подключение Redis (Новое)
|
// 3. Crypto & DB
|
||||||
// redisClient, err := redis.NewClient(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB)
|
if cfg.Security.SecretKey == "" {
|
||||||
// if err != nil {
|
logger.Log.Fatal("Security.SecretKey не задан в конфиге!")
|
||||||
// logger.Log.Fatal("Ошибка подключения к Redis", zap.Error(err))
|
}
|
||||||
// }
|
cryptoManager := crypto.NewCryptoManager(cfg.Security.SecretKey)
|
||||||
|
|
||||||
// 3. Подключение к БД (PostgreSQL)
|
|
||||||
database := db.NewPostgresDB(cfg.DB.DSN)
|
database := db.NewPostgresDB(cfg.DB.DSN)
|
||||||
|
|
||||||
// 4. Инициализация слоев
|
// 4. Repositories
|
||||||
rmsClient := rms.NewClient(cfg.RMS.BaseURL, cfg.RMS.Login, cfg.RMS.Password)
|
accountRepo := accountPkg.NewRepository(database)
|
||||||
catalogRepo := catalogPkg.NewRepository(database)
|
catalogRepo := catalogPkg.NewRepository(database)
|
||||||
recipesRepo := recipesPkg.NewRepository(database)
|
recipesRepo := recipesPkg.NewRepository(database)
|
||||||
invoicesRepo := invoicesPkg.NewRepository(database)
|
invoicesRepo := invoicesPkg.NewRepository(database)
|
||||||
@@ -70,124 +71,89 @@ func main() {
|
|||||||
recRepo := recRepoPkg.NewRepository(database)
|
recRepo := recRepoPkg.NewRepository(database)
|
||||||
ocrRepo := ocrRepoPkg.NewRepository(database)
|
ocrRepo := ocrRepoPkg.NewRepository(database)
|
||||||
draftsRepo := draftsPkg.NewRepository(database)
|
draftsRepo := draftsPkg.NewRepository(database)
|
||||||
|
supplierRepo := suppliersPkg.NewRepository(database)
|
||||||
|
|
||||||
syncService := sync.NewService(rmsClient, catalogRepo, recipesRepo, invoicesRepo, opsRepo)
|
// 5. RMS Factory
|
||||||
|
rmsFactory := rms.NewFactory(accountRepo, cryptoManager)
|
||||||
|
|
||||||
|
// 6. Services
|
||||||
|
pyClient := ocr_client.NewClient(cfg.OCR.ServiceURL)
|
||||||
|
|
||||||
|
syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)
|
||||||
recService := recServicePkg.NewService(recRepo)
|
recService := recServicePkg.NewService(recRepo)
|
||||||
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, pyClient)
|
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, pyClient)
|
||||||
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, rmsClient)
|
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, rmsFactory)
|
||||||
invoiceService := invServicePkg.NewService(rmsClient)
|
|
||||||
|
|
||||||
// --- Инициализация Handler'ов ---
|
// 7. Handlers
|
||||||
invoiceHandler := handlers.NewInvoiceHandler(invoiceService)
|
|
||||||
draftsHandler := handlers.NewDraftsHandler(draftsService)
|
draftsHandler := handlers.NewDraftsHandler(draftsService)
|
||||||
ocrHandler := handlers.NewOCRHandler(ocrService)
|
ocrHandler := handlers.NewOCRHandler(ocrService)
|
||||||
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
||||||
|
|
||||||
// --- БЛОК ПРОВЕРКИ СИНХРОНИЗАЦИИ (Run-once on start) ---
|
// 8. Telegram Bot (Передаем syncService)
|
||||||
go func() {
|
|
||||||
logger.Log.Info(">>> Запуск тестовой синхронизации...")
|
|
||||||
|
|
||||||
// 1. Каталог
|
|
||||||
if err := syncService.SyncCatalog(); err != nil {
|
|
||||||
logger.Log.Error("Ошибка синхронизации каталога", zap.Error(err))
|
|
||||||
} else {
|
|
||||||
logger.Log.Info("<<< Каталог успешно синхронизирован")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Техкарты
|
|
||||||
if err := syncService.SyncRecipes(); err != nil {
|
|
||||||
logger.Log.Error("Ошибка синхронизации техкарт", zap.Error(err))
|
|
||||||
} else {
|
|
||||||
logger.Log.Info("<<< Техкарты успешно синхронизированы")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Накладные
|
|
||||||
if err := syncService.SyncInvoices(); err != nil {
|
|
||||||
logger.Log.Error("Ошибка синхронизации накладных", zap.Error(err))
|
|
||||||
} else {
|
|
||||||
logger.Log.Info("<<< Накладные успешно синхронизированы")
|
|
||||||
}
|
|
||||||
// 4. Складские операции
|
|
||||||
if err := syncService.SyncStoreOperations(); err != nil {
|
|
||||||
logger.Log.Error("Ошибка синхронизации операций", zap.Error(err))
|
|
||||||
} else {
|
|
||||||
logger.Log.Info("<<< Операции успешно синхронизированы")
|
|
||||||
}
|
|
||||||
logger.Log.Info(">>> Запуск расчета рекомендаций...")
|
|
||||||
if err := recService.RefreshRecommendations(); err != nil {
|
|
||||||
logger.Log.Error("Ошибка расчета рекомендаций", zap.Error(err))
|
|
||||||
} else {
|
|
||||||
// Для отладки можно вывести пару штук
|
|
||||||
recs, _ := recService.GetRecommendations()
|
|
||||||
logger.Log.Info("<<< Анализ завершен", zap.Int("found", len(recs)))
|
|
||||||
}
|
|
||||||
// === ТЕСТ ОТПРАВКИ НАКЛАДНОЙ ===
|
|
||||||
// Запускаем через небольшую паузу, чтобы логи не перемешались
|
|
||||||
// time.Sleep(2 * time.Second)
|
|
||||||
// runManualInvoiceTest(rmsClient, catalogRepo)
|
|
||||||
// ===============================
|
|
||||||
}()
|
|
||||||
// -------------------------------------------------------
|
|
||||||
// Запуск бота (в отдельной горутине, т.к. Start() блокирует поток)
|
|
||||||
if cfg.Telegram.Token != "" {
|
if cfg.Telegram.Token != "" {
|
||||||
bot, err := tgBot.NewBot(cfg.Telegram, ocrService)
|
// !!! syncService добавлен в аргументы
|
||||||
|
bot, err := tgBot.NewBot(cfg.Telegram, ocrService, syncService, accountRepo, rmsFactory, cryptoManager)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
logger.Log.Fatal("Ошибка создания Telegram бота", zap.Error(err))
|
||||||
}
|
}
|
||||||
go bot.Start()
|
go bot.Start()
|
||||||
defer bot.Stop() // Graceful shutdown
|
defer bot.Stop()
|
||||||
} else {
|
|
||||||
logger.Log.Warn("Telegram token не задан, бот не запущен")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Запуск HTTP сервера (Gin)
|
// 9. HTTP Server
|
||||||
if cfg.App.Mode == "release" {
|
if cfg.App.Mode == "release" {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
}
|
}
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
// --- Настройка CORS ---
|
|
||||||
// Разрешаем запросы с любых источников для разработки Frontend
|
|
||||||
corsConfig := cors.DefaultConfig()
|
corsConfig := cors.DefaultConfig()
|
||||||
corsConfig.AllowAllOrigins = true // В продакшене заменить на AllowOrigins: []string{"http://domain.com"}
|
corsConfig.AllowAllOrigins = true
|
||||||
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
||||||
corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization"}
|
corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Telegram-User-ID"}
|
||||||
r.Use(cors.New(corsConfig))
|
r.Use(cors.New(corsConfig))
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
|
||||||
// Invoices
|
|
||||||
api.POST("/invoices/send", invoiceHandler.SendInvoice)
|
|
||||||
|
|
||||||
// Черновики
|
api.Use(middleware.AuthMiddleware(accountRepo))
|
||||||
|
{
|
||||||
|
// Drafts & Invoices
|
||||||
api.GET("/drafts", draftsHandler.GetDrafts)
|
api.GET("/drafts", draftsHandler.GetDrafts)
|
||||||
api.GET("/drafts/:id", draftsHandler.GetDraft)
|
api.GET("/drafts/:id", draftsHandler.GetDraft)
|
||||||
api.DELETE("/drafts/:id", draftsHandler.DeleteDraft)
|
api.DELETE("/drafts/:id", draftsHandler.DeleteDraft)
|
||||||
api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem)
|
api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem)
|
||||||
api.POST("/drafts/:id/commit", draftsHandler.CommitDraft)
|
api.POST("/drafts/:id/commit", draftsHandler.CommitDraft)
|
||||||
api.POST("/drafts/container", draftsHandler.AddContainer) // Добавление новой фасовки
|
api.POST("/drafts/container", draftsHandler.AddContainer)
|
||||||
|
|
||||||
// Склады
|
// Dictionaries
|
||||||
|
api.GET("/dictionaries", draftsHandler.GetDictionaries)
|
||||||
api.GET("/dictionaries/stores", draftsHandler.GetStores)
|
api.GET("/dictionaries/stores", draftsHandler.GetStores)
|
||||||
|
|
||||||
// Recommendations
|
// Recommendations
|
||||||
api.GET("/recommendations", recommendHandler.GetRecommendations)
|
api.GET("/recommendations", recommendHandler.GetRecommendations)
|
||||||
|
|
||||||
// OCR
|
// OCR & Matching
|
||||||
api.GET("/ocr/catalog", ocrHandler.GetCatalog)
|
api.GET("/ocr/catalog", ocrHandler.GetCatalog)
|
||||||
api.GET("/ocr/matches", ocrHandler.GetMatches)
|
api.GET("/ocr/matches", ocrHandler.GetMatches)
|
||||||
api.POST("/ocr/match", ocrHandler.SaveMatch)
|
api.POST("/ocr/match", ocrHandler.SaveMatch)
|
||||||
api.DELETE("/ocr/match", ocrHandler.DeleteMatch)
|
api.DELETE("/ocr/match", ocrHandler.DeleteMatch)
|
||||||
api.GET("/ocr/unmatched", ocrHandler.GetUnmatched)
|
api.GET("/ocr/unmatched", ocrHandler.GetUnmatched)
|
||||||
api.GET("/ocr/search", ocrHandler.SearchProducts)
|
api.GET("/ocr/search", ocrHandler.SearchProducts)
|
||||||
|
|
||||||
|
// Manual Sync Trigger
|
||||||
|
api.POST("/sync/all", func(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
// Запускаем в горутине, чтобы не держать соединение
|
||||||
|
go func() {
|
||||||
|
if err := syncService.SyncAllData(userID); err != nil {
|
||||||
|
logger.Log.Error("Manual sync failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
c.JSON(200, gin.H{"status": "sync_started", "message": "Синхронизация запущена в фоне"})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Простой хелсчек
|
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{"status": "ok", "time": time.Now().Format(time.RFC3339)})
|
||||||
"status": "ok",
|
|
||||||
"time": time.Now().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.Log.Info("Сервер запускается", zap.String("port", cfg.App.Port))
|
logger.Log.Info("Сервер запускается", zap.String("port", cfg.App.Port))
|
||||||
@@ -195,107 +161,3 @@ func main() {
|
|||||||
logger.Log.Fatal("Ошибка запуска сервера", zap.Error(err))
|
logger.Log.Fatal("Ошибка запуска сервера", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runManualInvoiceTest создает тестовую накладную и отправляет её в RMS
|
|
||||||
func runManualInvoiceTest(client rms.ClientI, catRepo catalog.Repository) {
|
|
||||||
logger.Log.Info(">>> [TEST] Начало теста создания накладной...")
|
|
||||||
|
|
||||||
// === НАСТРОЙКИ ТЕСТА ===
|
|
||||||
const (
|
|
||||||
MyStoreGUID = "1239d270-1bbe-f64f-b7ea-5f00518ef508" // <-- ВАШ СКЛАД
|
|
||||||
MySupplierGUID = "780aa87e-2688-4f99-915b-7924ca392ac1" // <-- ВАШ ПОСТАВЩИК
|
|
||||||
MyProductGUID = "607a1e96-f539-45d2-8709-3919f94bdc3e" // <-- ВАШ ТОВАР (Опционально)
|
|
||||||
)
|
|
||||||
// =======================
|
|
||||||
|
|
||||||
// 1. Поиск товара
|
|
||||||
var targetProduct catalog.Product
|
|
||||||
var found bool
|
|
||||||
|
|
||||||
// Если задан конкретный ID товара в константе - ищем его
|
|
||||||
if MyProductGUID != "00000000-0000-0000-0000-000000000000" {
|
|
||||||
products, _ := catRepo.GetAll() // В реальном коде лучше GetByID, но у нас repo пока простой
|
|
||||||
targetUUID := uuid.MustParse(MyProductGUID)
|
|
||||||
for _, p := range products {
|
|
||||||
if p.ID == targetUUID {
|
|
||||||
targetProduct = p
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
logger.Log.Error("[TEST] Товар с указанным MyProductGUID не найден в локальной БД")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Иначе ищем первый попавшийся GOODS
|
|
||||||
products, err := catRepo.GetAll()
|
|
||||||
if err != nil || len(products) == 0 {
|
|
||||||
logger.Log.Error("[TEST] БД пуста. Выполните SyncCatalog.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range products {
|
|
||||||
// Ищем именно товар (GOODS) или заготовку (PREPARED), но не группу и не блюдо, если нужно
|
|
||||||
if p.Type == "GOODS" {
|
|
||||||
targetProduct = p
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
logger.Log.Warn("[TEST] Не найдено товаров с типом GOODS. Берем первый попавшийся.")
|
|
||||||
targetProduct = products[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Log.Info("[TEST] Используем товар",
|
|
||||||
zap.String("name", targetProduct.Name),
|
|
||||||
zap.String("type", targetProduct.Type),
|
|
||||||
zap.String("uuid", targetProduct.ID.String()),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 2. Парсинг ID Склада и Поставщика
|
|
||||||
storeID := uuid.Nil
|
|
||||||
if MyStoreGUID != "00000000-0000-0000-0000-000000000000" {
|
|
||||||
storeID = uuid.MustParse(MyStoreGUID)
|
|
||||||
} else {
|
|
||||||
logger.Log.Warn("[TEST] ID склада не задан! iiko вернет ошибку валидации.")
|
|
||||||
storeID = uuid.New() // Рандом, чтобы XML собрался
|
|
||||||
}
|
|
||||||
|
|
||||||
supplierID := uuid.Nil
|
|
||||||
if MySupplierGUID != "00000000-0000-0000-0000-000000000000" {
|
|
||||||
supplierID = uuid.MustParse(MySupplierGUID)
|
|
||||||
} else {
|
|
||||||
logger.Log.Warn("[TEST] ID поставщика не задан!")
|
|
||||||
supplierID = uuid.New()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Формируем накладную
|
|
||||||
testInv := invoices.Invoice{
|
|
||||||
ID: uuid.Nil,
|
|
||||||
DocumentNumber: fmt.Sprintf("TEST-%d", time.Now().Unix()%10000),
|
|
||||||
DateIncoming: time.Now(),
|
|
||||||
SupplierID: supplierID,
|
|
||||||
DefaultStoreID: storeID,
|
|
||||||
Status: "NEW",
|
|
||||||
Items: []invoices.InvoiceItem{
|
|
||||||
{
|
|
||||||
ProductID: targetProduct.ID,
|
|
||||||
Amount: decimal.NewFromFloat(5.0),
|
|
||||||
Price: decimal.NewFromFloat(100.0),
|
|
||||||
Sum: decimal.NewFromFloat(500.0),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Отправляем
|
|
||||||
logger.Log.Info("[TEST] Отправка запроса в RMS...")
|
|
||||||
docNum, err := client.CreateIncomingInvoice(testInv)
|
|
||||||
if err != nil {
|
|
||||||
logger.Log.Error("[TEST] Ошибка отправки накладной", zap.Error(err))
|
|
||||||
} else {
|
|
||||||
logger.Log.Info("[TEST] УСПЕХ! Накладная создана.", zap.String("doc_number", docNum))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ rms:
|
|||||||
ocr:
|
ocr:
|
||||||
service_url: "http://ocr-service:5005"
|
service_url: "http://ocr-service:5005"
|
||||||
|
|
||||||
|
security:
|
||||||
|
secret_key: "mhrcadmin994525"
|
||||||
|
|
||||||
telegram:
|
telegram:
|
||||||
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
|
token: "7858843765:AAE5HBQHbef4fGLoMDV91bhHZQFcnsBDcv4"
|
||||||
admin_ids: [665599275]
|
admin_ids: [665599275]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Config struct {
|
|||||||
RMS RMSConfig
|
RMS RMSConfig
|
||||||
OCR OCRConfig
|
OCR OCRConfig
|
||||||
Telegram TelegramConfig
|
Telegram TelegramConfig
|
||||||
|
Security SecurityConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
@@ -31,23 +32,26 @@ type RedisConfig struct {
|
|||||||
Password string `mapstructure:"password"`
|
Password string `mapstructure:"password"`
|
||||||
DB int `mapstructure:"db"`
|
DB int `mapstructure:"db"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OCRConfig struct {
|
|
||||||
ServiceURL string `mapstructure:"service_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RMSConfig struct {
|
type RMSConfig struct {
|
||||||
BaseURL string `mapstructure:"base_url"`
|
BaseURL string `mapstructure:"base_url"`
|
||||||
Login string `mapstructure:"login"`
|
Login string `mapstructure:"login"`
|
||||||
Password string `mapstructure:"password"` // Исходный пароль, хеширование будет в клиенте
|
Password string `mapstructure:"password"` // Исходный пароль, хеширование будет в клиенте
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OCRConfig struct {
|
||||||
|
ServiceURL string `mapstructure:"service_url"`
|
||||||
|
}
|
||||||
|
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
Token string `mapstructure:"token"`
|
Token string `mapstructure:"token"`
|
||||||
AdminIDs []int64 `mapstructure:"admin_ids"`
|
AdminIDs []int64 `mapstructure:"admin_ids"`
|
||||||
WebAppURL string `mapstructure:"web_app_url"`
|
WebAppURL string `mapstructure:"web_app_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SecurityConfig struct {
|
||||||
|
SecretKey string `mapstructure:"secret_key"` // 32 bytes for AES-256
|
||||||
|
}
|
||||||
|
|
||||||
// LoadConfig загружает конфигурацию из файла и переменных окружения
|
// LoadConfig загружает конфигурацию из файла и переменных окружения
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
viper.AddConfigPath(path)
|
viper.AddConfigPath(path)
|
||||||
|
|||||||
63
internal/domain/account/entity.go
Normal file
63
internal/domain/account/entity.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User - Пользователь системы (Telegram аккаунт)
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
|
TelegramID int64 `gorm:"uniqueIndex;not null" json:"telegram_id"`
|
||||||
|
Username string `gorm:"type:varchar(100)" json:"username"`
|
||||||
|
FirstName string `gorm:"type:varchar(100)" json:"first_name"`
|
||||||
|
LastName string `gorm:"type:varchar(100)" json:"last_name"`
|
||||||
|
PhotoURL string `gorm:"type:text" json:"photo_url"`
|
||||||
|
|
||||||
|
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||||
|
|
||||||
|
// Связь с серверами
|
||||||
|
Servers []RMSServer `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"servers,omitempty"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RMSServer - Настройки подключения к iikoRMS
|
||||||
|
type RMSServer struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||||
|
|
||||||
|
Name string `gorm:"type:varchar(100);not null" json:"name"` // Название (напр. "Ресторан на Ленина")
|
||||||
|
|
||||||
|
// Credentials
|
||||||
|
BaseURL string `gorm:"type:varchar(255);not null" json:"base_url"`
|
||||||
|
Login string `gorm:"type:varchar(100);not null" json:"login"`
|
||||||
|
EncryptedPassword string `gorm:"type:text;not null" json:"-"` // Пароль храним зашифрованным
|
||||||
|
|
||||||
|
// Billing / Stats
|
||||||
|
InvoiceCount int `gorm:"default:0" json:"invoice_count"` // Счетчик успешно отправленных накладных
|
||||||
|
|
||||||
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repository интерфейс управления аккаунтами
|
||||||
|
type Repository interface {
|
||||||
|
// Users
|
||||||
|
GetOrCreateUser(telegramID int64, username, first, last string) (*User, error)
|
||||||
|
GetUserByTelegramID(telegramID int64) (*User, error)
|
||||||
|
|
||||||
|
// Servers
|
||||||
|
SaveServer(server *RMSServer) error
|
||||||
|
SetActiveServer(userID, serverID uuid.UUID) error
|
||||||
|
GetActiveServer(userID uuid.UUID) (*RMSServer, error) // Получить активный (первый попавшийся или помеченный)
|
||||||
|
GetAllServers(userID uuid.UUID) ([]RMSServer, error)
|
||||||
|
DeleteServer(serverID uuid.UUID) error
|
||||||
|
|
||||||
|
// Billing
|
||||||
|
IncrementInvoiceCount(serverID uuid.UUID) error
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
// MeasureUnit - Единица измерения (kg, l, pcs)
|
// MeasureUnit - Единица измерения (kg, l, pcs)
|
||||||
type MeasureUnit struct {
|
type MeasureUnit struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"`
|
||||||
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
Name string `gorm:"type:varchar(50);not null" json:"name"`
|
||||||
Code string `gorm:"type:varchar(50)" json:"code"`
|
Code string `gorm:"type:varchar(50)" json:"code"`
|
||||||
}
|
}
|
||||||
@@ -17,6 +18,7 @@ type MeasureUnit struct {
|
|||||||
// ProductContainer - Фасовка (упаковка) товара
|
// ProductContainer - Фасовка (упаковка) товара
|
||||||
type ProductContainer struct {
|
type ProductContainer struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"`
|
||||||
ProductID uuid.UUID `gorm:"type:uuid;index;not null" json:"product_id"`
|
ProductID uuid.UUID `gorm:"type:uuid;index;not null" json:"product_id"`
|
||||||
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
||||||
Count decimal.Decimal `gorm:"type:numeric(19,4);not null" json:"count"` // Коэфф. пересчета
|
Count decimal.Decimal `gorm:"type:numeric(19,4);not null" json:"count"` // Коэфф. пересчета
|
||||||
@@ -25,6 +27,7 @@ type ProductContainer struct {
|
|||||||
// Product - Номенклатура
|
// Product - Номенклатура
|
||||||
type Product struct {
|
type Product struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"`
|
||||||
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||||
Name string `gorm:"type:varchar(255);not null" json:"name"`
|
Name string `gorm:"type:varchar(255);not null" json:"name"`
|
||||||
Type string `gorm:"type:varchar(50);index" json:"type"` // GOODS, DISH, PREPARED
|
Type string `gorm:"type:varchar(50);index" json:"type"` // GOODS, DISH, PREPARED
|
||||||
@@ -53,11 +56,14 @@ type Product struct {
|
|||||||
type Repository interface {
|
type Repository interface {
|
||||||
SaveMeasureUnits(units []MeasureUnit) error
|
SaveMeasureUnits(units []MeasureUnit) error
|
||||||
SaveProducts(products []Product) error
|
SaveProducts(products []Product) error
|
||||||
SaveContainer(container ProductContainer) error // Добавление фасовки
|
SaveContainer(container ProductContainer) error
|
||||||
Search(query string) ([]Product, error)
|
|
||||||
GetAll() ([]Product, error)
|
Search(serverID uuid.UUID, query string) ([]Product, error)
|
||||||
GetActiveGoods() ([]Product, error)
|
GetActiveGoods(serverID uuid.UUID) ([]Product, error)
|
||||||
// --- Stores ---
|
|
||||||
SaveStores(stores []Store) error
|
SaveStores(stores []Store) error
|
||||||
GetActiveStores() ([]Store, error)
|
GetActiveStores(serverID uuid.UUID) ([]Store, error)
|
||||||
|
|
||||||
|
CountGoods(serverID uuid.UUID) (int64, error)
|
||||||
|
CountStores(serverID uuid.UUID) (int64, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store - Склад (в терминологии iiko: Entity с типом Account и подтипом INVENTORY_ASSETS)
|
// Store - Склад
|
||||||
type Store struct {
|
type Store struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"`
|
||||||
|
|
||||||
Name string `gorm:"type:varchar(255);not null" json:"name"`
|
Name string `gorm:"type:varchar(255);not null" json:"name"`
|
||||||
ParentCorporateID uuid.UUID `gorm:"type:uuid;index" json:"parent_corporate_id"` // ID юр.лица/торгового предприятия
|
ParentCorporateID uuid.UUID `gorm:"type:uuid;index" json:"parent_corporate_id"`
|
||||||
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
|
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -9,30 +9,30 @@ import (
|
|||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Статусы черновика
|
|
||||||
const (
|
const (
|
||||||
StatusProcessing = "PROCESSING" // OCR в процессе
|
StatusProcessing = "PROCESSING"
|
||||||
StatusReadyToVerify = "READY_TO_VERIFY" // Распознано, ждет проверки пользователем
|
StatusReadyToVerify = "READY_TO_VERIFY"
|
||||||
StatusCompleted = "COMPLETED" // Отправлено в RMS
|
StatusCompleted = "COMPLETED"
|
||||||
StatusError = "ERROR" // Ошибка обработки
|
StatusError = "ERROR"
|
||||||
StatusCanceled = "CANCELED" // Пользователь отменил
|
StatusCanceled = "CANCELED"
|
||||||
StatusDeleted = "DELETED" // Пользователь удалил
|
StatusDeleted = "DELETED"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DraftInvoice - Черновик накладной
|
|
||||||
type DraftInvoice struct {
|
type DraftInvoice struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
ChatID int64 `gorm:"index" json:"chat_id"` // ID чата в Telegram (кто прислал)
|
|
||||||
SenderPhotoURL string `gorm:"type:text" json:"photo_url"` // Ссылка на фото
|
// Привязка к аккаунту и серверу
|
||||||
|
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"rms_server_id"`
|
||||||
|
|
||||||
|
SenderPhotoURL string `gorm:"type:text" json:"photo_url"`
|
||||||
Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"`
|
Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"`
|
||||||
|
|
||||||
// Данные для отправки в RMS
|
|
||||||
DocumentNumber string `gorm:"type:varchar(100)" json:"document_number"`
|
DocumentNumber string `gorm:"type:varchar(100)" json:"document_number"`
|
||||||
DateIncoming *time.Time `json:"date_incoming"`
|
DateIncoming *time.Time `json:"date_incoming"`
|
||||||
SupplierID *uuid.UUID `gorm:"type:uuid" json:"supplier_id"`
|
SupplierID *uuid.UUID `gorm:"type:uuid" json:"supplier_id"`
|
||||||
|
|
||||||
StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"`
|
StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"`
|
||||||
// Связь со складом для Preload
|
|
||||||
Store *catalog.Store `gorm:"foreignKey:StoreID" json:"store,omitempty"`
|
Store *catalog.Store `gorm:"foreignKey:StoreID" json:"store,omitempty"`
|
||||||
|
|
||||||
Comment string `gorm:"type:text" json:"comment"`
|
Comment string `gorm:"type:text" json:"comment"`
|
||||||
@@ -44,38 +44,32 @@ type DraftInvoice struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DraftInvoiceItem - Позиция черновика
|
|
||||||
type DraftInvoiceItem struct {
|
type DraftInvoiceItem struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||||
DraftID uuid.UUID `gorm:"type:uuid;not null;index" json:"draft_id"`
|
DraftID uuid.UUID `gorm:"type:uuid;not null;index" json:"draft_id"`
|
||||||
|
|
||||||
// --- Результаты OCR (Исходные данные) ---
|
RawName string `gorm:"type:varchar(255);not null" json:"raw_name"`
|
||||||
RawName string `gorm:"type:varchar(255);not null" json:"raw_name"` // Текст с чека
|
RawAmount decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_amount"`
|
||||||
RawAmount decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_amount"` // Кол-во, которое увидел OCR
|
RawPrice decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_price"`
|
||||||
RawPrice decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_price"` // Цена, которую увидел OCR
|
|
||||||
|
|
||||||
// --- Результат Матчинга и Выбора пользователя ---
|
|
||||||
ProductID *uuid.UUID `gorm:"type:uuid;index" json:"product_id"`
|
ProductID *uuid.UUID `gorm:"type:uuid;index" json:"product_id"`
|
||||||
Product *catalog.Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
Product *catalog.Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||||
ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"`
|
ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"`
|
||||||
Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"`
|
Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"`
|
||||||
|
|
||||||
// Финальные цифры, которые пойдут в накладную
|
|
||||||
Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"quantity"`
|
Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"quantity"`
|
||||||
Price decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"price"`
|
Price decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"price"`
|
||||||
Sum decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"sum"`
|
Sum decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"sum"`
|
||||||
|
|
||||||
IsMatched bool `gorm:"default:false" json:"is_matched"` // Удалось ли системе найти пару автоматически
|
IsMatched bool `gorm:"default:false" json:"is_matched"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repository интерфейс
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
Create(draft *DraftInvoice) error
|
Create(draft *DraftInvoice) error
|
||||||
GetByID(id uuid.UUID) (*DraftInvoice, error)
|
GetByID(id uuid.UUID) (*DraftInvoice, error)
|
||||||
Update(draft *DraftInvoice) error
|
Update(draft *DraftInvoice) error
|
||||||
CreateItems(items []DraftInvoiceItem) error
|
CreateItems(items []DraftInvoiceItem) error
|
||||||
// UpdateItem обновляет конкретную строку (например, при ручном выборе товара)
|
|
||||||
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
|
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
|
||||||
Delete(id uuid.UUID) error
|
Delete(id uuid.UUID) error
|
||||||
GetActive() ([]DraftInvoice, error)
|
GetActive(userID uuid.UUID) ([]DraftInvoice, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
// Invoice - Приходная накладная
|
// Invoice - Приходная накладная
|
||||||
type Invoice struct {
|
type Invoice struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||||
DocumentNumber string `gorm:"type:varchar(100);index"`
|
DocumentNumber string `gorm:"type:varchar(100);index"`
|
||||||
DateIncoming time.Time `gorm:"index"`
|
DateIncoming time.Time `gorm:"index"`
|
||||||
SupplierID uuid.UUID `gorm:"type:uuid;index"`
|
SupplierID uuid.UUID `gorm:"type:uuid;index"`
|
||||||
@@ -39,6 +40,7 @@ type InvoiceItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
GetLastInvoiceDate() (*time.Time, error)
|
GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error)
|
||||||
SaveInvoices(invoices []Invoice) error
|
SaveInvoices(invoices []Invoice) error
|
||||||
|
CountRecent(serverID uuid.UUID, days int) (int64, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,38 +9,45 @@ import (
|
|||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProductMatch связывает текст из чека с конкретным товаром в iiko
|
// ProductMatch
|
||||||
type ProductMatch struct {
|
type ProductMatch struct {
|
||||||
RawName string `gorm:"type:varchar(255);primary_key" json:"raw_name"`
|
// RawName больше не PrimaryKey, так как в разных серверах один текст может значить разное
|
||||||
|
// Делаем составной ключ или суррогатный ID.
|
||||||
|
// Для простоты GORM: ID - uuid, а уникальность через индекс (RawName + ServerID)
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index:idx_raw_server,unique"`
|
||||||
|
|
||||||
|
RawName string `gorm:"type:varchar(255);not null;index:idx_raw_server,unique" json:"raw_name"`
|
||||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
|
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
|
||||||
Product catalog.Product `gorm:"foreignKey:ProductID" json:"product"`
|
Product catalog.Product `gorm:"foreignKey:ProductID" json:"product"`
|
||||||
|
|
||||||
// Количество и фасовки
|
|
||||||
Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:1" json:"quantity"`
|
Quantity decimal.Decimal `gorm:"type:numeric(19,4);default:1" json:"quantity"`
|
||||||
ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"`
|
ContainerID *uuid.UUID `gorm:"type:uuid;index" json:"container_id"`
|
||||||
|
|
||||||
// Для подгрузки данных о фасовке при чтении
|
|
||||||
Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"`
|
Container *catalog.ProductContainer `gorm:"foreignKey:ContainerID" json:"container,omitempty"`
|
||||||
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmatchedItem хранит строки, которые не удалось распознать, для подсказок
|
// UnmatchedItem тоже стоит делить, чтобы подсказывать пользователю только его нераспознанные,
|
||||||
|
// хотя глобальная база unmatched может быть полезна для аналитики.
|
||||||
|
// Сделаем раздельной для чистоты SaaS.
|
||||||
type UnmatchedItem struct {
|
type UnmatchedItem struct {
|
||||||
RawName string `gorm:"type:varchar(255);primary_key" json:"raw_name"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||||
Count int `gorm:"default:1" json:"count"` // Сколько раз встречалось
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index:idx_unm_raw_server,unique"`
|
||||||
|
|
||||||
|
RawName string `gorm:"type:varchar(255);not null;index:idx_unm_raw_server,unique" json:"raw_name"`
|
||||||
|
Count int `gorm:"default:1" json:"count"`
|
||||||
LastSeen time.Time `json:"last_seen"`
|
LastSeen time.Time `json:"last_seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
// SaveMatch теперь принимает quantity и containerID
|
SaveMatch(serverID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error
|
||||||
SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error
|
DeleteMatch(serverID uuid.UUID, rawName string) error
|
||||||
DeleteMatch(rawName string) error
|
FindMatch(serverID uuid.UUID, rawName string) (*ProductMatch, error)
|
||||||
FindMatch(rawName string) (*ProductMatch, error) // Возвращаем полную структуру, чтобы получить qty
|
GetAllMatches(serverID uuid.UUID) ([]ProductMatch, error)
|
||||||
GetAllMatches() ([]ProductMatch, error)
|
|
||||||
|
|
||||||
UpsertUnmatched(rawName string) error
|
UpsertUnmatched(serverID uuid.UUID, rawName string) error
|
||||||
GetTopUnmatched(limit int) ([]UnmatchedItem, error)
|
GetTopUnmatched(serverID uuid.UUID, limit int) ([]UnmatchedItem, error)
|
||||||
DeleteUnmatched(rawName string) error
|
DeleteUnmatched(serverID uuid.UUID, rawName string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const (
|
|||||||
// StoreOperation - запись из складского отчета
|
// StoreOperation - запись из складского отчета
|
||||||
type StoreOperation struct {
|
type StoreOperation struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||||
|
|
||||||
// Наш внутренний, "очищенный" тип операции
|
// Наш внутренний, "очищенный" тип операции
|
||||||
@@ -44,5 +45,5 @@ type StoreOperation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
SaveOperations(ops []StoreOperation, opType OperationType, dateFrom, dateTo time.Time) error
|
SaveOperations(ops []StoreOperation, serverID uuid.UUID, opType OperationType, dateFrom, dateTo time.Time) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
// Recipe - Технологическая карта
|
// Recipe - Технологическая карта
|
||||||
type Recipe struct {
|
type Recipe struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||||
DateFrom time.Time `gorm:"index"`
|
DateFrom time.Time `gorm:"index"`
|
||||||
DateTo *time.Time
|
DateTo *time.Time
|
||||||
@@ -23,6 +24,7 @@ type Recipe struct {
|
|||||||
// RecipeItem - Ингредиент
|
// RecipeItem - Ингредиент
|
||||||
type RecipeItem struct {
|
type RecipeItem struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||||
RecipeID uuid.UUID `gorm:"type:uuid;not null;index"`
|
RecipeID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
ProductID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||||
AmountIn decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
AmountIn decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||||
|
|||||||
29
internal/domain/suppliers/entity.go
Normal file
29
internal/domain/suppliers/entity.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package suppliers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Supplier - Поставщик (Контрагент)
|
||||||
|
type Supplier struct {
|
||||||
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
|
Name string `gorm:"type:varchar(255);not null" json:"name"`
|
||||||
|
Code string `gorm:"type:varchar(50)" json:"code"`
|
||||||
|
INN string `gorm:"type:varchar(20)" json:"inn"` // taxpayerIdNumber
|
||||||
|
|
||||||
|
// Привязка к конкретному серверу iiko (Multi-tenant)
|
||||||
|
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"-"`
|
||||||
|
|
||||||
|
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
|
||||||
|
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
SaveBatch(suppliers []Supplier) error
|
||||||
|
// GetRankedByUsage возвращает поставщиков, отсортированных по частоте использования в накладных за N дней
|
||||||
|
GetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]Supplier, error)
|
||||||
|
Count(serverID uuid.UUID) (int64, error)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/domain/catalog"
|
"rmser/internal/domain/catalog"
|
||||||
"rmser/internal/domain/drafts"
|
"rmser/internal/domain/drafts"
|
||||||
"rmser/internal/domain/invoices"
|
"rmser/internal/domain/invoices"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"rmser/internal/domain/operations"
|
"rmser/internal/domain/operations"
|
||||||
"rmser/internal/domain/recipes"
|
"rmser/internal/domain/recipes"
|
||||||
"rmser/internal/domain/recommendations"
|
"rmser/internal/domain/recommendations"
|
||||||
|
"rmser/internal/domain/suppliers"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
@@ -46,10 +48,13 @@ func NewPostgresDB(dsn string) *gorm.DB {
|
|||||||
|
|
||||||
// 4. Автомиграция
|
// 4. Автомиграция
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
|
&account.User{},
|
||||||
|
&account.RMSServer{},
|
||||||
&catalog.Product{},
|
&catalog.Product{},
|
||||||
&catalog.MeasureUnit{},
|
&catalog.MeasureUnit{},
|
||||||
&catalog.ProductContainer{},
|
&catalog.ProductContainer{},
|
||||||
&catalog.Store{},
|
&catalog.Store{},
|
||||||
|
&suppliers.Supplier{},
|
||||||
&recipes.Recipe{},
|
&recipes.Recipe{},
|
||||||
&recipes.RecipeItem{},
|
&recipes.RecipeItem{},
|
||||||
&invoices.Invoice{},
|
&invoices.Invoice{},
|
||||||
|
|||||||
117
internal/infrastructure/repository/account/postgres.go
Normal file
117
internal/infrastructure/repository/account/postgres.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"rmser/internal/domain/account"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pgRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(db *gorm.DB) account.Repository {
|
||||||
|
return &pgRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateUser находит пользователя или создает нового
|
||||||
|
func (r *pgRepository) GetOrCreateUser(telegramID int64, username, first, last string) (*account.User, error) {
|
||||||
|
var user account.User
|
||||||
|
err := r.db.Where("telegram_id = ?", telegramID).First(&user).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
// Создаем
|
||||||
|
newUser := account.User{
|
||||||
|
TelegramID: telegramID,
|
||||||
|
Username: username,
|
||||||
|
FirstName: first,
|
||||||
|
LastName: last,
|
||||||
|
}
|
||||||
|
if err := r.db.Create(&newUser).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &newUser, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем инфо, если изменилось (опционально)
|
||||||
|
if user.Username != username || user.FirstName != first {
|
||||||
|
user.Username = username
|
||||||
|
user.FirstName = first
|
||||||
|
user.LastName = last
|
||||||
|
r.db.Save(&user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetUserByTelegramID(telegramID int64) (*account.User, error) {
|
||||||
|
var user account.User
|
||||||
|
// Preload Servers чтобы сразу видеть подключения
|
||||||
|
err := r.db.Preload("Servers").Where("telegram_id = ?", telegramID).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) SaveServer(server *account.RMSServer) error {
|
||||||
|
return r.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
|
UpdateAll: true,
|
||||||
|
}).Create(server).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveServer делает указанный сервер активным, а остальные — неактивными
|
||||||
|
func (r *pgRepository) SetActiveServer(userID, serverID uuid.UUID) error {
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 1. Сбрасываем флаг у всех серверов пользователя
|
||||||
|
if err := tx.Model(&account.RMSServer{}).
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Update("is_active", false).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Ставим флаг целевому серверу
|
||||||
|
if err := tx.Model(&account.RMSServer{}).
|
||||||
|
Where("id = ? AND user_id = ?", serverID, userID).
|
||||||
|
Update("is_active", true).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, error) {
|
||||||
|
var server account.RMSServer
|
||||||
|
// Берем первый активный сервер. В будущем можно добавить поле IsSelected
|
||||||
|
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).First(&server).Error
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil // Нет серверов
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetAllServers(userID uuid.UUID) ([]account.RMSServer, error) {
|
||||||
|
var servers []account.RMSServer
|
||||||
|
err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Find(&servers).Error
|
||||||
|
return servers, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) DeleteServer(serverID uuid.UUID) error {
|
||||||
|
return r.db.Delete(&account.RMSServer{}, serverID).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) IncrementInvoiceCount(serverID uuid.UUID) error {
|
||||||
|
return r.db.Model(&account.RMSServer{}).
|
||||||
|
Where("id = ?", serverID).
|
||||||
|
UpdateColumn("invoice_count", gorm.Expr("invoice_count + ?", 1)).Error
|
||||||
|
}
|
||||||
@@ -16,12 +16,17 @@ func NewRepository(db *gorm.DB) catalog.Repository {
|
|||||||
return &pgRepository{db: db}
|
return &pgRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Запись (Save) ---
|
||||||
|
// При сохранении мы предполагаем, что serverID уже проставлен в Entity в слое Service.
|
||||||
|
// Но для надежности можно передавать serverID в метод Save, однако Service должен это контролировать.
|
||||||
|
// Оставим контракт Save(products []Product), где внутри products уже заполнен RMSServerID.
|
||||||
|
|
||||||
func (r *pgRepository) SaveMeasureUnits(units []catalog.MeasureUnit) error {
|
func (r *pgRepository) SaveMeasureUnits(units []catalog.MeasureUnit) error {
|
||||||
if len(units) == 0 {
|
if len(units) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return r.db.Clauses(clause.OnConflict{
|
return r.db.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}}, // ID глобально уникален (UUID), конфликтов между серверами не будет
|
||||||
UpdateAll: true,
|
UpdateAll: true,
|
||||||
}).CreateInBatches(units, 100).Error
|
}).CreateInBatches(units, 100).Error
|
||||||
}
|
}
|
||||||
@@ -29,7 +34,7 @@ func (r *pgRepository) SaveMeasureUnits(units []catalog.MeasureUnit) error {
|
|||||||
func (r *pgRepository) SaveProducts(products []catalog.Product) error {
|
func (r *pgRepository) SaveProducts(products []catalog.Product) error {
|
||||||
sorted := sortProductsByHierarchy(products)
|
sorted := sortProductsByHierarchy(products)
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
// 1. Сохраняем продукты (без контейнеров, чтобы ускорить и не дублировать)
|
// 1. Продукты
|
||||||
if err := tx.Omit("Containers").Clauses(clause.OnConflict{
|
if err := tx.Omit("Containers").Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
UpdateAll: true,
|
UpdateAll: true,
|
||||||
@@ -37,13 +42,12 @@ func (r *pgRepository) SaveProducts(products []catalog.Product) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Собираем все контейнеры в один слайс
|
// 2. Контейнеры
|
||||||
var allContainers []catalog.ProductContainer
|
var allContainers []catalog.ProductContainer
|
||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
allContainers = append(allContainers, p.Containers...)
|
allContainers = append(allContainers, p.Containers...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Сохраняем контейнеры
|
|
||||||
if len(allContainers) > 0 {
|
if len(allContainers) > 0 {
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
@@ -56,13 +60,66 @@ func (r *pgRepository) SaveProducts(products []catalog.Product) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) SaveContainer(container catalog.ProductContainer) error {
|
||||||
|
return r.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
|
UpdateAll: true,
|
||||||
|
}).Create(&container).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) SaveStores(stores []catalog.Store) error {
|
||||||
|
if len(stores) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
|
UpdateAll: true,
|
||||||
|
}).CreateInBatches(stores, 100).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Чтение (Read) с фильтрацией по ServerID ---
|
||||||
|
|
||||||
func (r *pgRepository) GetAll() ([]catalog.Product, error) {
|
func (r *pgRepository) GetAll() ([]catalog.Product, error) {
|
||||||
|
// Этот метод был legacy и грузил всё. Теперь он опасен без serverID.
|
||||||
|
// Оставляем заглушку или удаляем. Лучше удалить из интерфейса, но пока вернем пустой список
|
||||||
|
// чтобы не ломать сборку, пока не почистим вызовы.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetActiveGoods(serverID uuid.UUID) ([]catalog.Product, error) {
|
||||||
var products []catalog.Product
|
var products []catalog.Product
|
||||||
err := r.db.Preload("MainUnit").Find(&products).Error
|
err := r.db.
|
||||||
|
Preload("MainUnit").
|
||||||
|
Preload("Containers").
|
||||||
|
Where("rms_server_id = ? AND is_deleted = ? AND type IN ?", serverID, false, []string{"GOODS"}).
|
||||||
|
Order("name ASC").
|
||||||
|
Find(&products).Error
|
||||||
return products, err
|
return products, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вспомогательная функция сортировки (оставляем как была)
|
func (r *pgRepository) GetActiveStores(serverID uuid.UUID) ([]catalog.Store, error) {
|
||||||
|
var stores []catalog.Store
|
||||||
|
err := r.db.Where("rms_server_id = ? AND is_deleted = ?", serverID, false).Order("name ASC").Find(&stores).Error
|
||||||
|
return stores, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) Search(serverID uuid.UUID, query string) ([]catalog.Product, error) {
|
||||||
|
var products []catalog.Product
|
||||||
|
q := "%" + query + "%"
|
||||||
|
|
||||||
|
err := r.db.
|
||||||
|
Preload("MainUnit").
|
||||||
|
Preload("Containers").
|
||||||
|
Where("rms_server_id = ? AND is_deleted = ? AND type = ?", serverID, false, "GOODS").
|
||||||
|
Where("name ILIKE ? OR code ILIKE ? OR num ILIKE ?", q, q, q).
|
||||||
|
Order("name ASC").
|
||||||
|
Limit(20).
|
||||||
|
Find(&products).Error
|
||||||
|
|
||||||
|
return products, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortProductsByHierarchy - вспомогательная функция, оставляем как есть (копипаст из старого файла)
|
||||||
func sortProductsByHierarchy(products []catalog.Product) []catalog.Product {
|
func sortProductsByHierarchy(products []catalog.Product) []catalog.Product {
|
||||||
if len(products) == 0 {
|
if len(products) == 0 {
|
||||||
return products
|
return products
|
||||||
@@ -104,58 +161,18 @@ func sortProductsByHierarchy(products []catalog.Product) []catalog.Product {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveGoods возвращает только активные товары c подгруженной единицей измерения
|
func (r *pgRepository) CountGoods(serverID uuid.UUID) (int64, error) {
|
||||||
// GetActiveGoods оптимизирован: подгружаем Units и Containers
|
var count int64
|
||||||
func (r *pgRepository) GetActiveGoods() ([]catalog.Product, error) {
|
err := r.db.Model(&catalog.Product{}).
|
||||||
var products []catalog.Product
|
Where("rms_server_id = ? AND type IN ? AND is_deleted = ?", serverID, []string{"GOODS"}, false).
|
||||||
err := r.db.
|
Count(&count).Error
|
||||||
Preload("MainUnit").
|
return count, err
|
||||||
Preload("Containers"). // <-- Подгружаем фасовки
|
|
||||||
Where("is_deleted = ? AND type IN ?", false, []string{"GOODS"}).
|
|
||||||
Order("name ASC").
|
|
||||||
Find(&products).Error
|
|
||||||
return products, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) SaveStores(stores []catalog.Store) error {
|
func (r *pgRepository) CountStores(serverID uuid.UUID) (int64, error) {
|
||||||
if len(stores) == 0 {
|
var count int64
|
||||||
return nil
|
err := r.db.Model(&catalog.Store{}).
|
||||||
}
|
Where("rms_server_id = ? AND is_deleted = ?", serverID, false).
|
||||||
return r.db.Clauses(clause.OnConflict{
|
Count(&count).Error
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
return count, err
|
||||||
UpdateAll: true,
|
|
||||||
}).CreateInBatches(stores, 100).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *pgRepository) GetActiveStores() ([]catalog.Store, error) {
|
|
||||||
var stores []catalog.Store
|
|
||||||
err := r.db.Where("is_deleted = ?", false).Order("name ASC").Find(&stores).Error
|
|
||||||
return stores, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveContainer сохраняет или обновляет одну фасовку
|
|
||||||
func (r *pgRepository) SaveContainer(container catalog.ProductContainer) error {
|
|
||||||
return r.db.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
|
||||||
UpdateAll: true,
|
|
||||||
}).Create(&container).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search ищет товары по названию, артикулу или коду (ILIKE)
|
|
||||||
func (r *pgRepository) Search(query string) ([]catalog.Product, error) {
|
|
||||||
var products []catalog.Product
|
|
||||||
|
|
||||||
// Оборачиваем в проценты для поиска подстроки
|
|
||||||
q := "%" + query + "%"
|
|
||||||
|
|
||||||
err := r.db.
|
|
||||||
Preload("MainUnit").
|
|
||||||
Preload("Containers"). // Обязательно грузим фасовки, они нужны для выбора
|
|
||||||
Where("is_deleted = ? AND type = ?", false, "GOODS").
|
|
||||||
Where("name ILIKE ? OR code ILIKE ? OR num ILIKE ?", q, q, q).
|
|
||||||
Order("name ASC").
|
|
||||||
Limit(20). // Ограничиваем выдачу, чтобы не перегружать фронт
|
|
||||||
Find(&products).Error
|
|
||||||
|
|
||||||
return products, err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {
|
|||||||
return db.Order("draft_invoice_items.raw_name ASC")
|
return db.Order("draft_invoice_items.raw_name ASC")
|
||||||
}).
|
}).
|
||||||
Preload("Items.Product").
|
Preload("Items.Product").
|
||||||
Preload("Items.Product.MainUnit"). // Нужно для отображения единиц
|
Preload("Items.Product.MainUnit").
|
||||||
Preload("Items.Container").
|
Preload("Items.Container").
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
First(&draft).Error
|
First(&draft).Error
|
||||||
@@ -39,7 +39,7 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
|
func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
|
||||||
// Обновляем только основные поля шапки
|
// Обновляем поля шапки + привязки к серверу
|
||||||
return r.db.Model(draft).Updates(map[string]interface{}{
|
return r.db.Model(draft).Updates(map[string]interface{}{
|
||||||
"status": draft.Status,
|
"status": draft.Status,
|
||||||
"document_number": draft.DocumentNumber,
|
"document_number": draft.DocumentNumber,
|
||||||
@@ -48,6 +48,7 @@ func (r *pgRepository) Update(draft *drafts.DraftInvoice) error {
|
|||||||
"store_id": draft.StoreID,
|
"store_id": draft.StoreID,
|
||||||
"comment": draft.Comment,
|
"comment": draft.Comment,
|
||||||
"rms_invoice_id": draft.RMSInvoiceID,
|
"rms_invoice_id": draft.RMSInvoiceID,
|
||||||
|
"rms_server_id": draft.RMSServerID, // Вдруг поменялся, хотя не должен
|
||||||
"updated_at": gorm.Expr("NOW()"),
|
"updated_at": gorm.Expr("NOW()"),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
@@ -60,34 +61,29 @@ func (r *pgRepository) CreateItems(items []drafts.DraftInvoiceItem) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||||
// Пересчитываем сумму
|
|
||||||
sum := qty.Mul(price)
|
sum := qty.Mul(price)
|
||||||
|
|
||||||
// Определяем статус IsMatched: если productID задан - значит сматчено
|
|
||||||
isMatched := productID != nil
|
isMatched := productID != nil
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
return r.db.Model(&drafts.DraftInvoiceItem{}).
|
||||||
|
Where("id = ?", itemID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
"product_id": productID,
|
"product_id": productID,
|
||||||
"container_id": containerID,
|
"container_id": containerID,
|
||||||
"quantity": qty,
|
"quantity": qty,
|
||||||
"price": price,
|
"price": price,
|
||||||
"sum": sum,
|
"sum": sum,
|
||||||
"is_matched": isMatched,
|
"is_matched": isMatched,
|
||||||
}
|
}).Error
|
||||||
|
|
||||||
return r.db.Model(&drafts.DraftInvoiceItem{}).
|
|
||||||
Where("id = ?", itemID).
|
|
||||||
Updates(updates).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) Delete(id uuid.UUID) error {
|
func (r *pgRepository) Delete(id uuid.UUID) error {
|
||||||
return r.db.Delete(&drafts.DraftInvoice{}, id).Error
|
return r.db.Delete(&drafts.DraftInvoice{}, id).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) GetActive() ([]drafts.DraftInvoice, error) {
|
// GetActive фильтрует по UserID
|
||||||
|
func (r *pgRepository) GetActive(userID uuid.UUID) ([]drafts.DraftInvoice, error) {
|
||||||
var list []drafts.DraftInvoice
|
var list []drafts.DraftInvoice
|
||||||
|
|
||||||
// Выбираем статусы, которые считаем "активными"
|
|
||||||
activeStatuses := []string{
|
activeStatuses := []string{
|
||||||
drafts.StatusProcessing,
|
drafts.StatusProcessing,
|
||||||
drafts.StatusReadyToVerify,
|
drafts.StatusReadyToVerify,
|
||||||
@@ -96,9 +92,9 @@ func (r *pgRepository) GetActive() ([]drafts.DraftInvoice, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := r.db.
|
err := r.db.
|
||||||
Preload("Items"). // Нужны для подсчета суммы и количества
|
Preload("Items").
|
||||||
Preload("Store"). // Нужно для названия склада
|
Preload("Store").
|
||||||
Where("status IN ?", activeStatuses).
|
Where("user_id = ? AND status IN ?", userID, activeStatuses). // <-- FILTER
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
Find(&list).Error
|
Find(&list).Error
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"rmser/internal/domain/invoices"
|
"rmser/internal/domain/invoices"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
@@ -17,9 +18,10 @@ func NewRepository(db *gorm.DB) invoices.Repository {
|
|||||||
return &pgRepository{db: db}
|
return &pgRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) GetLastInvoiceDate() (*time.Time, error) {
|
func (r *pgRepository) GetLastInvoiceDate(serverID uuid.UUID) (*time.Time, error) {
|
||||||
var inv invoices.Invoice
|
var inv invoices.Invoice
|
||||||
err := r.db.Order("date_incoming DESC").First(&inv).Error
|
// Ищем последнюю накладную только для этого сервера
|
||||||
|
err := r.db.Where("rms_server_id = ?", serverID).Order("date_incoming DESC").First(&inv).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -38,6 +40,7 @@ func (r *pgRepository) SaveInvoices(list []invoices.Invoice) error {
|
|||||||
}).Create(&inv).Error; err != nil {
|
}).Create(&inv).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Удаляем старые Items для этой накладной
|
||||||
if err := tx.Where("invoice_id = ?", inv.ID).Delete(&invoices.InvoiceItem{}).Error; err != nil {
|
if err := tx.Where("invoice_id = ?", inv.ID).Delete(&invoices.InvoiceItem{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -50,3 +53,13 @@ func (r *pgRepository) SaveInvoices(list []invoices.Invoice) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) CountRecent(serverID uuid.UUID, days int) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
dateFrom := time.Now().AddDate(0, 0, -days)
|
||||||
|
|
||||||
|
err := r.db.Model(&invoices.Invoice{}).
|
||||||
|
Where("rms_server_id = ? AND date_incoming >= ?", serverID, dateFrom).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ func NewRepository(db *gorm.DB) ocr.Repository {
|
|||||||
return &pgRepository{db: db}
|
return &pgRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
func (r *pgRepository) SaveMatch(serverID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||||
|
|
||||||
match := ocr.ProductMatch{
|
match := ocr.ProductMatch{
|
||||||
|
RMSServerID: serverID,
|
||||||
RawName: normalized,
|
RawName: normalized,
|
||||||
ProductID: productID,
|
ProductID: productID,
|
||||||
Quantity: quantity,
|
Quantity: quantity,
|
||||||
@@ -31,31 +33,40 @@ func (r *pgRepository) SaveMatch(rawName string, productID uuid.UUID, quantity d
|
|||||||
}
|
}
|
||||||
|
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Используем OnConflict по составному индексу (raw_name, rms_server_id)
|
||||||
|
// Но GORM может потребовать названия ограничения.
|
||||||
|
// Проще сделать через Where().Assign().FirstOrCreate() или явно указать Columns если индекс есть.
|
||||||
|
// В Entity мы указали `uniqueIndex:idx_raw_server`.
|
||||||
|
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "raw_name"}},
|
// Указываем оба поля, входящие в unique index
|
||||||
|
Columns: []clause.Column{{Name: "raw_name"}, {Name: "rms_server_id"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"product_id", "quantity", "container_id", "updated_at"}),
|
DoUpdates: clause.AssignmentColumns([]string{"product_id", "quantity", "container_id", "updated_at"}),
|
||||||
}).Create(&match).Error; err != nil {
|
}).Create(&match).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Where("raw_name = ?", normalized).Delete(&ocr.UnmatchedItem{}).Error; err != nil {
|
// Удаляем из Unmatched для этого сервера
|
||||||
|
if err := tx.Where("rms_server_id = ? AND raw_name = ?", serverID, normalized).Delete(&ocr.UnmatchedItem{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) DeleteMatch(rawName string) error {
|
func (r *pgRepository) DeleteMatch(serverID uuid.UUID, rawName string) error {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||||
return r.db.Where("raw_name = ?", normalized).Delete(&ocr.ProductMatch{}).Error
|
return r.db.Where("rms_server_id = ? AND raw_name = ?", serverID, normalized).Delete(&ocr.ProductMatch{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) FindMatch(rawName string) (*ocr.ProductMatch, error) {
|
func (r *pgRepository) FindMatch(serverID uuid.UUID, rawName string) (*ocr.ProductMatch, error) {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||||
var match ocr.ProductMatch
|
var match ocr.ProductMatch
|
||||||
|
|
||||||
// Preload Container на случай, если нам сразу нужна инфа
|
err := r.db.Preload("Container").
|
||||||
err := r.db.Preload("Container").Where("raw_name = ?", normalized).First(&match).Error
|
Where("rms_server_id = ? AND raw_name = ?", serverID, normalized).
|
||||||
|
First(&match).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -65,35 +76,33 @@ func (r *pgRepository) FindMatch(rawName string) (*ocr.ProductMatch, error) {
|
|||||||
return &match, nil
|
return &match, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) GetAllMatches() ([]ocr.ProductMatch, error) {
|
func (r *pgRepository) GetAllMatches(serverID uuid.UUID) ([]ocr.ProductMatch, error) {
|
||||||
var matches []ocr.ProductMatch
|
var matches []ocr.ProductMatch
|
||||||
// Подгружаем Товар, Единицу и Фасовку
|
|
||||||
err := r.db.
|
err := r.db.
|
||||||
Preload("Product").
|
Preload("Product").
|
||||||
Preload("Product.MainUnit").
|
Preload("Product.MainUnit").
|
||||||
Preload("Container").
|
Preload("Container").
|
||||||
|
Where("rms_server_id = ?", serverID).
|
||||||
Order("updated_at DESC").
|
Order("updated_at DESC").
|
||||||
Find(&matches).Error
|
Find(&matches).Error
|
||||||
return matches, err
|
return matches, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertUnmatched увеличивает счетчик встречаемости
|
func (r *pgRepository) UpsertUnmatched(serverID uuid.UUID, rawName string) error {
|
||||||
func (r *pgRepository) UpsertUnmatched(rawName string) error {
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||||
if normalized == "" {
|
if normalized == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Используем сырой SQL или GORM upsert expression
|
|
||||||
// PostgreSQL: INSERT ... ON CONFLICT DO UPDATE SET count = count + 1
|
|
||||||
item := ocr.UnmatchedItem{
|
item := ocr.UnmatchedItem{
|
||||||
|
RMSServerID: serverID,
|
||||||
RawName: normalized,
|
RawName: normalized,
|
||||||
Count: 1,
|
Count: 1,
|
||||||
LastSeen: time.Now(),
|
LastSeen: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.db.Clauses(clause.OnConflict{
|
return r.db.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "raw_name"}},
|
Columns: []clause.Column{{Name: "raw_name"}, {Name: "rms_server_id"}},
|
||||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||||
"count": gorm.Expr("unmatched_items.count + 1"),
|
"count": gorm.Expr("unmatched_items.count + 1"),
|
||||||
"last_seen": time.Now(),
|
"last_seen": time.Now(),
|
||||||
@@ -101,13 +110,16 @@ func (r *pgRepository) UpsertUnmatched(rawName string) error {
|
|||||||
}).Create(&item).Error
|
}).Create(&item).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) GetTopUnmatched(limit int) ([]ocr.UnmatchedItem, error) {
|
func (r *pgRepository) GetTopUnmatched(serverID uuid.UUID, limit int) ([]ocr.UnmatchedItem, error) {
|
||||||
var items []ocr.UnmatchedItem
|
var items []ocr.UnmatchedItem
|
||||||
err := r.db.Order("count DESC, last_seen DESC").Limit(limit).Find(&items).Error
|
err := r.db.Where("rms_server_id = ?", serverID).
|
||||||
|
Order("count DESC, last_seen DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&items).Error
|
||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) DeleteUnmatched(rawName string) error {
|
func (r *pgRepository) DeleteUnmatched(serverID uuid.UUID, rawName string) error {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
normalized := strings.ToLower(strings.TrimSpace(rawName))
|
||||||
return r.db.Where("raw_name = ?", normalized).Delete(&ocr.UnmatchedItem{}).Error
|
return r.db.Where("rms_server_id = ? AND raw_name = ?", serverID, normalized).Delete(&ocr.UnmatchedItem{}).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
|
|
||||||
"rmser/internal/domain/operations"
|
"rmser/internal/domain/operations"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,19 +18,15 @@ func NewRepository(db *gorm.DB) operations.Repository {
|
|||||||
return &pgRepository{db: db}
|
return &pgRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) SaveOperations(ops []operations.StoreOperation, opType operations.OperationType, dateFrom, dateTo time.Time) error {
|
func (r *pgRepository) SaveOperations(ops []operations.StoreOperation, serverID uuid.UUID, opType operations.OperationType, dateFrom, dateTo time.Time) error {
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
// 1. Удаляем старые записи этого типа, которые пересекаются с периодом.
|
// Удаляем старые записи этого типа, но ТОЛЬКО для конкретного сервера
|
||||||
// Так как отчет агрегированный, мы привязываемся к периоду "с" и "по".
|
if err := tx.Where("rms_server_id = ? AND op_type = ? AND period_from >= ? AND period_to <= ?",
|
||||||
// Упрощение: удаляем всё, где PeriodFrom совпадает с текущей выгрузкой,
|
serverID, opType, dateFrom, dateTo).
|
||||||
// предполагая, что мы всегда грузим одними и теми же квантами (например, месяц или неделя).
|
|
||||||
// Для надежности удалим всё, что попадает в диапазон.
|
|
||||||
if err := tx.Where("op_type = ? AND period_from >= ? AND period_to <= ?", opType, dateFrom, dateTo).
|
|
||||||
Delete(&operations.StoreOperation{}).Error; err != nil {
|
Delete(&operations.StoreOperation{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Вставляем новые
|
|
||||||
if len(ops) > 0 {
|
if len(ops) > 0 {
|
||||||
if err := tx.CreateInBatches(ops, 500).Error; err != nil {
|
if err := tx.CreateInBatches(ops, 500).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func NewRepository(db *gorm.DB) recipes.Repository {
|
|||||||
return &pgRepository{db: db}
|
return &pgRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Техкарты сохраняются пачкой, serverID внутри структуры
|
||||||
func (r *pgRepository) SaveRecipes(list []recipes.Recipe) error {
|
func (r *pgRepository) SaveRecipes(list []recipes.Recipe) error {
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
for _, recipe := range list {
|
for _, recipe := range list {
|
||||||
|
|||||||
60
internal/infrastructure/repository/suppliers/postgres.go
Normal file
60
internal/infrastructure/repository/suppliers/postgres.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package suppliers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"rmser/internal/domain/suppliers"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pgRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepository(db *gorm.DB) suppliers.Repository {
|
||||||
|
return &pgRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) SaveBatch(list []suppliers.Supplier) error {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "id"}},
|
||||||
|
UpdateAll: true,
|
||||||
|
}).CreateInBatches(list, 100).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRankedByUsage возвращает поставщиков для конкретного сервера,
|
||||||
|
// отсортированных по количеству накладных за последние N дней.
|
||||||
|
func (r *pgRepository) GetRankedByUsage(serverID uuid.UUID, daysLookBack int) ([]suppliers.Supplier, error) {
|
||||||
|
var result []suppliers.Supplier
|
||||||
|
|
||||||
|
dateThreshold := time.Now().AddDate(0, 0, -daysLookBack)
|
||||||
|
|
||||||
|
// SQL: Join Suppliers с Invoices, Group By Supplier, Order By Count DESC
|
||||||
|
// Учитываем только активных поставщиков и накладные этого же сервера (через supplier_id + default_store_id косвенно,
|
||||||
|
// но лучше явно фильтровать suppliers по rms_server_id).
|
||||||
|
// *Примечание:* Invoices пока не имеют поля rms_server_id явно в старой схеме,
|
||||||
|
// но мы должны фильтровать Suppliers по serverID.
|
||||||
|
|
||||||
|
err := r.db.Table("suppliers").
|
||||||
|
Select("suppliers.*, COUNT(invoices.id) as usage_count").
|
||||||
|
Joins("LEFT JOIN invoices ON invoices.supplier_id = suppliers.id AND invoices.date_incoming >= ?", dateThreshold).
|
||||||
|
Where("suppliers.rms_server_id = ? AND suppliers.is_deleted = ?", serverID, false).
|
||||||
|
Group("suppliers.id").
|
||||||
|
Order("usage_count DESC, suppliers.name ASC").
|
||||||
|
Find(&result).Error
|
||||||
|
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) Count(serverID uuid.UUID) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&suppliers.Supplier{}).
|
||||||
|
Where("rms_server_id = ? AND is_deleted = ?", serverID, false).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"rmser/internal/domain/catalog"
|
"rmser/internal/domain/catalog"
|
||||||
"rmser/internal/domain/invoices"
|
"rmser/internal/domain/invoices"
|
||||||
"rmser/internal/domain/recipes"
|
"rmser/internal/domain/recipes"
|
||||||
|
"rmser/internal/domain/suppliers"
|
||||||
"rmser/pkg/logger"
|
"rmser/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ type ClientI interface {
|
|||||||
Logout() error
|
Logout() error
|
||||||
FetchCatalog() ([]catalog.Product, error)
|
FetchCatalog() ([]catalog.Product, error)
|
||||||
FetchStores() ([]catalog.Store, error)
|
FetchStores() ([]catalog.Store, error)
|
||||||
|
FetchSuppliers() ([]suppliers.Supplier, error)
|
||||||
FetchMeasureUnits() ([]catalog.MeasureUnit, error)
|
FetchMeasureUnits() ([]catalog.MeasureUnit, error)
|
||||||
FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)
|
FetchRecipes(dateFrom, dateTo time.Time) ([]recipes.Recipe, error)
|
||||||
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
|
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
|
||||||
@@ -755,3 +757,39 @@ func (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)
|
|||||||
|
|
||||||
return result.Response, nil
|
return result.Response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchSuppliers загружает список поставщиков через XML API
|
||||||
|
func (c *Client) FetchSuppliers() ([]suppliers.Supplier, error) {
|
||||||
|
// Endpoint /resto/api/suppliers
|
||||||
|
resp, err := c.doRequest("GET", "/resto/api/suppliers", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get suppliers error: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var xmlData SuppliersListXML
|
||||||
|
if err := xml.NewDecoder(resp.Body).Decode(&xmlData); err != nil {
|
||||||
|
return nil, fmt.Errorf("xml decode suppliers error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []suppliers.Supplier
|
||||||
|
for _, emp := range xmlData.Employees {
|
||||||
|
id, err := uuid.Parse(emp.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleted := emp.Deleted == "true"
|
||||||
|
|
||||||
|
result = append(result, suppliers.Supplier{
|
||||||
|
ID: id,
|
||||||
|
Name: emp.Name,
|
||||||
|
Code: emp.Code,
|
||||||
|
INN: emp.TaxpayerIdNumber,
|
||||||
|
IsDeleted: isDeleted,
|
||||||
|
// RMSServerID проставляется в сервисе перед сохранением
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -243,3 +243,18 @@ type ErrorDTO struct {
|
|||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Suppliers XML (Legacy API /resto/api/suppliers) ---
|
||||||
|
|
||||||
|
type SuppliersListXML struct {
|
||||||
|
XMLName xml.Name `xml:"employees"`
|
||||||
|
Employees []SupplierXML `xml:"employee"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SupplierXML struct {
|
||||||
|
ID string `xml:"id"`
|
||||||
|
Name string `xml:"name"`
|
||||||
|
Code string `xml:"code"`
|
||||||
|
TaxpayerIdNumber string `xml:"taxpayerIdNumber"` // ИНН
|
||||||
|
Deleted string `xml:"deleted"` // "true" / "false"
|
||||||
|
}
|
||||||
|
|||||||
115
internal/infrastructure/rms/factory.go
Normal file
115
internal/infrastructure/rms/factory.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package rms
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
|
"rmser/pkg/crypto"
|
||||||
|
"rmser/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Factory управляет созданием и переиспользованием клиентов RMS
|
||||||
|
type Factory struct {
|
||||||
|
accountRepo account.Repository
|
||||||
|
cryptoManager *crypto.CryptoManager
|
||||||
|
|
||||||
|
// Кэш активных клиентов: ServerID -> *Client
|
||||||
|
mu sync.RWMutex
|
||||||
|
clients map[uuid.UUID]*Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFactory(repo account.Repository, cm *crypto.CryptoManager) *Factory {
|
||||||
|
return &Factory{
|
||||||
|
accountRepo: repo,
|
||||||
|
cryptoManager: cm,
|
||||||
|
clients: make(map[uuid.UUID]*Client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientByServerID возвращает готовый клиент для конкретного сервера
|
||||||
|
func (f *Factory) GetClientByServerID(serverID uuid.UUID) (ClientI, error) {
|
||||||
|
// 1. Пытаемся найти в кэше (быстрый путь)
|
||||||
|
f.mu.RLock()
|
||||||
|
client, exists := f.clients[serverID]
|
||||||
|
f.mu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Если нет в кэше - ищем в БД (медленный путь)
|
||||||
|
// Здесь нам нужен метод GetServerByID, но в репо есть только GetAll/GetActive.
|
||||||
|
// Для MVP загрузим все сервера юзера и найдем нужный, либо (лучше) добавим метод в репо позже.
|
||||||
|
// ПОКА: предполагаем, что factory используется в контексте User, поэтому лучше метод GetClientForUser
|
||||||
|
return nil, fmt.Errorf("client not found in cache (use GetClientForUser or implement GetServerByID)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientForUser находит активный сервер пользователя и возвращает клиент
|
||||||
|
func (f *Factory) GetClientForUser(userID uuid.UUID) (ClientI, error) {
|
||||||
|
// 1. Получаем настройки активного сервера из БД
|
||||||
|
server, err := f.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db error: %w", err)
|
||||||
|
}
|
||||||
|
if server == nil {
|
||||||
|
return nil, fmt.Errorf("у пользователя нет активного сервера RMS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Проверяем кэш по ID сервера
|
||||||
|
f.mu.RLock()
|
||||||
|
cachedClient, exists := f.clients[server.ID]
|
||||||
|
f.mu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return cachedClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Создаем новый клиент под блокировкой (защита от гонки)
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
// Double check
|
||||||
|
if cachedClient, exists := f.clients[server.ID]; exists {
|
||||||
|
return cachedClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расшифровка пароля
|
||||||
|
plainPass, err := f.cryptoManager.Decrypt(server.EncryptedPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ошибка расшифровки пароля RMS: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание клиента
|
||||||
|
newClient := NewClient(server.BaseURL, server.Login, plainPass)
|
||||||
|
|
||||||
|
// Можно сразу проверить авторизацию (опционально, но полезно для fail-fast)
|
||||||
|
// if err := newClient.Auth(); err != nil { ... }
|
||||||
|
// Но лучше лениво, чтобы не тормозить старт.
|
||||||
|
|
||||||
|
f.clients[server.ID] = newClient
|
||||||
|
|
||||||
|
// Запускаем очистку старых клиентов из мапы? Пока нет, iiko токены живут не вечно,
|
||||||
|
// но структура Client легкая. Можно добавить TTL позже.
|
||||||
|
|
||||||
|
logger.Log.Info("RMS Factory: Client created and cached",
|
||||||
|
zap.String("server_name", server.Name),
|
||||||
|
zap.String("user_id", userID.String()))
|
||||||
|
|
||||||
|
return newClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateClientFromRawCredentials создает клиент без сохранения в кэш (для тестов подключения)
|
||||||
|
func (f *Factory) CreateClientFromRawCredentials(url, login, password string) *Client {
|
||||||
|
return NewClient(url, login, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCache сбрасывает кэш для сервера (например, при смене пароля)
|
||||||
|
func (f *Factory) ClearCache(serverID uuid.UUID) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
delete(f.clients, serverID)
|
||||||
|
}
|
||||||
@@ -10,10 +10,12 @@ import (
|
|||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/domain/catalog"
|
"rmser/internal/domain/catalog"
|
||||||
"rmser/internal/domain/drafts"
|
"rmser/internal/domain/drafts"
|
||||||
"rmser/internal/domain/invoices"
|
"rmser/internal/domain/invoices"
|
||||||
"rmser/internal/domain/ocr"
|
"rmser/internal/domain/ocr"
|
||||||
|
"rmser/internal/domain/suppliers"
|
||||||
"rmser/internal/infrastructure/rms"
|
"rmser/internal/infrastructure/rms"
|
||||||
"rmser/pkg/logger"
|
"rmser/pkg/logger"
|
||||||
)
|
)
|
||||||
@@ -22,60 +24,75 @@ type Service struct {
|
|||||||
draftRepo drafts.Repository
|
draftRepo drafts.Repository
|
||||||
ocrRepo ocr.Repository
|
ocrRepo ocr.Repository
|
||||||
catalogRepo catalog.Repository
|
catalogRepo catalog.Repository
|
||||||
rmsClient rms.ClientI
|
accountRepo account.Repository
|
||||||
|
supplierRepo suppliers.Repository
|
||||||
|
rmsFactory *rms.Factory
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
draftRepo drafts.Repository,
|
draftRepo drafts.Repository,
|
||||||
ocrRepo ocr.Repository,
|
ocrRepo ocr.Repository,
|
||||||
catalogRepo catalog.Repository,
|
catalogRepo catalog.Repository,
|
||||||
rmsClient rms.ClientI,
|
accountRepo account.Repository,
|
||||||
|
supplierRepo suppliers.Repository,
|
||||||
|
rmsFactory *rms.Factory,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
draftRepo: draftRepo,
|
draftRepo: draftRepo,
|
||||||
ocrRepo: ocrRepo,
|
ocrRepo: ocrRepo,
|
||||||
catalogRepo: catalogRepo,
|
catalogRepo: catalogRepo,
|
||||||
rmsClient: rmsClient,
|
accountRepo: accountRepo,
|
||||||
|
supplierRepo: supplierRepo,
|
||||||
|
rmsFactory: rmsFactory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDraft возвращает черновик с позициями
|
func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||||
func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) {
|
// TODO: Проверить что userID совпадает с draft.UserID
|
||||||
return s.draftRepo.GetByID(id)
|
return s.draftRepo.GetByID(draftID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetActiveDrafts(userID uuid.UUID) ([]drafts.DraftInvoice, error) {
|
||||||
|
return s.draftRepo.GetActive(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDictionaries возвращает Склады и Поставщиков для пользователя
|
||||||
|
func (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, error) {
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return nil, fmt.Errorf("active server not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
stores, _ := s.catalogRepo.GetActiveStores(server.ID)
|
||||||
|
|
||||||
|
// Ранжированные поставщики (топ за 90 дней)
|
||||||
|
suppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"stores": stores,
|
||||||
|
"suppliers": suppliersList,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDraft реализует логику "Отмена -> Удаление"
|
|
||||||
func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
|
func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
|
||||||
|
// Без изменений логики, только вызов репо
|
||||||
draft, err := s.draftRepo.GetByID(id)
|
draft, err := s.draftRepo.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сценарий 2: Если уже ОТМЕНЕН -> УДАЛЯЕМ (Soft Delete статусом)
|
|
||||||
if draft.Status == drafts.StatusCanceled {
|
if draft.Status == drafts.StatusCanceled {
|
||||||
draft.Status = drafts.StatusDeleted
|
draft.Status = drafts.StatusDeleted
|
||||||
if err := s.draftRepo.Update(draft); err != nil {
|
s.draftRepo.Update(draft)
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
logger.Log.Info("Черновик удален (скрыт)", zap.String("id", id.String()))
|
|
||||||
return drafts.StatusDeleted, nil
|
return drafts.StatusDeleted, nil
|
||||||
}
|
}
|
||||||
|
if draft.Status != drafts.StatusCompleted {
|
||||||
// Сценарий 1: Если активен -> ОТМЕНЯЕМ
|
|
||||||
// Разрешаем отменять только незавершенные
|
|
||||||
if draft.Status != drafts.StatusCompleted && draft.Status != drafts.StatusDeleted {
|
|
||||||
draft.Status = drafts.StatusCanceled
|
draft.Status = drafts.StatusCanceled
|
||||||
if err := s.draftRepo.Update(draft); err != nil {
|
s.draftRepo.Update(draft)
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
logger.Log.Info("Черновик перемещен в отмененные", zap.String("id", id.String()))
|
|
||||||
return drafts.StatusCanceled, nil
|
return drafts.StatusCanceled, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return draft.Status, nil
|
return draft.Status, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateDraftHeader обновляет шапку (дата, поставщик, склад, комментарий)
|
|
||||||
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error {
|
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error {
|
||||||
draft, err := s.draftRepo.GetByID(id)
|
draft, err := s.draftRepo.GetByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -84,65 +101,46 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
|
|||||||
if draft.Status == drafts.StatusCompleted {
|
if draft.Status == drafts.StatusCompleted {
|
||||||
return errors.New("черновик уже отправлен")
|
return errors.New("черновик уже отправлен")
|
||||||
}
|
}
|
||||||
|
|
||||||
draft.StoreID = storeID
|
draft.StoreID = storeID
|
||||||
draft.SupplierID = supplierID
|
draft.SupplierID = supplierID
|
||||||
draft.DateIncoming = &date
|
draft.DateIncoming = &date
|
||||||
draft.Comment = comment
|
draft.Comment = comment
|
||||||
|
|
||||||
return s.draftRepo.Update(draft)
|
return s.draftRepo.Update(draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateItem обновляет позицию с авто-восстановлением статуса черновика
|
|
||||||
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||||
// 1. Проверяем статус черновика для реализации Auto-Restore
|
|
||||||
draft, err := s.draftRepo.GetByID(draftID)
|
draft, err := s.draftRepo.GetByID(draftID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если черновик был в корзине (CANCELED), возвращаем его в работу
|
|
||||||
if draft.Status == drafts.StatusCanceled {
|
if draft.Status == drafts.StatusCanceled {
|
||||||
draft.Status = drafts.StatusReadyToVerify
|
draft.Status = drafts.StatusReadyToVerify
|
||||||
if err := s.draftRepo.Update(draft); err != nil {
|
s.draftRepo.Update(draft)
|
||||||
logger.Log.Error("Не удалось восстановить статус черновика при редактировании", zap.Error(err))
|
|
||||||
// Не прерываем выполнение, пробуем обновить строку
|
|
||||||
} else {
|
|
||||||
logger.Log.Info("Черновик автоматически восстановлен из отмененных", zap.String("id", draftID.String()))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Обновляем саму строку (существующий вызов репозитория)
|
|
||||||
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
|
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitDraft отправляет накладную в RMS
|
// CommitDraft отправляет накладную
|
||||||
func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||||
// 1. Загружаем актуальное состояние черновика
|
// 1. Клиент для пользователя
|
||||||
draft, err := s.draftRepo.GetByID(id)
|
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Черновик
|
||||||
|
draft, err := s.draftRepo.GetByID(draftID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if draft.Status == drafts.StatusCompleted {
|
if draft.Status == drafts.StatusCompleted {
|
||||||
return "", errors.New("накладная уже отправлена")
|
return "", errors.New("накладная уже отправлена")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Валидация
|
// 3. Сборка Invoice
|
||||||
if draft.StoreID == nil || *draft.StoreID == uuid.Nil {
|
|
||||||
return "", errors.New("не выбран склад")
|
|
||||||
}
|
|
||||||
if draft.SupplierID == nil || *draft.SupplierID == uuid.Nil {
|
|
||||||
return "", errors.New("не выбран поставщик")
|
|
||||||
}
|
|
||||||
if draft.DateIncoming == nil {
|
|
||||||
return "", errors.New("не выбрана дата")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сборка Invoice для отправки
|
|
||||||
inv := invoices.Invoice{
|
inv := invoices.Invoice{
|
||||||
ID: uuid.Nil, // iiko создаст новый
|
ID: uuid.Nil,
|
||||||
DocumentNumber: draft.DocumentNumber, // Может быть пустой, iiko присвоит
|
DocumentNumber: draft.DocumentNumber,
|
||||||
DateIncoming: *draft.DateIncoming,
|
DateIncoming: *draft.DateIncoming,
|
||||||
SupplierID: *draft.SupplierID,
|
SupplierID: *draft.SupplierID,
|
||||||
DefaultStoreID: *draft.StoreID,
|
DefaultStoreID: *draft.StoreID,
|
||||||
@@ -152,11 +150,10 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
|||||||
|
|
||||||
for _, dItem := range draft.Items {
|
for _, dItem := range draft.Items {
|
||||||
if dItem.ProductID == nil {
|
if dItem.ProductID == nil {
|
||||||
// Пропускаем нераспознанные или кидаем ошибку?
|
continue // Skip unrecognized
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Расчет суммы (если не задана, считаем)
|
// Если суммы нет, считаем
|
||||||
sum := dItem.Sum
|
sum := dItem.Sum
|
||||||
if sum.IsZero() {
|
if sum.IsZero() {
|
||||||
sum = dItem.Quantity.Mul(dItem.Price)
|
sum = dItem.Quantity.Mul(dItem.Price)
|
||||||
@@ -169,7 +166,6 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
|||||||
Sum: sum,
|
Sum: sum,
|
||||||
ContainerID: dItem.ContainerID,
|
ContainerID: dItem.ContainerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
inv.Items = append(inv.Items, invItem)
|
inv.Items = append(inv.Items, invItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,86 +173,64 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
|||||||
return "", errors.New("нет распознанных позиций для отправки")
|
return "", errors.New("нет распознанных позиций для отправки")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отправка
|
// 4. Отправка в RMS
|
||||||
docNum, err := s.rmsClient.CreateIncomingInvoice(inv)
|
docNum, err := client.CreateIncomingInvoice(inv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновление статуса
|
// 5. Обновление статуса
|
||||||
draft.Status = drafts.StatusCompleted
|
draft.Status = drafts.StatusCompleted
|
||||||
// Можно сохранить docNum, если бы было поле в Draft, но у нас есть rms_invoice_id (uuid),
|
s.draftRepo.Update(draft)
|
||||||
// а возвращается строка номера. Ок, просто меняем статус.
|
|
||||||
if err := s.draftRepo.Update(draft); err != nil {
|
|
||||||
logger.Log.Error("Failed to update draft status after commit", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. ОБУЧЕНИЕ (Deferred Learning)
|
// 6. БИЛЛИНГ: Увеличиваем счетчик накладных
|
||||||
// Запускаем в горутине, чтобы не задерживать ответ пользователю
|
server, _ := s.accountRepo.GetActiveServer(userID)
|
||||||
go s.learnFromDraft(draft)
|
if server != nil {
|
||||||
|
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
||||||
|
logger.Log.Error("Billing increment failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
// 7. Обучение (передаем ID сервера для сохранения маппинга)
|
||||||
|
go s.learnFromDraft(draft, server.ID)
|
||||||
|
}
|
||||||
|
|
||||||
return docNum, nil
|
return docNum, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// learnFromDraft сохраняет новые связи на основе подтвержденного черновика
|
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice, serverID uuid.UUID) {
|
||||||
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) {
|
|
||||||
for _, item := range draft.Items {
|
for _, item := range draft.Items {
|
||||||
// Учимся только если:
|
|
||||||
// 1. Есть RawName (текст из чека)
|
|
||||||
// 2. Пользователь (или OCR) выбрал ProductID
|
|
||||||
if item.RawName != "" && item.ProductID != nil {
|
if item.RawName != "" && item.ProductID != nil {
|
||||||
|
|
||||||
// Если нужно запоминать коэффициент (например, всегда 1 или то, что ввел юзер),
|
|
||||||
// то берем item.Quantity. Но обычно для матчинга мы запоминаем факт связи,
|
|
||||||
// а дефолтное кол-во ставим 1.
|
|
||||||
qty := decimal.NewFromFloat(1.0)
|
qty := decimal.NewFromFloat(1.0)
|
||||||
|
err := s.ocrRepo.SaveMatch(serverID, item.RawName, *item.ProductID, qty, item.ContainerID)
|
||||||
err := s.ocrRepo.SaveMatch(item.RawName, *item.ProductID, qty, item.ContainerID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Warn("Failed to learn match",
|
logger.Log.Warn("Failed to learn match", zap.Error(err))
|
||||||
zap.String("raw", item.RawName),
|
|
||||||
zap.Error(err))
|
|
||||||
} else {
|
|
||||||
logger.Log.Info("Learned match", zap.String("raw", item.RawName))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetActiveStores возвращает список складов
|
func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
|
||||||
func (s *Service) GetActiveStores() ([]catalog.Store, error) {
|
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||||
return s.catalogRepo.GetActiveStores()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetActiveDrafts возвращает список черновиков в работе
|
|
||||||
func (s *Service) GetActiveDrafts() ([]drafts.DraftInvoice, error) {
|
|
||||||
return s.draftRepo.GetActive()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateProductContainer создает новую фасовку в iiko и сохраняет её в локальной БД
|
|
||||||
// Возвращает UUID созданной фасовки.
|
|
||||||
func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
|
|
||||||
// 1. Получаем полную карточку товара из iiko
|
|
||||||
// Используем инфраструктурный DTO, так как нам нужна полная структура для апдейта
|
|
||||||
fullProduct, err := s.rmsClient.GetProductByID(productID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("ошибка получения товара из iiko: %w", err)
|
return uuid.Nil, err
|
||||||
|
}
|
||||||
|
server, _ := s.accountRepo.GetActiveServer(userID) // нужен ServerID для сохранения в локальную БД
|
||||||
|
|
||||||
|
fullProduct, err := client.GetProductByID(productID)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("error fetching product: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Валидация на дубликаты (по имени или коэффициенту)
|
// Валидация на дубли
|
||||||
// iiko разрешает дубли, но нам это не нужно.
|
|
||||||
targetCount, _ := count.Float64()
|
targetCount, _ := count.Float64()
|
||||||
for _, c := range fullProduct.Containers {
|
for _, c := range fullProduct.Containers {
|
||||||
if !c.Deleted && (c.Name == name || (c.Count == targetCount)) {
|
if !c.Deleted && (c.Name == name || (c.Count == targetCount)) {
|
||||||
// Если такая фасовка уже есть, возвращаем её ID
|
|
||||||
// (Можно добавить логику обновления имени, но пока просто вернем ID)
|
|
||||||
if c.ID != nil && *c.ID != "" {
|
if c.ID != nil && *c.ID != "" {
|
||||||
return uuid.Parse(*c.ID)
|
return uuid.Parse(*c.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Вычисляем следующий num (iiko использует строки "1", "2"...)
|
// Next Num
|
||||||
maxNum := 0
|
maxNum := 0
|
||||||
for _, c := range fullProduct.Containers {
|
for _, c := range fullProduct.Containers {
|
||||||
if n, err := strconv.Atoi(c.Num); err == nil {
|
if n, err := strconv.Atoi(c.Num); err == nil {
|
||||||
@@ -267,32 +241,27 @@ func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count
|
|||||||
}
|
}
|
||||||
nextNum := strconv.Itoa(maxNum + 1)
|
nextNum := strconv.Itoa(maxNum + 1)
|
||||||
|
|
||||||
// 4. Добавляем новую фасовку в список
|
// Add
|
||||||
newContainerDTO := rms.ContainerFullDTO{
|
newContainerDTO := rms.ContainerFullDTO{
|
||||||
ID: nil, // Null, чтобы iiko создала новый ID
|
ID: nil,
|
||||||
Num: nextNum,
|
Num: nextNum,
|
||||||
Name: name,
|
Name: name,
|
||||||
Count: targetCount,
|
Count: targetCount,
|
||||||
UseInFront: true,
|
UseInFront: true,
|
||||||
Deleted: false,
|
Deleted: false,
|
||||||
// Остальные поля можно оставить 0/false по умолчанию
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fullProduct.Containers = append(fullProduct.Containers, newContainerDTO)
|
fullProduct.Containers = append(fullProduct.Containers, newContainerDTO)
|
||||||
|
|
||||||
// 5. Отправляем обновление в iiko
|
// Update RMS
|
||||||
updatedProduct, err := s.rmsClient.UpdateProduct(*fullProduct)
|
updatedProduct, err := client.UpdateProduct(*fullProduct)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.Nil, fmt.Errorf("ошибка обновления товара в iiko: %w", err)
|
return uuid.Nil, fmt.Errorf("error updating product: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Ищем нашу созданную фасовку в ответе, чтобы получить её ID
|
// Find created ID
|
||||||
// Ищем по уникальной комбинации Name + Count, которую мы только что отправили
|
|
||||||
var createdID uuid.UUID
|
var createdID uuid.UUID
|
||||||
found := false
|
found := false
|
||||||
|
|
||||||
for _, c := range updatedProduct.Containers {
|
for _, c := range updatedProduct.Containers {
|
||||||
// Сравниваем float с небольшим эпсилоном на всякий случай, хотя JSON должен вернуть точно
|
|
||||||
if c.Name == name && c.Count == targetCount && !c.Deleted {
|
if c.Name == name && c.Count == targetCount && !c.Deleted {
|
||||||
if c.ID != nil {
|
if c.ID != nil {
|
||||||
createdID, err = uuid.Parse(*c.ID)
|
createdID, err = uuid.Parse(*c.ID)
|
||||||
@@ -305,28 +274,18 @@ func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
return uuid.Nil, errors.New("фасовка отправлена, но сервер не вернул её ID (возможно, ошибка логики поиска)")
|
return uuid.Nil, errors.New("container created but id not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Сохраняем новую фасовку в локальную БД
|
// Save Local
|
||||||
newLocalContainer := catalog.ProductContainer{
|
newLocalContainer := catalog.ProductContainer{
|
||||||
ID: createdID,
|
ID: createdID,
|
||||||
|
RMSServerID: server.ID, // <-- NEW
|
||||||
ProductID: productID,
|
ProductID: productID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Count: count,
|
Count: count,
|
||||||
}
|
}
|
||||||
|
s.catalogRepo.SaveContainer(newLocalContainer)
|
||||||
if err := s.catalogRepo.SaveContainer(newLocalContainer); err != nil {
|
|
||||||
logger.Log.Error("Ошибка сохранения новой фасовки в локальную БД", zap.Error(err))
|
|
||||||
// Не возвращаем ошибку клиенту, так как в iiko она уже создана.
|
|
||||||
// Просто в следующем SyncCatalog она подтянется, но лучше иметь её сразу.
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Log.Info("Создана новая фасовка",
|
|
||||||
zap.String("product_id", productID.String()),
|
|
||||||
zap.String("container_id", createdID.String()),
|
|
||||||
zap.String("name", name),
|
|
||||||
zap.String("count", count.String()))
|
|
||||||
|
|
||||||
return createdID, nil
|
return createdID, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,58 +6,66 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/domain/catalog"
|
"rmser/internal/domain/catalog"
|
||||||
"rmser/internal/domain/drafts"
|
"rmser/internal/domain/drafts"
|
||||||
"rmser/internal/domain/ocr"
|
"rmser/internal/domain/ocr"
|
||||||
"rmser/internal/infrastructure/ocr_client"
|
"rmser/internal/infrastructure/ocr_client"
|
||||||
"rmser/pkg/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ocrRepo ocr.Repository
|
ocrRepo ocr.Repository
|
||||||
catalogRepo catalog.Repository
|
catalogRepo catalog.Repository
|
||||||
draftRepo drafts.Repository
|
draftRepo drafts.Repository
|
||||||
pyClient *ocr_client.Client // Клиент к Python сервису
|
accountRepo account.Repository // <-- NEW
|
||||||
|
pyClient *ocr_client.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
ocrRepo ocr.Repository,
|
ocrRepo ocr.Repository,
|
||||||
catalogRepo catalog.Repository,
|
catalogRepo catalog.Repository,
|
||||||
draftRepo drafts.Repository,
|
draftRepo drafts.Repository,
|
||||||
|
accountRepo account.Repository, // <-- NEW
|
||||||
pyClient *ocr_client.Client,
|
pyClient *ocr_client.Client,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
ocrRepo: ocrRepo,
|
ocrRepo: ocrRepo,
|
||||||
catalogRepo: catalogRepo,
|
catalogRepo: catalogRepo,
|
||||||
draftRepo: draftRepo,
|
draftRepo: draftRepo,
|
||||||
|
accountRepo: accountRepo,
|
||||||
pyClient: pyClient,
|
pyClient: pyClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessReceiptImage - Создает черновик, распознает, сохраняет результаты
|
// ProcessReceiptImage
|
||||||
func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData []byte) (*drafts.DraftInvoice, error) {
|
func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {
|
||||||
// 1. Создаем заготовку черновика
|
// 1. Получаем активный сервер для UserID
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return nil, fmt.Errorf("no active server for user")
|
||||||
|
}
|
||||||
|
serverID := server.ID
|
||||||
|
|
||||||
|
// 2. Создаем черновик
|
||||||
draft := &drafts.DraftInvoice{
|
draft := &drafts.DraftInvoice{
|
||||||
ChatID: chatID,
|
UserID: userID, // <-- Исправлено с ChatID на UserID
|
||||||
|
RMSServerID: serverID, // <-- NEW
|
||||||
Status: drafts.StatusProcessing,
|
Status: drafts.StatusProcessing,
|
||||||
}
|
}
|
||||||
if err := s.draftRepo.Create(draft); err != nil {
|
if err := s.draftRepo.Create(draft); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create draft: %w", err)
|
return nil, fmt.Errorf("failed to create draft: %w", err)
|
||||||
}
|
}
|
||||||
logger.Log.Info("Создан черновик", zap.String("draft_id", draft.ID.String()))
|
|
||||||
|
|
||||||
// 2. Отправляем в Python OCR
|
// 3. Отправляем в Python OCR
|
||||||
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
|
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Ставим статус ошибки
|
|
||||||
draft.Status = drafts.StatusError
|
draft.Status = drafts.StatusError
|
||||||
_ = s.draftRepo.Update(draft)
|
_ = s.draftRepo.Update(draft)
|
||||||
return nil, fmt.Errorf("python ocr error: %w", err)
|
return nil, fmt.Errorf("python ocr error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Обрабатываем результаты и создаем Items
|
// 4. Матчинг (с учетом ServerID)
|
||||||
var draftItems []drafts.DraftInvoiceItem
|
var draftItems []drafts.DraftInvoiceItem
|
||||||
|
|
||||||
for _, rawItem := range rawResult.Items {
|
for _, rawItem := range rawResult.Items {
|
||||||
@@ -66,60 +74,33 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
|
|||||||
RawName: rawItem.RawName,
|
RawName: rawItem.RawName,
|
||||||
RawAmount: decimal.NewFromFloat(rawItem.Amount),
|
RawAmount: decimal.NewFromFloat(rawItem.Amount),
|
||||||
RawPrice: decimal.NewFromFloat(rawItem.Price),
|
RawPrice: decimal.NewFromFloat(rawItem.Price),
|
||||||
// Quantity/Price по умолчанию берем как Raw, если не будет пересчета
|
|
||||||
Quantity: decimal.NewFromFloat(rawItem.Amount),
|
Quantity: decimal.NewFromFloat(rawItem.Amount),
|
||||||
Price: decimal.NewFromFloat(rawItem.Price),
|
Price: decimal.NewFromFloat(rawItem.Price),
|
||||||
Sum: decimal.NewFromFloat(rawItem.Sum),
|
Sum: decimal.NewFromFloat(rawItem.Sum),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пытаемся найти матчинг
|
match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) // <-- ServerID
|
||||||
match, err := s.ocrRepo.FindMatch(rawItem.RawName)
|
|
||||||
if err != nil {
|
|
||||||
logger.Log.Error("db error finding match", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if match != nil {
|
if match != nil {
|
||||||
item.IsMatched = true
|
item.IsMatched = true
|
||||||
item.ProductID = &match.ProductID
|
item.ProductID = &match.ProductID
|
||||||
item.ContainerID = match.ContainerID
|
item.ContainerID = match.ContainerID
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Если не нашли - сохраняем в Unmatched для статистики и подсказок
|
s.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName) // <-- ServerID
|
||||||
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
|
|
||||||
logger.Log.Warn("failed to save unmatched", zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
draftItems = append(draftItems, item)
|
draftItems = append(draftItems, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Сохраняем позиции в БД
|
// 5. Сохраняем
|
||||||
draft.Status = drafts.StatusReadyToVerify
|
draft.Status = drafts.StatusReadyToVerify
|
||||||
|
s.draftRepo.Update(draft)
|
||||||
if err := s.draftRepo.Update(draft); err != nil {
|
s.draftRepo.CreateItems(draftItems)
|
||||||
return nil, fmt.Errorf("failed to update draft status: %w", err)
|
|
||||||
}
|
|
||||||
draft.Items = draftItems
|
|
||||||
|
|
||||||
if err := s.draftRepo.CreateItems(draftItems); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to save items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return draft, nil
|
return draft, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessedItem - результат обработки одной строки чека
|
// Добавить структуры в конец файла
|
||||||
type ProcessedItem struct {
|
|
||||||
RawName string
|
|
||||||
Amount decimal.Decimal
|
|
||||||
Price decimal.Decimal
|
|
||||||
Sum decimal.Decimal
|
|
||||||
|
|
||||||
IsMatched bool
|
|
||||||
ProductID *uuid.UUID
|
|
||||||
MatchSource string // "learned", "auto", "manual"
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContainerForIndex struct {
|
type ContainerForIndex struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -134,9 +115,14 @@ type ProductForIndex struct {
|
|||||||
Containers []ContainerForIndex `json:"containers"`
|
Containers []ContainerForIndex `json:"containers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCatalogForIndexing - возвращает облегченный каталог
|
// GetCatalogForIndexing
|
||||||
func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
|
func (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, error) {
|
||||||
products, err := s.catalogRepo.GetActiveGoods()
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return nil, fmt.Errorf("no server")
|
||||||
|
}
|
||||||
|
|
||||||
|
products, err := s.catalogRepo.GetActiveGoods(server.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -169,37 +155,45 @@ func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveMapping сохраняет привязку с количеством и фасовкой
|
func (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Product, error) {
|
||||||
func (s *Service) SaveMapping(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
|
||||||
return s.ocrRepo.SaveMatch(rawName, productID, quantity, containerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteMatch удаляет ошибочную привязку
|
|
||||||
func (s *Service) DeleteMatch(rawName string) error {
|
|
||||||
return s.ocrRepo.DeleteMatch(rawName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetKnownMatches возвращает список всех обученных связей
|
|
||||||
func (s *Service) GetKnownMatches() ([]ocr.ProductMatch, error) {
|
|
||||||
return s.ocrRepo.GetAllMatches()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUnmatchedItems возвращает список частых нераспознанных строк
|
|
||||||
func (s *Service) GetUnmatchedItems() ([]ocr.UnmatchedItem, error) {
|
|
||||||
// Берем топ 50 нераспознанных
|
|
||||||
return s.ocrRepo.GetTopUnmatched(50)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindKnownMatch ищет, знаем ли мы уже этот товар
|
|
||||||
func (s *Service) FindKnownMatch(rawName string) (*ocr.ProductMatch, error) {
|
|
||||||
return s.ocrRepo.FindMatch(rawName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SearchProducts ищет товары в БД по части названия, коду или артикулу
|
|
||||||
func (s *Service) SearchProducts(query string) ([]catalog.Product, error) {
|
|
||||||
if len(query) < 2 {
|
if len(query) < 2 {
|
||||||
// Слишком короткий запрос, возвращаем пустой список
|
|
||||||
return []catalog.Product{}, nil
|
return []catalog.Product{}, nil
|
||||||
}
|
}
|
||||||
return s.catalogRepo.Search(query)
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return nil, fmt.Errorf("no server")
|
||||||
|
}
|
||||||
|
return s.catalogRepo.Search(server.ID, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return fmt.Errorf("no server")
|
||||||
|
}
|
||||||
|
return s.ocrRepo.SaveMatch(server.ID, rawName, productID, quantity, containerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteMatch(userID uuid.UUID, rawName string) error {
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return fmt.Errorf("no server")
|
||||||
|
}
|
||||||
|
return s.ocrRepo.DeleteMatch(server.ID, rawName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetKnownMatches(userID uuid.UUID) ([]ocr.ProductMatch, error) {
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return nil, fmt.Errorf("no server")
|
||||||
|
}
|
||||||
|
return s.ocrRepo.GetAllMatches(server.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetUnmatchedItems(userID uuid.UUID) ([]ocr.UnmatchedItem, error) {
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return nil, fmt.Errorf("no server")
|
||||||
|
}
|
||||||
|
return s.ocrRepo.GetTopUnmatched(server.ID, 50)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,263 +4,238 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/domain/catalog"
|
"rmser/internal/domain/catalog"
|
||||||
"rmser/internal/domain/invoices"
|
"rmser/internal/domain/invoices"
|
||||||
"rmser/internal/domain/operations"
|
"rmser/internal/domain/operations"
|
||||||
"rmser/internal/domain/recipes"
|
"rmser/internal/domain/recipes"
|
||||||
|
"rmser/internal/domain/suppliers"
|
||||||
"rmser/internal/infrastructure/rms"
|
"rmser/internal/infrastructure/rms"
|
||||||
"rmser/pkg/logger"
|
"rmser/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Пресеты от пользователя
|
|
||||||
PresetPurchases = "1a3297e1-cb05-55dc-98a7-c13f13bc85a7" // Закупки
|
PresetPurchases = "1a3297e1-cb05-55dc-98a7-c13f13bc85a7" // Закупки
|
||||||
PresetUsage = "24d9402e-2d01-eca1-ebeb-7981f7d1cb86" // Расход
|
PresetUsage = "24d9402e-2d01-eca1-ebeb-7981f7d1cb86" // Расход
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
rmsClient rms.ClientI
|
rmsFactory *rms.Factory
|
||||||
|
accountRepo account.Repository
|
||||||
catalogRepo catalog.Repository
|
catalogRepo catalog.Repository
|
||||||
recipeRepo recipes.Repository
|
recipeRepo recipes.Repository
|
||||||
invoiceRepo invoices.Repository
|
invoiceRepo invoices.Repository
|
||||||
opRepo operations.Repository
|
opRepo operations.Repository
|
||||||
|
supplierRepo suppliers.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
rmsClient rms.ClientI,
|
rmsFactory *rms.Factory,
|
||||||
|
accountRepo account.Repository,
|
||||||
catalogRepo catalog.Repository,
|
catalogRepo catalog.Repository,
|
||||||
recipeRepo recipes.Repository,
|
recipeRepo recipes.Repository,
|
||||||
invoiceRepo invoices.Repository,
|
invoiceRepo invoices.Repository,
|
||||||
opRepo operations.Repository,
|
opRepo operations.Repository,
|
||||||
|
supplierRepo suppliers.Repository,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
rmsClient: rmsClient,
|
rmsFactory: rmsFactory,
|
||||||
|
accountRepo: accountRepo,
|
||||||
catalogRepo: catalogRepo,
|
catalogRepo: catalogRepo,
|
||||||
recipeRepo: recipeRepo,
|
recipeRepo: recipeRepo,
|
||||||
invoiceRepo: invoiceRepo,
|
invoiceRepo: invoiceRepo,
|
||||||
opRepo: opRepo,
|
opRepo: opRepo,
|
||||||
|
supplierRepo: supplierRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncCatalog загружает номенклатуру и сохраняет в БД
|
// SyncAllData запускает полную синхронизацию для конкретного пользователя
|
||||||
func (s *Service) SyncCatalog() error {
|
func (s *Service) SyncAllData(userID uuid.UUID) error {
|
||||||
logger.Log.Info("Начало синхронизации справочников...")
|
logger.Log.Info("Запуск полной синхронизации", zap.String("user_id", userID.String()))
|
||||||
|
|
||||||
// 1. Склады (INVENTORY_ASSETS) - важно для создания накладных
|
// 1. Получаем клиент и инфо о сервере
|
||||||
if err := s.SyncStores(); err != nil {
|
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||||
logger.Log.Error("Ошибка синхронизации складов", zap.Error(err))
|
if err != nil {
|
||||||
// Не прерываем, идем дальше
|
return err
|
||||||
|
}
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return fmt.Errorf("active server not found for user %s", userID)
|
||||||
|
}
|
||||||
|
serverID := server.ID
|
||||||
|
|
||||||
|
// 2. Справочники
|
||||||
|
if err := s.syncStores(client, serverID); err != nil {
|
||||||
|
logger.Log.Error("Sync Stores failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
if err := s.syncMeasureUnits(client, serverID); err != nil {
|
||||||
|
logger.Log.Error("Sync Units failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Единицы измерения
|
// 3. Поставщики
|
||||||
if err := s.syncMeasureUnits(); err != nil {
|
if err := s.syncSuppliers(client, serverID); err != nil {
|
||||||
|
logger.Log.Error("Sync Suppliers failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Товары
|
||||||
|
if err := s.syncProducts(client, serverID); err != nil {
|
||||||
|
logger.Log.Error("Sync Products failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Техкарты (тяжелый запрос)
|
||||||
|
if err := s.syncRecipes(client, serverID); err != nil {
|
||||||
|
logger.Log.Error("Sync Recipes failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Накладные (история)
|
||||||
|
if err := s.syncInvoices(client, serverID); err != nil {
|
||||||
|
logger.Log.Error("Sync Invoices failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Складские операции (тяжелый запрос)
|
||||||
|
// Для MVP можно отключить, если долго грузится
|
||||||
|
// if err := s.SyncStoreOperations(client, serverID); err != nil {
|
||||||
|
// logger.Log.Error("Sync Operations failed", zap.Error(err))
|
||||||
|
// }
|
||||||
|
|
||||||
|
logger.Log.Info("Синхронизация завершена", zap.String("user_id", userID.String()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncSuppliers(c rms.ClientI, serverID uuid.UUID) error {
|
||||||
|
list, err := c.FetchSuppliers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Проставляем ServerID
|
||||||
|
for i := range list {
|
||||||
|
list[i].RMSServerID = serverID
|
||||||
|
}
|
||||||
|
return s.supplierRepo.SaveBatch(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncStores(c rms.ClientI, serverID uuid.UUID) error {
|
||||||
|
stores, err := c.FetchStores()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range stores {
|
||||||
|
stores[i].RMSServerID = serverID
|
||||||
|
}
|
||||||
|
return s.catalogRepo.SaveStores(stores)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncMeasureUnits(c rms.ClientI, serverID uuid.UUID) error {
|
||||||
|
units, err := c.FetchMeasureUnits()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range units {
|
||||||
|
units[i].RMSServerID = serverID
|
||||||
|
}
|
||||||
|
return s.catalogRepo.SaveMeasureUnits(units)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncProducts(c rms.ClientI, serverID uuid.UUID) error {
|
||||||
|
products, err := c.FetchCatalog()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Важно: Проставляем ID рекурсивно и в фасовки
|
||||||
|
for i := range products {
|
||||||
|
products[i].RMSServerID = serverID
|
||||||
|
for j := range products[i].Containers {
|
||||||
|
products[i].Containers[j].RMSServerID = serverID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.catalogRepo.SaveProducts(products)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncRecipes(c rms.ClientI, serverID uuid.UUID) error {
|
||||||
|
dateFrom := time.Now().AddDate(0, -3, 0) // За 3 месяца
|
||||||
|
dateTo := time.Now()
|
||||||
|
recipesList, err := c.FetchRecipes(dateFrom, dateTo)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Товары
|
for i := range recipesList {
|
||||||
logger.Log.Info("Запрос товаров из RMS...")
|
recipesList[i].RMSServerID = serverID
|
||||||
products, err := s.rmsClient.FetchCatalog()
|
for j := range recipesList[i].Items {
|
||||||
if err != nil {
|
recipesList[i].Items[j].RMSServerID = serverID
|
||||||
return fmt.Errorf("ошибка получения каталога из RMS: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.catalogRepo.SaveProducts(products); err != nil {
|
|
||||||
return fmt.Errorf("ошибка сохранения продуктов в БД: %w", err)
|
|
||||||
}
|
}
|
||||||
|
return s.recipeRepo.SaveRecipes(recipesList)
|
||||||
logger.Log.Info("Синхронизация номенклатуры завершена", zap.Int("count", len(products)))
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) syncMeasureUnits() error {
|
func (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID) error {
|
||||||
logger.Log.Info("Синхронизация единиц измерения...")
|
lastDate, err := s.invoiceRepo.GetLastInvoiceDate(serverID)
|
||||||
units, err := s.rmsClient.FetchMeasureUnits()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ошибка получения ед.изм: %w", err)
|
return err
|
||||||
}
|
|
||||||
if err := s.catalogRepo.SaveMeasureUnits(units); err != nil {
|
|
||||||
return fmt.Errorf("ошибка сохранения ед.изм: %w", err)
|
|
||||||
}
|
|
||||||
logger.Log.Info("Единицы измерения обновлены", zap.Int("count", len(units)))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию)
|
|
||||||
func (s *Service) SyncRecipes() error {
|
|
||||||
logger.Log.Info("Начало синхронизации техкарт")
|
|
||||||
|
|
||||||
// RMS требует dateFrom. Берем широкий диапазон, например, с начала года или фиксированную дату,
|
|
||||||
// либо можно сделать конфигурируемым. Для примера берем -3 месяца от текущей даты.
|
|
||||||
// В реальном проде лучше брать дату последнего изменения, если API поддерживает revision,
|
|
||||||
// но V2 API iiko часто требует полной перезагрузки актуальных карт.
|
|
||||||
dateFrom := time.Now().AddDate(0, -3, 0)
|
|
||||||
dateTo := time.Now() // +1 месяц вперед на случай будущих меню
|
|
||||||
|
|
||||||
recipes, err := s.rmsClient.FetchRecipes(dateFrom, dateTo)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ошибка получения техкарт из RMS: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.recipeRepo.SaveRecipes(recipes); err != nil {
|
|
||||||
return fmt.Errorf("ошибка сохранения техкарт в БД: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Log.Info("Синхронизация техкарт завершена", zap.Int("count", len(recipes)))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncInvoices загружает накладные. Если в базе пусто, грузит за последние N дней.
|
|
||||||
func (s *Service) SyncInvoices() error {
|
|
||||||
logger.Log.Info("Начало синхронизации накладных")
|
|
||||||
|
|
||||||
lastDate, err := s.invoiceRepo.GetLastInvoiceDate()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ошибка получения даты последней накладной: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var from time.Time
|
var from time.Time
|
||||||
to := time.Now()
|
to := time.Now()
|
||||||
|
|
||||||
if lastDate != nil {
|
if lastDate != nil {
|
||||||
// Берем следующий день после последней загрузки или тот же день, чтобы обновить изменения
|
|
||||||
from = *lastDate
|
from = *lastDate
|
||||||
} else {
|
} else {
|
||||||
// Дефолтная загрузка за 30 дней назад
|
from = time.Now().AddDate(0, 0, -45) // 45 дней по дефолту
|
||||||
from = time.Now().AddDate(0, 0, -30)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Log.Info("Запрос накладных", zap.Time("from", from), zap.Time("to", to))
|
invs, err := c.FetchInvoices(from, to)
|
||||||
|
|
||||||
invoices, err := s.rmsClient.FetchInvoices(from, to)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ошибка получения накладных из RMS: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(invoices) == 0 {
|
for i := range invs {
|
||||||
logger.Log.Info("Новых накладных не найдено")
|
invs[i].RMSServerID = serverID
|
||||||
return nil
|
// В Items пока не добавляли ServerID
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.invoiceRepo.SaveInvoices(invoices); err != nil {
|
if len(invs) > 0 {
|
||||||
return fmt.Errorf("ошибка сохранения накладных в БД: %w", err)
|
return s.invoiceRepo.SaveInvoices(invs)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Log.Info("Синхронизация накладных завершена", zap.Int("count", len(invoices)))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// classifyOperation определяет тип операции на основе DocumentType
|
// SyncStoreOperations публичный, если нужно вызывать отдельно
|
||||||
func classifyOperation(docType string) operations.OperationType {
|
func (s *Service) SyncStoreOperations(c rms.ClientI, serverID uuid.UUID) error {
|
||||||
switch docType {
|
|
||||||
// === ПРИХОД (PURCHASE) ===
|
|
||||||
case "INCOMING_INVOICE": // Приходная накладная
|
|
||||||
return operations.OpTypePurchase
|
|
||||||
case "INCOMING_SERVICE": // Акт приема услуг (редко товары, но бывает)
|
|
||||||
return operations.OpTypePurchase
|
|
||||||
|
|
||||||
// === РАСХОД (USAGE) ===
|
|
||||||
case "SALES_DOCUMENT": // Акт реализации (продажа)
|
|
||||||
return operations.OpTypeUsage
|
|
||||||
case "WRITEOFF_DOCUMENT": // Акт списания (порча, проработки)
|
|
||||||
return operations.OpTypeUsage
|
|
||||||
case "OUTGOING_INVOICE": // Расходная накладная
|
|
||||||
return operations.OpTypeUsage
|
|
||||||
case "SESSION_ACCEPTANCE": // Принятие смены (иногда агрегирует продажи)
|
|
||||||
return operations.OpTypeUsage
|
|
||||||
case "DISASSEMBLE_DOCUMENT": // Акт разбора (расход целого)
|
|
||||||
return operations.OpTypeUsage
|
|
||||||
|
|
||||||
// === Спорные/Игнорируемые ===
|
|
||||||
// RETURNED_INVOICE (Возвратная накладная) - технически это уменьшение прихода,
|
|
||||||
// но для рекомендаций "что мы покупаем" лучше обрабатывать отдельно или как минус-purchase.
|
|
||||||
// Пока отнесем к UNKNOWN, чтобы не портить статистику чистого прихода,
|
|
||||||
// либо можно считать как Purchase с отрицательным Amount (если XML дает минус).
|
|
||||||
case "RETURNED_INVOICE":
|
|
||||||
return operations.OpTypeUnknown
|
|
||||||
|
|
||||||
case "INTERNAL_TRANSFER":
|
|
||||||
return operations.OpTypeUnknown // Перемещение нас не интересует в рамках рекомендаций "купил/продал"
|
|
||||||
case "INCOMING_INVENTORY":
|
|
||||||
return operations.OpTypeUnknown // Инвентаризация
|
|
||||||
|
|
||||||
default:
|
|
||||||
return operations.OpTypeUnknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncStores загружает список складов
|
|
||||||
func (s *Service) SyncStores() error {
|
|
||||||
logger.Log.Info("Синхронизация складов...")
|
|
||||||
stores, err := s.rmsClient.FetchStores()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("ошибка получения складов из RMS: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.catalogRepo.SaveStores(stores); err != nil {
|
|
||||||
return fmt.Errorf("ошибка сохранения складов в БД: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Log.Info("Склады обновлены", zap.Int("count", len(stores)))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) SyncStoreOperations() error {
|
|
||||||
dateTo := time.Now()
|
dateTo := time.Now()
|
||||||
dateFrom := dateTo.AddDate(0, 0, -30)
|
dateFrom := dateTo.AddDate(0, 0, -30)
|
||||||
|
|
||||||
// 1. Синхронизируем Закупки (PresetPurchases)
|
if err := s.syncReport(c, serverID, PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {
|
||||||
// Мы передаем OpTypePurchase, чтобы репозиторий знал, какую "полку" очистить перед записью.
|
return fmt.Errorf("purchases sync error: %w", err)
|
||||||
if err := s.syncReport(PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {
|
|
||||||
return fmt.Errorf("ошибка синхронизации закупок: %w", err)
|
|
||||||
}
|
}
|
||||||
|
if err := s.syncReport(c, serverID, PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {
|
||||||
// 2. Синхронизируем Расход (PresetUsage)
|
return fmt.Errorf("usage sync error: %w", err)
|
||||||
if err := s.syncReport(PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {
|
|
||||||
return fmt.Errorf("ошибка синхронизации расхода: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) syncReport(presetID string, targetOpType operations.OperationType, from, to time.Time) error {
|
func (s *Service) syncReport(c rms.ClientI, serverID uuid.UUID, presetID string, targetOpType operations.OperationType, from, to time.Time) error {
|
||||||
logger.Log.Info("Запрос отчета RMS", zap.String("preset", presetID))
|
items, err := c.FetchStoreOperations(presetID, from, to)
|
||||||
|
|
||||||
items, err := s.rmsClient.FetchStoreOperations(presetID, from, to)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ops []operations.StoreOperation
|
var ops []operations.StoreOperation
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
// 1. Валидация товара
|
|
||||||
pID, err := uuid.Parse(item.ProductID)
|
pID, err := uuid.Parse(item.ProductID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Определение реального типа операции
|
|
||||||
realOpType := classifyOperation(item.DocumentType)
|
realOpType := classifyOperation(item.DocumentType)
|
||||||
|
if realOpType == operations.OpTypeUnknown || realOpType != targetOpType {
|
||||||
// 3. Фильтрация "мусора"
|
|
||||||
// Если мы грузим отчет "Закупки", но туда попало "Перемещение" (из-за кривого пресета),
|
|
||||||
// мы это пропустим. Либо если документ неизвестного типа.
|
|
||||||
if realOpType == operations.OpTypeUnknown {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Важно: Мы сохраняем только то, что соответствует целевому типу этапа синхронизации.
|
|
||||||
// Если в пресете "Закупки" попалась "Реализация", мы не должны писать её в "Закупки",
|
|
||||||
// и не должны писать в "Расход" (так как мы сейчас чистим "Закупки").
|
|
||||||
if realOpType != targetOpType {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
ops = append(ops, operations.StoreOperation{
|
ops = append(ops, operations.StoreOperation{
|
||||||
|
RMSServerID: serverID,
|
||||||
ProductID: pID,
|
ProductID: pID,
|
||||||
OpType: realOpType,
|
OpType: realOpType,
|
||||||
DocumentType: item.DocumentType,
|
DocumentType: item.DocumentType,
|
||||||
@@ -274,13 +249,59 @@ func (s *Service) syncReport(presetID string, targetOpType operations.OperationT
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.opRepo.SaveOperations(ops, targetOpType, from, to); err != nil {
|
return s.opRepo.SaveOperations(ops, serverID, targetOpType, from, to)
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
func classifyOperation(docType string) operations.OperationType {
|
||||||
|
switch docType {
|
||||||
|
case "INCOMING_INVOICE", "INCOMING_SERVICE":
|
||||||
|
return operations.OpTypePurchase
|
||||||
|
case "SALES_DOCUMENT", "WRITEOFF_DOCUMENT", "OUTGOING_INVOICE", "SESSION_ACCEPTANCE", "DISASSEMBLE_DOCUMENT":
|
||||||
|
return operations.OpTypeUsage
|
||||||
|
default:
|
||||||
|
return operations.OpTypeUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем структуру для возврата статистики
|
||||||
|
type SyncStats struct {
|
||||||
|
ServerName string
|
||||||
|
ProductsCount int64
|
||||||
|
StoresCount int64
|
||||||
|
SuppliersCount int64
|
||||||
|
InvoicesLast30 int64
|
||||||
|
LastInvoice *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSyncStats собирает информацию о данных текущего сервера
|
||||||
|
func (s *Service) GetSyncStats(userID uuid.UUID) (*SyncStats, error) {
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return nil, fmt.Errorf("нет активного сервера")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Log.Info("Отчет сохранен",
|
stats := &SyncStats{
|
||||||
zap.String("op_type", string(targetOpType)),
|
ServerName: server.Name,
|
||||||
zap.Int("received", len(items)),
|
}
|
||||||
zap.Int("saved", len(ops)))
|
|
||||||
return nil
|
// Параллельный запуск не обязателен, запросы Count очень быстрые
|
||||||
|
if cnt, err := s.catalogRepo.CountGoods(server.ID); err == nil {
|
||||||
|
stats.ProductsCount = cnt
|
||||||
|
}
|
||||||
|
|
||||||
|
if cnt, err := s.catalogRepo.CountStores(server.ID); err == nil {
|
||||||
|
stats.StoresCount = cnt
|
||||||
|
}
|
||||||
|
|
||||||
|
if cnt, err := s.supplierRepo.Count(server.ID); err == nil {
|
||||||
|
stats.SuppliersCount = cnt
|
||||||
|
}
|
||||||
|
|
||||||
|
if cnt, err := s.invoiceRepo.CountRecent(server.ID, 30); err == nil {
|
||||||
|
stats.InvoicesLast30 = cnt
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.LastInvoice, _ = s.invoiceRepo.GetLastInvoiceDate(server.ID)
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ func NewDraftsHandler(service *drafts.Service) *DraftsHandler {
|
|||||||
return &DraftsHandler{service: service}
|
return &DraftsHandler{service: service}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDraft возвращает полные данные черновика
|
// GetDraft
|
||||||
func (h *DraftsHandler) GetDraft(c *gin.Context) {
|
func (h *DraftsHandler) GetDraft(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := uuid.Parse(idStr)
|
id, err := uuid.Parse(idStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,7 +31,7 @@ func (h *DraftsHandler) GetDraft(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
draft, err := h.service.GetDraft(id)
|
draft, err := h.service.GetDraft(id, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "draft not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "draft not found"})
|
||||||
return
|
return
|
||||||
@@ -38,17 +39,37 @@ func (h *DraftsHandler) GetDraft(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, draft)
|
c.JSON(http.StatusOK, draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStores возвращает список складов
|
// GetDictionaries (бывший GetStores)
|
||||||
func (h *DraftsHandler) GetStores(c *gin.Context) {
|
func (h *DraftsHandler) GetDictionaries(c *gin.Context) {
|
||||||
stores, err := h.service.GetActiveStores()
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
|
data, err := h.service.GetDictionaries(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Log.Error("GetDictionaries error", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, stores)
|
c.JSON(http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateItemDTO - тело запроса на изменение строки
|
// GetStores - устаревший метод для обратной совместимости
|
||||||
|
// Возвращает массив складов
|
||||||
|
func (h *DraftsHandler) GetStores(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
|
// Используем логику из GetDictionaries, но возвращаем только stores
|
||||||
|
dict, err := h.service.GetDictionaries(userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("GetStores error", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// dict["stores"] уже содержит []catalog.Store
|
||||||
|
c.JSON(http.StatusOK, dict["stores"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateItemDTO
|
||||||
type UpdateItemDTO struct {
|
type UpdateItemDTO struct {
|
||||||
ProductID *string `json:"product_id"`
|
ProductID *string `json:"product_id"`
|
||||||
ContainerID *string `json:"container_id"`
|
ContainerID *string `json:"container_id"`
|
||||||
@@ -57,6 +78,7 @@ type UpdateItemDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DraftsHandler) UpdateItem(c *gin.Context) {
|
func (h *DraftsHandler) UpdateItem(c *gin.Context) {
|
||||||
|
// userID := c.MustGet("userID").(uuid.UUID) // Пока не используется в UpdateItem, но можно добавить проверку владельца
|
||||||
draftID, _ := uuid.Parse(c.Param("id"))
|
draftID, _ := uuid.Parse(c.Param("id"))
|
||||||
itemID, _ := uuid.Parse(c.Param("itemId"))
|
itemID, _ := uuid.Parse(c.Param("itemId"))
|
||||||
|
|
||||||
@@ -99,8 +121,8 @@ type CommitRequestDTO struct {
|
|||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommitDraft сохраняет шапку и отправляет в RMS
|
|
||||||
func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
draftID, err := uuid.Parse(c.Param("id"))
|
draftID, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
||||||
@@ -113,10 +135,9 @@ func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Парсинг данных шапки
|
|
||||||
date, err := time.Parse("2006-01-02", req.DateIncoming)
|
date, err := time.Parse("2006-01-02", req.DateIncoming)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date format (YYYY-MM-DD)"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date format"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
storeID, err := uuid.Parse(req.StoreID)
|
storeID, err := uuid.Parse(req.StoreID)
|
||||||
@@ -130,35 +151,30 @@ func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Обновляем шапку
|
|
||||||
if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment); err != nil {
|
if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update header: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update header: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Отправляем
|
docNum, err := h.service.CommitDraft(draftID, userID)
|
||||||
docNum, err := h.service.CommitDraft(draftID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Commit failed", zap.Error(err))
|
logger.Log.Error("Commit failed", zap.Error(err))
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "RMS error: " + err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "RMS error: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{"status": "completed", "document_number": docNum})
|
||||||
"status": "completed",
|
|
||||||
"document_number": docNum,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddContainerRequestDTO - запрос на создание фасовки
|
|
||||||
type AddContainerRequestDTO struct {
|
type AddContainerRequestDTO struct {
|
||||||
ProductID string `json:"product_id" binding:"required"`
|
ProductID string `json:"product_id" binding:"required"`
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Count float64 `json:"count" binding:"required,gt=0"`
|
Count float64 `json:"count" binding:"required,gt=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddContainer создает новую фасовку для товара
|
|
||||||
func (h *DraftsHandler) AddContainer(c *gin.Context) {
|
func (h *DraftsHandler) AddContainer(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
var req AddContainerRequestDTO
|
var req AddContainerRequestDTO
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -171,29 +187,22 @@ func (h *DraftsHandler) AddContainer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Конвертация float64 -> decimal
|
|
||||||
countDec := decimal.NewFromFloat(req.Count)
|
countDec := decimal.NewFromFloat(req.Count)
|
||||||
|
|
||||||
// Вызов сервиса
|
newID, err := h.service.CreateProductContainer(userID, pID, req.Name, countDec)
|
||||||
newID, err := h.service.CreateProductContainer(pID, req.Name, countDec)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Failed to create container", zap.Error(err))
|
logger.Log.Error("Failed to create container", zap.Error(err))
|
||||||
// Можно возвращать 502, если ошибка от RMS
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{"status": "created", "container_id": newID.String()})
|
||||||
"status": "created",
|
|
||||||
"container_id": newID.String(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DraftListItemDTO - структура элемента списка
|
|
||||||
type DraftListItemDTO struct {
|
type DraftListItemDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DocumentNumber string `json:"document_number"`
|
DocumentNumber string `json:"document_number"`
|
||||||
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
|
DateIncoming string `json:"date_incoming"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ItemsCount int `json:"items_count"`
|
ItemsCount int `json:"items_count"`
|
||||||
TotalSum float64 `json:"total_sum"`
|
TotalSum float64 `json:"total_sum"`
|
||||||
@@ -201,38 +210,30 @@ type DraftListItemDTO struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDrafts возвращает список активных черновиков
|
|
||||||
func (h *DraftsHandler) GetDrafts(c *gin.Context) {
|
func (h *DraftsHandler) GetDrafts(c *gin.Context) {
|
||||||
list, err := h.service.GetActiveDrafts()
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
list, err := h.service.GetActiveDrafts(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Failed to fetch drafts", zap.Error(err))
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response := make([]DraftListItemDTO, 0, len(list))
|
response := make([]DraftListItemDTO, 0, len(list))
|
||||||
|
|
||||||
for _, d := range list {
|
for _, d := range list {
|
||||||
// Расчет суммы
|
|
||||||
var totalSum decimal.Decimal
|
var totalSum decimal.Decimal
|
||||||
for _, item := range d.Items {
|
for _, item := range d.Items {
|
||||||
// Если item.Sum посчитана - берем её, иначе (qty * price)
|
|
||||||
if !item.Sum.IsZero() {
|
if !item.Sum.IsZero() {
|
||||||
totalSum = totalSum.Add(item.Sum)
|
totalSum = totalSum.Add(item.Sum)
|
||||||
} else {
|
} else {
|
||||||
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
|
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sumFloat, _ := totalSum.Float64()
|
sumFloat, _ := totalSum.Float64()
|
||||||
|
|
||||||
// Форматирование даты
|
|
||||||
dateStr := ""
|
dateStr := ""
|
||||||
if d.DateIncoming != nil {
|
if d.DateIncoming != nil {
|
||||||
dateStr = d.DateIncoming.Format("2006-01-02")
|
dateStr = d.DateIncoming.Format("2006-01-02")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Имя склада
|
|
||||||
storeName := ""
|
storeName := ""
|
||||||
if d.Store != nil {
|
if d.Store != nil {
|
||||||
storeName = d.Store.Name
|
storeName = d.Store.Name
|
||||||
@@ -249,12 +250,11 @@ func (h *DraftsHandler) GetDrafts(c *gin.Context) {
|
|||||||
CreatedAt: d.CreatedAt.Format(time.RFC3339),
|
CreatedAt: d.CreatedAt.Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDraft обрабатывает запрос на удаление/отмену
|
|
||||||
func (h *DraftsHandler) DeleteDraft(c *gin.Context) {
|
func (h *DraftsHandler) DeleteDraft(c *gin.Context) {
|
||||||
|
// userID := c.MustGet("userID").(uuid.UUID) // Можно добавить проверку владельца
|
||||||
idStr := c.Param("id")
|
idStr := c.Param("id")
|
||||||
id, err := uuid.Parse(idStr)
|
id, err := uuid.Parse(idStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -264,14 +264,9 @@ func (h *DraftsHandler) DeleteDraft(c *gin.Context) {
|
|||||||
|
|
||||||
newStatus, err := h.service.DeleteDraft(id)
|
newStatus, err := h.service.DeleteDraft(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Failed to delete draft", zap.Error(err))
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Возвращаем новый статус, чтобы фронтенд знал, удалился он совсем или стал CANCELED
|
c.JSON(http.StatusOK, gin.H{"status": newStatus, "id": id.String()})
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"status": newStatus,
|
|
||||||
"id": id.String(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -22,9 +22,19 @@ func NewOCRHandler(service *ocrService.Service) *OCRHandler {
|
|||||||
|
|
||||||
// GetCatalog возвращает список товаров для OCR сервиса
|
// GetCatalog возвращает список товаров для OCR сервиса
|
||||||
func (h *OCRHandler) GetCatalog(c *gin.Context) {
|
func (h *OCRHandler) GetCatalog(c *gin.Context) {
|
||||||
items, err := h.service.GetCatalogForIndexing()
|
// Если этот эндпоинт дергает Python-скрипт без токена пользователя - это проблема безопасности.
|
||||||
|
// Либо Python скрипт должен передавать токен админа/системы и ID сервера в query.
|
||||||
|
// ПОКА: Предполагаем, что запрос идет от фронта или с заголовком X-Telegram-User-ID.
|
||||||
|
|
||||||
|
// Если заголовка нет (вызов от скрипта), пробуем взять server_id из query (небезопасно, но для MVP)
|
||||||
|
// Или лучше так: этот метод вызывается Фронтендом для поиска? Нет, название GetCatalogForIndexing намекает на OCR.
|
||||||
|
// Оставим пока требование UserID.
|
||||||
|
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
|
items, err := h.service.GetCatalogForIndexing(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Ошибка получения каталога для OCR", zap.Error(err))
|
logger.Log.Error("Ошибка получения каталога", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -38,8 +48,9 @@ type MatchRequest struct {
|
|||||||
ContainerID *string `json:"container_id"`
|
ContainerID *string `json:"container_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveMatch сохраняет привязку (обучение)
|
|
||||||
func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
var req MatchRequest
|
var req MatchRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -64,7 +75,7 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.service.SaveMapping(req.RawName, pID, qty, contID); err != nil {
|
if err := h.service.SaveMapping(userID, req.RawName, pID, qty, contID); err != nil {
|
||||||
logger.Log.Error("Ошибка сохранения матчинга", zap.Error(err))
|
logger.Log.Error("Ошибка сохранения матчинга", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -73,18 +84,16 @@ func (h *OCRHandler) SaveMatch(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "saved"})
|
c.JSON(http.StatusOK, gin.H{"status": "saved"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteMatch удаляет связь
|
|
||||||
func (h *OCRHandler) DeleteMatch(c *gin.Context) {
|
func (h *OCRHandler) DeleteMatch(c *gin.Context) {
|
||||||
// Получаем raw_name из query параметров, так как в URL path могут быть спецсимволы
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
// Пример: DELETE /api/ocr/match?raw_name=Хлеб%20Бородинский
|
|
||||||
rawName := c.Query("raw_name")
|
rawName := c.Query("raw_name")
|
||||||
|
|
||||||
if rawName == "" {
|
if rawName == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "raw_name is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "raw_name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.service.DeleteMatch(rawName); err != nil {
|
if err := h.service.DeleteMatch(userID, rawName); err != nil {
|
||||||
logger.Log.Error("Ошибка удаления матча", zap.Error(err))
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -92,43 +101,32 @@ func (h *OCRHandler) DeleteMatch(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchProducts ищет товары (для автокомплита)
|
|
||||||
func (h *OCRHandler) SearchProducts(c *gin.Context) {
|
func (h *OCRHandler) SearchProducts(c *gin.Context) {
|
||||||
query := c.Query("q") // ?q=молоко
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
if query == "" {
|
query := c.Query("q")
|
||||||
c.JSON(http.StatusOK, []interface{}{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
products, err := h.service.SearchProducts(query)
|
products, err := h.service.SearchProducts(userID, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Search error", zap.Error(err))
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Отдаем на фронт упрощенную структуру или полную, в зависимости от нужд.
|
|
||||||
// Product entity уже содержит JSON теги, так что можно отдать напрямую.
|
|
||||||
c.JSON(http.StatusOK, products)
|
c.JSON(http.StatusOK, products)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMatches возвращает список всех обученных связей
|
|
||||||
func (h *OCRHandler) GetMatches(c *gin.Context) {
|
func (h *OCRHandler) GetMatches(c *gin.Context) {
|
||||||
matches, err := h.service.GetKnownMatches()
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
matches, err := h.service.GetKnownMatches(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Ошибка получения списка матчей", zap.Error(err))
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, matches)
|
c.JSON(http.StatusOK, matches)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUnmatched возвращает список нераспознанных позиций для подсказок
|
|
||||||
func (h *OCRHandler) GetUnmatched(c *gin.Context) {
|
func (h *OCRHandler) GetUnmatched(c *gin.Context) {
|
||||||
items, err := h.service.GetUnmatchedItems()
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
items, err := h.service.GetUnmatchedItems(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Ошибка получения списка unmatched", zap.Error(err))
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
49
internal/transport/http/middleware/auth.go
Normal file
49
internal/transport/http/middleware/auth.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMiddleware извлекает Telegram User ID и находит User UUID
|
||||||
|
func AuthMiddleware(accountRepo account.Repository) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// 1. Ищем в заголовке (стандартный путь)
|
||||||
|
tgIDStr := c.GetHeader("X-Telegram-User-ID")
|
||||||
|
|
||||||
|
// 2. Если нет в заголовке, ищем в Query (для отладки в браузере)
|
||||||
|
// Пример: /api/drafts?_tg_id=12345678
|
||||||
|
if tgIDStr == "" {
|
||||||
|
tgIDStr = c.Query("_tg_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tgIDStr == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Telegram-User-ID header or _tg_id param"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tgID, err := strconv.ParseInt(tgIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Telegram ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем пользователя в БД
|
||||||
|
user, err := accountRepo.GetUserByTelegramID(tgID)
|
||||||
|
if err != nil {
|
||||||
|
// Если пользователя нет - значит он не нажал /start в боте
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "User not registered via Bot. Please start the bot first."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кладем UUID пользователя в контекст
|
||||||
|
c.Set("userID", user.ID)
|
||||||
|
c.Set("telegramID", tgID)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,23 +8,48 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
tele "gopkg.in/telebot.v3"
|
tele "gopkg.in/telebot.v3"
|
||||||
"gopkg.in/telebot.v3/middleware"
|
"gopkg.in/telebot.v3/middleware"
|
||||||
|
|
||||||
"rmser/config"
|
"rmser/config"
|
||||||
|
"rmser/internal/domain/account"
|
||||||
|
"rmser/internal/infrastructure/rms"
|
||||||
"rmser/internal/services/ocr"
|
"rmser/internal/services/ocr"
|
||||||
|
"rmser/internal/services/sync"
|
||||||
|
"rmser/pkg/crypto"
|
||||||
"rmser/pkg/logger"
|
"rmser/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
b *tele.Bot
|
b *tele.Bot
|
||||||
ocrService *ocr.Service
|
ocrService *ocr.Service
|
||||||
|
syncService *sync.Service
|
||||||
|
accountRepo account.Repository
|
||||||
|
rmsFactory *rms.Factory
|
||||||
|
cryptoManager *crypto.CryptoManager
|
||||||
|
|
||||||
|
fsm *StateManager
|
||||||
adminIDs map[int64]struct{}
|
adminIDs map[int64]struct{}
|
||||||
webAppURL string
|
webAppURL string
|
||||||
|
|
||||||
|
// UI Elements (Menus)
|
||||||
|
menuMain *tele.ReplyMarkup
|
||||||
|
menuServers *tele.ReplyMarkup
|
||||||
|
menuDicts *tele.ReplyMarkup
|
||||||
|
menuBalance *tele.ReplyMarkup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
|
func NewBot(
|
||||||
|
cfg config.TelegramConfig,
|
||||||
|
ocrService *ocr.Service,
|
||||||
|
syncService *sync.Service,
|
||||||
|
accountRepo account.Repository,
|
||||||
|
rmsFactory *rms.Factory,
|
||||||
|
cryptoManager *crypto.CryptoManager,
|
||||||
|
) (*Bot, error) {
|
||||||
|
|
||||||
pref := tele.Settings{
|
pref := tele.Settings{
|
||||||
Token: cfg.Token,
|
Token: cfg.Token,
|
||||||
Poller: &tele.LongPoller{Timeout: 10 * time.Second},
|
Poller: &tele.LongPoller{Timeout: 10 * time.Second},
|
||||||
@@ -46,62 +71,372 @@ func NewBot(cfg config.TelegramConfig, ocrService *ocr.Service) (*Bot, error) {
|
|||||||
bot := &Bot{
|
bot := &Bot{
|
||||||
b: b,
|
b: b,
|
||||||
ocrService: ocrService,
|
ocrService: ocrService,
|
||||||
|
syncService: syncService,
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
rmsFactory: rmsFactory,
|
||||||
|
cryptoManager: cryptoManager,
|
||||||
|
fsm: NewStateManager(),
|
||||||
adminIDs: admins,
|
adminIDs: admins,
|
||||||
webAppURL: cfg.WebAppURL,
|
webAppURL: cfg.WebAppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если в конфиге пусто, ставим заглушку, чтобы не падало, но предупреждаем
|
|
||||||
if bot.webAppURL == "" {
|
if bot.webAppURL == "" {
|
||||||
logger.Log.Warn("Telegram WebAppURL не задан в конфиге! Кнопки работать не будут.")
|
|
||||||
bot.webAppURL = "http://example.com"
|
bot.webAppURL = "http://example.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bot.initMenus()
|
||||||
bot.initHandlers()
|
bot.initHandlers()
|
||||||
return bot, nil
|
return bot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initMenus инициализирует статические кнопки
|
||||||
|
func (bot *Bot) initMenus() {
|
||||||
|
// --- MAIN MENU ---
|
||||||
|
bot.menuMain = &tele.ReplyMarkup{}
|
||||||
|
btnServers := bot.menuMain.Data("🖥 Серверы", "nav_servers")
|
||||||
|
btnDicts := bot.menuMain.Data("🔄 Справочники", "nav_dicts")
|
||||||
|
btnBalance := bot.menuMain.Data("💰 Баланс", "nav_balance")
|
||||||
|
btnApp := bot.menuMain.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL})
|
||||||
|
|
||||||
|
bot.menuMain.Inline(
|
||||||
|
bot.menuMain.Row(btnServers, btnDicts),
|
||||||
|
bot.menuMain.Row(btnBalance),
|
||||||
|
bot.menuMain.Row(btnApp),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- SERVERS MENU (Dynamic part logic is in handler) ---
|
||||||
|
bot.menuServers = &tele.ReplyMarkup{}
|
||||||
|
|
||||||
|
// --- DICTIONARIES MENU ---
|
||||||
|
bot.menuDicts = &tele.ReplyMarkup{}
|
||||||
|
btnSync := bot.menuDicts.Data("⚡️ Обновить данные", "act_sync")
|
||||||
|
btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main")
|
||||||
|
bot.menuDicts.Inline(
|
||||||
|
bot.menuDicts.Row(btnSync),
|
||||||
|
bot.menuDicts.Row(btnBack),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- BALANCE MENU ---
|
||||||
|
bot.menuBalance = &tele.ReplyMarkup{}
|
||||||
|
btnDeposit := bot.menuBalance.Data("💳 Пополнить (Demo)", "act_deposit")
|
||||||
|
bot.menuBalance.Inline(
|
||||||
|
bot.menuBalance.Row(btnDeposit),
|
||||||
|
bot.menuBalance.Row(btnBack),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) initHandlers() {
|
||||||
|
bot.b.Use(middleware.Logger())
|
||||||
|
bot.b.Use(bot.registrationMiddleware)
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
bot.b.Handle("/start", bot.renderMainMenu)
|
||||||
|
|
||||||
|
// Navigation Callbacks
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu)
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "nav_servers"}, bot.renderServersMenu)
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "nav_dicts"}, bot.renderDictsMenu)
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "nav_balance"}, bot.renderBalanceMenu)
|
||||||
|
|
||||||
|
// Actions Callbacks
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "act_add_server"}, bot.startAddServerFlow)
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "act_sync"}, bot.triggerSync)
|
||||||
|
bot.b.Handle(&tele.Btn{Unique: "act_deposit"}, func(c tele.Context) error {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Функция пополнения в разработке 🛠"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dynamic Handler for server selection ("set_server_UUID")
|
||||||
|
bot.b.Handle(tele.OnCallback, bot.handleCallback)
|
||||||
|
|
||||||
|
// Input Handlers
|
||||||
|
bot.b.Handle(tele.OnText, bot.handleText)
|
||||||
|
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
||||||
|
}
|
||||||
|
|
||||||
func (bot *Bot) Start() {
|
func (bot *Bot) Start() {
|
||||||
logger.Log.Info("Запуск Telegram бота...")
|
logger.Log.Info("Запуск Telegram бота...")
|
||||||
bot.b.Start()
|
bot.b.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) Stop() {
|
func (bot *Bot) Stop() { bot.b.Stop() }
|
||||||
bot.b.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Middleware для проверки прав (только админы)
|
func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
||||||
func (bot *Bot) authMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
|
||||||
return func(c tele.Context) error {
|
return func(c tele.Context) error {
|
||||||
if len(bot.adminIDs) > 0 {
|
user := c.Sender()
|
||||||
if _, ok := bot.adminIDs[c.Sender().ID]; !ok {
|
_, err := bot.accountRepo.GetOrCreateUser(user.ID, user.Username, user.FirstName, user.LastName)
|
||||||
return c.Send("⛔ У вас нет доступа к этому боту.")
|
if err != nil {
|
||||||
}
|
logger.Log.Error("Failed to register user", zap.Error(err))
|
||||||
}
|
}
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) initHandlers() {
|
// --- RENDERERS (View Layer) ---
|
||||||
bot.b.Use(middleware.Logger())
|
|
||||||
bot.b.Use(bot.authMiddleware)
|
|
||||||
|
|
||||||
bot.b.Handle("/start", func(c tele.Context) error {
|
func (bot *Bot) renderMainMenu(c tele.Context) error {
|
||||||
return c.Send("👋 Привет! Я RMSER Bot.\nОтправь мне фото накладной или чека, и я попробую его распознать.")
|
// Сбрасываем стейты FSM, если пользователь вернулся в меню
|
||||||
|
bot.fsm.Reset(c.Sender().ID)
|
||||||
|
|
||||||
|
txt := "👋 <b>Панель управления RMSER</b>\n\n" +
|
||||||
|
"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников."
|
||||||
|
|
||||||
|
return c.EditOrSend(txt, bot.menuMain, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) renderServersMenu(c tele.Context) error {
|
||||||
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
servers, err := bot.accountRepo.GetAllServers(userDB.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("Ошибка БД: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
var rows []tele.Row
|
||||||
|
|
||||||
|
// Генерируем кнопки для каждого сервера
|
||||||
|
for _, s := range servers {
|
||||||
|
icon := "🔴"
|
||||||
|
if s.IsActive {
|
||||||
|
icon = "🟢"
|
||||||
|
}
|
||||||
|
// Payload: "set_server_<UUID>"
|
||||||
|
btn := menu.Data(fmt.Sprintf("%s %s", icon, s.Name), "set_server_"+s.ID.String())
|
||||||
|
rows = append(rows, menu.Row(btn))
|
||||||
|
}
|
||||||
|
|
||||||
|
btnAdd := menu.Data("➕ Добавить сервер", "act_add_server")
|
||||||
|
btnBack := menu.Data("🔙 Назад", "nav_main")
|
||||||
|
|
||||||
|
rows = append(rows, menu.Row(btnAdd))
|
||||||
|
rows = append(rows, menu.Row(btnBack))
|
||||||
|
|
||||||
|
menu.Inline(rows...)
|
||||||
|
|
||||||
|
txt := fmt.Sprintf("<b>🖥 Ваши серверы (%d):</b>\n\nНажмите на сервер, чтобы сделать его активным.", len(servers))
|
||||||
|
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) renderDictsMenu(c tele.Context) error {
|
||||||
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
|
stats, err := bot.syncService.GetSyncStats(userDB.ID)
|
||||||
|
|
||||||
|
var txt string
|
||||||
|
if err != nil {
|
||||||
|
txt = fmt.Sprintf("⚠️ <b>Статус:</b> Ошибка (%v)", err)
|
||||||
|
} else {
|
||||||
|
lastUpdate := "—"
|
||||||
|
if stats.LastInvoice != nil {
|
||||||
|
lastUpdate = stats.LastInvoice.Format("02.01.2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
txt = fmt.Sprintf("<b>🔄 Состояние справочников</b>\n\n"+
|
||||||
|
"🏢 <b>Сервер:</b> %s\n"+
|
||||||
|
"📦 <b>Товары:</b> %d\n"+
|
||||||
|
"🚚 <b>Поставщики:</b> %d\n"+
|
||||||
|
"🏭 <b>Склады:</b> %d\n\n"+
|
||||||
|
"📄 <b>Накладные (30дн):</b> %d\n"+
|
||||||
|
"📅 <b>Посл. документ:</b> %s\n\n"+
|
||||||
|
"Нажмите «Обновить», чтобы синхронизировать данные.",
|
||||||
|
stats.ServerName,
|
||||||
|
stats.ProductsCount,
|
||||||
|
stats.SuppliersCount,
|
||||||
|
stats.StoresCount,
|
||||||
|
stats.InvoicesLast30,
|
||||||
|
lastUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.EditOrSend(txt, bot.menuDicts, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) renderBalanceMenu(c tele.Context) error {
|
||||||
|
// Заглушка баланса
|
||||||
|
txt := "<b>💰 Ваш баланс</b>\n\n" +
|
||||||
|
"💵 Текущий счет: <b>0.00 ₽</b>\n" +
|
||||||
|
"💎 Тариф: <b>Free</b>\n\n" +
|
||||||
|
"Пока сервис работает в бета-режиме, использование бесплатно."
|
||||||
|
|
||||||
|
return c.EditOrSend(txt, bot.menuBalance, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LOGIC HANDLERS ---
|
||||||
|
|
||||||
|
func (bot *Bot) handleCallback(c tele.Context) error {
|
||||||
|
data := c.Callback().Data
|
||||||
|
|
||||||
|
// Обработка выбора сервера "set_server_..."
|
||||||
|
if strings.HasPrefix(data, "set_server_") {
|
||||||
|
serverIDStr := strings.TrimPrefix(data, "set_server_")
|
||||||
|
// Удаляем лишние пробелы/символы, которые telebot иногда добавляет (уникальный префикс \f)
|
||||||
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
|
// Telebot v3: Callback data is prefixed with \f followed by unique id.
|
||||||
|
// But here we use 'data' which is the payload.
|
||||||
|
// NOTE: data variable contains what we passed in .Data() second arg.
|
||||||
|
|
||||||
|
// Split by | just in case middleware adds something, but usually raw string is fine.
|
||||||
|
parts := strings.Split(serverIDStr, "|") // Защита от старых форматов
|
||||||
|
serverIDStr = parts[0]
|
||||||
|
|
||||||
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
|
// 1. Ищем сервер в базе, чтобы убедиться что это сервер этого юзера
|
||||||
|
servers, _ := bot.accountRepo.GetAllServers(userDB.ID)
|
||||||
|
var found bool
|
||||||
|
for _, s := range servers {
|
||||||
|
if s.ID.String() == serverIDStr {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Сервер не найден или доступ запрещен"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Делаем активным
|
||||||
|
// Важно: нужно спарсить UUID
|
||||||
|
// Telebot sometimes sends garbage if Unique is not handled properly.
|
||||||
|
// But we handle OnCallback generally.
|
||||||
|
|
||||||
|
// Fix: В Telebot 3 Data() возвращает payload как есть.
|
||||||
|
// Но лучше быть аккуратным.
|
||||||
|
|
||||||
|
if err := bot.accountRepo.SetActiveServer(userDB.ID, parseUUID(serverIDStr)); err != nil {
|
||||||
|
logger.Log.Error("Failed to set active server", zap.Error(err))
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка смены сервера"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Сбрасываем кэш фабрики клиентов (чтобы при следующем запросе создался клиент с новыми кредами, если бы они поменялись,
|
||||||
|
// но тут меняется сам сервер, так что Factory.GetClientForUser просто возьмет другой сервер)
|
||||||
|
// Для надежности можно ничего не делать, Factory сама разберется.
|
||||||
|
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"})
|
||||||
|
return bot.renderServersMenu(c) // Перерисовываем меню
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) triggerSync(c tele.Context) error {
|
||||||
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."})
|
||||||
|
|
||||||
|
// Запускаем в фоне, но уведомляем юзера
|
||||||
|
go func() {
|
||||||
|
if err := bot.syncService.SyncAllData(userDB.ID); err != nil {
|
||||||
|
logger.Log.Error("Manual sync failed", zap.Error(err))
|
||||||
|
bot.b.Send(c.Sender(), "❌ Ошибка синхронизации. Проверьте настройки сервера.")
|
||||||
|
} else {
|
||||||
|
bot.b.Send(c.Sender(), "✅ Синхронизация успешно завершена!")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FSM: ADD SERVER FLOW ---
|
||||||
|
|
||||||
|
func (bot *Bot) startAddServerFlow(c tele.Context) error {
|
||||||
|
bot.fsm.SetState(c.Sender().ID, StateAddServerURL)
|
||||||
|
return c.EditOrSend("🔗 Введите <b>URL</b> вашего сервера iikoRMS.\nПример: <code>https://iiko.myrest.ru:443</code>\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (bot *Bot) handleText(c tele.Context) error {
|
||||||
|
userID := c.Sender().ID
|
||||||
|
state := bot.fsm.GetState(userID)
|
||||||
|
text := strings.TrimSpace(c.Text())
|
||||||
|
|
||||||
|
// Глобальная отмена
|
||||||
|
if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" {
|
||||||
|
bot.fsm.Reset(userID)
|
||||||
|
return bot.renderMainMenu(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == StateNone {
|
||||||
|
return c.Send("Используйте меню для навигации 👇")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case StateAddServerURL:
|
||||||
|
if !strings.HasPrefix(text, "http") {
|
||||||
|
return c.Send("❌ URL должен начинаться с http:// или https://\nПопробуйте снова.")
|
||||||
|
}
|
||||||
|
bot.fsm.UpdateContext(userID, func(ctx *UserContext) {
|
||||||
|
ctx.TempURL = strings.TrimRight(text, "/")
|
||||||
|
ctx.State = StateAddServerLogin
|
||||||
})
|
})
|
||||||
|
return c.Send("👤 Введите <b>логин</b> пользователя iiko:")
|
||||||
|
|
||||||
bot.b.Handle(tele.OnPhoto, bot.handlePhoto)
|
case StateAddServerLogin:
|
||||||
|
bot.fsm.UpdateContext(userID, func(ctx *UserContext) {
|
||||||
|
ctx.TempLogin = text
|
||||||
|
ctx.State = StateAddServerPassword
|
||||||
|
})
|
||||||
|
return c.Send("🔑 Введите <b>пароль</b>:")
|
||||||
|
|
||||||
|
case StateAddServerPassword:
|
||||||
|
password := text
|
||||||
|
ctx := bot.fsm.GetContext(userID)
|
||||||
|
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
|
||||||
|
|
||||||
|
// Check connection
|
||||||
|
tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)
|
||||||
|
if err := tempClient.Auth(); err != nil {
|
||||||
|
bot.b.Delete(msg)
|
||||||
|
return c.Send(fmt.Sprintf("❌ Ошибка: %v\nПопробуйте ввести пароль снова или начните сначала /add_server", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
encPass, _ := bot.cryptoManager.Encrypt(password)
|
||||||
|
userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "")
|
||||||
|
|
||||||
|
newServer := &account.RMSServer{
|
||||||
|
UserID: userDB.ID,
|
||||||
|
Name: "iiko Server " + time.Now().Format("15:04"), // Генерируем имя, чтобы не спрашивать лишнего
|
||||||
|
BaseURL: ctx.TempURL,
|
||||||
|
Login: ctx.TempLogin,
|
||||||
|
EncryptedPassword: encPass,
|
||||||
|
IsActive: true, // Сразу делаем активным
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала сохраняем, потом делаем активным (через репо сохранения)
|
||||||
|
if err := bot.accountRepo.SaveServer(newServer); err != nil {
|
||||||
|
return c.Send("Ошибка БД: " + err.Error())
|
||||||
|
}
|
||||||
|
// Устанавливаем активным (сбрасывая другие)
|
||||||
|
bot.accountRepo.SetActiveServer(userDB.ID, newServer.ID)
|
||||||
|
|
||||||
|
bot.fsm.Reset(userID)
|
||||||
|
bot.b.Delete(msg)
|
||||||
|
c.Send("✅ <b>Сервер добавлен и выбран активным!</b>", tele.ModeHTML)
|
||||||
|
|
||||||
|
// Auto-sync
|
||||||
|
go bot.syncService.SyncAllData(userDB.ID)
|
||||||
|
|
||||||
|
return bot.renderMainMenu(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (bot *Bot) handlePhoto(c tele.Context) error {
|
func (bot *Bot) handlePhoto(c tele.Context) error {
|
||||||
// 1. Скачиваем фото
|
userDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("Ошибка базы данных пользователей")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = bot.rmsFactory.GetClientForUser(userDB.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("⛔ У вас не настроен сервер iiko.\nИспользуйте /add_server для настройки.")
|
||||||
|
}
|
||||||
|
|
||||||
photo := c.Message().Photo
|
photo := c.Message().Photo
|
||||||
// Берем файл самого высокого качества (последний в массиве, но telebot дает удобный доступ)
|
|
||||||
file, err := bot.b.FileByID(photo.FileID)
|
file, err := bot.b.FileByID(photo.FileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Send("Ошибка доступа к файлу.")
|
return c.Send("Ошибка доступа к файлу.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Читаем тело файла
|
|
||||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath)
|
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath)
|
||||||
resp, err := http.Get(fileURL)
|
resp, err := http.Get(fileURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -116,17 +451,15 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
|||||||
|
|
||||||
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
|
c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...")
|
||||||
|
|
||||||
// 2. Отправляем в сервис (добавили ID чата)
|
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) // Чуть увеличим таймаут
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
draft, err := bot.ocrService.ProcessReceiptImage(ctx, c.Chat().ID, imgData)
|
draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("OCR processing failed", zap.Error(err))
|
logger.Log.Error("OCR processing failed", zap.Error(err))
|
||||||
return c.Send("❌ Ошибка обработки: " + err.Error())
|
return c.Send("❌ Ошибка обработки: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Анализ результатов для сообщения
|
|
||||||
matchedCount := 0
|
matchedCount := 0
|
||||||
for _, item := range draft.Items {
|
for _, item := range draft.Items {
|
||||||
if item.IsMatched {
|
if item.IsMatched {
|
||||||
@@ -134,29 +467,24 @@ func (bot *Bot) handlePhoto(c tele.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Формируем URL. Для Mini App это должен быть https URL вашего фронтенда.
|
|
||||||
// Фронтенд должен уметь роутить /invoice/:id
|
|
||||||
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
baseURL := strings.TrimRight(bot.webAppURL, "/")
|
||||||
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String())
|
||||||
|
|
||||||
// Формируем текст сообщения
|
|
||||||
var msgText string
|
var msgText string
|
||||||
if matchedCount == len(draft.Items) {
|
if matchedCount == len(draft.Items) {
|
||||||
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
|
msgText = fmt.Sprintf("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
|
||||||
} else {
|
} else {
|
||||||
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления. Нажмите кнопку ниже, чтобы исправить.", matchedCount, len(draft.Items))
|
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления.", matchedCount, len(draft.Items))
|
||||||
}
|
}
|
||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
|
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL})
|
||||||
// Используем WebApp, а не URL
|
menu.Inline(menu.Row(btnOpen))
|
||||||
btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{
|
|
||||||
URL: fullURL,
|
|
||||||
})
|
|
||||||
|
|
||||||
menu.Inline(
|
|
||||||
menu.Row(btnOpen),
|
|
||||||
)
|
|
||||||
|
|
||||||
return c.Send(msgText, menu, tele.ModeHTML)
|
return c.Send(msgText, menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseUUID(s string) uuid.UUID {
|
||||||
|
id, _ := uuid.Parse(s)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|||||||
77
internal/transport/telegram/fsm.go
Normal file
77
internal/transport/telegram/fsm.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// Состояния пользователя
|
||||||
|
type State int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateNone State = iota
|
||||||
|
StateAddServerURL
|
||||||
|
StateAddServerLogin
|
||||||
|
StateAddServerPassword
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserContext хранит временные данные в процессе диалога
|
||||||
|
type UserContext struct {
|
||||||
|
State State
|
||||||
|
TempURL string
|
||||||
|
TempLogin string
|
||||||
|
TempPassword string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateManager управляет состояниями
|
||||||
|
type StateManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
states map[int64]*UserContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStateManager() *StateManager {
|
||||||
|
return &StateManager{
|
||||||
|
states: make(map[int64]*UserContext),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) GetState(userID int64) State {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
if ctx, ok := sm.states[userID]; ok {
|
||||||
|
return ctx.State
|
||||||
|
}
|
||||||
|
return StateNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) SetState(userID int64, state State) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := sm.states[userID]; !ok {
|
||||||
|
sm.states[userID] = &UserContext{}
|
||||||
|
}
|
||||||
|
sm.states[userID].State = state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) GetContext(userID int64) *UserContext {
|
||||||
|
sm.mu.RLock()
|
||||||
|
defer sm.mu.RUnlock()
|
||||||
|
if ctx, ok := sm.states[userID]; ok {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return &UserContext{} // Return empty safe struct
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) UpdateContext(userID int64, updater func(*UserContext)) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := sm.states[userID]; !ok {
|
||||||
|
sm.states[userID] = &UserContext{}
|
||||||
|
}
|
||||||
|
updater(sm.states[userID])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) Reset(userID int64) {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
delete(sm.states, userID)
|
||||||
|
}
|
||||||
80
pkg/crypto/crypto.go
Normal file
80
pkg/crypto/crypto.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256" // <-- Добавлен импорт
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CryptoManager занимается шифрованием чувствительных данных (паролей RMS)
|
||||||
|
type CryptoManager struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCryptoManager(secretKey string) *CryptoManager {
|
||||||
|
// Исправление:
|
||||||
|
// AES требует строго 16, 24 или 32 байта.
|
||||||
|
// Чтобы не заставлять пользователя считать символы в конфиге,
|
||||||
|
// мы хешируем любую строку в SHA-256, получая всегда валидные 32 байта.
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte(secretKey))
|
||||||
|
keyBytes := hasher.Sum(nil)
|
||||||
|
|
||||||
|
return &CryptoManager{key: keyBytes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt шифрует строку и возвращает base64
|
||||||
|
func (m *CryptoManager) Encrypt(plaintext string) (string, error) {
|
||||||
|
block, err := aes.NewCipher(m.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt расшифровывает base64 строку
|
||||||
|
func (m *CryptoManager) Decrypt(ciphertextBase64 string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(ciphertextBase64)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := aes.NewCipher(m.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
@@ -1,24 +1,57 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Result, Button } from 'antd';
|
||||||
import { Providers } from './components/layout/Providers';
|
import { Providers } from './components/layout/Providers';
|
||||||
import { AppLayout } from './components/layout/AppLayout';
|
import { AppLayout } from './components/layout/AppLayout';
|
||||||
import { Dashboard } from './pages/Dashboard';
|
|
||||||
import { OcrLearning } from './pages/OcrLearning';
|
import { OcrLearning } from './pages/OcrLearning';
|
||||||
import { InvoiceDraftPage } from './pages/InvoiceDraftPage';
|
import { InvoiceDraftPage } from './pages/InvoiceDraftPage';
|
||||||
import { DraftsList } from './pages/DraftsList';
|
import { DraftsList } from './pages/DraftsList';
|
||||||
|
import { UNAUTHORIZED_EVENT } from './services/api';
|
||||||
|
|
||||||
|
// Компонент заглушки для 401 ошибки
|
||||||
|
const UnauthorizedScreen = () => (
|
||||||
|
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
|
||||||
|
<Result
|
||||||
|
status="403"
|
||||||
|
title="Доступ запрещен"
|
||||||
|
subTitle="Мы не нашли вас в базе данных. Пожалуйста, запустите бота и настройте сервер."
|
||||||
|
extra={
|
||||||
|
<Button type="primary" href="https://t.me/RmserBot" target="_blank">
|
||||||
|
Перейти в бота @RmserBot
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleUnauthorized = () => setIsUnauthorized(true);
|
||||||
|
|
||||||
|
// Подписываемся на событие из api.ts
|
||||||
|
window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(UNAUTHORIZED_EVENT, handleUnauthorized);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isUnauthorized) {
|
||||||
|
return <UnauthorizedScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Providers>
|
<Providers>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppLayout />}>
|
<Route path="/" element={<AppLayout />}>
|
||||||
<Route index element={<Dashboard />} />
|
{/* Если Dashboard удален, можно сделать редирект на invoices */}
|
||||||
|
<Route index element={<Navigate to="/invoices" replace />} />
|
||||||
|
|
||||||
<Route path="ocr" element={<OcrLearning />} />
|
<Route path="ocr" element={<OcrLearning />} />
|
||||||
|
|
||||||
{/* Список черновиков */}
|
|
||||||
<Route path="invoices" element={<DraftsList />} />
|
<Route path="invoices" element={<DraftsList />} />
|
||||||
|
|
||||||
{/* Редактирование черновика */}
|
|
||||||
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
|
<Route path="invoice/:id" element={<InvoiceDraftPage />} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -24,8 +24,14 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// --- ЗАПРОСЫ ---
|
// --- ЗАПРОСЫ ---
|
||||||
const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores });
|
|
||||||
const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers });
|
// Получаем сразу все справочники одним запросом
|
||||||
|
const dictQuery = useQuery({
|
||||||
|
queryKey: ['dictionaries'],
|
||||||
|
queryFn: api.getDictionaries,
|
||||||
|
staleTime: 1000 * 60 * 5 // Кэшируем на 5 минут
|
||||||
|
});
|
||||||
|
|
||||||
const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
|
const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
|
||||||
|
|
||||||
const draftQuery = useQuery({
|
const draftQuery = useQuery({
|
||||||
@@ -39,8 +45,9 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const draft = draftQuery.data;
|
const draft = draftQuery.data;
|
||||||
|
const stores = dictQuery.data?.stores || [];
|
||||||
|
const suppliers = dictQuery.data?.suppliers || [];
|
||||||
|
|
||||||
// ... (МУТАЦИИ оставляем без изменений) ...
|
|
||||||
const updateItemMutation = useMutation({
|
const updateItemMutation = useMutation({
|
||||||
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
|
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
|
||||||
api.updateDraftItem(id!, vars.itemId, vars.payload),
|
api.updateDraftItem(id!, vars.itemId, vars.payload),
|
||||||
@@ -115,11 +122,14 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleCommit = async () => {
|
const handleCommit = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Валидируем форму (включая нового поставщика)
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
|
||||||
if (invalidItemsCount > 0) {
|
if (invalidItemsCount > 0) {
|
||||||
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`);
|
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
commitMutation.mutate({
|
commitMutation.mutate({
|
||||||
date_incoming: values.date_incoming.format('YYYY-MM-DD'),
|
date_incoming: values.date_incoming.format('YYYY-MM-DD'),
|
||||||
store_id: values.store_id,
|
store_id: values.store_id,
|
||||||
@@ -127,7 +137,7 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
comment: values.comment || '',
|
comment: values.comment || '',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Заполните обязательные поля');
|
message.error('Заполните обязательные поля (Склад, Поставщик)');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,12 +172,11 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingBottom: 60 }}>
|
<div style={{ paddingBottom: 60 }}>
|
||||||
{/* Header: Уплотненный, без переноса слов */}
|
{/* Header */}
|
||||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/invoices')} size="small" />
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/invoices')} size="small" />
|
||||||
|
|
||||||
{/* Контейнер заголовка и бейджа */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
<span style={{ fontSize: 18, fontWeight: 'bold', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 18, fontWeight: 'bold', whiteSpace: 'nowrap' }}>
|
||||||
{draft.document_number ? `№${draft.document_number}` : 'Черновик'}
|
{draft.document_number ? `№${draft.document_number}` : 'Черновик'}
|
||||||
@@ -189,7 +198,7 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form: Compact margins */}
|
{/* Form: Склады и Поставщики */}
|
||||||
<div style={{ background: '#fff', padding: 12, borderRadius: 8, marginBottom: 12, opacity: isCanceled ? 0.6 : 1 }}>
|
<div style={{ background: '#fff', padding: 12, borderRadius: 8, marginBottom: 12, opacity: isCanceled ? 0.6 : 1 }}>
|
||||||
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
|
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
|
||||||
<Row gutter={10}>
|
<Row gutter={10}>
|
||||||
@@ -199,22 +208,27 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item label="Склад" name="store_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
|
<Form.Item label="Склад" name="store_id" rules={[{ required: true, message: 'Выберите склад' }]} style={{ marginBottom: 8 }}>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Куда?"
|
placeholder="Куда?"
|
||||||
loading={storesQuery.isLoading}
|
loading={dictQuery.isLoading}
|
||||||
options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))}
|
options={stores.map(s => ({ label: s.name, value: s.id }))}
|
||||||
size="middle"
|
size="middle"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
|
{/* Поле Поставщика (Обязательное) */}
|
||||||
|
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]} style={{ marginBottom: 8 }}>
|
||||||
<Select
|
<Select
|
||||||
placeholder="От кого?"
|
placeholder="От кого?"
|
||||||
loading={suppliersQuery.isLoading}
|
loading={dictQuery.isLoading}
|
||||||
options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))}
|
options={suppliers.map(s => ({ label: s.name, value: s.id }))}
|
||||||
size="middle"
|
size="middle"
|
||||||
|
showSearch
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
|
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
|
||||||
@@ -243,14 +257,14 @@ export const InvoiceDraftPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions */}
|
||||||
<Affix offsetBottom={60} /* Высота нижнего меню */>
|
<Affix offsetBottom={60}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
borderTop: '1px solid #eee',
|
borderTop: '1px solid #eee',
|
||||||
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
|
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
borderRadius: '8px 8px 0 0' // Скругление сверху
|
borderRadius: '8px 8px 0 0'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
<span style={{ fontSize: 11, color: '#888', lineHeight: 1 }}>Итого:</span>
|
<span style={{ fontSize: 11, color: '#888', lineHeight: 1 }}>Итого:</span>
|
||||||
|
|||||||
@@ -13,15 +13,25 @@ import type {
|
|||||||
DraftInvoice,
|
DraftInvoice,
|
||||||
UpdateDraftItemRequest,
|
UpdateDraftItemRequest,
|
||||||
CommitDraftRequest,
|
CommitDraftRequest,
|
||||||
// Новые типы
|
|
||||||
ProductSearchResult,
|
ProductSearchResult,
|
||||||
AddContainerRequest,
|
AddContainerRequest,
|
||||||
AddContainerResponse
|
AddContainerResponse,
|
||||||
|
DictionariesResponse,
|
||||||
|
DraftSummary
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Базовый URL
|
// Базовый URL
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
|
||||||
|
|
||||||
|
// Телеграм объект
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
|
||||||
|
// ID для локальной разработки (Fallback)
|
||||||
|
const DEBUG_USER_ID = 665599275;
|
||||||
|
|
||||||
|
// Событие для глобальной обработки 401
|
||||||
|
export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -29,33 +39,36 @@ const apiClient = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Request Interceptor (Авторизация) ---
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
// 1. Пробуем взять ID из Telegram WebApp
|
||||||
|
// 2. Ищем в URL параметрах (удобно для тестов в браузере: ?_tg_id=123)
|
||||||
|
// 3. Используем хардкод для локальной разработки
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const paramId = urlParams.get('_tg_id');
|
||||||
|
|
||||||
|
const userId = tg?.initDataUnsafe?.user?.id || paramId || DEBUG_USER_ID;
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
config.headers['X-Telegram-User-ID'] = userId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Response Interceptor (Обработка ошибок) ---
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
// Генерируем кастомное событие, которое поймает App.tsx
|
||||||
|
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
|
||||||
|
}
|
||||||
console.error('API Error:', error);
|
console.error('API Error:', error);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Мок поставщиков
|
|
||||||
const MOCK_SUPPLIERS: Supplier[] = [
|
|
||||||
{ id: '00000000-0000-0000-0000-000000000001', name: 'ООО "Рога и Копыта"' },
|
|
||||||
{ id: '00000000-0000-0000-0000-000000000002', name: 'ИП Иванов (Овощи)' },
|
|
||||||
{ id: '00000000-0000-0000-0000-000000000003', name: 'Metro Cash&Carry' },
|
|
||||||
{ id: '00000000-0000-0000-0000-000000000004', name: 'Simple Wine' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// интерфейс для списка (краткий)
|
|
||||||
export interface DraftSummary {
|
|
||||||
id: string;
|
|
||||||
document_number: string;
|
|
||||||
date_incoming: string;
|
|
||||||
status: string;
|
|
||||||
items_count: number;
|
|
||||||
total_sum: number;
|
|
||||||
store_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
checkHealth: async (): Promise<HealthResponse> => {
|
checkHealth: async (): Promise<HealthResponse> => {
|
||||||
const { data } = await apiClient.get<HealthResponse>('/health');
|
const { data } = await apiClient.get<HealthResponse>('/health');
|
||||||
@@ -67,14 +80,11 @@ export const api = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Оставляем для совместимости со старыми компонентами (если используются),
|
|
||||||
// но в Draft Flow будем использовать поиск.
|
|
||||||
getCatalogItems: async (): Promise<CatalogItem[]> => {
|
getCatalogItems: async (): Promise<CatalogItem[]> => {
|
||||||
const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog');
|
const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Поиск товаров ---
|
|
||||||
searchProducts: async (query: string): Promise<ProductSearchResult[]> => {
|
searchProducts: async (query: string): Promise<ProductSearchResult[]> => {
|
||||||
const { data } = await apiClient.get<ProductSearchResult[]>('/ocr/search', {
|
const { data } = await apiClient.get<ProductSearchResult[]>('/ocr/search', {
|
||||||
params: { q: query }
|
params: { q: query }
|
||||||
@@ -82,9 +92,7 @@ export const api = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Создание фасовки ---
|
|
||||||
createContainer: async (payload: AddContainerRequest): Promise<AddContainerResponse> => {
|
createContainer: async (payload: AddContainerRequest): Promise<AddContainerResponse> => {
|
||||||
// Внимание: URL эндпоинта взят из вашего ТЗ (/drafts/container)
|
|
||||||
const { data } = await apiClient.post<AddContainerResponse>('/drafts/container', payload);
|
const { data } = await apiClient.post<AddContainerResponse>('/drafts/container', payload);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -109,15 +117,28 @@ export const api = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getStores: async (): Promise<Store[]> => {
|
// --- НОВЫЙ МЕТОД: Получение всех справочников ---
|
||||||
const { data } = await apiClient.get<Store[]>('/dictionaries/stores');
|
getDictionaries: async (): Promise<DictionariesResponse> => {
|
||||||
|
const { data } = await apiClient.get<DictionariesResponse>('/dictionaries');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Старые методы оставляем для совместимости, но они могут вызывать getDictionaries внутри или deprecated endpoint
|
||||||
|
getStores: async (): Promise<Store[]> => {
|
||||||
|
// Можно использовать новый эндпоинт и возвращать часть данных
|
||||||
|
const { data } = await apiClient.get<DictionariesResponse>('/dictionaries');
|
||||||
|
return data.stores;
|
||||||
|
},
|
||||||
|
|
||||||
getSuppliers: async (): Promise<Supplier[]> => {
|
getSuppliers: async (): Promise<Supplier[]> => {
|
||||||
return new Promise((resolve) => {
|
// Реальный запрос вместо мока
|
||||||
setTimeout(() => resolve(MOCK_SUPPLIERS), 300);
|
const { data } = await apiClient.get<DictionariesResponse>('/dictionaries');
|
||||||
});
|
return data.suppliers;
|
||||||
|
},
|
||||||
|
|
||||||
|
getDrafts: async (): Promise<DraftSummary[]> => {
|
||||||
|
const { data } = await apiClient.get<DraftSummary[]>('/drafts');
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
getDraft: async (id: string): Promise<DraftInvoice> => {
|
getDraft: async (id: string): Promise<DraftInvoice> => {
|
||||||
@@ -125,12 +146,6 @@ export const api = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Получить список черновиков
|
|
||||||
getDrafts: async (): Promise<DraftSummary[]> => {
|
|
||||||
const { data } = await apiClient.get<DraftSummary[]>('/drafts');
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => {
|
updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => {
|
||||||
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
|
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
|
||||||
return data;
|
return data;
|
||||||
@@ -141,7 +156,6 @@ export const api = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Отменить/Удалить черновик
|
|
||||||
deleteDraft: async (id: string): Promise<void> => {
|
deleteDraft: async (id: string): Promise<void> => {
|
||||||
await apiClient.delete(`/drafts/${id}`);
|
await apiClient.delete(`/drafts/${id}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ export interface Supplier {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DictionariesResponse {
|
||||||
|
stores: Store[];
|
||||||
|
suppliers: Supplier[];
|
||||||
|
}
|
||||||
|
|
||||||
// --- Черновик Накладной (Draft) ---
|
// --- Черновик Накладной (Draft) ---
|
||||||
|
|
||||||
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED';
|
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED';
|
||||||
@@ -146,6 +151,18 @@ export interface DraftItem {
|
|||||||
container?: ProductContainer; // Развернутый объект для UI
|
container?: ProductContainer; // Развернутый объект для UI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Список Черновиков (Summary) ---
|
||||||
|
export interface DraftSummary {
|
||||||
|
id: UUID;
|
||||||
|
document_number: string;
|
||||||
|
date_incoming: string;
|
||||||
|
status: DraftStatus; // Используем существующий тип статуса
|
||||||
|
items_count: number;
|
||||||
|
total_sum: number;
|
||||||
|
store_name?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DraftInvoice {
|
export interface DraftInvoice {
|
||||||
id: UUID;
|
id: UUID;
|
||||||
status: DraftStatus;
|
status: DraftStatus;
|
||||||
|
|||||||
24
rmser-view/src/vite-env.d.ts
vendored
Normal file
24
rmser-view/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface TelegramWebApp {
|
||||||
|
initData: string;
|
||||||
|
initDataUnsafe: {
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name?: string;
|
||||||
|
username?: string;
|
||||||
|
language_code?: string;
|
||||||
|
};
|
||||||
|
// ... другие поля по необходимости
|
||||||
|
};
|
||||||
|
close: () => void;
|
||||||
|
expand: () => void;
|
||||||
|
// ... другие методы
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
Telegram?: {
|
||||||
|
WebApp: TelegramWebApp;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user