mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
0202-финиш перед десктопом
пересчет поправил редактирование с перепроведением галка автопроведения работает рекомендации починил
This commit is contained in:
49
cmd/main.go
49
cmd/main.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,8 +18,8 @@ import (
|
|||||||
|
|
||||||
"rmser/internal/services/auth"
|
"rmser/internal/services/auth"
|
||||||
"rmser/internal/transport/http/middleware"
|
"rmser/internal/transport/http/middleware"
|
||||||
"rmser/internal/transport/ws"
|
|
||||||
tgBot "rmser/internal/transport/telegram"
|
tgBot "rmser/internal/transport/telegram"
|
||||||
|
"rmser/internal/transport/ws"
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
accountPkg "rmser/internal/infrastructure/repository/account"
|
accountPkg "rmser/internal/infrastructure/repository/account"
|
||||||
@@ -43,6 +44,7 @@ import (
|
|||||||
photosServicePkg "rmser/internal/services/photos"
|
photosServicePkg "rmser/internal/services/photos"
|
||||||
recServicePkg "rmser/internal/services/recommend"
|
recServicePkg "rmser/internal/services/recommend"
|
||||||
"rmser/internal/services/sync"
|
"rmser/internal/services/sync"
|
||||||
|
"rmser/internal/services/worker"
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
"rmser/internal/transport/http/handlers"
|
"rmser/internal/transport/http/handlers"
|
||||||
@@ -100,13 +102,21 @@ func main() {
|
|||||||
ykClient := yookassa.NewClient(cfg.YooKassa.ShopID, cfg.YooKassa.SecretKey)
|
ykClient := yookassa.NewClient(cfg.YooKassa.ShopID, cfg.YooKassa.SecretKey)
|
||||||
billingService := billingServicePkg.NewService(billingRepo, accountRepo, ykClient)
|
billingService := billingServicePkg.NewService(billingRepo, accountRepo, ykClient)
|
||||||
|
|
||||||
syncService := sync.NewService(rmsFactory, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)
|
syncService := sync.NewService(rmsFactory, cryptoManager, accountRepo, catalogRepo, recipesRepo, invoicesRepo, opsRepo, supplierRepo)
|
||||||
|
|
||||||
|
// Создаем сервис рекомендаций до SyncWorker, так как он нужен для обновления рекомендаций
|
||||||
recService := recServicePkg.NewService(recRepo)
|
recService := recServicePkg.NewService(recRepo)
|
||||||
|
|
||||||
|
// 6.1 SyncWorker для фоновой синхронизации
|
||||||
|
syncWorker := worker.NewSyncWorker(syncService, accountRepo, rmsFactory, recService, logger.Log)
|
||||||
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||||
|
go syncWorker.Run(workerCtx)
|
||||||
|
defer workerCancel()
|
||||||
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, photosRepo, pyClient, cfg.App.StoragePath)
|
ocrService := ocrServicePkg.NewService(ocrRepo, catalogRepo, draftsRepo, accountRepo, photosRepo, pyClient, cfg.App.StoragePath)
|
||||||
// Устанавливаем DevIDs для OCR сервиса
|
// Устанавливаем DevIDs для OCR сервиса
|
||||||
ocrService.SetDevIDs(cfg.App.DevIDs)
|
ocrService.SetDevIDs(cfg.App.DevIDs)
|
||||||
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, photosRepo, invoicesRepo, rmsFactory, billingService)
|
draftsService := draftsServicePkg.NewService(draftsRepo, ocrRepo, catalogRepo, accountRepo, supplierRepo, photosRepo, invoicesRepo, rmsFactory, billingService)
|
||||||
invoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, rmsFactory)
|
invoicesService := invoicesServicePkg.NewService(invoicesRepo, draftsRepo, supplierRepo, accountRepo, rmsFactory)
|
||||||
photosService := photosServicePkg.NewService(photosRepo, draftsRepo, accountRepo)
|
photosService := photosServicePkg.NewService(photosRepo, draftsRepo, accountRepo)
|
||||||
|
|
||||||
// 7. WebSocket сервер для desktop авторизации
|
// 7. WebSocket сервер для desktop авторизации
|
||||||
@@ -121,7 +131,7 @@ func main() {
|
|||||||
billingHandler := handlers.NewBillingHandler(billingService)
|
billingHandler := handlers.NewBillingHandler(billingService)
|
||||||
ocrHandler := handlers.NewOCRHandler(ocrService)
|
ocrHandler := handlers.NewOCRHandler(ocrService)
|
||||||
photosHandler := handlers.NewPhotosHandler(photosService)
|
photosHandler := handlers.NewPhotosHandler(photosService)
|
||||||
recommendHandler := handlers.NewRecommendationsHandler(recService)
|
recommendHandler := handlers.NewRecommendationsHandler(recService, accountRepo)
|
||||||
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
|
settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo)
|
||||||
settingsHandler.SetRMSFactory(rmsFactory)
|
settingsHandler.SetRMSFactory(rmsFactory)
|
||||||
invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)
|
invoicesHandler := handlers.NewInvoiceHandler(invoicesService, syncService)
|
||||||
@@ -215,6 +225,7 @@ func main() {
|
|||||||
api.GET("/ocr/search", ocrHandler.SearchProducts)
|
api.GET("/ocr/search", ocrHandler.SearchProducts)
|
||||||
|
|
||||||
// Invoices
|
// Invoices
|
||||||
|
api.GET("/invoices/stats", invoicesHandler.GetStats)
|
||||||
api.GET("/invoices/:id", invoicesHandler.GetInvoice)
|
api.GET("/invoices/:id", invoicesHandler.GetInvoice)
|
||||||
api.POST("/invoices/sync", invoicesHandler.SyncInvoices)
|
api.POST("/invoices/sync", invoicesHandler.SyncInvoices)
|
||||||
|
|
||||||
@@ -225,8 +236,36 @@ func main() {
|
|||||||
// Запускаем в горутине, чтобы не держать соединение
|
// Запускаем в горутине, чтобы не держать соединение
|
||||||
go func() {
|
go func() {
|
||||||
if err := syncService.SyncAllData(userID, force); err != nil {
|
if err := syncService.SyncAllData(userID, force); err != nil {
|
||||||
logger.Log.Error("Manual sync failed", zap.Error(err))
|
logger.Log.Error("Manual sync failed",
|
||||||
|
zap.String("user_id", userID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновляем рекомендации после успешной синхронизации
|
||||||
|
// Получаем активный сервер пользователя
|
||||||
|
server, err := accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("Ошибка получения активного сервера для обновления рекомендаций",
|
||||||
|
zap.String("user_id", userID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if server != nil {
|
||||||
|
if err := recService.RefreshRecommendations(server.ID); err != nil {
|
||||||
|
logger.Log.Error("Ошибка обновления рекомендаций после ручной синхронизации",
|
||||||
|
zap.String("user_id", userID.String()),
|
||||||
|
zap.String("server_id", server.ID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
if err := accountRepo.UpdateLastSync(server.ID); err != nil {
|
||||||
|
logger.Log.Error("Ошибка обновления времени синхронизации",
|
||||||
|
zap.String("user_id", userID.String()),
|
||||||
|
zap.String("server_id", server.ID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}()
|
}()
|
||||||
c.JSON(200, gin.H{"status": "sync_started", "message": "Синхронизация запущена в фоне"})
|
c.JSON(200, gin.H{"status": "sync_started", "message": "Синхронизация запущена в фоне"})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Роли пользователей
|
// Роли пользователей
|
||||||
@@ -75,6 +76,14 @@ type RMSServer struct {
|
|||||||
// Stats
|
// Stats
|
||||||
InvoiceCount int `gorm:"default:0" json:"invoice_count"`
|
InvoiceCount int `gorm:"default:0" json:"invoice_count"`
|
||||||
|
|
||||||
|
// Sync settings
|
||||||
|
SyncInterval int `gorm:"default:360" json:"sync_interval"` // Интервал синхронизации в минутах (default: 6 часов)
|
||||||
|
LastSyncAt *time.Time `json:"last_sync_at"` // Время последней успешной синхронизации
|
||||||
|
LastActivityAt *time.Time `json:"last_activity_at"` // Время последнего действия пользователя
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -134,4 +143,12 @@ type Repository interface {
|
|||||||
|
|
||||||
// SetMuteDraftNotifications включает/выключает уведомления для пользователя
|
// SetMuteDraftNotifications включает/выключает уведомления для пользователя
|
||||||
SetMuteDraftNotifications(userID, serverID uuid.UUID, mute bool) error
|
SetMuteDraftNotifications(userID, serverID uuid.UUID, mute bool) error
|
||||||
|
|
||||||
|
// === Синхронизация и активность ===
|
||||||
|
// UpdateLastActivity обновляет время последней активности пользователя на сервере
|
||||||
|
UpdateLastActivity(serverID uuid.UUID) error
|
||||||
|
// UpdateLastSync обновляет время последней успешной синхронизации
|
||||||
|
UpdateLastSync(serverID uuid.UUID) error
|
||||||
|
// GetServersForSync возвращает серверы, готовые для синхронизации
|
||||||
|
GetServersForSync(idleThreshold time.Duration) ([]RMSServer, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ type DraftInvoiceItem struct {
|
|||||||
IsMatched bool `gorm:"default:false" json:"is_matched"`
|
IsMatched bool `gorm:"default:false" json:"is_matched"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LinkedDraftInfo содержит информацию о связанном черновике
|
||||||
|
type LinkedDraftInfo struct {
|
||||||
|
DraftID uuid.UUID
|
||||||
|
PhotoURL string
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -102,6 +108,6 @@ type Repository interface {
|
|||||||
// GetActive возвращает активные черновики для СЕРВЕРА (а не юзера)
|
// GetActive возвращает активные черновики для СЕРВЕРА (а не юзера)
|
||||||
GetActive(serverID uuid.UUID) ([]DraftInvoice, error)
|
GetActive(serverID uuid.UUID) ([]DraftInvoice, error)
|
||||||
|
|
||||||
// GetRMSInvoiceIDToPhotoURLMap возвращает мапу rms_invoice_id -> sender_photo_url для сервера, где rms_invoice_id не NULL
|
// GetLinkedDraftsMap возвращает мапу rms_invoice_id -> LinkedDraftInfo для сервера, где rms_invoice_id не NULL
|
||||||
GetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error)
|
GetLinkedDraftsMap(serverID uuid.UUID) (map[uuid.UUID]LinkedDraftInfo, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,5 @@ type Repository interface {
|
|||||||
GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]Invoice, error)
|
GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]Invoice, error)
|
||||||
SaveInvoices(invoices []Invoice) error
|
SaveInvoices(invoices []Invoice) error
|
||||||
CountRecent(serverID uuid.UUID, days int) (int64, error)
|
CountRecent(serverID uuid.UUID, days int) (int64, error)
|
||||||
|
GetStats(serverID uuid.UUID) (total int64, lastMonth int64, last24h int64, err error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
// Recommendation - Результат анализа
|
// Recommendation - Результат анализа
|
||||||
type Recommendation struct {
|
type Recommendation 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"`
|
||||||
Type string `gorm:"type:varchar(50);index"`
|
Type string `gorm:"type:varchar(50);index"`
|
||||||
ProductID uuid.UUID `gorm:"type:uuid;index"`
|
ProductID uuid.UUID `gorm:"type:uuid;index"`
|
||||||
ProductName string `gorm:"type:varchar(255)"`
|
ProductName string `gorm:"type:varchar(255)"`
|
||||||
@@ -29,15 +30,15 @@ type Recommendation struct {
|
|||||||
|
|
||||||
// Repository отвечает за аналитические выборки и хранение результатов
|
// Repository отвечает за аналитические выборки и хранение результатов
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
// Методы анализа (возвращают список структур, но не пишут в БД)
|
// Методы анализа — добавить serverID
|
||||||
FindUnusedGoods() ([]Recommendation, error)
|
FindUnusedGoods(serverID uuid.UUID) ([]Recommendation, error)
|
||||||
FindNoIncomingIngredients(days int) ([]Recommendation, error)
|
FindNoIncomingIngredients(serverID uuid.UUID, days int) ([]Recommendation, error)
|
||||||
FindStaleGoods(days int) ([]Recommendation, error)
|
FindStaleGoods(serverID uuid.UUID, days int) ([]Recommendation, error)
|
||||||
FindDishesInRecipes() ([]Recommendation, error)
|
FindDishesInRecipes(serverID uuid.UUID) ([]Recommendation, error)
|
||||||
FindPurchasedButUnused(days int) ([]Recommendation, error)
|
FindPurchasedButUnused(serverID uuid.UUID, days int) ([]Recommendation, error)
|
||||||
FindUsageWithoutPurchase(days int) ([]Recommendation, error)
|
FindUsageWithoutPurchase(serverID uuid.UUID, days int) ([]Recommendation, error)
|
||||||
|
|
||||||
// Методы "Кэша" в БД
|
// Методы хранения — добавить serverID
|
||||||
SaveAll(items []Recommendation) error // Удаляет старые и пишет новые
|
SaveAll(serverID uuid.UUID, items []Recommendation) error
|
||||||
GetAll() ([]Recommendation, error)
|
GetAll(serverID uuid.UUID) ([]Recommendation, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"rmser/internal/domain/account"
|
"rmser/internal/domain/account"
|
||||||
|
"rmser/pkg/logger"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,7 +80,8 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
|
|||||||
var created bool
|
var created bool
|
||||||
|
|
||||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
err := tx.Where("base_url = ?", cleanURL).First(&server).Error
|
// Сначала ищем среди удаленных серверов
|
||||||
|
err := tx.Unscoped().Where("base_url = ?", cleanURL).First(&server).Error
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -100,8 +103,17 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
created = true
|
created = true
|
||||||
|
} else if server.DeletedAt.Valid {
|
||||||
|
// --- СЦЕНАРИЙ 2: ВОССТАНОВЛЕНИЕ УДАЛЕННОГО СЕРВЕРА ---
|
||||||
|
// Восстанавливаем сервер, сохраняя старые значения Balance, InvoiceCount и ID
|
||||||
|
server.Name = name
|
||||||
|
server.DeletedAt = gorm.DeletedAt{} // Сбрасываем deleted_at
|
||||||
|
if err := tx.Save(&server).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
created = true // При восстановлении пользователь становится владельцем
|
||||||
} else {
|
} else {
|
||||||
// --- СЦЕНАРИЙ 2: СУЩЕСТВУЮЩИЙ СЕРВЕР ---
|
// --- СЦЕНАРИЙ 3: СУЩЕСТВУЮЩИЙ АКТИВНЫЙ СЕРВЕР ---
|
||||||
var userCount int64
|
var userCount int64
|
||||||
tx.Model(&account.ServerUser{}).Where("server_id = ?", server.ID).Count(&userCount)
|
tx.Model(&account.ServerUser{}).Where("server_id = ?", server.ID).Count(&userCount)
|
||||||
if userCount >= int64(server.MaxUsers) {
|
if userCount >= int64(server.MaxUsers) {
|
||||||
@@ -156,9 +168,92 @@ func (r *pgRepository) SaveServerSettings(server *account.RMSServer) error {
|
|||||||
"root_group_guid": server.RootGroupGUID,
|
"root_group_guid": server.RootGroupGUID,
|
||||||
"auto_process": server.AutoProcess,
|
"auto_process": server.AutoProcess,
|
||||||
"max_users": server.MaxUsers,
|
"max_users": server.MaxUsers,
|
||||||
|
"sync_interval": server.SyncInterval,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateLastActivity обновляет время последней активности пользователя
|
||||||
|
func (r *pgRepository) UpdateLastActivity(serverID uuid.UUID) error {
|
||||||
|
result := r.db.Model(&account.RMSServer{}).
|
||||||
|
Where("id = ?", serverID).
|
||||||
|
Update("last_activity_at", gorm.Expr("NOW()"))
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
logger.Log.Error("Failed to update last_activity_at",
|
||||||
|
zap.String("server_id", serverID.String()),
|
||||||
|
zap.Error(result.Error))
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
logger.Log.Warn("UpdateLastActivity: server not found",
|
||||||
|
zap.String("server_id", serverID.String()))
|
||||||
|
return fmt.Errorf("сервер не найден")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLastSync обновляет время последней успешной синхронизации
|
||||||
|
func (r *pgRepository) UpdateLastSync(serverID uuid.UUID) error {
|
||||||
|
result := r.db.Model(&account.RMSServer{}).
|
||||||
|
Where("id = ?", serverID).
|
||||||
|
Update("last_sync_at", gorm.Expr("NOW()"))
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
logger.Log.Error("Failed to update last_sync_at",
|
||||||
|
zap.String("server_id", serverID.String()),
|
||||||
|
zap.Error(result.Error))
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
logger.Log.Warn("UpdateLastSync: server not found",
|
||||||
|
zap.String("server_id", serverID.String()))
|
||||||
|
return fmt.Errorf("сервер не найден")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetServersForSync возвращает серверы, готовые для синхронизации
|
||||||
|
func (r *pgRepository) GetServersForSync(idleThreshold time.Duration) ([]account.RMSServer, error) {
|
||||||
|
var servers []account.RMSServer
|
||||||
|
|
||||||
|
// Конвертируем duration в минуты для SQL
|
||||||
|
idleMinutes := int(idleThreshold.Minutes())
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT * FROM rms_servers
|
||||||
|
WHERE
|
||||||
|
deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
-- Случай 1: Настало время периодической синхронизации
|
||||||
|
(EXTRACT(EPOCH FROM (NOW() - COALESCE(last_sync_at, '1970-01-01'::timestamp))) / 60) >= sync_interval
|
||||||
|
OR
|
||||||
|
-- Случай 2: Прошло N мин с последней активности, и активность была ПОЗЖЕ синхронизации
|
||||||
|
(
|
||||||
|
last_activity_at > last_sync_at
|
||||||
|
AND (EXTRACT(EPOCH FROM (NOW() - last_activity_at)) / 60) >= ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
err := r.db.Raw(query, idleMinutes).Scan(&servers).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("Failed to get servers for sync",
|
||||||
|
zap.Int("idle_threshold_minutes", idleMinutes),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log.Info("Servers ready for sync",
|
||||||
|
zap.Int("count", len(servers)),
|
||||||
|
zap.Int("idle_threshold_minutes", idleMinutes))
|
||||||
|
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *pgRepository) SetActiveServer(userID, serverID uuid.UUID) error {
|
func (r *pgRepository) SetActiveServer(userID, serverID uuid.UUID) error {
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
// Проверка доступа
|
// Проверка доступа
|
||||||
@@ -252,7 +347,7 @@ func (r *pgRepository) GetAllAvailableServers(userID uuid.UUID) ([]account.RMSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) DeleteServer(serverID uuid.UUID) error {
|
func (r *pgRepository) DeleteServer(serverID uuid.UUID) error {
|
||||||
// Полное удаление сервера и всех связей
|
// Мягкое удаление сервера и всех связей
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Where("server_id = ?", serverID).Delete(&account.ServerUser{}).Error; err != nil {
|
if err := tx.Where("server_id = ?", serverID).Delete(&account.ServerUser{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -160,20 +160,23 @@ func (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, err
|
|||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) GetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error) {
|
func (r *pgRepository) GetLinkedDraftsMap(serverID uuid.UUID) (map[uuid.UUID]drafts.LinkedDraftInfo, error) {
|
||||||
var draftsList []drafts.DraftInvoice
|
var draftsList []drafts.DraftInvoice
|
||||||
err := r.db.
|
err := r.db.
|
||||||
Select("rms_invoice_id", "sender_photo_url").
|
Select("id", "rms_invoice_id", "sender_photo_url").
|
||||||
Where("rms_server_id = ? AND rms_invoice_id IS NOT NULL", serverID).
|
Where("rms_server_id = ? AND rms_invoice_id IS NOT NULL", serverID).
|
||||||
Find(&draftsList).Error
|
Find(&draftsList).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make(map[uuid.UUID]string)
|
result := make(map[uuid.UUID]drafts.LinkedDraftInfo)
|
||||||
for _, d := range draftsList {
|
for _, d := range draftsList {
|
||||||
if d.RMSInvoiceID != nil {
|
if d.RMSInvoiceID != nil {
|
||||||
result[*d.RMSInvoiceID] = d.SenderPhotoURL
|
result[*d.RMSInvoiceID] = drafts.LinkedDraftInfo{
|
||||||
|
DraftID: d.ID,
|
||||||
|
PhotoURL: d.SenderPhotoURL,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|||||||
@@ -87,3 +87,17 @@ func (r *pgRepository) CountRecent(serverID uuid.UUID, days int) (int64, error)
|
|||||||
Count(&count).Error
|
Count(&count).Error
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *pgRepository) GetStats(serverID uuid.UUID) (total int64, lastMonth int64, last24h int64, err error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE status != 'DELETED') as total,
|
||||||
|
COUNT(*) FILTER (WHERE status != 'DELETED' AND created_at >= NOW() - INTERVAL '1 month') as last_month,
|
||||||
|
COUNT(*) FILTER (WHERE status != 'DELETED' AND created_at >= NOW() - INTERVAL '24 hours') as last_24h
|
||||||
|
FROM invoices
|
||||||
|
WHERE rms_server_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
err = r.db.Raw(query, serverID).Row().Scan(&total, &lastMonth, &last24h)
|
||||||
|
return total, lastMonth, last24h, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package recommendations
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"rmser/internal/domain/operations"
|
"rmser/internal/domain/operations"
|
||||||
"rmser/internal/domain/recommendations"
|
"rmser/internal/domain/recommendations"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type pgRepository struct {
|
type pgRepository struct {
|
||||||
@@ -21,11 +21,18 @@ func NewRepository(db *gorm.DB) recommendations.Repository {
|
|||||||
|
|
||||||
// --- Методы Хранения ---
|
// --- Методы Хранения ---
|
||||||
|
|
||||||
func (r *pgRepository) SaveAll(items []recommendations.Recommendation) error {
|
func (r *pgRepository) SaveAll(serverID uuid.UUID, items []recommendations.Recommendation) error {
|
||||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&recommendations.Recommendation{}).Error; err != nil {
|
// Удаляем только записи ЭТОГО сервера
|
||||||
|
if err := tx.Where("rms_server_id = ?", serverID).Delete(&recommendations.Recommendation{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проставляем server_id для всех записей
|
||||||
|
for i := range items {
|
||||||
|
items[i].RMSServerID = serverID
|
||||||
|
}
|
||||||
|
|
||||||
if len(items) > 0 {
|
if len(items) > 0 {
|
||||||
if err := tx.CreateInBatches(items, 100).Error; err != nil {
|
if err := tx.CreateInBatches(items, 100).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -35,16 +42,16 @@ func (r *pgRepository) SaveAll(items []recommendations.Recommendation) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *pgRepository) GetAll() ([]recommendations.Recommendation, error) {
|
func (r *pgRepository) GetAll(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
|
||||||
var items []recommendations.Recommendation
|
var items []recommendations.Recommendation
|
||||||
err := r.db.Find(&items).Error
|
err := r.db.Where("rms_server_id = ?", serverID).Find(&items).Error
|
||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Методы Аналитики ---
|
// --- Методы Аналитики ---
|
||||||
|
|
||||||
// 1. Товары (GOODS/PREPARED), не используемые в техкартах
|
// 1. Товары (GOODS/PREPARED), не используемые в техкартах
|
||||||
func (r *pgRepository) FindUnusedGoods() ([]recommendations.Recommendation, error) {
|
func (r *pgRepository) FindUnusedGoods(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
|
||||||
var results []recommendations.Recommendation
|
var results []recommendations.Recommendation
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
@@ -54,27 +61,30 @@ func (r *pgRepository) FindUnusedGoods() ([]recommendations.Recommendation, erro
|
|||||||
'Товар не используется ни в одной техкарте' as reason,
|
'Товар не используется ни в одной техкарте' as reason,
|
||||||
? as type
|
? as type
|
||||||
FROM products p
|
FROM products p
|
||||||
WHERE p.type IN ('GOODS', 'PREPARED')
|
WHERE p.rms_server_id = ?
|
||||||
AND p.is_deleted = false -- Проверка на удаление
|
AND p.type IN ('GOODS', 'PREPARED')
|
||||||
|
AND p.is_deleted = false
|
||||||
AND p.id NOT IN (
|
AND p.id NOT IN (
|
||||||
SELECT DISTINCT product_id FROM recipe_items
|
SELECT DISTINCT ri.product_id FROM recipe_items ri
|
||||||
|
JOIN recipes r ON ri.recipe_id = r.id
|
||||||
|
WHERE r.rms_server_id = ?
|
||||||
)
|
)
|
||||||
AND p.id NOT IN (
|
AND p.id NOT IN (
|
||||||
SELECT DISTINCT product_id FROM recipes
|
SELECT DISTINCT r.product_id FROM recipes r
|
||||||
|
WHERE r.rms_server_id = ?
|
||||||
)
|
)
|
||||||
ORDER BY p.name ASC
|
ORDER BY p.name ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
if err := r.db.Raw(query, recommendations.TypeUnused).Scan(&results).Error; err != nil {
|
if err := r.db.Raw(query, recommendations.TypeUnused, serverID, serverID, serverID).Scan(&results).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Закупается, но нет в техкартах
|
// 2. Закупается, но нет в техкартах
|
||||||
func (r *pgRepository) FindPurchasedButUnused(days int) ([]recommendations.Recommendation, error) {
|
func (r *pgRepository) FindPurchasedButUnused(serverID uuid.UUID, days int) ([]recommendations.Recommendation, error) {
|
||||||
var results []recommendations.Recommendation
|
var results []recommendations.Recommendation
|
||||||
dateFrom := time.Now().AddDate(0, 0, -days)
|
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
@@ -84,26 +94,33 @@ func (r *pgRepository) FindPurchasedButUnused(days int) ([]recommendations.Recom
|
|||||||
? as type
|
? as type
|
||||||
FROM store_operations so
|
FROM store_operations so
|
||||||
JOIN products p ON so.product_id = p.id
|
JOIN products p ON so.product_id = p.id
|
||||||
WHERE
|
WHERE
|
||||||
so.op_type = ?
|
so.rms_server_id = ?
|
||||||
AND so.period_from >= ?
|
AND so.op_type = ?
|
||||||
AND p.is_deleted = false -- Проверка на удаление
|
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
|
||||||
AND p.id NOT IN (
|
AND p.is_deleted = false
|
||||||
SELECT DISTINCT product_id FROM recipe_items
|
AND p.id NOT IN (
|
||||||
|
SELECT DISTINCT ri.product_id FROM recipe_items ri
|
||||||
|
JOIN recipes r ON ri.recipe_id = r.id
|
||||||
|
WHERE r.rms_server_id = ?
|
||||||
)
|
)
|
||||||
ORDER BY p.name ASC
|
ORDER BY p.name ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
if err := r.db.Raw(query, recommendations.TypePurchasedButUnused, operations.OpTypePurchase, dateFrom).Scan(&results).Error; err != nil {
|
if err := r.db.Raw(query,
|
||||||
|
recommendations.TypePurchasedButUnused,
|
||||||
|
serverID,
|
||||||
|
operations.OpTypePurchase,
|
||||||
|
serverID,
|
||||||
|
).Scan(&results).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Ингредиенты в актуальных техкартах без закупок
|
// 3. Ингредиенты в актуальных техкартах без закупок
|
||||||
func (r *pgRepository) FindNoIncomingIngredients(days int) ([]recommendations.Recommendation, error) {
|
func (r *pgRepository) FindNoIncomingIngredients(serverID uuid.UUID, days int) ([]recommendations.Recommendation, error) {
|
||||||
var results []recommendations.Recommendation
|
var results []recommendations.Recommendation
|
||||||
dateFrom := time.Now().AddDate(0, 0, -days)
|
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -115,31 +132,38 @@ func (r *pgRepository) FindNoIncomingIngredients(days int) ([]recommendations.Re
|
|||||||
JOIN recipes r ON ri.recipe_id = r.id
|
JOIN recipes r ON ri.recipe_id = r.id
|
||||||
JOIN products p ON ri.product_id = p.id
|
JOIN products p ON ri.product_id = p.id
|
||||||
JOIN products parent ON r.product_id = parent.id
|
JOIN products parent ON r.product_id = parent.id
|
||||||
WHERE
|
WHERE
|
||||||
(r.date_to IS NULL OR r.date_to >= CURRENT_DATE)
|
r.rms_server_id = ?
|
||||||
|
AND (r.date_to IS NULL OR r.date_to >= CURRENT_DATE)
|
||||||
AND p.type = 'GOODS'
|
AND p.type = 'GOODS'
|
||||||
AND p.is_deleted = false -- Сам ингредиент не удален
|
AND p.is_deleted = false
|
||||||
AND parent.is_deleted = false -- Блюдо, в которое он входит, не удалено
|
AND parent.is_deleted = false
|
||||||
AND p.id NOT IN (
|
AND p.id NOT IN (
|
||||||
SELECT product_id
|
SELECT so.product_id
|
||||||
FROM store_operations
|
FROM store_operations so
|
||||||
WHERE op_type = ?
|
WHERE so.rms_server_id = ?
|
||||||
AND period_from >= ?
|
AND so.op_type = ?
|
||||||
|
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
|
||||||
)
|
)
|
||||||
GROUP BY p.id, p.name
|
GROUP BY p.id, p.name
|
||||||
ORDER BY p.name ASC
|
ORDER BY p.name ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
if err := r.db.Raw(query, strconv.Itoa(days), recommendations.TypeNoIncoming, operations.OpTypePurchase, dateFrom).Scan(&results).Error; err != nil {
|
if err := r.db.Raw(query,
|
||||||
|
strconv.Itoa(days),
|
||||||
|
recommendations.TypeNoIncoming,
|
||||||
|
serverID,
|
||||||
|
serverID,
|
||||||
|
operations.OpTypePurchase,
|
||||||
|
).Scan(&results).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Товары, которые закупаем, но не расходуем ("Висяки")
|
// 4. Товары, которые закупаем, но не расходуем ("Висяки")
|
||||||
func (r *pgRepository) FindStaleGoods(days int) ([]recommendations.Recommendation, error) {
|
func (r *pgRepository) FindStaleGoods(serverID uuid.UUID, days int) ([]recommendations.Recommendation, error) {
|
||||||
var results []recommendations.Recommendation
|
var results []recommendations.Recommendation
|
||||||
dateFrom := time.Now().AddDate(0, 0, -days)
|
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
@@ -149,30 +173,38 @@ func (r *pgRepository) FindStaleGoods(days int) ([]recommendations.Recommendatio
|
|||||||
? as type
|
? as type
|
||||||
FROM store_operations so
|
FROM store_operations so
|
||||||
JOIN products p ON so.product_id = p.id
|
JOIN products p ON so.product_id = p.id
|
||||||
WHERE
|
WHERE
|
||||||
so.op_type = ?
|
so.rms_server_id = ?
|
||||||
AND so.period_from >= ?
|
AND so.op_type = ?
|
||||||
AND p.is_deleted = false -- Проверка на удаление
|
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
|
||||||
AND p.id NOT IN (
|
AND p.is_deleted = false
|
||||||
SELECT product_id
|
AND p.id NOT IN (
|
||||||
FROM store_operations
|
SELECT so2.product_id
|
||||||
WHERE op_type = ?
|
FROM store_operations so2
|
||||||
AND period_from >= ?
|
WHERE so2.rms_server_id = ?
|
||||||
|
AND so2.op_type = ?
|
||||||
|
AND so2.period_to >= CURRENT_DATE - INTERVAL '1 day'
|
||||||
)
|
)
|
||||||
ORDER BY p.name ASC
|
ORDER BY p.name ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
reason := fmt.Sprintf("Были закупки, но нет расхода за %d дн.", days)
|
reason := fmt.Sprintf("Были закупки, но нет расхода за %d дн.", days)
|
||||||
|
|
||||||
if err := r.db.Raw(query, reason, recommendations.TypeStale, operations.OpTypePurchase, dateFrom, operations.OpTypeUsage, dateFrom).
|
if err := r.db.Raw(query,
|
||||||
Scan(&results).Error; err != nil {
|
reason,
|
||||||
|
recommendations.TypeStale,
|
||||||
|
serverID,
|
||||||
|
operations.OpTypePurchase,
|
||||||
|
serverID,
|
||||||
|
operations.OpTypeUsage,
|
||||||
|
).Scan(&results).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Блюдо используется в техкарте другого блюда
|
// 5. Блюдо используется в техкарте другого блюда
|
||||||
func (r *pgRepository) FindDishesInRecipes() ([]recommendations.Recommendation, error) {
|
func (r *pgRepository) FindDishesInRecipes(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
|
||||||
var results []recommendations.Recommendation
|
var results []recommendations.Recommendation
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
@@ -186,23 +218,23 @@ func (r *pgRepository) FindDishesInRecipes() ([]recommendations.Recommendation,
|
|||||||
JOIN recipes r ON ri.recipe_id = r.id
|
JOIN recipes r ON ri.recipe_id = r.id
|
||||||
JOIN products parent ON r.product_id = parent.id
|
JOIN products parent ON r.product_id = parent.id
|
||||||
WHERE
|
WHERE
|
||||||
child.type = 'DISH'
|
r.rms_server_id = ?
|
||||||
AND child.is_deleted = false -- Вложенное блюдо не удалено
|
AND child.type = 'DISH'
|
||||||
AND parent.is_deleted = false -- Родительское блюдо не удалено
|
AND child.is_deleted = false
|
||||||
|
AND parent.is_deleted = false
|
||||||
AND (r.date_to IS NULL OR r.date_to >= CURRENT_DATE)
|
AND (r.date_to IS NULL OR r.date_to >= CURRENT_DATE)
|
||||||
ORDER BY child.name ASC
|
ORDER BY child.name ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
if err := r.db.Raw(query, recommendations.TypeDishInRecipe).Scan(&results).Error; err != nil {
|
if err := r.db.Raw(query, recommendations.TypeDishInRecipe, serverID).Scan(&results).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Есть расход (Usage), но нет прихода (Purchase)
|
// 6. Есть расход (Usage), но нет прихода (Purchase)
|
||||||
func (r *pgRepository) FindUsageWithoutPurchase(days int) ([]recommendations.Recommendation, error) {
|
func (r *pgRepository) FindUsageWithoutPurchase(serverID uuid.UUID, days int) ([]recommendations.Recommendation, error) {
|
||||||
var results []recommendations.Recommendation
|
var results []recommendations.Recommendation
|
||||||
dateFrom := time.Now().AddDate(0, 0, -days)
|
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
@@ -212,30 +244,31 @@ func (r *pgRepository) FindUsageWithoutPurchase(days int) ([]recommendations.Rec
|
|||||||
? as type
|
? as type
|
||||||
FROM store_operations so
|
FROM store_operations so
|
||||||
JOIN products p ON so.product_id = p.id
|
JOIN products p ON so.product_id = p.id
|
||||||
WHERE
|
WHERE
|
||||||
so.op_type = ? -- Есть расход (продажа/списание)
|
so.rms_server_id = ?
|
||||||
AND so.period_from >= ?
|
AND so.op_type = ?
|
||||||
AND p.type = 'GOODS' -- Только для товаров
|
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
|
||||||
AND p.is_deleted = false -- Товар жив
|
AND p.type = 'GOODS'
|
||||||
AND p.id NOT IN ( -- Но не было закупок
|
AND p.is_deleted = false
|
||||||
SELECT product_id
|
AND p.id NOT IN (
|
||||||
FROM store_operations
|
SELECT so2.product_id
|
||||||
WHERE op_type = ?
|
FROM store_operations so2
|
||||||
AND period_from >= ?
|
WHERE so2.rms_server_id = ?
|
||||||
|
AND so2.op_type = ?
|
||||||
|
AND so2.period_to >= CURRENT_DATE - INTERVAL '1 day'
|
||||||
)
|
)
|
||||||
ORDER BY p.name ASC
|
ORDER BY p.name ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
reason := fmt.Sprintf("Товар расходуется (продажи/списания), но не закупался последние %d дн.", days)
|
reason := fmt.Sprintf("Товар расходуется (продажи/списания), но не закупался последние %d дн.", days)
|
||||||
|
|
||||||
// Аргументы: reason, type, OpUsage, date, OpPurchase, date
|
|
||||||
if err := r.db.Raw(query,
|
if err := r.db.Raw(query,
|
||||||
reason,
|
reason,
|
||||||
recommendations.TypeUsageNoIncoming,
|
recommendations.TypeUsageNoIncoming,
|
||||||
|
serverID,
|
||||||
operations.OpTypeUsage,
|
operations.OpTypeUsage,
|
||||||
dateFrom,
|
serverID,
|
||||||
operations.OpTypePurchase,
|
operations.OpTypePurchase,
|
||||||
dateFrom,
|
|
||||||
).Scan(&results).Error; err != nil {
|
).Scan(&results).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type ClientI interface {
|
|||||||
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
|
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
|
||||||
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
|
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
|
||||||
CreateIncomingInvoice(inv invoices.Invoice) (string, error)
|
CreateIncomingInvoice(inv invoices.Invoice) (string, error)
|
||||||
|
UnprocessIncomingInvoice(inv invoices.Invoice) error
|
||||||
GetProductByID(id uuid.UUID) (*ProductFullDTO, error)
|
GetProductByID(id uuid.UUID) (*ProductFullDTO, error)
|
||||||
UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)
|
UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)
|
||||||
}
|
}
|
||||||
@@ -555,9 +556,24 @@ func (c *Client) FetchStoreOperations(presetID string, from, to time.Time) ([]St
|
|||||||
return report.Items, nil
|
return report.Items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateIncomingInvoice отправляет накладную в iiko
|
// buildInvoiceXML формирует XML payload для накладной на основе доменной сущности
|
||||||
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
func (c *Client) buildInvoiceXML(inv invoices.Invoice) ([]byte, error) {
|
||||||
// 1. Маппинг Domain -> XML DTO
|
// Защита от паники с recover
|
||||||
|
var panicErr error
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Log.Error("Паника в buildInvoiceXML",
|
||||||
|
zap.Any("panic", r),
|
||||||
|
zap.Stack("stack"),
|
||||||
|
)
|
||||||
|
panicErr = fmt.Errorf("panic recovered: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if panicErr != nil {
|
||||||
|
return nil, panicErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маппинг Domain -> XML DTO
|
||||||
|
|
||||||
// Статус по умолчанию NEW, если не передан
|
// Статус по умолчанию NEW, если не передан
|
||||||
status := inv.Status
|
status := inv.Status
|
||||||
@@ -592,7 +608,21 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
|||||||
reqDTO.ID = inv.ID.String()
|
reqDTO.ID = inv.ID.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Логирование перед циклом по Items
|
||||||
|
logger.Log.Debug("Начинаем формирование XML для позиций накладной",
|
||||||
|
zap.Int("items_count", len(inv.Items)),
|
||||||
|
)
|
||||||
|
|
||||||
for i, item := range inv.Items {
|
for i, item := range inv.Items {
|
||||||
|
// Проверка что продукт загружен (по полю ID)
|
||||||
|
if item.Product.ID == uuid.Nil {
|
||||||
|
logger.Log.Warn("Пропуск позиции: Product не загружен",
|
||||||
|
zap.String("product_id", item.ProductID.String()),
|
||||||
|
zap.Int("index", i),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
amount, _ := item.Amount.Float64()
|
amount, _ := item.Amount.Float64()
|
||||||
price, _ := item.Price.Float64()
|
price, _ := item.Price.Float64()
|
||||||
sum, _ := item.Sum.Float64()
|
sum, _ := item.Sum.Float64()
|
||||||
@@ -610,18 +640,47 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
|||||||
xmlItem.ContainerId = item.ContainerID.String()
|
xmlItem.ContainerId = item.ContainerID.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверка MainUnitID перед обращением
|
||||||
|
if item.Product.MainUnitID != nil {
|
||||||
|
xmlItem.AmountUnit = item.Product.MainUnitID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логирование каждого добавленного item
|
||||||
|
logger.Log.Debug("Добавление позиции в XML",
|
||||||
|
zap.String("product_id", item.ProductID.String()),
|
||||||
|
zap.Float64("amount", amount),
|
||||||
|
zap.String("product_name", item.Product.Name),
|
||||||
|
)
|
||||||
|
|
||||||
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, xmlItem)
|
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, xmlItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Маршалинг в XML
|
// Маршалинг в XML
|
||||||
xmlBytes, err := xml.Marshal(reqDTO)
|
xmlBytes, err := xml.Marshal(reqDTO)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("xml marshal error: %w", err)
|
return nil, fmt.Errorf("xml marshal error: %w", err)
|
||||||
}
|
}
|
||||||
// Добавляем XML header вручную
|
// Добавляем XML header вручную
|
||||||
xmlPayload := []byte(xml.Header + string(xmlBytes))
|
xmlPayload := []byte(xml.Header + string(xmlBytes))
|
||||||
|
|
||||||
// 3. Получение токена
|
// Логирование XML перед отправкой
|
||||||
|
logger.Log.Debug("XML payload подготовлен",
|
||||||
|
zap.String("xml_payload", string(xmlPayload)),
|
||||||
|
zap.Int("payload_size", len(xmlPayload)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return xmlPayload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIncomingInvoice отправляет накладную в iiko
|
||||||
|
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
||||||
|
// 1. Формирование XML payload
|
||||||
|
xmlPayload, err := c.buildInvoiceXML(inv)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ошибка формирования XML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Получение токена
|
||||||
if err := c.ensureToken(); err != nil {
|
if err := c.ensureToken(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -630,7 +689,7 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
|||||||
token := c.token
|
token := c.token
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
|
||||||
// 4. Формирование URL
|
// 3. Формирование URL
|
||||||
endpoint, _ := url.Parse(c.baseURL + "/resto/api/documents/import/incomingInvoice")
|
endpoint, _ := url.Parse(c.baseURL + "/resto/api/documents/import/incomingInvoice")
|
||||||
q := endpoint.Query()
|
q := endpoint.Query()
|
||||||
q.Set("key", token)
|
q.Set("key", token)
|
||||||
@@ -646,7 +705,7 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
|||||||
zap.String("body_payload", string(xmlPayload)),
|
zap.String("body_payload", string(xmlPayload)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 5. Отправка
|
// 4. Отправка
|
||||||
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload))
|
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -666,9 +725,9 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Логируем ответ для симметрии
|
// Логируем ответ для симметрии
|
||||||
logger.Log.Info("RMS POST Response Debug",
|
logger.Log.Debug("Получен ответ от iiko",
|
||||||
zap.Int("status_code", resp.StatusCode),
|
zap.Int("status_code", resp.StatusCode),
|
||||||
zap.String("response_body", string(respBody)),
|
zap.String("raw_response", string(respBody)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
@@ -691,6 +750,89 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
|||||||
return result.DocumentNumber, nil
|
return result.DocumentNumber, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnprocessIncomingInvoice выполняет распроведение накладной в iiko
|
||||||
|
func (c *Client) UnprocessIncomingInvoice(inv invoices.Invoice) error {
|
||||||
|
// 1. Формирование XML payload
|
||||||
|
xmlPayload, err := c.buildInvoiceXML(inv)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка формирования XML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Получение токена
|
||||||
|
if err := c.ensureToken(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
token := c.token
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
// 3. Формирование URL
|
||||||
|
endpoint, _ := url.Parse(c.baseURL + "/resto/api/documents/unprocess/incomingInvoice")
|
||||||
|
q := endpoint.Query()
|
||||||
|
q.Set("key", token)
|
||||||
|
endpoint.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
fullURL := endpoint.String()
|
||||||
|
|
||||||
|
// Логирование запроса
|
||||||
|
logger.Log.Info("RMS Unprocess Request",
|
||||||
|
zap.String("method", "POST"),
|
||||||
|
zap.String("url", fullURL),
|
||||||
|
zap.String("document_number", inv.DocumentNumber),
|
||||||
|
zap.String("invoice_id", inv.ID.String()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. Отправка POST запроса
|
||||||
|
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка создания запроса: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/xml")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка сети: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Читаем ответ
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ошибка чтения ответа: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем ответ
|
||||||
|
logger.Log.Debug("Получен ответ от iiko на распроведение",
|
||||||
|
zap.Int("status_code", resp.StatusCode),
|
||||||
|
zap.String("raw_response", string(respBody)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("http error %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка результата валидации
|
||||||
|
var result DocumentValidationResult
|
||||||
|
if err := xml.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return fmt.Errorf("ошибка разбора XML ответа: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !result.Valid {
|
||||||
|
logger.Log.Warn("RMS Invoice Unprocess Failed",
|
||||||
|
zap.String("error", result.ErrorMessage),
|
||||||
|
zap.String("additional", result.AdditionalInfo),
|
||||||
|
)
|
||||||
|
return fmt.Errorf("распроведение не удалось: %s (info: %s)", result.ErrorMessage, result.AdditionalInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Log.Info("RMS Invoice Unprocess Success",
|
||||||
|
zap.String("document_number", result.DocumentNumber),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetProductByID получает полную структуру товара по ID (через /list?ids=...)
|
// GetProductByID получает полную структуру товара по ID (через /list?ids=...)
|
||||||
func (c *Client) GetProductByID(id uuid.UUID) (*ProductFullDTO, error) {
|
func (c *Client) GetProductByID(id uuid.UUID) (*ProductFullDTO, error) {
|
||||||
// Параметр ids должен быть списком. iiko ожидает ids=UUID
|
// Параметр ids должен быть списком. iiko ожидает ids=UUID
|
||||||
|
|||||||
@@ -157,9 +157,6 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if draft.Status == drafts.StatusCompleted {
|
|
||||||
return errors.New("черновик уже отправлен")
|
|
||||||
}
|
|
||||||
draft.StoreID = storeID
|
draft.StoreID = storeID
|
||||||
draft.SupplierID = supplierID
|
draft.SupplierID = supplierID
|
||||||
draft.DateIncoming = &date
|
draft.DateIncoming = &date
|
||||||
@@ -227,7 +224,7 @@ func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) {
|
|||||||
return sumFloat, nil
|
return sumFloat, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecalculateItemFields - логика пересчета Qty/Price/Sum
|
// RecalculateItemFields - логика пересчета Q->P->S->Q (Quantity -> Price -> Sum -> Quantity) с использованием decimal для точности
|
||||||
func (s *Service) RecalculateItemFields(item *drafts.DraftInvoiceItem, editedField drafts.EditedField) {
|
func (s *Service) RecalculateItemFields(item *drafts.DraftInvoiceItem, editedField drafts.EditedField) {
|
||||||
if item.LastEditedField1 != editedField {
|
if item.LastEditedField1 != editedField {
|
||||||
item.LastEditedField2 = item.LastEditedField1
|
item.LastEditedField2 = item.LastEditedField1
|
||||||
@@ -265,6 +262,29 @@ func (s *Service) RecalculateItemFields(item *drafts.DraftInvoiceItem, editedFie
|
|||||||
case drafts.FieldSum:
|
case drafts.FieldSum:
|
||||||
item.Sum = item.Quantity.Mul(item.Price)
|
item.Sum = item.Quantity.Mul(item.Price)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Дополнительная проверка для гарантии консистентности всех полей (Q->P->S->Q)
|
||||||
|
// Используется только для обеспечения точности, не влияет на логику выбора пересчитываемого поля
|
||||||
|
if !item.Price.IsZero() && !item.Quantity.IsZero() {
|
||||||
|
calculatedSum := item.Quantity.Mul(item.Price)
|
||||||
|
if !calculatedSum.Equal(item.Sum) {
|
||||||
|
item.Sum = calculatedSum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !item.Price.IsZero() && !item.Sum.IsZero() {
|
||||||
|
calculatedQuantity := item.Sum.Div(item.Price)
|
||||||
|
if !calculatedQuantity.Equal(item.Quantity) {
|
||||||
|
item.Quantity = calculatedQuantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !item.Quantity.IsZero() && !item.Sum.IsZero() {
|
||||||
|
calculatedPrice := item.Sum.Div(item.Quantity)
|
||||||
|
if !calculatedPrice.Equal(item.Price) {
|
||||||
|
item.Price = calculatedPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateItem обновлен для поддержки динамического пересчета
|
// UpdateItem обновлен для поддержки динамического пересчета
|
||||||
@@ -293,17 +313,10 @@ func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
field := drafts.EditedField(editedField)
|
// Просто присваиваем значения от фронтенда без пересчета
|
||||||
switch field {
|
currentItem.Quantity = qty
|
||||||
case drafts.FieldQuantity:
|
currentItem.Price = price
|
||||||
currentItem.Quantity = qty
|
currentItem.Sum = sum
|
||||||
case drafts.FieldPrice:
|
|
||||||
currentItem.Price = price
|
|
||||||
case drafts.FieldSum:
|
|
||||||
currentItem.Sum = sum
|
|
||||||
}
|
|
||||||
|
|
||||||
s.RecalculateItemFields(currentItem, field)
|
|
||||||
|
|
||||||
if draft.Status == drafts.StatusCanceled {
|
if draft.Status == drafts.StatusCanceled {
|
||||||
draft.Status = drafts.StatusReadyToVerify
|
draft.Status = drafts.StatusReadyToVerify
|
||||||
@@ -311,20 +324,18 @@ func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, co
|
|||||||
}
|
}
|
||||||
|
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"product_id": currentItem.ProductID,
|
"product_id": currentItem.ProductID,
|
||||||
"container_id": currentItem.ContainerID,
|
"container_id": currentItem.ContainerID,
|
||||||
"quantity": currentItem.Quantity,
|
"quantity": currentItem.Quantity,
|
||||||
"price": currentItem.Price,
|
"price": currentItem.Price,
|
||||||
"sum": currentItem.Sum,
|
"sum": currentItem.Sum,
|
||||||
"last_edited_field1": currentItem.LastEditedField1,
|
"is_matched": currentItem.IsMatched,
|
||||||
"last_edited_field2": currentItem.LastEditedField2,
|
|
||||||
"is_matched": currentItem.IsMatched,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.draftRepo.UpdateItem(itemID, updates)
|
return s.draftRepo.UpdateItem(itemID, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
func (s *Service) CommitDraft(draftID, userID uuid.UUID, isProcessed bool) (string, error) {
|
||||||
server, err := s.accountRepo.GetActiveServer(userID)
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("active server not found: %w", err)
|
return "", fmt.Errorf("active server not found: %w", err)
|
||||||
@@ -347,17 +358,13 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
return "", errors.New("черновик принадлежит другому серверу")
|
return "", errors.New("черновик принадлежит другому серверу")
|
||||||
}
|
}
|
||||||
|
|
||||||
if draft.Status == drafts.StatusCompleted {
|
|
||||||
return "", errors.New("накладная уже отправлена")
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := s.rmsFactory.GetClientForUser(userID)
|
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
targetStatus := "NEW"
|
targetStatus := "NEW"
|
||||||
if server.AutoProcess {
|
if isProcessed {
|
||||||
targetStatus = "PROCESSED"
|
targetStatus = "PROCESSED"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +380,11 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
|
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Если черновик уже был отправлен ранее, передаем RMSInvoiceID для обновления
|
||||||
|
if draft.RMSInvoiceID != nil {
|
||||||
|
inv.ID = *draft.RMSInvoiceID
|
||||||
|
}
|
||||||
|
|
||||||
for _, dItem := range draft.Items {
|
for _, dItem := range draft.Items {
|
||||||
if dItem.ProductID == nil {
|
if dItem.ProductID == nil {
|
||||||
continue
|
continue
|
||||||
@@ -405,6 +417,12 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
Price: priceToSend,
|
Price: priceToSend,
|
||||||
Sum: sum,
|
Sum: sum,
|
||||||
ContainerID: dItem.ContainerID,
|
ContainerID: dItem.ContainerID,
|
||||||
|
Product: func() catalog.Product {
|
||||||
|
if dItem.Product != nil {
|
||||||
|
return *dItem.Product
|
||||||
|
}
|
||||||
|
return catalog.Product{}
|
||||||
|
}(),
|
||||||
}
|
}
|
||||||
inv.Items = append(inv.Items, invItem)
|
inv.Items = append(inv.Items, invItem)
|
||||||
}
|
}
|
||||||
@@ -415,7 +433,22 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
|
|
||||||
docNum, err := client.CreateIncomingInvoice(inv)
|
docNum, err := client.CreateIncomingInvoice(inv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
// Если накладная уже проведена, пробуем распровести и повторить
|
||||||
|
if strings.Contains(err.Error(), "Changing processed") {
|
||||||
|
logger.Log.Info("Накладная проведена, выполняю распроведение...", zap.String("doc_num", draft.DocumentNumber))
|
||||||
|
|
||||||
|
if unprocessErr := client.UnprocessIncomingInvoice(inv); unprocessErr != nil {
|
||||||
|
return "", fmt.Errorf("не удалось распровести накладную: %w", unprocessErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторяем попытку создания накладной после распроведения
|
||||||
|
docNum, err = client.CreateIncomingInvoice(inv)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
invoices, err := client.FetchInvoices(*draft.DateIncoming, *draft.DateIncoming)
|
invoices, err := client.FetchInvoices(*draft.DateIncoming, *draft.DateIncoming)
|
||||||
@@ -434,6 +467,7 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
|||||||
for _, invoice := range invoices {
|
for _, invoice := range invoices {
|
||||||
if invoice.DocumentNumber == docNum {
|
if invoice.DocumentNumber == docNum {
|
||||||
draft.RMSInvoiceID = &invoice.ID
|
draft.RMSInvoiceID = &invoice.ID
|
||||||
|
draft.DocumentNumber = invoice.DocumentNumber
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -555,19 +589,20 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID,
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UnifiedInvoiceDTO struct {
|
type UnifiedInvoiceDTO struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
DocumentNumber string `json:"document_number"`
|
DocumentNumber string `json:"document_number"`
|
||||||
IncomingNumber string `json:"incoming_number"`
|
IncomingNumber string `json:"incoming_number"`
|
||||||
DateIncoming time.Time `json:"date_incoming"`
|
DateIncoming time.Time `json:"date_incoming"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
TotalSum float64 `json:"total_sum"`
|
TotalSum float64 `json:"total_sum"`
|
||||||
StoreName string `json:"store_name"`
|
StoreName string `json:"store_name"`
|
||||||
ItemsCount int `json:"items_count"`
|
ItemsCount int `json:"items_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
IsAppCreated bool `json:"is_app_created"`
|
IsAppCreated bool `json:"is_app_created"`
|
||||||
PhotoURL string `json:"photo_url"`
|
PhotoURL string `json:"photo_url"`
|
||||||
ItemsPreview string `json:"items_preview"`
|
ItemsPreview string `json:"items_preview"`
|
||||||
|
DraftID *uuid.UUID `json:"draft_id,omitempty"` // ID черновика для SYNCED накладных
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]UnifiedInvoiceDTO, error) {
|
func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]UnifiedInvoiceDTO, error) {
|
||||||
@@ -586,7 +621,7 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
photoMap, err := s.draftRepo.GetRMSInvoiceIDToPhotoURLMap(server.ID)
|
linkedDraftsMap, err := s.draftRepo.GetLinkedDraftsMap(server.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -647,9 +682,11 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
|
|||||||
|
|
||||||
isAppCreated := false
|
isAppCreated := false
|
||||||
photoURL := ""
|
photoURL := ""
|
||||||
if url, exists := photoMap[inv.ID]; exists {
|
var draftID *uuid.UUID
|
||||||
|
if linkedInfo, exists := linkedDraftsMap[inv.ID]; exists {
|
||||||
isAppCreated = true
|
isAppCreated = true
|
||||||
photoURL = url
|
photoURL = linkedInfo.PhotoURL
|
||||||
|
draftID = &linkedInfo.DraftID
|
||||||
}
|
}
|
||||||
|
|
||||||
var itemsPreview string
|
var itemsPreview string
|
||||||
@@ -679,6 +716,7 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
|
|||||||
IsAppCreated: isAppCreated,
|
IsAppCreated: isAppCreated,
|
||||||
PhotoURL: photoURL,
|
PhotoURL: photoURL,
|
||||||
ItemsPreview: itemsPreview,
|
ItemsPreview: itemsPreview,
|
||||||
|
DraftID: draftID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,6 +777,14 @@ func (s *Service) CreateDraft(userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновляем время последней активности сервера
|
||||||
|
if err := s.accountRepo.UpdateLastActivity(server.ID); err != nil {
|
||||||
|
logger.Log.Warn("Не удалось обновить время активности",
|
||||||
|
zap.String("server_id", server.ID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
// Не возвращаем ошибку - это некритично
|
||||||
|
}
|
||||||
|
|
||||||
return draft, nil
|
return draft, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,11 +1013,6 @@ func (s *Service) SaveDraftFull(draftID, userID uuid.UUID, req drafts.UpdateDraf
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, что черновик не завершен
|
|
||||||
if draft.Status == drafts.StatusCompleted {
|
|
||||||
return errors.New("черновик уже отправлен")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем шапку черновика, если переданы поля
|
// Обновляем шапку черновика, если переданы поля
|
||||||
headerUpdated := false
|
headerUpdated := false
|
||||||
|
|
||||||
@@ -1084,54 +1125,17 @@ func (s *Service) SaveDraftFull(draftID, userID uuid.UUID, req drafts.UpdateDraf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Определяем, какое поле редактируется
|
// Просто присваиваем значения от фронтенда без пересчета
|
||||||
editedField := itemReq.EditedField
|
|
||||||
if editedField == "" {
|
|
||||||
if itemReq.Sum != nil {
|
|
||||||
editedField = "sum"
|
|
||||||
} else if itemReq.Price != nil {
|
|
||||||
editedField = "price"
|
|
||||||
} else if itemReq.Quantity != nil {
|
|
||||||
editedField = "quantity"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем числовые поля
|
|
||||||
qty := decimal.Zero
|
|
||||||
if itemReq.Quantity != nil {
|
if itemReq.Quantity != nil {
|
||||||
qty = decimal.NewFromFloat(*itemReq.Quantity)
|
currentItem.Quantity = decimal.NewFromFloat(*itemReq.Quantity)
|
||||||
} else {
|
|
||||||
qty = currentItem.Quantity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
price := decimal.Zero
|
|
||||||
if itemReq.Price != nil {
|
if itemReq.Price != nil {
|
||||||
price = decimal.NewFromFloat(*itemReq.Price)
|
currentItem.Price = decimal.NewFromFloat(*itemReq.Price)
|
||||||
} else {
|
|
||||||
price = currentItem.Price
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sum := decimal.Zero
|
|
||||||
if itemReq.Sum != nil {
|
if itemReq.Sum != nil {
|
||||||
sum = decimal.NewFromFloat(*itemReq.Sum)
|
currentItem.Sum = decimal.NewFromFloat(*itemReq.Sum)
|
||||||
} else {
|
|
||||||
sum = currentItem.Sum
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Применяем изменения в зависимости от редактируемого поля
|
|
||||||
field := drafts.EditedField(editedField)
|
|
||||||
switch field {
|
|
||||||
case drafts.FieldQuantity:
|
|
||||||
currentItem.Quantity = qty
|
|
||||||
case drafts.FieldPrice:
|
|
||||||
currentItem.Price = price
|
|
||||||
case drafts.FieldSum:
|
|
||||||
currentItem.Sum = sum
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пересчитываем поля
|
|
||||||
s.RecalculateItemFields(currentItem, field)
|
|
||||||
|
|
||||||
// Обновляем статус черновика, если он был отменен
|
// Обновляем статус черновика, если он был отменен
|
||||||
if draft.Status == drafts.StatusCanceled {
|
if draft.Status == drafts.StatusCanceled {
|
||||||
draft.Status = drafts.StatusReadyToVerify
|
draft.Status = drafts.StatusReadyToVerify
|
||||||
@@ -1142,14 +1146,12 @@ func (s *Service) SaveDraftFull(draftID, userID uuid.UUID, req drafts.UpdateDraf
|
|||||||
|
|
||||||
// Сохраняем обновленную позицию
|
// Сохраняем обновленную позицию
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"product_id": currentItem.ProductID,
|
"product_id": currentItem.ProductID,
|
||||||
"container_id": currentItem.ContainerID,
|
"container_id": currentItem.ContainerID,
|
||||||
"quantity": currentItem.Quantity,
|
"quantity": currentItem.Quantity,
|
||||||
"price": currentItem.Price,
|
"price": currentItem.Price,
|
||||||
"sum": currentItem.Sum,
|
"sum": currentItem.Sum,
|
||||||
"last_edited_field1": currentItem.LastEditedField1,
|
"is_matched": currentItem.IsMatched,
|
||||||
"last_edited_field2": currentItem.LastEditedField2,
|
|
||||||
"is_matched": currentItem.IsMatched,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.draftRepo.UpdateItem(itemID, updates); err != nil {
|
if err := s.draftRepo.UpdateItem(itemID, updates); err != nil {
|
||||||
@@ -1158,5 +1160,13 @@ func (s *Service) SaveDraftFull(draftID, userID uuid.UUID, req drafts.UpdateDraf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновляем время последней активности сервера
|
||||||
|
if err := s.accountRepo.UpdateLastActivity(draft.RMSServerID); err != nil {
|
||||||
|
logger.Log.Warn("Не удалось обновить время активности",
|
||||||
|
zap.String("server_id", draft.RMSServerID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
// Не возвращаем ошибку - это некритично
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/domain/drafts"
|
"rmser/internal/domain/drafts"
|
||||||
invDomain "rmser/internal/domain/invoices"
|
invDomain "rmser/internal/domain/invoices"
|
||||||
"rmser/internal/domain/suppliers"
|
"rmser/internal/domain/suppliers"
|
||||||
@@ -19,16 +20,18 @@ type Service struct {
|
|||||||
repo invDomain.Repository
|
repo invDomain.Repository
|
||||||
draftsRepo drafts.Repository
|
draftsRepo drafts.Repository
|
||||||
supplierRepo suppliers.Repository
|
supplierRepo suppliers.Repository
|
||||||
|
accountRepo account.Repository
|
||||||
rmsFactory *rms.Factory
|
rmsFactory *rms.Factory
|
||||||
// Здесь можно добавить репозитории каталога и контрагентов для валидации,
|
// Здесь можно добавить репозитории каталога и контрагентов для валидации,
|
||||||
// но для краткости пока опустим глубокую валидацию.
|
// но для краткости пока опустим глубокую валидацию.
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo invDomain.Repository, draftsRepo drafts.Repository, supplierRepo suppliers.Repository, rmsFactory *rms.Factory) *Service {
|
func NewService(repo invDomain.Repository, draftsRepo drafts.Repository, supplierRepo suppliers.Repository, accountRepo account.Repository, rmsFactory *rms.Factory) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
draftsRepo: draftsRepo,
|
draftsRepo: draftsRepo,
|
||||||
supplierRepo: supplierRepo,
|
supplierRepo: supplierRepo,
|
||||||
|
accountRepo: accountRepo,
|
||||||
rmsFactory: rmsFactory,
|
rmsFactory: rmsFactory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +102,13 @@ func (s *Service) SendInvoiceToRMS(req CreateRequestDTO, userID uuid.UUID) (stri
|
|||||||
return docNum, nil
|
return docNum, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvoiceStatsDTO - DTO для статистики накладных
|
||||||
|
type InvoiceStatsDTO struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
LastMonth int64 `json:"last_month"`
|
||||||
|
Last24h int64 `json:"last_24h"`
|
||||||
|
}
|
||||||
|
|
||||||
// InvoiceDetailsDTO - DTO для ответа на запрос деталей накладной
|
// InvoiceDetailsDTO - DTO для ответа на запрос деталей накладной
|
||||||
type InvoiceDetailsDTO struct {
|
type InvoiceDetailsDTO struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
@@ -145,7 +155,7 @@ func (s *Service) GetInvoice(id uuid.UUID) (*InvoiceDetailsDTO, error) {
|
|||||||
Number: inv.DocumentNumber,
|
Number: inv.DocumentNumber,
|
||||||
Date: inv.DateIncoming.Format("2006-01-02"),
|
Date: inv.DateIncoming.Format("2006-01-02"),
|
||||||
Status: "COMPLETED", // Для синхронизированных накладных статус всегда COMPLETED
|
Status: "COMPLETED", // Для синхронизированных накладных статус всегда COMPLETED
|
||||||
Items: make([]struct {
|
Items: make([]struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Quantity float64 `json:"quantity"`
|
Quantity float64 `json:"quantity"`
|
||||||
Price float64 `json:"price"`
|
Price float64 `json:"price"`
|
||||||
@@ -166,3 +176,32 @@ func (s *Service) GetInvoice(id uuid.UUID) (*InvoiceDetailsDTO, error) {
|
|||||||
|
|
||||||
return dto, nil
|
return dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStats возвращает статистику по накладным для пользователя
|
||||||
|
func (s *Service) GetStats(userID uuid.UUID) (*InvoiceStatsDTO, error) {
|
||||||
|
// Получаем активный сервер пользователя
|
||||||
|
server, err := s.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ошибка получения активного сервера: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if server == nil {
|
||||||
|
return &InvoiceStatsDTO{
|
||||||
|
Total: 0,
|
||||||
|
LastMonth: 0,
|
||||||
|
Last24h: 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем статистику из репозитория
|
||||||
|
total, lastMonth, last24h, err := s.repo.GetStats(server.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ошибка получения статистики: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &InvoiceStatsDTO{
|
||||||
|
Total: total,
|
||||||
|
LastMonth: lastMonth,
|
||||||
|
Last24h: last24h,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package recommend
|
package recommend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"rmser/internal/domain/recommendations"
|
"rmser/internal/domain/recommendations"
|
||||||
@@ -20,56 +21,56 @@ func NewService(repo recommendations.Repository) *Service {
|
|||||||
return &Service{repo: repo}
|
return &Service{repo: repo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshRecommendations выполняет анализ и сохраняет результаты в БД
|
// RefreshRecommendations выполняет анализ и сохраняет результаты в БД для конкретного сервера
|
||||||
func (s *Service) RefreshRecommendations() error {
|
func (s *Service) RefreshRecommendations(serverID uuid.UUID) error {
|
||||||
logger.Log.Info("Запуск пересчета рекомендаций...")
|
logger.Log.Info("Запуск пересчета рекомендаций...", zap.String("server_id", serverID.String()))
|
||||||
|
|
||||||
var all []recommendations.Recommendation
|
var all []recommendations.Recommendation
|
||||||
|
|
||||||
// 1. Unused
|
// 1. Unused
|
||||||
if unused, err := s.repo.FindUnusedGoods(); err == nil {
|
if unused, err := s.repo.FindUnusedGoods(serverID); err == nil {
|
||||||
all = append(all, unused...)
|
all = append(all, unused...)
|
||||||
} else {
|
} else {
|
||||||
logger.Log.Error("Ошибка unused", zap.Error(err))
|
logger.Log.Error("Ошибка unused", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Purchased but Unused
|
// 2. Purchased but Unused
|
||||||
if purchUnused, err := s.repo.FindPurchasedButUnused(AnalyzeDaysNoIncoming); err == nil {
|
if purchUnused, err := s.repo.FindPurchasedButUnused(serverID, AnalyzeDaysNoIncoming); err == nil {
|
||||||
all = append(all, purchUnused...)
|
all = append(all, purchUnused...)
|
||||||
} else {
|
} else {
|
||||||
logger.Log.Error("Ошибка purchased_unused", zap.Error(err))
|
logger.Log.Error("Ошибка purchased_unused", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. No Incoming (Ингредиенты без закупок)
|
// 3. No Incoming (Ингредиенты без закупок)
|
||||||
if noInc, err := s.repo.FindNoIncomingIngredients(AnalyzeDaysNoIncoming); err == nil {
|
if noInc, err := s.repo.FindNoIncomingIngredients(serverID, AnalyzeDaysNoIncoming); err == nil {
|
||||||
all = append(all, noInc...)
|
all = append(all, noInc...)
|
||||||
} else {
|
} else {
|
||||||
logger.Log.Error("Ошибка no_incoming", zap.Error(err))
|
logger.Log.Error("Ошибка no_incoming", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Usage without Purchase (Расход без прихода) <-- НОВОЕ
|
// 4. Usage without Purchase (Расход без прихода)
|
||||||
if usageNoPurch, err := s.repo.FindUsageWithoutPurchase(AnalyzeDaysNoIncoming); err == nil {
|
if usageNoPurch, err := s.repo.FindUsageWithoutPurchase(serverID, AnalyzeDaysNoIncoming); err == nil {
|
||||||
all = append(all, usageNoPurch...)
|
all = append(all, usageNoPurch...)
|
||||||
} else {
|
} else {
|
||||||
logger.Log.Error("Ошибка usage_no_purchase", zap.Error(err))
|
logger.Log.Error("Ошибка usage_no_purchase", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Stale (Неликвид)
|
// 5. Stale (Неликвид)
|
||||||
if stale, err := s.repo.FindStaleGoods(AnalyzeDaysStale); err == nil {
|
if stale, err := s.repo.FindStaleGoods(serverID, AnalyzeDaysStale); err == nil {
|
||||||
all = append(all, stale...)
|
all = append(all, stale...)
|
||||||
} else {
|
} else {
|
||||||
logger.Log.Error("Ошибка stale", zap.Error(err))
|
logger.Log.Error("Ошибка stale", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Dish in Recipe
|
// 6. Dish in Recipe
|
||||||
if dishInRec, err := s.repo.FindDishesInRecipes(); err == nil {
|
if dishInRec, err := s.repo.FindDishesInRecipes(serverID); err == nil {
|
||||||
all = append(all, dishInRec...)
|
all = append(all, dishInRec...)
|
||||||
} else {
|
} else {
|
||||||
logger.Log.Error("Ошибка dish_in_recipe", zap.Error(err))
|
logger.Log.Error("Ошибка dish_in_recipe", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем
|
// Сохраняем
|
||||||
if err := s.repo.SaveAll(all); err != nil {
|
if err := s.repo.SaveAll(serverID, all); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ func (s *Service) RefreshRecommendations() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetRecommendations() ([]recommendations.Recommendation, error) {
|
// GetRecommendations возвращает рекомендации для конкретного сервера
|
||||||
return s.repo.GetAll()
|
func (s *Service) GetRecommendations(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
|
||||||
|
return s.repo.GetAll(serverID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"rmser/internal/domain/recipes"
|
"rmser/internal/domain/recipes"
|
||||||
"rmser/internal/domain/suppliers"
|
"rmser/internal/domain/suppliers"
|
||||||
"rmser/internal/infrastructure/rms"
|
"rmser/internal/infrastructure/rms"
|
||||||
|
"rmser/pkg/crypto"
|
||||||
"rmser/pkg/logger"
|
"rmser/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,17 +25,19 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
rmsFactory *rms.Factory
|
rmsFactory *rms.Factory
|
||||||
accountRepo account.Repository
|
cryptoManager *crypto.CryptoManager
|
||||||
catalogRepo catalog.Repository
|
accountRepo account.Repository
|
||||||
recipeRepo recipes.Repository
|
catalogRepo catalog.Repository
|
||||||
invoiceRepo invoices.Repository
|
recipeRepo recipes.Repository
|
||||||
opRepo operations.Repository
|
invoiceRepo invoices.Repository
|
||||||
supplierRepo suppliers.Repository
|
opRepo operations.Repository
|
||||||
|
supplierRepo suppliers.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
rmsFactory *rms.Factory,
|
rmsFactory *rms.Factory,
|
||||||
|
cryptoManager *crypto.CryptoManager,
|
||||||
accountRepo account.Repository,
|
accountRepo account.Repository,
|
||||||
catalogRepo catalog.Repository,
|
catalogRepo catalog.Repository,
|
||||||
recipeRepo recipes.Repository,
|
recipeRepo recipes.Repository,
|
||||||
@@ -43,16 +46,73 @@ func NewService(
|
|||||||
supplierRepo suppliers.Repository,
|
supplierRepo suppliers.Repository,
|
||||||
) *Service {
|
) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
rmsFactory: rmsFactory,
|
rmsFactory: rmsFactory,
|
||||||
accountRepo: accountRepo,
|
cryptoManager: cryptoManager,
|
||||||
catalogRepo: catalogRepo,
|
accountRepo: accountRepo,
|
||||||
recipeRepo: recipeRepo,
|
catalogRepo: catalogRepo,
|
||||||
invoiceRepo: invoiceRepo,
|
recipeRepo: recipeRepo,
|
||||||
opRepo: opRepo,
|
invoiceRepo: invoiceRepo,
|
||||||
supplierRepo: supplierRepo,
|
opRepo: opRepo,
|
||||||
|
supplierRepo: supplierRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncAllDataForServer запускает полную синхронизацию для конкретного сервера
|
||||||
|
func (s *Service) SyncAllDataForServer(serverID uuid.UUID, force bool) error {
|
||||||
|
logger.Log.Info("Запуск синхронизации по серверу", zap.String("server_id", serverID.String()), zap.Bool("force", force))
|
||||||
|
|
||||||
|
// 1. Получаем информацию о сервере
|
||||||
|
server, err := s.accountRepo.GetServerByID(serverID)
|
||||||
|
if err != nil || server == nil {
|
||||||
|
return fmt.Errorf("server not found: %s", serverID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Получаем креды владельца сервера для подключения
|
||||||
|
baseURL, login, encryptedPass, err := s.getOwnerCredentials(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get owner credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Расшифровываем пароль
|
||||||
|
plainPass, err := s.cryptoManager.Decrypt(encryptedPass)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Создаем клиент RMS
|
||||||
|
client := s.rmsFactory.CreateClientFromRawCredentials(baseURL, login, plainPass)
|
||||||
|
|
||||||
|
return s.syncAllWithClient(client, serverID, force)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOwnerCredentials возвращает учетные данные владельца сервера
|
||||||
|
func (s *Service) getOwnerCredentials(serverID uuid.UUID) (url, login, encryptedPass string, err error) {
|
||||||
|
// Находим владельца сервера
|
||||||
|
users, err := s.accountRepo.GetServerUsers(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownerLink *account.ServerUser
|
||||||
|
for i := range users {
|
||||||
|
if users[i].Role == account.RoleOwner {
|
||||||
|
ownerLink = &users[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ownerLink == nil {
|
||||||
|
return "", "", "", fmt.Errorf("owner not found for server %s", serverID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если у владельца есть личные креды - используем их
|
||||||
|
if ownerLink.Login != "" && ownerLink.EncryptedPassword != "" {
|
||||||
|
return ownerLink.Server.BaseURL, ownerLink.Login, ownerLink.EncryptedPassword, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", "", fmt.Errorf("owner has no credentials for server %s", serverID)
|
||||||
|
}
|
||||||
|
|
||||||
// SyncAllData запускает полную синхронизацию для конкретного пользователя
|
// SyncAllData запускает полную синхронизацию для конкретного пользователя
|
||||||
func (s *Service) SyncAllData(userID uuid.UUID, force bool) error {
|
func (s *Service) SyncAllData(userID uuid.UUID, force bool) error {
|
||||||
logger.Log.Info("Запуск синхронизации", zap.String("user_id", userID.String()), zap.Bool("force", force))
|
logger.Log.Info("Запуск синхронизации", zap.String("user_id", userID.String()), zap.Bool("force", force))
|
||||||
@@ -68,6 +128,12 @@ func (s *Service) SyncAllData(userID uuid.UUID, force bool) error {
|
|||||||
}
|
}
|
||||||
serverID := server.ID
|
serverID := server.ID
|
||||||
|
|
||||||
|
return s.syncAllWithClient(client, serverID, force)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncAllWithClient выполняет синхронизацию с готовым клиентом
|
||||||
|
func (s *Service) syncAllWithClient(client rms.ClientI, serverID uuid.UUID, force bool) error {
|
||||||
|
|
||||||
// 2. Справочники
|
// 2. Справочники
|
||||||
if err := s.syncStores(client, serverID); err != nil {
|
if err := s.syncStores(client, serverID); err != nil {
|
||||||
logger.Log.Error("Sync Stores failed", zap.Error(err))
|
logger.Log.Error("Sync Stores failed", zap.Error(err))
|
||||||
@@ -96,13 +162,12 @@ func (s *Service) SyncAllData(userID uuid.UUID, force bool) error {
|
|||||||
logger.Log.Error("Sync Invoices failed", zap.Error(err))
|
logger.Log.Error("Sync Invoices failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Складские операции (тяжелый запрос)
|
// 7. Складские операции
|
||||||
// Для MVP можно отключить, если долго грузится
|
if err := s.SyncStoreOperations(client, serverID); err != nil {
|
||||||
// if err := s.SyncStoreOperations(client, serverID); err != nil {
|
logger.Log.Error("Sync Operations failed", zap.Error(err))
|
||||||
// logger.Log.Error("Sync Operations failed", zap.Error(err))
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
logger.Log.Info("Синхронизация завершена", zap.String("user_id", userID.String()))
|
logger.Log.Info("Синхронизация завершена", zap.String("server_id", serverID.String()))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +301,7 @@ func (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID, force bool) er
|
|||||||
// SyncStoreOperations публичный, если нужно вызывать отдельно
|
// SyncStoreOperations публичный, если нужно вызывать отдельно
|
||||||
func (s *Service) SyncStoreOperations(c rms.ClientI, serverID uuid.UUID) error {
|
func (s *Service) SyncStoreOperations(c rms.ClientI, serverID uuid.UUID) error {
|
||||||
dateTo := time.Now()
|
dateTo := time.Now()
|
||||||
dateFrom := dateTo.AddDate(0, 0, -30)
|
dateFrom := dateTo.AddDate(0, 0, -90) // 90 дней — соответствует периоду анализа рекомендаций
|
||||||
|
|
||||||
if err := s.syncReport(c, serverID, PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {
|
if err := s.syncReport(c, serverID, PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {
|
||||||
return fmt.Errorf("purchases sync error: %w", err)
|
return fmt.Errorf("purchases sync error: %w", err)
|
||||||
|
|||||||
136
internal/services/worker/sync_worker.go
Normal file
136
internal/services/worker/sync_worker.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
|
"rmser/internal/infrastructure/rms"
|
||||||
|
"rmser/internal/services/recommend"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncService интерфейс для синхронизации данных
|
||||||
|
type SyncService interface {
|
||||||
|
// SyncAllDataForServer синхронизирует данные для конкретного сервера
|
||||||
|
SyncAllDataForServer(serverID uuid.UUID, force bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncWorker фоновый процесс для автоматической синхронизации данных с iiko серверами
|
||||||
|
type SyncWorker struct {
|
||||||
|
syncService SyncService // сервис для синхронизации
|
||||||
|
accountRepo account.Repository // репозиторий для работы с серверами
|
||||||
|
rmsFactory *rms.Factory // фабрика для создания клиентов RMS
|
||||||
|
recService *recommend.Service // сервис рекомендаций
|
||||||
|
logger *zap.Logger
|
||||||
|
tickerInterval time.Duration // интервал проверки (например, 1 минута)
|
||||||
|
idleThreshold time.Duration // порог простоя (10 минут)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSyncWorker создает новый экземпляр SyncWorker
|
||||||
|
func NewSyncWorker(
|
||||||
|
syncService SyncService,
|
||||||
|
accountRepo account.Repository,
|
||||||
|
rmsFactory *rms.Factory,
|
||||||
|
recService *recommend.Service,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *SyncWorker {
|
||||||
|
return &SyncWorker{
|
||||||
|
syncService: syncService,
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
rmsFactory: rmsFactory,
|
||||||
|
recService: recService,
|
||||||
|
logger: logger,
|
||||||
|
tickerInterval: 1 * time.Minute,
|
||||||
|
idleThreshold: 10 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run запускает фоновый процесс синхронизации
|
||||||
|
func (w *SyncWorker) Run(ctx context.Context) {
|
||||||
|
w.logger.Info("Запуск SyncWorker",
|
||||||
|
zap.Duration("ticker_interval", w.tickerInterval),
|
||||||
|
zap.Duration("idle_threshold", w.idleThreshold))
|
||||||
|
|
||||||
|
ticker := time.NewTicker(w.tickerInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Первый запуск сразу
|
||||||
|
w.processSync(ctx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
w.logger.Info("Остановка SyncWorker")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w.processSync(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processSync обрабатывает синхронизацию для всех серверов, готовых к синхронизации
|
||||||
|
func (w *SyncWorker) processSync(ctx context.Context) {
|
||||||
|
// Получаем серверы, готовые для синхронизации
|
||||||
|
servers, err := w.accountRepo.GetServersForSync(w.idleThreshold)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Error("Ошибка получения серверов для синхронизации", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(servers) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Info("Найдены серверы для синхронизации",
|
||||||
|
zap.Int("count", len(servers)))
|
||||||
|
|
||||||
|
for _, server := range servers {
|
||||||
|
// Обрабатываем каждый сервер в отдельной горутине
|
||||||
|
go w.syncServer(ctx, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncServer выполняет синхронизацию для конкретного сервера
|
||||||
|
func (w *SyncWorker) syncServer(ctx context.Context, server account.RMSServer) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
w.logger.Error("Паника при синхронизации сервера",
|
||||||
|
zap.String("server_id", server.ID.String()),
|
||||||
|
zap.Any("recover", r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.logger.Info("Начало синхронизации сервера",
|
||||||
|
zap.String("server_id", server.ID.String()),
|
||||||
|
zap.String("server_name", server.Name))
|
||||||
|
|
||||||
|
// Вызываем синхронизацию через syncService
|
||||||
|
err := w.syncService.SyncAllDataForServer(server.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Error("Ошибка синхронизации сервера",
|
||||||
|
zap.String("server_id", server.ID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем время последней синхронизации
|
||||||
|
err = w.accountRepo.UpdateLastSync(server.ID)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Error("Ошибка обновления времени синхронизации",
|
||||||
|
zap.String("server_id", server.ID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем рекомендации после успешной синхронизации
|
||||||
|
if err := w.recService.RefreshRecommendations(server.ID); err != nil {
|
||||||
|
w.logger.Error("Ошибка обновления рекомендаций",
|
||||||
|
zap.String("server_id", server.ID.String()),
|
||||||
|
zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Info("Синхронизация сервера завершена успешно",
|
||||||
|
zap.String("server_id", server.ID.String()))
|
||||||
|
}
|
||||||
@@ -203,50 +203,98 @@ type CommitRequestDTO struct {
|
|||||||
SupplierID string `json:"supplier_id"`
|
SupplierID string `json:"supplier_id"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
IncomingDocNum string `json:"incoming_document_number"`
|
IncomingDocNum string `json:"incoming_document_number"`
|
||||||
|
IsProcessed bool `json:"is_processed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
||||||
userID := c.MustGet("userID").(uuid.UUID)
|
// Защита от паники
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logger.Log.Error("CRITICAL PANIC in CommitDraft Handler",
|
||||||
|
zap.Any("panic", r),
|
||||||
|
zap.Stack("stack"),
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Internal Server Error: %v", r)})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
logger.Log.Info("--- HANDLER: Start CommitDraft ---", zap.String("path", c.Request.URL.Path))
|
||||||
|
|
||||||
|
userID, ok := c.Get("userID")
|
||||||
|
if !ok {
|
||||||
|
logger.Log.Error("HANDLER: UserID missing in context")
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userUUID := userID.(uuid.UUID)
|
||||||
|
logger.Log.Info("HANDLER: UserID extracted", zap.String("user_id", userUUID.String()))
|
||||||
|
|
||||||
draftID, err := uuid.Parse(c.Param("id"))
|
draftID, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Log.Warn("HANDLER: Invalid DraftID", zap.String("param", c.Param("id")), zap.Error(err))
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logger.Log.Info("HANDLER: DraftID parsed", zap.String("draft_id", draftID.String()))
|
||||||
|
|
||||||
var req CommitRequestDTO
|
var req CommitRequestDTO
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
logger.Log.Error("HANDLER: JSON Binding failed", zap.Error(err))
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logger.Log.Info("HANDLER: Payload bound",
|
||||||
|
zap.String("date_incoming", req.DateIncoming),
|
||||||
|
zap.String("store_id", req.StoreID),
|
||||||
|
zap.String("supplier_id", req.SupplierID),
|
||||||
|
zap.String("incoming_doc_num", req.IncomingDocNum),
|
||||||
|
zap.Bool("is_processed", req.IsProcessed),
|
||||||
|
)
|
||||||
|
|
||||||
date, err := time.Parse("2006-01-02", req.DateIncoming)
|
date, err := time.Parse("2006-01-02", req.DateIncoming)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Log.Error("HANDLER: Date parsing failed", zap.String("date", req.DateIncoming), zap.Error(err))
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date format"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date format"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
storeID, err := uuid.Parse(req.StoreID)
|
storeID, err := uuid.Parse(req.StoreID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Log.Error("HANDLER: StoreID parsing failed", zap.String("store_id", req.StoreID), zap.Error(err))
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid store id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid store id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
supplierID, err := uuid.Parse(req.SupplierID)
|
supplierID, err := uuid.Parse(req.SupplierID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Log.Error("HANDLER: SupplierID parsing failed", zap.String("supplier_id", req.SupplierID), zap.Error(err))
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid supplier id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid supplier id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Log.Info("HANDLER: Calling UpdateDraftHeader...",
|
||||||
|
zap.String("draft_id", draftID.String()),
|
||||||
|
zap.String("store_id", storeID.String()),
|
||||||
|
zap.String("supplier_id", supplierID.String()),
|
||||||
|
zap.Time("date", date),
|
||||||
|
)
|
||||||
|
|
||||||
if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment, req.IncomingDocNum); err != nil {
|
if err := h.service.UpdateDraftHeader(draftID, &storeID, &supplierID, date, req.Comment, req.IncomingDocNum); err != nil {
|
||||||
|
logger.Log.Error("HANDLER: UpdateDraftHeader failed", zap.Error(err))
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
docNum, err := h.service.CommitDraft(draftID, userID)
|
logger.Log.Info("HANDLER: Calling CommitDraft service...", zap.String("draft_id", draftID.String()), zap.String("user_id", userUUID.String()))
|
||||||
|
|
||||||
|
docNum, err := h.service.CommitDraft(draftID, userUUID, req.IsProcessed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Log.Error("Commit failed", zap.Error(err))
|
logger.Log.Warn("HANDLER: CommitDraft service 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Log.Info("HANDLER: Success!", zap.String("doc_num", docNum))
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "completed", "document_number": docNum})
|
c.JSON(http.StatusOK, gin.H{"status": "completed", "document_number": docNum})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,3 +110,23 @@ func (h *InvoiceHandler) SyncInvoices(c *gin.Context) {
|
|||||||
"message": "Синхронизация запущена",
|
"message": "Синхронизация запущена",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetStats godoc
|
||||||
|
// @Summary Получить статистику по накладным
|
||||||
|
// @Description Возвращает статистику по накладным для текущего пользователя
|
||||||
|
// @Tags invoices
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} invService.InvoiceStatsDTO
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
func (h *InvoiceHandler) GetStats(c *gin.Context) {
|
||||||
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
|
stats, err := h.service.GetStats(userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("Ошибка получения статистики", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Ошибка получения статистики"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,29 +4,56 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"rmser/internal/domain/account"
|
||||||
"rmser/internal/services/recommend"
|
"rmser/internal/services/recommend"
|
||||||
"rmser/pkg/logger"
|
"rmser/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RecommendationsHandler struct {
|
type RecommendationsHandler struct {
|
||||||
service *recommend.Service
|
service *recommend.Service
|
||||||
|
accountRepo account.Repository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRecommendationsHandler(service *recommend.Service) *RecommendationsHandler {
|
func NewRecommendationsHandler(service *recommend.Service, accountRepo account.Repository) *RecommendationsHandler {
|
||||||
return &RecommendationsHandler{service: service}
|
return &RecommendationsHandler{
|
||||||
|
service: service,
|
||||||
|
accountRepo: accountRepo,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecommendations godoc
|
// GetRecommendations godoc
|
||||||
// @Summary Получить список рекомендаций
|
// @Summary Получить список рекомендаций
|
||||||
// @Description Возвращает сгенерированные рекомендации (проблемные зоны учета)
|
// @Description Возвращает сгенерированные рекомендации (проблемные зоны учета) для активного сервера
|
||||||
// @Tags recommendations
|
// @Tags recommendations
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} recommendations.Recommendation
|
// @Success 200 {array} recommendations.Recommendation
|
||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
func (h *RecommendationsHandler) GetRecommendations(c *gin.Context) {
|
func (h *RecommendationsHandler) GetRecommendations(c *gin.Context) {
|
||||||
recs, err := h.service.GetRecommendations()
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
|
// Получаем активный сервер пользователя
|
||||||
|
server, err := h.accountRepo.GetActiveServer(userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Log.Error("Ошибка получения активного сервера", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get active server"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if server == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no active server"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала обновляем рекомендации
|
||||||
|
if err := h.service.RefreshRecommendations(server.ID); err != nil {
|
||||||
|
logger.Log.Error("Ошибка обновления рекомендаций", zap.Error(err))
|
||||||
|
// Не прерываем выполнение, продолжаем с текущими данными
|
||||||
|
}
|
||||||
|
|
||||||
|
// Затем получаем рекомендации
|
||||||
|
recs, err := h.service.GetRecommendations(server.ID)
|
||||||
if err != nil {
|
if 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()})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -48,13 +49,16 @@ func (h *SettingsHandler) SetNotifier(n Notifier) {
|
|||||||
|
|
||||||
// SettingsResponse - DTO для отдачи настроек
|
// SettingsResponse - DTO для отдачи настроек
|
||||||
type SettingsResponse struct {
|
type SettingsResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
DefaultStoreID *string `json:"default_store_id"` // Nullable
|
DefaultStoreID *string `json:"default_store_id"` // Nullable
|
||||||
RootGroupID *string `json:"root_group_id"` // Nullable
|
RootGroupID *string `json:"root_group_id"` // Nullable
|
||||||
AutoConduct bool `json:"auto_conduct"`
|
AutoConduct bool `json:"auto_conduct"`
|
||||||
Role string `json:"role"` // OWNER, ADMIN, OPERATOR
|
Role string `json:"role"` // OWNER, ADMIN, OPERATOR
|
||||||
|
SyncInterval int `json:"sync_interval"` // Интервал синхронизации в минутах
|
||||||
|
LastSyncAt *time.Time `json:"last_sync_at"` // Время последней синхронизации
|
||||||
|
LastActivityAt *time.Time `json:"last_activity_at"` // Время последней активности
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSettings возвращает настройки активного сервера + роль пользователя
|
// GetSettings возвращает настройки активного сервера + роль пользователя
|
||||||
@@ -77,11 +81,14 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resp := SettingsResponse{
|
resp := SettingsResponse{
|
||||||
ID: server.ID.String(),
|
ID: server.ID.String(),
|
||||||
Name: server.Name,
|
Name: server.Name,
|
||||||
BaseURL: server.BaseURL,
|
BaseURL: server.BaseURL,
|
||||||
AutoConduct: server.AutoProcess,
|
AutoConduct: server.AutoProcess,
|
||||||
Role: string(role),
|
Role: string(role),
|
||||||
|
SyncInterval: server.SyncInterval,
|
||||||
|
LastSyncAt: server.LastSyncAt,
|
||||||
|
LastActivityAt: server.LastActivityAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if server.DefaultStoreID != nil {
|
if server.DefaultStoreID != nil {
|
||||||
@@ -96,16 +103,17 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettingsDTO
|
// UpdateSettingsDTO - DTO для частичного обновления настроек (PATCH-семантика)
|
||||||
type UpdateSettingsDTO struct {
|
type UpdateSettingsDTO struct {
|
||||||
Name string `json:"name"`
|
Name *string `json:"name"`
|
||||||
DefaultStoreID string `json:"default_store_id"`
|
DefaultStoreID *string `json:"default_store_id"`
|
||||||
RootGroupID string `json:"root_group_id"`
|
RootGroupID *string `json:"root_group_id"`
|
||||||
AutoProcess bool `json:"auto_process"`
|
AutoProcess *bool `json:"auto_process"` // Legacy для обратной совместимости
|
||||||
AutoConduct bool `json:"auto_conduct"`
|
AutoConduct *bool `json:"auto_conduct"` // Новое поле
|
||||||
|
SyncInterval *int `json:"sync_interval,omitempty"` // Интервал синхронизации в минутах (5 - 10080)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSettings сохраняет настройки
|
// UpdateSettings сохраняет настройки с PATCH-семантикой
|
||||||
func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
||||||
userID := c.MustGet("userID").(uuid.UUID)
|
userID := c.MustGet("userID").(uuid.UUID)
|
||||||
|
|
||||||
@@ -115,6 +123,11 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Логирование полученных данных для отладки
|
||||||
|
logger.Log.Info("Получен запрос на обновление настроек",
|
||||||
|
zap.Any("request", req),
|
||||||
|
)
|
||||||
|
|
||||||
server, err := h.accountRepo.GetActiveServer(userID)
|
server, err := h.accountRepo.GetActiveServer(userID)
|
||||||
if err != nil || server == nil {
|
if err != nil || server == nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
|
||||||
@@ -132,31 +145,56 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name != "" {
|
// Обновление имени (только если передано)
|
||||||
server.Name = req.Name
|
if req.Name != nil {
|
||||||
|
server.Name = *req.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.AutoConduct {
|
// Обновление флага авто-проведения
|
||||||
server.AutoProcess = true
|
if req.AutoConduct != nil {
|
||||||
} else {
|
server.AutoProcess = *req.AutoConduct
|
||||||
server.AutoProcess = req.AutoProcess || req.AutoConduct
|
} else if req.AutoProcess != nil {
|
||||||
|
// Fallback для старых клиентов, которые используют legacy поле
|
||||||
|
server.AutoProcess = *req.AutoProcess
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.DefaultStoreID != "" {
|
// Обновление интервала синхронизации
|
||||||
if uid, err := uuid.Parse(req.DefaultStoreID); err == nil {
|
if req.SyncInterval != nil {
|
||||||
server.DefaultStoreID = &uid
|
// Валидация диапазона: от 5 минут до 1 недели (10080 минут)
|
||||||
|
if *req.SyncInterval < 5 || *req.SyncInterval > 10080 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "sync_interval должен быть от 5 минут до 1 недели (10080 минут)"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else {
|
server.SyncInterval = *req.SyncInterval
|
||||||
server.DefaultStoreID = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.RootGroupID != "" {
|
// Обновление DefaultStoreID
|
||||||
if uid, err := uuid.Parse(req.RootGroupID); err == nil {
|
if req.DefaultStoreID != nil {
|
||||||
server.RootGroupGUID = &uid
|
if *req.DefaultStoreID == "" {
|
||||||
|
// Пустая строка -> сбрасываем в nil
|
||||||
|
server.DefaultStoreID = nil
|
||||||
|
} else {
|
||||||
|
// UUID -> обновляем
|
||||||
|
if uid, err := uuid.Parse(*req.DefaultStoreID); err == nil {
|
||||||
|
server.DefaultStoreID = &uid
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
server.RootGroupGUID = nil
|
|
||||||
}
|
}
|
||||||
|
// Если nil -> не трогаем текущее значение
|
||||||
|
|
||||||
|
// Обновление RootGroupID
|
||||||
|
if req.RootGroupID != nil {
|
||||||
|
if *req.RootGroupID == "" {
|
||||||
|
// Пустая строка -> сбрасываем в nil
|
||||||
|
server.RootGroupGUID = nil
|
||||||
|
} else {
|
||||||
|
// UUID -> обновляем
|
||||||
|
if uid, err := uuid.Parse(*req.RootGroupID); err == nil {
|
||||||
|
server.RootGroupGUID = &uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Если nil -> не трогаем текущее значение
|
||||||
|
|
||||||
if err := h.accountRepo.SaveServerSettings(server); err != nil {
|
if err := h.accountRepo.SaveServerSettings(server); err != nil {
|
||||||
logger.Log.Error("Failed to save settings", zap.Error(err))
|
logger.Log.Error("Failed to save settings", zap.Error(err))
|
||||||
|
|||||||
@@ -202,17 +202,17 @@ func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
|
|||||||
|
|
||||||
func (bot *Bot) handleStartCommand(c tele.Context) error {
|
func (bot *Bot) handleStartCommand(c tele.Context) error {
|
||||||
payload := c.Message().Payload
|
payload := c.Message().Payload
|
||||||
|
|
||||||
// Обработка desktop авторизации
|
// Обработка desktop авторизации
|
||||||
if payload != "" && strings.HasPrefix(payload, "auth_") {
|
if payload != "" && strings.HasPrefix(payload, "auth_") {
|
||||||
sessionID := strings.TrimPrefix(payload, "auth_")
|
sessionID := strings.TrimPrefix(payload, "auth_")
|
||||||
telegramID := c.Sender().ID
|
telegramID := c.Sender().ID
|
||||||
|
|
||||||
logger.Log.Info("Обработка desktop авторизации",
|
logger.Log.Info("Обработка desktop авторизации",
|
||||||
zap.String("session_id", sessionID),
|
zap.String("session_id", sessionID),
|
||||||
zap.Int64("telegram_id", telegramID),
|
zap.Int64("telegram_id", telegramID),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := bot.authService.ConfirmDesktopAuth(sessionID, telegramID); err != nil {
|
if err := bot.authService.ConfirmDesktopAuth(sessionID, telegramID); err != nil {
|
||||||
logger.Log.Error("Ошибка подтверждения desktop авторизации",
|
logger.Log.Error("Ошибка подтверждения desktop авторизации",
|
||||||
zap.String("session_id", sessionID),
|
zap.String("session_id", sessionID),
|
||||||
@@ -221,10 +221,10 @@ func (bot *Bot) handleStartCommand(c tele.Context) error {
|
|||||||
)
|
)
|
||||||
return c.Send("❌ Ошибка авторизации. Попробуйте снова.", tele.ModeHTML)
|
return c.Send("❌ Ошибка авторизации. Попробуйте снова.", tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Send("✅ Авторизация успешна! Вы можете вернуться в приложение.", tele.ModeHTML)
|
return c.Send("✅ Авторизация успешна! Вы можете вернуться в приложение.", tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload != "" && strings.HasPrefix(payload, "invite_") {
|
if payload != "" && strings.HasPrefix(payload, "invite_") {
|
||||||
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
|
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
|
||||||
}
|
}
|
||||||
@@ -417,19 +417,70 @@ func (bot *Bot) renderServersMenu(c tele.Context) error {
|
|||||||
}
|
}
|
||||||
role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID)
|
||||||
label := fmt.Sprintf("%s %s (%s)", icon, s.Name, role)
|
label := fmt.Sprintf("%s %s (%s)", icon, s.Name, role)
|
||||||
btn := menu.Data(label, "set_server_"+s.ID.String())
|
btn := menu.Data(label, "srv_menu_"+s.ID.String())
|
||||||
rows = append(rows, menu.Row(btn))
|
rows = append(rows, menu.Row(btn))
|
||||||
}
|
}
|
||||||
|
|
||||||
btnAdd := menu.Data("➕ Добавить сервер", "act_add_server")
|
btnAdd := menu.Data("➕ Добавить сервер", "act_add_server")
|
||||||
btnDel := menu.Data("⚙️ Управление / Удаление", "act_del_server_menu")
|
|
||||||
btnBack := menu.Data("🔙 Назад", "nav_main")
|
btnBack := menu.Data("🔙 Назад", "nav_main")
|
||||||
|
|
||||||
rows = append(rows, menu.Row(btnAdd, btnDel))
|
rows = append(rows, menu.Row(btnAdd))
|
||||||
rows = append(rows, menu.Row(btnBack))
|
rows = append(rows, menu.Row(btnBack))
|
||||||
menu.Inline(rows...)
|
menu.Inline(rows...)
|
||||||
|
|
||||||
txt := fmt.Sprintf("<b>🖥 Ваши серверы (%d):</b>\n\nНажмите на сервер, чтобы сделать его активным.", len(servers))
|
txt := fmt.Sprintf("<b>🖥 Ваши серверы (%d):</b>\n\nНажмите на сервер для управления.", len(servers))
|
||||||
|
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderServerMenu показывает подменю управления конкретным сервером
|
||||||
|
func (bot *Bot) renderServerMenu(c tele.Context, serverID uuid.UUID) error {
|
||||||
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
|
||||||
|
server, err := bot.accountRepo.GetServerByID(serverID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Send("Ошибка: сервер не найден")
|
||||||
|
}
|
||||||
|
|
||||||
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
||||||
|
activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)
|
||||||
|
isActive := activeServer != nil && activeServer.ID == server.ID
|
||||||
|
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
var rows []tele.Row
|
||||||
|
|
||||||
|
// Кнопка "Выбрать активным" (доступна всем)
|
||||||
|
if !isActive {
|
||||||
|
btnSetActive := menu.Data("✅ Выбрать активным", "srv_set_active_"+server.ID.String())
|
||||||
|
rows = append(rows, menu.Row(btnSetActive))
|
||||||
|
} else {
|
||||||
|
btnActive := menu.Data("🟢 Активный сервер", "noop")
|
||||||
|
rows = append(rows, menu.Row(btnActive))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Показать URL/Логин" (доступна Admin и Owner)
|
||||||
|
if role == account.RoleOwner || role == account.RoleAdmin {
|
||||||
|
btnShowCreds := menu.Data("👁 Показать URL/Логин", "srv_show_creds_"+server.ID.String())
|
||||||
|
btnInvite := menu.Data("📩 Пригласить сотрудника", fmt.Sprintf("gen_invite_%s", server.ID.String()))
|
||||||
|
rows = append(rows, menu.Row(btnShowCreds, btnInvite))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Обновить логин-пароль" (только Owner)
|
||||||
|
if role == account.RoleOwner {
|
||||||
|
btnUpdateCreds := menu.Data("✏️ Обновить логин-пароль", "srv_update_creds_"+server.ID.String())
|
||||||
|
rows = append(rows, menu.Row(btnUpdateCreds))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка "Удалить сервер" (только Owner)
|
||||||
|
if role == account.RoleOwner {
|
||||||
|
btnDelete := menu.Data("❌ Удалить сервер", "srv_delete_"+server.ID.String())
|
||||||
|
rows = append(rows, menu.Row(btnDelete))
|
||||||
|
}
|
||||||
|
|
||||||
|
btnBack := menu.Data("🔙 Назад к списку", "nav_servers")
|
||||||
|
rows = append(rows, menu.Row(btnBack))
|
||||||
|
menu.Inline(rows...)
|
||||||
|
|
||||||
|
txt := fmt.Sprintf("<b>⚙️ Управление сервером</b>\n\n🏢 <b>Название:</b> %s\n🔗 <b>URL:</b> %s\n👤 <b>Ваша роль:</b> %s",
|
||||||
|
server.Name, server.BaseURL, role)
|
||||||
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
return c.EditOrSend(txt, menu, tele.ModeHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +587,131 @@ func (bot *Bot) handleCallback(c tele.Context) error {
|
|||||||
return bot.handleBillingCallbacks(c, data, userDB)
|
return bot.handleBillingCallbacks(c, data, userDB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обработка кнопок подменю сервера
|
||||||
|
if strings.HasPrefix(data, "srv_menu_") {
|
||||||
|
serverIDStr := strings.TrimPrefix(data, "srv_menu_")
|
||||||
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
|
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||||
|
serverIDStr = serverIDStr[:idx]
|
||||||
|
}
|
||||||
|
targetID := parseUUID(serverIDStr)
|
||||||
|
return bot.renderServerMenu(c, targetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(data, "srv_set_active_") {
|
||||||
|
serverIDStr := strings.TrimPrefix(data, "srv_set_active_")
|
||||||
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
|
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||||
|
serverIDStr = serverIDStr[:idx]
|
||||||
|
}
|
||||||
|
targetID := parseUUID(serverIDStr)
|
||||||
|
if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {
|
||||||
|
logger.Log.Error("Failed to set active server", zap.Error(err))
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: доступ запрещен"})
|
||||||
|
}
|
||||||
|
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран активным"})
|
||||||
|
return bot.renderServerMenu(c, targetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(data, "srv_show_creds_") {
|
||||||
|
serverIDStr := strings.TrimPrefix(data, "srv_show_creds_")
|
||||||
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
|
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||||
|
serverIDStr = serverIDStr[:idx]
|
||||||
|
}
|
||||||
|
targetID := parseUUID(serverIDStr)
|
||||||
|
server, err := bot.accountRepo.GetServerByID(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: сервер не найден"})
|
||||||
|
}
|
||||||
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
||||||
|
if role != account.RoleOwner && role != account.RoleAdmin {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: недостаточно прав"})
|
||||||
|
}
|
||||||
|
// Получаем личные креды пользователя через GetServerUsers
|
||||||
|
serverUsers, err := bot.accountRepo.GetServerUsers(server.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка получения данных"})
|
||||||
|
}
|
||||||
|
var login string
|
||||||
|
for _, su := range serverUsers {
|
||||||
|
if su.UserID == userDB.ID {
|
||||||
|
login = su.Login
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if login == "" {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "У вас нет сохраненных учетных данных"})
|
||||||
|
}
|
||||||
|
c.Respond()
|
||||||
|
return c.Send(fmt.Sprintf("🔑 <b>Учетные данные сервера</b>\n\n🏢 <b>Название:</b> %s\n🔗 <b>URL:</b> %s\n👤 <b>Логин:</b> %s\n🔒 <b>Пароль:</b> ***скрыт***",
|
||||||
|
server.Name, server.BaseURL, login), tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(data, "srv_update_creds_") {
|
||||||
|
serverIDStr := strings.TrimPrefix(data, "srv_update_creds_")
|
||||||
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
|
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||||
|
serverIDStr = serverIDStr[:idx]
|
||||||
|
}
|
||||||
|
targetID := parseUUID(serverIDStr)
|
||||||
|
server, err := bot.accountRepo.GetServerByID(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: сервер не найден"})
|
||||||
|
}
|
||||||
|
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
|
||||||
|
if role != account.RoleOwner {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: только владелец может обновлять учетные данные"})
|
||||||
|
}
|
||||||
|
// Сохраняем ID сервера в контексте FSM
|
||||||
|
bot.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) {
|
||||||
|
ctx.EditingServerID = server.ID
|
||||||
|
ctx.TempURL = server.BaseURL
|
||||||
|
})
|
||||||
|
bot.fsm.SetState(c.Sender().ID, StateUpdateServerLogin)
|
||||||
|
c.Respond()
|
||||||
|
return c.EditOrSend("✏️ <b>Обновление учетных данных</b>\n\nВведите новый <b>логин</b> для сервера <b>"+server.Name+"</b>.\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(data, "srv_delete_") {
|
||||||
|
serverIDStr := strings.TrimPrefix(data, "srv_delete_")
|
||||||
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
|
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||||
|
serverIDStr = serverIDStr[:idx]
|
||||||
|
}
|
||||||
|
targetID := parseUUID(serverIDStr)
|
||||||
|
role, err := bot.accountRepo.GetUserRole(userDB.ID, targetID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка прав доступа"})
|
||||||
|
}
|
||||||
|
if role != account.RoleOwner {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Только владелец может удалить сервер"})
|
||||||
|
}
|
||||||
|
// Подтверждение удаления
|
||||||
|
menu := &tele.ReplyMarkup{}
|
||||||
|
btnYes := menu.Data("✅ Да, удалить", "srv_delete_confirm_"+targetID.String())
|
||||||
|
btnNo := menu.Data("❌ Отмена", "srv_menu_"+targetID.String())
|
||||||
|
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
|
||||||
|
server, _ := bot.accountRepo.GetServerByID(targetID)
|
||||||
|
return c.EditOrSend("⚠️ <b>Подтверждение удаления</b>\n\nВы уверены, что хотите удалить сервер <b>"+server.Name+"</b>?\n\nЭто действие необратимо!", menu, tele.ModeHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(data, "srv_delete_confirm_") {
|
||||||
|
serverIDStr := strings.TrimPrefix(data, "srv_delete_confirm_")
|
||||||
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
|
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
|
||||||
|
serverIDStr = serverIDStr[:idx]
|
||||||
|
}
|
||||||
|
targetID := parseUUID(serverIDStr)
|
||||||
|
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
|
||||||
|
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
|
||||||
|
}
|
||||||
|
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||||
|
c.Respond(&tele.CallbackResponse{Text: "Сервер удален"})
|
||||||
|
return bot.renderServersMenu(c)
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(data, "set_server_") {
|
if strings.HasPrefix(data, "set_server_") {
|
||||||
serverIDStr := strings.TrimPrefix(data, "set_server_")
|
serverIDStr := strings.TrimPrefix(data, "set_server_")
|
||||||
serverIDStr = strings.TrimSpace(serverIDStr)
|
serverIDStr = strings.TrimSpace(serverIDStr)
|
||||||
@@ -799,6 +975,7 @@ func (bot *Bot) handleText(c tele.Context) error {
|
|||||||
userID := c.Sender().ID
|
userID := c.Sender().ID
|
||||||
state := bot.fsm.GetState(userID)
|
state := bot.fsm.GetState(userID)
|
||||||
text := strings.TrimSpace(c.Text())
|
text := strings.TrimSpace(c.Text())
|
||||||
|
userDB, _ := bot.accountRepo.GetUserByTelegramID(userID)
|
||||||
|
|
||||||
if bot.maintenanceMode && !bot.isDev(userID) {
|
if bot.maintenanceMode && !bot.isDev(userID) {
|
||||||
return c.Send("Сервис на обслуживании", tele.ModeHTML)
|
return c.Send("Сервис на обслуживании", tele.ModeHTML)
|
||||||
@@ -888,6 +1065,52 @@ func (bot *Bot) handleText(c tele.Context) error {
|
|||||||
ctx.BillingTargetURL = text
|
ctx.BillingTargetURL = text
|
||||||
})
|
})
|
||||||
return bot.renderTariffShowcase(c, text)
|
return bot.renderTariffShowcase(c, text)
|
||||||
|
|
||||||
|
case StateUpdateServerLogin:
|
||||||
|
ctx := bot.fsm.GetContext(userID)
|
||||||
|
if ctx.EditingServerID == uuid.Nil {
|
||||||
|
bot.fsm.Reset(userID)
|
||||||
|
return bot.renderMainMenu(c)
|
||||||
|
}
|
||||||
|
bot.fsm.UpdateContext(userID, func(uCtx *UserContext) {
|
||||||
|
uCtx.TempLogin = text
|
||||||
|
uCtx.State = StateUpdateServerPassword
|
||||||
|
})
|
||||||
|
return c.Send("🔑 Введите новый <b>пароль</b>:")
|
||||||
|
|
||||||
|
case StateUpdateServerPassword:
|
||||||
|
password := text
|
||||||
|
ctx := bot.fsm.GetContext(userID)
|
||||||
|
if ctx.EditingServerID == uuid.Nil {
|
||||||
|
bot.fsm.Reset(userID)
|
||||||
|
return bot.renderMainMenu(c)
|
||||||
|
}
|
||||||
|
server, err := bot.accountRepo.GetServerByID(ctx.EditingServerID)
|
||||||
|
if err != nil {
|
||||||
|
bot.fsm.Reset(userID)
|
||||||
|
return c.Send("❌ Ошибка: сервер не найден")
|
||||||
|
}
|
||||||
|
// Проверяем новые креды
|
||||||
|
msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...")
|
||||||
|
tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password)
|
||||||
|
if err := tempClient.Auth(); err != nil {
|
||||||
|
bot.b.Delete(msg)
|
||||||
|
bot.fsm.Reset(userID)
|
||||||
|
return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err))
|
||||||
|
}
|
||||||
|
// Шифруем пароль и сохраняем
|
||||||
|
encPass, _ := bot.cryptoManager.Encrypt(password)
|
||||||
|
// Обновляем креды через ConnectServer (он обновит существующую связь)
|
||||||
|
_, err = bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, server.Name)
|
||||||
|
bot.b.Delete(msg)
|
||||||
|
if err != nil {
|
||||||
|
bot.fsm.Reset(userID)
|
||||||
|
return c.Send("❌ Ошибка обновления данных")
|
||||||
|
}
|
||||||
|
bot.fsm.Reset(userID)
|
||||||
|
bot.rmsFactory.ClearCacheForUser(userDB.ID)
|
||||||
|
c.Send("✅ <b>Учетные данные обновлены!</b>\n\nТеперь вы можете использовать новые логин и пароль для подключения к серверу.", tele.ModeHTML)
|
||||||
|
return bot.renderServerMenu(c, server.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ const (
|
|||||||
StateAddServerConfirmName
|
StateAddServerConfirmName
|
||||||
StateAddServerInputName
|
StateAddServerInputName
|
||||||
StateBillingGiftURL
|
StateBillingGiftURL
|
||||||
|
StateUpdateServerLogin // Обновление логина для существующего сервера
|
||||||
|
StateUpdateServerPassword // Обновление пароля для существующего сервера
|
||||||
|
|
||||||
// Состояния редактора черновиков (начиная с 100)
|
// Состояния редактора черновиков (начиная с 100)
|
||||||
StateDraftEditItemName State = 100 // Ожидание ввода нового названия позиции
|
StateDraftEditItemName State = 100 // Ожидание ввода нового названия позиции
|
||||||
StateDraftEditItemQty State = 101 // Ожидание ввода количества
|
StateDraftEditItemQty State = 101 // Ожидание ввода количества
|
||||||
StateDraftEditItemPrice State = 102 // Ожидание ввода цены
|
StateDraftEditItemPrice State = 102 // Ожидание ввода цены
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +36,9 @@ type UserContext struct {
|
|||||||
TempPassword string
|
TempPassword string
|
||||||
TempServerName string
|
TempServerName string
|
||||||
|
|
||||||
|
// Поля для обновления сервера
|
||||||
|
EditingServerID uuid.UUID // ID редактируемого сервера
|
||||||
|
|
||||||
// Поля для биллинга
|
// Поля для биллинга
|
||||||
BillingTargetURL string
|
BillingTargetURL string
|
||||||
|
|
||||||
|
|||||||
17
migrations/20250202040746_add_sync_fields_to_rms_servers.sql
Normal file
17
migrations/20250202040746_add_sync_fields_to_rms_servers.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Добавляем поля для синхронизации в таблицу rms_servers
|
||||||
|
-- Миграция для отслеживания активности и времени синхронизации
|
||||||
|
|
||||||
|
-- Добавляем колонку sync_interval со значением по умолчанию 360 (6 часов)
|
||||||
|
ALTER TABLE rms_servers ADD COLUMN sync_interval INTEGER NOT NULL DEFAULT 360;
|
||||||
|
|
||||||
|
-- Добавляем колонку last_sync_at (время последней успешной синхронизации)
|
||||||
|
ALTER TABLE rms_servers ADD COLUMN last_sync_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
-- Добавляем колонку last_activity_at (время последнего действия пользователя)
|
||||||
|
ALTER TABLE rms_servers ADD COLUMN last_activity_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
-- Создаем индекс для оптимизации запросов на синхронизацию
|
||||||
|
CREATE INDEX idx_rms_servers_sync ON rms_servers(deleted_at, last_sync_at, sync_interval);
|
||||||
|
|
||||||
|
-- Создаем индекс для оптимизации запросов по активности
|
||||||
|
CREATE INDEX idx_rms_servers_activity ON rms_servers(deleted_at, last_activity_at);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE recommendations ADD COLUMN IF NOT EXISTS rms_server_id UUID;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recommendations_server_id ON recommendations(rms_server_id);
|
||||||
|
|
||||||
|
-- Удаляем старые записи без server_id (они невалидны)
|
||||||
|
DELETE FROM recommendations WHERE rms_server_id IS NULL;
|
||||||
|
|
||||||
|
-- Делаем поле NOT NULL после очистки
|
||||||
|
ALTER TABLE recommendations ALTER COLUMN rms_server_id SET NOT NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE recommendations DROP COLUMN IF EXISTS rms_server_id;
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Log *zap.Logger
|
var Log *zap.Logger
|
||||||
|
|
||||||
func Init(mode string) {
|
func Init(mode string) {
|
||||||
var config zap.Config
|
var config zap.Config
|
||||||
|
|
||||||
if mode == "release" {
|
if mode == "release" {
|
||||||
config = zap.NewProductionConfig()
|
config = zap.NewProductionConfig()
|
||||||
} else {
|
} else {
|
||||||
config = zap.NewDevelopmentConfig()
|
config = zap.NewDevelopmentConfig()
|
||||||
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
Log, err = config.Build()
|
Log, err = config.Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("не удалось инициализировать логгер: " + err.Error())
|
panic("не удалось инициализировать логгер: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
useLocation,
|
useLocation,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { Result, Button } from "antd";
|
import { Result, Button, Spin } 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 { OcrLearning } from "./pages/OcrLearning";
|
import { OcrLearning } from "./pages/OcrLearning";
|
||||||
@@ -18,10 +18,13 @@ import { UNAUTHORIZED_EVENT, MAINTENANCE_EVENT } from "./services/api";
|
|||||||
import MaintenancePage from "./pages/MaintenancePage";
|
import MaintenancePage from "./pages/MaintenancePage";
|
||||||
import { usePlatform } from "./hooks/usePlatform";
|
import { usePlatform } from "./hooks/usePlatform";
|
||||||
import { useAuthStore } from "./stores/authStore";
|
import { useAuthStore } from "./stores/authStore";
|
||||||
|
import { useServerStore } from "./stores/serverStore";
|
||||||
import { DesktopAuthScreen } from "./pages/desktop/auth/DesktopAuthScreen";
|
import { DesktopAuthScreen } from "./pages/desktop/auth/DesktopAuthScreen";
|
||||||
import { MobileBrowserStub } from "./pages/desktop/auth/MobileBrowserStub";
|
import { MobileBrowserStub } from "./pages/desktop/auth/MobileBrowserStub";
|
||||||
import { DesktopLayout } from "./layouts/DesktopLayout/DesktopLayout";
|
import { DesktopLayout } from "./layouts/DesktopLayout/DesktopLayout";
|
||||||
import { InvoicesDashboard } from "./pages/desktop/dashboard/InvoicesDashboard";
|
import { InvoicesDashboard } from "./pages/desktop/dashboard/InvoicesDashboard";
|
||||||
|
import OperatorRestricted from "./components/OperatorRestricted";
|
||||||
|
import type { UserRole } from "./services/types";
|
||||||
|
|
||||||
// Компонент-заглушка для внешних браузеров
|
// Компонент-заглушка для внешних браузеров
|
||||||
const NotInTelegramScreen = () => (
|
const NotInTelegramScreen = () => (
|
||||||
@@ -64,9 +67,12 @@ const ProtectedDesktopRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
const AppContent = () => {
|
const AppContent = () => {
|
||||||
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
||||||
const [isMaintenance, setIsMaintenance] = useState(false);
|
const [isMaintenance, setIsMaintenance] = useState(false);
|
||||||
|
const [userRole, setUserRole] = useState<UserRole | null>(null);
|
||||||
|
const [isLoadingRole, setIsLoadingRole] = useState(true);
|
||||||
const tg = window.Telegram?.WebApp;
|
const tg = window.Telegram?.WebApp;
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const location = useLocation(); // Теперь это безопасно, т.к. мы внутри BrowserRouter
|
const location = useLocation();
|
||||||
|
const { activeServer, fetchServers } = useServerStore();
|
||||||
|
|
||||||
// Проверяем, есть ли данные от Telegram
|
// Проверяем, есть ли данные от Telegram
|
||||||
const isInTelegram = !!tg?.initData;
|
const isInTelegram = !!tg?.initData;
|
||||||
@@ -74,6 +80,28 @@ const AppContent = () => {
|
|||||||
// Проверяем, находимся ли мы на десктопном роуте
|
// Проверяем, находимся ли мы на десктопном роуте
|
||||||
const isDesktopRoute = location.pathname.startsWith("/web");
|
const isDesktopRoute = location.pathname.startsWith("/web");
|
||||||
|
|
||||||
|
// Загружаем роль пользователя и список серверов при монтировании
|
||||||
|
useEffect(() => {
|
||||||
|
const loadUserData = async () => {
|
||||||
|
try {
|
||||||
|
// Загружаем список серверов (там есть информация о роли)
|
||||||
|
await fetchServers();
|
||||||
|
|
||||||
|
// Если есть активный сервер, получаем роль из него
|
||||||
|
const currentServer = useServerStore.getState().activeServer;
|
||||||
|
if (currentServer) {
|
||||||
|
setUserRole(currentServer.role);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при загрузке данных пользователя:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingRole(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadUserData();
|
||||||
|
}, [fetchServers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUnauthorized = () => setIsUnauthorized(true);
|
const handleUnauthorized = () => setIsUnauthorized(true);
|
||||||
const handleMaintenance = () => setIsMaintenance(true);
|
const handleMaintenance = () => setIsMaintenance(true);
|
||||||
@@ -90,6 +118,23 @@ const AppContent = () => {
|
|||||||
};
|
};
|
||||||
}, [tg]);
|
}, [tg]);
|
||||||
|
|
||||||
|
// Показываем лоадер пока загружается роль
|
||||||
|
if (isLoadingRole) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" tip="Загрузка..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Если открыто не в Telegram и это не десктопный роут — блокируем всё
|
// Если открыто не в Telegram и это не десктопный роут — блокируем всё
|
||||||
if (!isInTelegram && !isDesktopRoute) {
|
if (!isInTelegram && !isDesktopRoute) {
|
||||||
return <NotInTelegramScreen />;
|
return <NotInTelegramScreen />;
|
||||||
@@ -100,6 +145,11 @@ const AppContent = () => {
|
|||||||
return <MobileBrowserStub />;
|
return <MobileBrowserStub />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Заглушка для операторов (только для мобильной версии в Telegram)
|
||||||
|
if (userRole === 'OPERATOR' && isInTelegram) {
|
||||||
|
return <OperatorRestricted serverName={activeServer?.name} />;
|
||||||
|
}
|
||||||
|
|
||||||
// Если бэкенд вернул 401
|
// Если бэкенд вернул 401
|
||||||
if (isUnauthorized) {
|
if (isUnauthorized) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
90
rmser-view/src/components/OperatorRestricted.tsx
Normal file
90
rmser-view/src/components/OperatorRestricted.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Result, Button, Spin } from "antd";
|
||||||
|
import { StopOutlined, CameraOutlined } from "@ant-design/icons";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverName?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компонент заглушки для операторов.
|
||||||
|
* Отображается вместо основного интерфейса приложения для пользователей с ролью OPERATOR.
|
||||||
|
* Операторы могут загружать фото накладных только через Telegram-бота.
|
||||||
|
*/
|
||||||
|
const OperatorRestricted: React.FC<Props> = ({
|
||||||
|
serverName,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
// Показываем лоадер пока идёт загрузка настроек
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
background: "#f5f5f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" tip="Загрузка..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: "100vh",
|
||||||
|
padding: 24,
|
||||||
|
background: "#f5f5f5",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Result
|
||||||
|
icon={<StopOutlined style={{ color: "#faad14" }} />}
|
||||||
|
title="Доступ ограничен"
|
||||||
|
subTitle={
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<p>
|
||||||
|
Вы вошли как <strong>Оператор</strong>
|
||||||
|
{serverName && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
на сервере <strong>{serverName}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Операторы могут загружать фото накладных только через
|
||||||
|
Telegram-бота.
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: 16, color: "#666" }}>
|
||||||
|
Для доступа к полному интерфейсу обратитесь к администратору
|
||||||
|
сервера.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CameraOutlined />}
|
||||||
|
size="large"
|
||||||
|
onClick={() => {
|
||||||
|
// Открываем Telegram-бота
|
||||||
|
window.location.href = "https://t.me/RmserBot";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Открыть бота в Telegram
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OperatorRestricted;
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
UndoOutlined,
|
UndoOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
|
import { apiClient } from "../../services/api";
|
||||||
|
|
||||||
interface ExcelPreviewModalProps {
|
interface ExcelPreviewModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -38,18 +39,23 @@ const ExcelPreviewModal: React.FC<ExcelPreviewModalProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("ExcelPreviewModal: Start loading", fileUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Загрузка файла как arrayBuffer
|
// Загрузка файла через apiClient с авторизацией
|
||||||
const response = await fetch(fileUrl);
|
const response = await apiClient.get(fileUrl, {
|
||||||
|
responseType: "arraybuffer",
|
||||||
if (!response.ok) {
|
});
|
||||||
throw new Error(`Ошибка загрузки файла: ${response.status}`);
|
console.log(
|
||||||
}
|
"ExcelPreviewModal: Got response",
|
||||||
|
response.status,
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
response.data.byteLength
|
||||||
|
);
|
||||||
|
const arrayBuffer = response.data;
|
||||||
|
|
||||||
// Чтение Excel файла
|
// Чтение Excel файла
|
||||||
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
const workbook = XLSX.read(arrayBuffer, { type: "array" });
|
||||||
|
console.log("ExcelPreviewModal: Workbook parsed", workbook.SheetNames);
|
||||||
|
|
||||||
// Получение первого листа
|
// Получение первого листа
|
||||||
const firstSheetName = workbook.SheetNames[0];
|
const firstSheetName = workbook.SheetNames[0];
|
||||||
@@ -61,11 +67,26 @@ const ExcelPreviewModal: React.FC<ExcelPreviewModalProps> = ({
|
|||||||
}) as (string | number | boolean | null | undefined)[][];
|
}) as (string | number | boolean | null | undefined)[][];
|
||||||
|
|
||||||
setData(jsonData);
|
setData(jsonData);
|
||||||
|
console.log("ExcelPreviewModal: Data set, rows:", jsonData.length);
|
||||||
// Сброс масштаба при загрузке нового файла
|
// Сброс масштаба при загрузке нового файла
|
||||||
setScale(1);
|
setScale(1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Ошибка при загрузке Excel файла:", error);
|
console.error("ExcelPreviewModal Error:", error);
|
||||||
message.error("Не удалось загрузить Excel файл");
|
|
||||||
|
// Обработка ошибок авторизации (401) обрабатывается в интерсепторе apiClient
|
||||||
|
if (error && typeof error === "object" && "response" in error) {
|
||||||
|
const axiosError = error as { response?: { status?: number } };
|
||||||
|
if (axiosError.response?.status === 401) {
|
||||||
|
message.error(
|
||||||
|
"Ошибка авторизации. Необходима повторная авторизация."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
message.error("Не удалось загрузить Excel файл");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error("Не удалось загрузить Excel файл");
|
||||||
|
}
|
||||||
|
|
||||||
setData([]);
|
setData([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -94,10 +115,18 @@ const ExcelPreviewModal: React.FC<ExcelPreviewModalProps> = ({
|
|||||||
setScale(1);
|
setScale(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработчик закрытия модалки
|
||||||
|
*/
|
||||||
|
const handleCancel = () => {
|
||||||
|
setData([]);
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={visible}
|
open={visible}
|
||||||
onCancel={onCancel}
|
onCancel={handleCancel}
|
||||||
width="90%"
|
width="90%"
|
||||||
footer={null}
|
footer={null}
|
||||||
title="Предпросмотр Excel"
|
title="Предпросмотр Excel"
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Tag,
|
Tag,
|
||||||
Image,
|
Image,
|
||||||
|
Checkbox,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import {
|
import {
|
||||||
CheckOutlined,
|
// CheckOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
ExclamationCircleFilled,
|
ExclamationCircleFilled,
|
||||||
StopOutlined,
|
StopOutlined,
|
||||||
@@ -82,6 +83,9 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
// Состояние для просмотра Excel файла
|
// Состояние для просмотра Excel файла
|
||||||
const [excelPreviewVisible, setExcelPreviewVisible] = useState(false);
|
const [excelPreviewVisible, setExcelPreviewVisible] = useState(false);
|
||||||
|
|
||||||
|
// Состояние для чекбокса "Проведено"
|
||||||
|
const [isProcessed, setIsProcessed] = useState(true); // По умолчанию true для MVP
|
||||||
|
|
||||||
// --- ЗАПРОСЫ ---
|
// --- ЗАПРОСЫ ---
|
||||||
|
|
||||||
const dictQuery = useQuery({
|
const dictQuery = useQuery({
|
||||||
@@ -282,6 +286,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
supplier_id: values.supplier_id,
|
supplier_id: values.supplier_id,
|
||||||
comment: values.comment || "",
|
comment: values.comment || "",
|
||||||
incoming_document_number: values.incoming_document_number || "",
|
incoming_document_number: values.incoming_document_number || "",
|
||||||
|
is_processed: isProcessed,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
message.error("Заполните обязательные поля (Склад, Поставщик)");
|
message.error("Заполните обязательные поля (Склад, Поставщик)");
|
||||||
@@ -289,6 +294,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isCanceled = draft?.status === "CANCELED";
|
const isCanceled = draft?.status === "CANCELED";
|
||||||
|
const isCompleted = draft?.status === "COMPLETED";
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (isDirty) {
|
if (isDirty) {
|
||||||
@@ -512,7 +518,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
padding: 8,
|
padding: 6,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
opacity: isCanceled ? 0.6 : 1,
|
opacity: isCanceled ? 0.6 : 1,
|
||||||
@@ -523,17 +529,18 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
onValuesChange={() => markAsDirty()}
|
onValuesChange={() => markAsDirty()}
|
||||||
>
|
>
|
||||||
<Row gutter={[8, 8]}>
|
<Row gutter={[4, 4]}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="date_incoming"
|
name="date_incoming"
|
||||||
|
label="Дата"
|
||||||
rules={[{ required: true }]}
|
rules={[{ required: true }]}
|
||||||
style={{ marginBottom: 0 }}
|
style={{ marginBottom: 0 }}
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
style={{ width: "100%" }}
|
style={{ width: "100%" }}
|
||||||
format="DD.MM.YYYY"
|
format="DD.MM.YYYY"
|
||||||
placeholder="Дата..."
|
placeholder=""
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -541,13 +548,14 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="incoming_document_number"
|
name="incoming_document_number"
|
||||||
|
label="№ входящего"
|
||||||
style={{ marginBottom: 0 }}
|
style={{ marginBottom: 0 }}
|
||||||
>
|
>
|
||||||
<Input placeholder="№ входящего..." size="small" />
|
<Input placeholder="" size="small" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row gutter={[8, 8]}>
|
<Row gutter={[4, 4]}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="store_id"
|
name="store_id"
|
||||||
@@ -705,14 +713,39 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<CheckOutlined />}
|
// icon={<CheckOutlined />}
|
||||||
onClick={handleCommit}
|
onClick={handleCommit}
|
||||||
loading={commitMutation.isPending}
|
loading={commitMutation.isPending}
|
||||||
disabled={invalidItemsCount > 0 || isCanceled}
|
disabled={invalidItemsCount > 0 || isCanceled}
|
||||||
style={{ height: 36, padding: "0 20px" }}
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
paddingLeft: 40,
|
||||||
|
height: 36,
|
||||||
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
{isCanceled ? "Восстановить" : "Отправить"}
|
<Checkbox
|
||||||
|
checked={isProcessed}
|
||||||
|
onChange={(e) => setIsProcessed(e.target.checked)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
disabled={invalidItemsCount > 0 || isCanceled}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 10,
|
||||||
|
top: "50%",
|
||||||
|
transform: "translateY(-50%)",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ marginLeft: 8 }}>
|
||||||
|
{isCanceled
|
||||||
|
? "Восстановить"
|
||||||
|
: isCompleted
|
||||||
|
? "Обновить в iiko"
|
||||||
|
: isProcessed
|
||||||
|
? "Провести и отправить"
|
||||||
|
: "Сохранить (без проведения)"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -736,7 +769,7 @@ export const DraftEditor: React.FC<DraftEditorProps> = ({
|
|||||||
<ExcelPreviewModal
|
<ExcelPreviewModal
|
||||||
visible={excelPreviewVisible}
|
visible={excelPreviewVisible}
|
||||||
onCancel={() => setExcelPreviewVisible(false)}
|
onCancel={() => setExcelPreviewVisible(false)}
|
||||||
fileUrl={draft.photo_url ? getStaticUrl(draft.photo_url) : ""}
|
fileUrl={draft.photo_url || ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
FileImageOutlined,
|
FileImageOutlined,
|
||||||
FileExcelOutlined,
|
FileExcelOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
RestOutlined,
|
ArrowLeftOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { api, getStaticUrl } from "../../services/api";
|
import { api, getStaticUrl } from "../../services/api";
|
||||||
import type { DraftStatus } from "../../services/types";
|
import type { DraftStatus } from "../../services/types";
|
||||||
@@ -140,7 +140,7 @@ export const InvoiceViewer: React.FC<InvoiceViewerProps> = ({
|
|||||||
{onBack && (
|
{onBack && (
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<RestOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
size="small"
|
size="small"
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
@@ -256,7 +256,7 @@ export const InvoiceViewer: React.FC<InvoiceViewerProps> = ({
|
|||||||
<ExcelPreviewModal
|
<ExcelPreviewModal
|
||||||
visible={excelPreviewVisible}
|
visible={excelPreviewVisible}
|
||||||
onCancel={() => setExcelPreviewVisible(false)}
|
onCancel={() => setExcelPreviewVisible(false)}
|
||||||
fileUrl={invoice.photo_url ? getStaticUrl(invoice.photo_url) : ""}
|
fileUrl={invoice.photo_url || ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
76
rmser-view/src/components/settings/SyncBlock.tsx
Normal file
76
rmser-view/src/components/settings/SyncBlock.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card, Button, Typography, Space, Tooltip } from "antd";
|
||||||
|
import { SyncOutlined } from "@ant-design/icons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import "dayjs/locale/ru";
|
||||||
|
import type { UserRole } from "../../services/types";
|
||||||
|
|
||||||
|
// Настройка dayjs для русской локали и относительного времени
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale("ru");
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface SyncBlockProps {
|
||||||
|
lastSyncAt: string | null;
|
||||||
|
userRole: UserRole;
|
||||||
|
onSync: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SyncBlock: React.FC<SyncBlockProps> = ({
|
||||||
|
lastSyncAt,
|
||||||
|
userRole,
|
||||||
|
onSync,
|
||||||
|
isLoading = false,
|
||||||
|
}) => {
|
||||||
|
// Проверяем, есть ли права на синхронизацию
|
||||||
|
const canSync = userRole === "OWNER" || userRole === "ADMIN";
|
||||||
|
|
||||||
|
// Форматируем дату последней синхронизации
|
||||||
|
const formatLastSync = (dateStr: string | null): string => {
|
||||||
|
if (!dateStr) {
|
||||||
|
return "Никогда";
|
||||||
|
}
|
||||||
|
const date = dayjs(dateStr);
|
||||||
|
const formatted = date.format("DD.MM.YYYY HH:mm");
|
||||||
|
const relative = date.fromNow();
|
||||||
|
return `${formatted} (${relative})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Space direction="vertical" style={{ width: "100%" }} size="middle">
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{ fontSize: 16, display: "block", marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
Синхронизация данных
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary">
|
||||||
|
Последняя синхронизация: {formatLastSync(lastSyncAt)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip title={!canSync ? "Только для администраторов" : undefined}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SyncOutlined spin={isLoading} />}
|
||||||
|
onClick={onSync}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={!canSync}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Синхронизировать
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Загружает справочники, накладные и пересчитывает рекомендации
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
rmser-view/src/hooks/useUserRole.ts
Normal file
63
rmser-view/src/hooks/useUserRole.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
import type { UserSettings, UserRole } from '../services/types';
|
||||||
|
|
||||||
|
interface UseUserRoleResult {
|
||||||
|
/** Роль текущего пользователя или null если не загружено */
|
||||||
|
role: UserRole | null;
|
||||||
|
/** Полные настройки пользователя */
|
||||||
|
settings: UserSettings | null;
|
||||||
|
/** Состояние загрузки */
|
||||||
|
loading: boolean;
|
||||||
|
/** Ошибка загрузки */
|
||||||
|
error: string | null;
|
||||||
|
/** Функция для повторной загрузки настроек */
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
/** Является ли пользователь оператором */
|
||||||
|
isOperator: boolean;
|
||||||
|
/** Является ли пользователь админом или владельцем */
|
||||||
|
isAdminOrOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хук для получения роли пользователя и настроек.
|
||||||
|
* Автоматически загружает настройки при монтировании компонента.
|
||||||
|
*/
|
||||||
|
export const useUserRole = (): UseUserRoleResult => {
|
||||||
|
const [settings, setSettings] = useState<UserSettings | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchSettings = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await api.getSettings();
|
||||||
|
setSettings(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка при загрузке настроек:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось загрузить настройки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Загружаем настройки при монтировании
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, [fetchSettings]);
|
||||||
|
|
||||||
|
const role = settings?.role ?? null;
|
||||||
|
const isOperator = role === 'OPERATOR';
|
||||||
|
const isAdminOrOwner = role === 'ADMIN' || role === 'OWNER';
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
settings,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchSettings,
|
||||||
|
isOperator,
|
||||||
|
isAdminOrOwner,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -161,10 +161,21 @@ export const DraftsList: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleInvoiceClick = (item: UnifiedInvoice) => {
|
const handleInvoiceClick = (item: UnifiedInvoice) => {
|
||||||
|
// Если это черновик - используем его ID
|
||||||
if (item.type === "DRAFT") {
|
if (item.type === "DRAFT") {
|
||||||
navigate("/invoice/draft/" + item.id);
|
navigate("/invoice/draft/" + item.id);
|
||||||
} else if (item.type === "SYNCED") {
|
return;
|
||||||
navigate("/invoice/view/" + item.id);
|
}
|
||||||
|
|
||||||
|
// Если это синхронизированная накладная
|
||||||
|
if (item.type === "SYNCED") {
|
||||||
|
// Если у нее есть ссылка на черновик (пришла с бэка) - открываем редактор черновика
|
||||||
|
if (item.draft_id) {
|
||||||
|
navigate("/invoice/draft/" + item.draft_id);
|
||||||
|
} else {
|
||||||
|
// Иначе просто просмотр
|
||||||
|
navigate("/invoice/view/" + item.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -285,31 +296,33 @@ export const DraftsList: React.FC = () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 12,
|
marginBottom: 8,
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
padding: 12,
|
padding: 8,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Flex align="center" gap={8}>
|
<Flex vertical gap={8}>
|
||||||
<Text style={{ fontSize: 13 }}>Период:</Text>
|
<Text style={{ fontSize: 13 }}>Период:</Text>
|
||||||
<DatePicker
|
<Flex align="center" gap={8}>
|
||||||
value={startDate}
|
<DatePicker
|
||||||
onChange={setStartDate}
|
value={startDate}
|
||||||
format="DD.MM.YYYY"
|
onChange={setStartDate}
|
||||||
size="small"
|
format="DD.MM.YYYY"
|
||||||
placeholder="Начало"
|
size="small"
|
||||||
style={{ width: 110 }}
|
placeholder="Начало"
|
||||||
/>
|
style={{ width: 110 }}
|
||||||
<Text type="secondary">—</Text>
|
/>
|
||||||
<DatePicker
|
<Text type="secondary">—</Text>
|
||||||
value={endDate}
|
<DatePicker
|
||||||
onChange={setEndDate}
|
value={endDate}
|
||||||
format="DD.MM.YYYY"
|
onChange={setEndDate}
|
||||||
size="small"
|
format="DD.MM.YYYY"
|
||||||
placeholder="Конец"
|
size="small"
|
||||||
style={{ width: 110 }}
|
placeholder="Конец"
|
||||||
/>
|
style={{ width: 110 }}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { api } from "../services/api";
|
|||||||
import type { UserSettings } from "../services/types";
|
import type { UserSettings } from "../services/types";
|
||||||
import { TeamList } from "../components/settings/TeamList";
|
import { TeamList } from "../components/settings/TeamList";
|
||||||
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
|
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
|
||||||
|
import { SyncBlock } from "../components/settings/SyncBlock";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -83,6 +84,17 @@ export const SettingsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const syncMutation = useMutation({
|
||||||
|
mutationFn: () => api.syncAll(true),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success("Синхронизация запущена в фоне");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error("Ошибка запуска синхронизации");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// --- Эффекты ---
|
// --- Эффекты ---
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,7 +108,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
saveMutation.mutate(values);
|
console.log("Settings Form Values:", values);
|
||||||
|
saveMutation.mutate({
|
||||||
|
...values,
|
||||||
|
auto_conduct: !!values.auto_conduct,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ошибки валидации
|
// Ошибки валидации
|
||||||
}
|
}
|
||||||
@@ -117,121 +133,126 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const showTeamSettings =
|
const showTeamSettings =
|
||||||
currentUserRole === "ADMIN" || currentUserRole === "OWNER";
|
currentUserRole === "ADMIN" || currentUserRole === "OWNER";
|
||||||
|
|
||||||
// Сохраняем JSX в переменную вместо создания вложенного компонента
|
|
||||||
const generalSettingsContent = (
|
|
||||||
<Form form={form} layout="vertical">
|
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
|
||||||
<Form.Item
|
|
||||||
label="Склад по умолчанию"
|
|
||||||
name="default_store_id"
|
|
||||||
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
placeholder="Не выбрано"
|
|
||||||
allowClear
|
|
||||||
loading={dictQuery.isLoading}
|
|
||||||
options={dictQuery.data?.stores.map((s) => ({
|
|
||||||
label: s.name,
|
|
||||||
value: s.id,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label="Корневая группа товаров"
|
|
||||||
name="root_group_id"
|
|
||||||
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
|
|
||||||
>
|
|
||||||
<TreeSelect
|
|
||||||
showSearch
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
|
||||||
placeholder="Выберите папку"
|
|
||||||
allowClear
|
|
||||||
treeDefaultExpandAll={false}
|
|
||||||
treeData={groupsQuery.data}
|
|
||||||
fieldNames={{
|
|
||||||
label: "title",
|
|
||||||
value: "value",
|
|
||||||
children: "children",
|
|
||||||
}}
|
|
||||||
treeNodeFilterProp="title"
|
|
||||||
suffixIcon={<FolderOpenOutlined />}
|
|
||||||
loading={groupsQuery.isLoading}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="auto_conduct"
|
|
||||||
valuePropName="checked"
|
|
||||||
style={{ marginBottom: 0 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Text>Проводить накладные автоматически</Text>
|
|
||||||
<br />
|
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
||||||
Если выключено, накладные в iiko будут создаваться как
|
|
||||||
"Непроведенные"
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Switch />
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<SaveOutlined />}
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
onClick={handleSave}
|
|
||||||
loading={saveMutation.isPending}
|
|
||||||
>
|
|
||||||
Сохранить настройки
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{currentUserRole === "OWNER" && (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
style={{
|
|
||||||
marginTop: 24,
|
|
||||||
borderColor: "#ff4d4f",
|
|
||||||
borderWidth: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Title level={5} style={{ color: "#ff4d4f", marginBottom: 16 }}>
|
|
||||||
Опасная зона
|
|
||||||
</Title>
|
|
||||||
<Popconfirm
|
|
||||||
title="Вы уверены?"
|
|
||||||
description="Это удалит ВСЕ черновики, которые еще не были отправлены в iiko. Это действие необратимо."
|
|
||||||
onConfirm={() => deleteAllDraftsMutation.mutate()}
|
|
||||||
okText="Удалить"
|
|
||||||
cancelText="Отмена"
|
|
||||||
okButtonProps={{ danger: true }}
|
|
||||||
>
|
|
||||||
<Button danger block loading={deleteAllDraftsMutation.isPending}>
|
|
||||||
Удалить все черновики
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
|
|
||||||
const tabsItems = [
|
const tabsItems = [
|
||||||
{
|
{
|
||||||
key: "general",
|
key: "general",
|
||||||
label: "Общие",
|
label: "Общие",
|
||||||
icon: <SettingOutlined />,
|
icon: <SettingOutlined />,
|
||||||
children: generalSettingsContent,
|
children: (
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<SyncBlock
|
||||||
|
lastSyncAt={settingsQuery.data?.last_sync_at || null}
|
||||||
|
userRole={currentUserRole}
|
||||||
|
onSync={() => syncMutation.mutate()}
|
||||||
|
isLoading={syncMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
label="Склад по умолчанию"
|
||||||
|
name="default_store_id"
|
||||||
|
tooltip="Этот склад будет выбираться автоматически при создании новой накладной"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Не выбрано"
|
||||||
|
allowClear
|
||||||
|
loading={dictQuery.isLoading}
|
||||||
|
options={dictQuery.data?.stores.map((s) => ({
|
||||||
|
label: s.name,
|
||||||
|
value: s.id,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Корневая группа товаров"
|
||||||
|
name="root_group_id"
|
||||||
|
tooltip="Товары для распознавания будут искаться только внутри этой группы (и её подгрупп)."
|
||||||
|
>
|
||||||
|
<TreeSelect
|
||||||
|
showSearch
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
|
||||||
|
placeholder="Выберите папку"
|
||||||
|
allowClear
|
||||||
|
treeDefaultExpandAll={false}
|
||||||
|
treeData={groupsQuery.data}
|
||||||
|
fieldNames={{
|
||||||
|
label: "title",
|
||||||
|
value: "value",
|
||||||
|
children: "children",
|
||||||
|
}}
|
||||||
|
treeNodeFilterProp="title"
|
||||||
|
suffixIcon={<FolderOpenOutlined />}
|
||||||
|
loading={groupsQuery.isLoading}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Text>Проводить накладные автоматически</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Если выключено, накладные в iiko будут создаваться как
|
||||||
|
"Непроведенные"
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="auto_conduct" valuePropName="checked" noStyle>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
Сохранить настройки
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{currentUserRole === "OWNER" && (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginTop: 24,
|
||||||
|
borderColor: "#ff4d4f",
|
||||||
|
borderWidth: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title level={5} style={{ color: "#ff4d4f", marginBottom: 16 }}>
|
||||||
|
Опасная зона
|
||||||
|
</Title>
|
||||||
|
<Popconfirm
|
||||||
|
title="Вы уверены?"
|
||||||
|
description="Это удалит ВСЕ черновики, которые еще не были отправлены в iiko. Это действие необратимо."
|
||||||
|
onConfirm={() => deleteAllDraftsMutation.mutate()}
|
||||||
|
okText="Удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
block
|
||||||
|
loading={deleteAllDraftsMutation.isPending}
|
||||||
|
>
|
||||||
|
Удалить все черновики
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const UNAUTHORIZED_EVENT = 'rms_unauthorized';
|
|||||||
// Событие для режима технического обслуживания (503)
|
// Событие для режима технического обслуживания (503)
|
||||||
export const MAINTENANCE_EVENT = 'rms_maintenance';
|
export const MAINTENANCE_EVENT = 'rms_maintenance';
|
||||||
|
|
||||||
const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -264,7 +264,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getStats: async (): Promise<InvoiceStats> => {
|
getStats: async (): Promise<InvoiceStats> => {
|
||||||
const { data } = await apiClient.get<InvoiceStats>('/stats/invoices');
|
const { data } = await apiClient.get<InvoiceStats>('/invoices/stats');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -298,6 +298,12 @@ export const api = {
|
|||||||
syncInvoices: async (): Promise<void> => {
|
syncInvoices: async (): Promise<void> => {
|
||||||
await apiClient.post('/invoices/sync');
|
await apiClient.post('/invoices/sync');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
syncAll: async (force = true): Promise<void> => {
|
||||||
|
await apiClient.post('/sync/all', null, {
|
||||||
|
params: { force }
|
||||||
|
});
|
||||||
|
},
|
||||||
getPhotos: async (page = 1, limit = 20): Promise<GetPhotosResponse> => {
|
getPhotos: async (page = 1, limit = 20): Promise<GetPhotosResponse> => {
|
||||||
const { data } = await apiClient.get<GetPhotosResponse>('/photos', {
|
const { data } = await apiClient.get<GetPhotosResponse>('/photos', {
|
||||||
params: { page, limit }
|
params: { page, limit }
|
||||||
|
|||||||
@@ -156,6 +156,9 @@ export interface UserSettings {
|
|||||||
default_store_id: UUID | null;
|
default_store_id: UUID | null;
|
||||||
auto_conduct: boolean;
|
auto_conduct: boolean;
|
||||||
role: UserRole; // Добавляем поле роли в настройки текущего пользователя
|
role: UserRole; // Добавляем поле роли в настройки текущего пользователя
|
||||||
|
last_sync_at: string | null; // Время последней синхронизации с iiko
|
||||||
|
last_activity_at: string | null; // Время последней активности
|
||||||
|
sync_interval: number; // Интервал синхронизации в минутах
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceStats {
|
export interface InvoiceStats {
|
||||||
@@ -255,6 +258,7 @@ export interface CommitDraftRequest {
|
|||||||
supplier_id: UUID;
|
supplier_id: UUID;
|
||||||
comment: string;
|
comment: string;
|
||||||
incoming_document_number?: string;
|
incoming_document_number?: string;
|
||||||
|
is_processed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReorderDraftItemsRequest {
|
export interface ReorderDraftItemsRequest {
|
||||||
@@ -286,6 +290,7 @@ export interface UnifiedInvoice {
|
|||||||
is_app_created: boolean; // Создано ли через наше приложение
|
is_app_created: boolean; // Создано ли через наше приложение
|
||||||
items_preview: string; // Краткое содержание товаров
|
items_preview: string; // Краткое содержание товаров
|
||||||
photo_url: string | null; // Ссылка на фото чека
|
photo_url: string | null; // Ссылка на фото чека
|
||||||
|
draft_id?: string; // ID черновика для SYNCED накладных, созданных в приложении
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceDetails {
|
export interface InvoiceDetails {
|
||||||
|
|||||||
@@ -3,17 +3,25 @@ import type { DraftItem } from '../services/types';
|
|||||||
/**
|
/**
|
||||||
* Пересчитывает значения полей элемента черновика на основе измененного поля.
|
* Пересчитывает значения полей элемента черновика на основе измененного поля.
|
||||||
*
|
*
|
||||||
* @param item - Исходный элемент черновика
|
* Логика "Треугольник": Q (Quantity) -> P (Price) -> S (Sum) -> Q...
|
||||||
|
* Правило: "Пересчитываем значение, следующее за редактируемым.
|
||||||
|
* Оставляем значение, предшествующее редактируемому."
|
||||||
|
*
|
||||||
|
* - Если меняем Quantity (Q): Previous=Sum (Keep), Next=Price (Recalc). Price = Sum / Quantity
|
||||||
|
* - Если меняем Price (P): Previous=Quantity (Keep), Next=Sum (Recalc). Sum = Quantity * Price
|
||||||
|
* - Если меняем Sum (S): Previous=Price (Keep), Next=Quantity (Recalc). Quantity = Sum / Price
|
||||||
|
*
|
||||||
|
* @param item - Исходный элемент черновика (содержит "предыдущие/сохраняемые" значения)
|
||||||
* @param changedField - Измененное поле ('quantity' | 'price' | 'sum')
|
* @param changedField - Измененное поле ('quantity' | 'price' | 'sum')
|
||||||
* @param newValue - Новое значение измененного поля
|
* @param newValue - Новое значение измененного поля
|
||||||
* @returns Новый объект DraftItem с пересчитанными значениями
|
* @returns Новый объект DraftItem с пересчитанными значениями
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // При изменении количества
|
* // При изменении количества (пересчитываем цену, сумма сохраняется)
|
||||||
* const updated = recalculateItem(item, 'quantity', 5);
|
* const updated = recalculateItem(item, 'quantity', 5);
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // При изменении суммы
|
* // При изменении суммы (пересчитываем количество, цена сохраняется)
|
||||||
* const updated = recalculateItem(item, 'sum', 100);
|
* const updated = recalculateItem(item, 'sum', 100);
|
||||||
*/
|
*/
|
||||||
export function recalculateItem(
|
export function recalculateItem(
|
||||||
@@ -23,38 +31,53 @@ export function recalculateItem(
|
|||||||
): DraftItem {
|
): DraftItem {
|
||||||
switch (changedField) {
|
switch (changedField) {
|
||||||
case 'quantity': {
|
case 'quantity': {
|
||||||
// При изменении количества пересчитываем сумму: sum = qty * price
|
// Меняем Quantity (Q): Previous=Sum (Keep), Next=Price (Recalc)
|
||||||
return {
|
// Price = Sum / Quantity
|
||||||
...item,
|
// Обрабатываем деление на ноль
|
||||||
quantity: newValue,
|
if (newValue === 0) {
|
||||||
sum: newValue * item.price,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'price': {
|
|
||||||
// При изменении цены пересчитываем сумму: sum = qty * price
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
price: newValue,
|
|
||||||
sum: item.quantity * newValue,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'sum': {
|
|
||||||
// При изменении суммы пересчитываем цену: price = sum / qty
|
|
||||||
// Обрабатываем случай деления на ноль
|
|
||||||
if (item.quantity === 0) {
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
sum: newValue,
|
quantity: newValue,
|
||||||
price: 0,
|
price: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newPrice = item.sum / newValue;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
quantity: newValue,
|
||||||
|
price: newPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'price': {
|
||||||
|
// Меняем Price (P): Previous=Quantity (Keep), Next=Sum (Recalc)
|
||||||
|
// Sum = Quantity * Price
|
||||||
|
const newSum = item.quantity * newValue;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
price: newValue,
|
||||||
|
sum: newSum,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sum': {
|
||||||
|
// Меняем Sum (S): Previous=Price (Keep), Next=Quantity (Recalc)
|
||||||
|
// Quantity = Sum / Price
|
||||||
|
// Обрабатываем деление на ноль
|
||||||
|
if (item.price === 0) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
sum: newValue,
|
||||||
|
quantity: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const newQuantity = newValue / item.price;
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
sum: newValue,
|
sum: newValue,
|
||||||
price: newValue / item.quantity,
|
quantity: newQuantity,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user