0202-финиш перед десктопом

пересчет поправил
редактирование с перепроведением
галка автопроведения работает
рекомендации починил
This commit is contained in:
2026-02-02 13:53:38 +03:00
parent 10882f55c8
commit 88620f3fb6
37 changed files with 1905 additions and 11162 deletions

View File

@@ -4,6 +4,7 @@ import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Роли пользователей
@@ -75,6 +76,14 @@ type RMSServer struct {
// Stats
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"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -134,4 +143,12 @@ type Repository interface {
// SetMuteDraftNotifications включает/выключает уведомления для пользователя
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)
}

View File

@@ -82,6 +82,12 @@ type DraftInvoiceItem struct {
IsMatched bool `gorm:"default:false" json:"is_matched"`
}
// LinkedDraftInfo содержит информацию о связанном черновике
type LinkedDraftInfo struct {
DraftID uuid.UUID
PhotoURL string
}
type Repository interface {
Create(draft *DraftInvoice) error
GetByID(id uuid.UUID) (*DraftInvoice, error)
@@ -102,6 +108,6 @@ type Repository interface {
// GetActive возвращает активные черновики для СЕРВЕРА (а не юзера)
GetActive(serverID uuid.UUID) ([]DraftInvoice, error)
// GetRMSInvoiceIDToPhotoURLMap возвращает мапу rms_invoice_id -> sender_photo_url для сервера, где rms_invoice_id не NULL
GetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error)
// GetLinkedDraftsMap возвращает мапу rms_invoice_id -> LinkedDraftInfo для сервера, где rms_invoice_id не NULL
GetLinkedDraftsMap(serverID uuid.UUID) (map[uuid.UUID]LinkedDraftInfo, error)
}

View File

@@ -47,4 +47,5 @@ type Repository interface {
GetByPeriod(serverID uuid.UUID, from, to time.Time) ([]Invoice, error)
SaveInvoices(invoices []Invoice) error
CountRecent(serverID uuid.UUID, days int) (int64, error)
GetStats(serverID uuid.UUID) (total int64, lastMonth int64, last24h int64, err error)
}

View File

@@ -19,6 +19,7 @@ const (
// Recommendation - Результат анализа
type Recommendation struct {
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"`
ProductID uuid.UUID `gorm:"type:uuid;index"`
ProductName string `gorm:"type:varchar(255)"`
@@ -29,15 +30,15 @@ type Recommendation struct {
// Repository отвечает за аналитические выборки и хранение результатов
type Repository interface {
// Методы анализа (возвращают список структур, но не пишут в БД)
FindUnusedGoods() ([]Recommendation, error)
FindNoIncomingIngredients(days int) ([]Recommendation, error)
FindStaleGoods(days int) ([]Recommendation, error)
FindDishesInRecipes() ([]Recommendation, error)
FindPurchasedButUnused(days int) ([]Recommendation, error)
FindUsageWithoutPurchase(days int) ([]Recommendation, error)
// Методы анализа — добавить serverID
FindUnusedGoods(serverID uuid.UUID) ([]Recommendation, error)
FindNoIncomingIngredients(serverID uuid.UUID, days int) ([]Recommendation, error)
FindStaleGoods(serverID uuid.UUID, days int) ([]Recommendation, error)
FindDishesInRecipes(serverID uuid.UUID) ([]Recommendation, error)
FindPurchasedButUnused(serverID uuid.UUID, days int) ([]Recommendation, error)
FindUsageWithoutPurchase(serverID uuid.UUID, days int) ([]Recommendation, error)
// Методы "Кэша" в БД
SaveAll(items []Recommendation) error // Удаляет старые и пишет новые
GetAll() ([]Recommendation, error)
// Методы хранения — добавить serverID
SaveAll(serverID uuid.UUID, items []Recommendation) error
GetAll(serverID uuid.UUID) ([]Recommendation, error)
}

View File

@@ -7,8 +7,10 @@ import (
"time"
"rmser/internal/domain/account"
"rmser/pkg/logger"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -78,7 +80,8 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
var created bool
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 {
return err
}
@@ -100,8 +103,17 @@ func (r *pgRepository) ConnectServer(userID uuid.UUID, rawURL, login, encryptedP
return err
}
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 {
// --- СЦЕНАРИЙ 2: СУЩЕСТВУЮЩИЙ СЕРВЕР ---
// --- СЦЕНАРИЙ 3: СУЩЕСТВУЮЩИЙ АКТИВНЫЙ СЕРВЕР ---
var userCount int64
tx.Model(&account.ServerUser{}).Where("server_id = ?", server.ID).Count(&userCount)
if userCount >= int64(server.MaxUsers) {
@@ -156,9 +168,92 @@ func (r *pgRepository) SaveServerSettings(server *account.RMSServer) error {
"root_group_guid": server.RootGroupGUID,
"auto_process": server.AutoProcess,
"max_users": server.MaxUsers,
"sync_interval": server.SyncInterval,
}).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 {
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 {
// Полное удаление сервера и всех связей
// Мягкое удаление сервера и всех связей
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("server_id = ?", serverID).Delete(&account.ServerUser{}).Error; err != nil {
return err

View File

@@ -160,20 +160,23 @@ func (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, 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
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).
Find(&draftsList).Error
if err != nil {
return nil, err
}
result := make(map[uuid.UUID]string)
result := make(map[uuid.UUID]drafts.LinkedDraftInfo)
for _, d := range draftsList {
if d.RMSInvoiceID != nil {
result[*d.RMSInvoiceID] = d.SenderPhotoURL
result[*d.RMSInvoiceID] = drafts.LinkedDraftInfo{
DraftID: d.ID,
PhotoURL: d.SenderPhotoURL,
}
}
}
return result, nil

View File

@@ -87,3 +87,17 @@ func (r *pgRepository) CountRecent(serverID uuid.UUID, days int) (int64, error)
Count(&count).Error
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
}

View File

@@ -3,12 +3,12 @@ package recommendations
import (
"fmt"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"rmser/internal/domain/operations"
"rmser/internal/domain/recommendations"
"gorm.io/gorm"
)
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 {
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
}
// Проставляем server_id для всех записей
for i := range items {
items[i].RMSServerID = serverID
}
if len(items) > 0 {
if err := tx.CreateInBatches(items, 100).Error; err != nil {
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
err := r.db.Find(&items).Error
err := r.db.Where("rms_server_id = ?", serverID).Find(&items).Error
return items, err
}
// --- Методы Аналитики ---
// 1. Товары (GOODS/PREPARED), не используемые в техкартах
func (r *pgRepository) FindUnusedGoods() ([]recommendations.Recommendation, error) {
func (r *pgRepository) FindUnusedGoods(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
var results []recommendations.Recommendation
query := `
@@ -54,27 +61,30 @@ func (r *pgRepository) FindUnusedGoods() ([]recommendations.Recommendation, erro
'Товар не используется ни в одной техкарте' as reason,
? as type
FROM products p
WHERE p.type IN ('GOODS', 'PREPARED')
AND p.is_deleted = false -- Проверка на удаление
WHERE p.rms_server_id = ?
AND p.type IN ('GOODS', 'PREPARED')
AND p.is_deleted = false
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 (
SELECT DISTINCT product_id FROM recipes
SELECT DISTINCT r.product_id FROM recipes r
WHERE r.rms_server_id = ?
)
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 results, nil
}
// 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
dateFrom := time.Now().AddDate(0, 0, -days)
query := `
SELECT DISTINCT
@@ -84,26 +94,33 @@ func (r *pgRepository) FindPurchasedButUnused(days int) ([]recommendations.Recom
? as type
FROM store_operations so
JOIN products p ON so.product_id = p.id
WHERE
so.op_type = ?
AND so.period_from >= ?
AND p.is_deleted = false -- Проверка на удаление
AND p.id NOT IN (
SELECT DISTINCT product_id FROM recipe_items
WHERE
so.rms_server_id = ?
AND so.op_type = ?
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
AND p.is_deleted = false
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
`
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 results, nil
}
// 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
dateFrom := time.Now().AddDate(0, 0, -days)
query := `
SELECT
@@ -115,31 +132,38 @@ func (r *pgRepository) FindNoIncomingIngredients(days int) ([]recommendations.Re
JOIN recipes r ON ri.recipe_id = r.id
JOIN products p ON ri.product_id = p.id
JOIN products parent ON r.product_id = parent.id
WHERE
(r.date_to IS NULL OR r.date_to >= CURRENT_DATE)
WHERE
r.rms_server_id = ?
AND (r.date_to IS NULL OR r.date_to >= CURRENT_DATE)
AND p.type = 'GOODS'
AND p.is_deleted = false -- Сам ингредиент не удален
AND parent.is_deleted = false -- Блюдо, в которое он входит, не удалено
AND p.is_deleted = false
AND parent.is_deleted = false
AND p.id NOT IN (
SELECT product_id
FROM store_operations
WHERE op_type = ?
AND period_from >= ?
SELECT so.product_id
FROM store_operations so
WHERE so.rms_server_id = ?
AND so.op_type = ?
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
)
GROUP BY p.id, p.name
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 results, nil
}
// 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
dateFrom := time.Now().AddDate(0, 0, -days)
query := `
SELECT DISTINCT
@@ -149,30 +173,38 @@ func (r *pgRepository) FindStaleGoods(days int) ([]recommendations.Recommendatio
? as type
FROM store_operations so
JOIN products p ON so.product_id = p.id
WHERE
so.op_type = ?
AND so.period_from >= ?
AND p.is_deleted = false -- Проверка на удаление
AND p.id NOT IN (
SELECT product_id
FROM store_operations
WHERE op_type = ?
AND period_from >= ?
WHERE
so.rms_server_id = ?
AND so.op_type = ?
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
AND p.is_deleted = false
AND p.id NOT IN (
SELECT so2.product_id
FROM store_operations so2
WHERE so2.rms_server_id = ?
AND so2.op_type = ?
AND so2.period_to >= CURRENT_DATE - INTERVAL '1 day'
)
ORDER BY p.name ASC
`
reason := fmt.Sprintf("Были закупки, но нет расхода за %d дн.", days)
if err := r.db.Raw(query, reason, recommendations.TypeStale, operations.OpTypePurchase, dateFrom, operations.OpTypeUsage, dateFrom).
Scan(&results).Error; err != nil {
if err := r.db.Raw(query,
reason,
recommendations.TypeStale,
serverID,
operations.OpTypePurchase,
serverID,
operations.OpTypeUsage,
).Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
// 5. Блюдо используется в техкарте другого блюда
func (r *pgRepository) FindDishesInRecipes() ([]recommendations.Recommendation, error) {
func (r *pgRepository) FindDishesInRecipes(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
var results []recommendations.Recommendation
query := `
@@ -186,23 +218,23 @@ func (r *pgRepository) FindDishesInRecipes() ([]recommendations.Recommendation,
JOIN recipes r ON ri.recipe_id = r.id
JOIN products parent ON r.product_id = parent.id
WHERE
child.type = 'DISH'
AND child.is_deleted = false -- Вложенное блюдо не удалено
AND parent.is_deleted = false -- Родительское блюдо не удалено
r.rms_server_id = ?
AND child.type = 'DISH'
AND child.is_deleted = false
AND parent.is_deleted = false
AND (r.date_to IS NULL OR r.date_to >= CURRENT_DATE)
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 results, nil
}
// 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
dateFrom := time.Now().AddDate(0, 0, -days)
query := `
SELECT DISTINCT
@@ -212,30 +244,31 @@ func (r *pgRepository) FindUsageWithoutPurchase(days int) ([]recommendations.Rec
? as type
FROM store_operations so
JOIN products p ON so.product_id = p.id
WHERE
so.op_type = ? -- Есть расход (продажа/списание)
AND so.period_from >= ?
AND p.type = 'GOODS' -- Только для товаров
AND p.is_deleted = false -- Товар жив
AND p.id NOT IN ( -- Но не было закупок
SELECT product_id
FROM store_operations
WHERE op_type = ?
AND period_from >= ?
WHERE
so.rms_server_id = ?
AND so.op_type = ?
AND so.period_to >= CURRENT_DATE - INTERVAL '1 day'
AND p.type = 'GOODS'
AND p.is_deleted = false
AND p.id NOT IN (
SELECT so2.product_id
FROM store_operations so2
WHERE so2.rms_server_id = ?
AND so2.op_type = ?
AND so2.period_to >= CURRENT_DATE - INTERVAL '1 day'
)
ORDER BY p.name ASC
`
reason := fmt.Sprintf("Товар расходуется (продажи/списания), но не закупался последние %d дн.", days)
// Аргументы: reason, type, OpUsage, date, OpPurchase, date
if err := r.db.Raw(query,
reason,
recommendations.TypeUsageNoIncoming,
serverID,
operations.OpTypeUsage,
dateFrom,
serverID,
operations.OpTypePurchase,
dateFrom,
).Scan(&results).Error; err != nil {
return nil, err
}

View File

@@ -40,6 +40,7 @@ type ClientI interface {
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
CreateIncomingInvoice(inv invoices.Invoice) (string, error)
UnprocessIncomingInvoice(inv invoices.Invoice) error
GetProductByID(id uuid.UUID) (*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
}
// CreateIncomingInvoice отправляет накладную в iiko
func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
// 1. Маппинг Domain -> XML DTO
// buildInvoiceXML формирует XML payload для накладной на основе доменной сущности
func (c *Client) buildInvoiceXML(inv invoices.Invoice) ([]byte, error) {
// Защита от паники с 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, если не передан
status := inv.Status
@@ -592,7 +608,21 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
reqDTO.ID = inv.ID.String()
}
// Логирование перед циклом по Items
logger.Log.Debug("Начинаем формирование XML для позиций накладной",
zap.Int("items_count", len(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()
price, _ := item.Price.Float64()
sum, _ := item.Sum.Float64()
@@ -610,18 +640,47 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
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)
}
// 2. Маршалинг в XML
// Маршалинг в XML
xmlBytes, err := xml.Marshal(reqDTO)
if err != nil {
return "", fmt.Errorf("xml marshal error: %w", err)
return nil, fmt.Errorf("xml marshal error: %w", err)
}
// Добавляем XML header вручную
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 {
return "", err
}
@@ -630,7 +689,7 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
token := c.token
c.mu.RUnlock()
// 4. Формирование URL
// 3. Формирование URL
endpoint, _ := url.Parse(c.baseURL + "/resto/api/documents/import/incomingInvoice")
q := endpoint.Query()
q.Set("key", token)
@@ -646,7 +705,7 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
zap.String("body_payload", string(xmlPayload)),
)
// 5. Отправка
// 4. Отправка
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload))
if err != nil {
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.String("response_body", string(respBody)),
zap.String("raw_response", string(respBody)),
)
if resp.StatusCode != http.StatusOK {
@@ -691,6 +750,89 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
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=...)
func (c *Client) GetProductByID(id uuid.UUID) (*ProductFullDTO, error) {
// Параметр ids должен быть списком. iiko ожидает ids=UUID

View File

@@ -157,9 +157,6 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
if err != nil {
return err
}
if draft.Status == drafts.StatusCompleted {
return errors.New("черновик уже отправлен")
}
draft.StoreID = storeID
draft.SupplierID = supplierID
draft.DateIncoming = &date
@@ -227,7 +224,7 @@ func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) {
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) {
if item.LastEditedField1 != editedField {
item.LastEditedField2 = item.LastEditedField1
@@ -265,6 +262,29 @@ func (s *Service) RecalculateItemFields(item *drafts.DraftInvoiceItem, editedFie
case drafts.FieldSum:
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 обновлен для поддержки динамического пересчета
@@ -293,17 +313,10 @@ func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, co
}
}
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)
// Просто присваиваем значения от фронтенда без пересчета
currentItem.Quantity = qty
currentItem.Price = price
currentItem.Sum = sum
if draft.Status == drafts.StatusCanceled {
draft.Status = drafts.StatusReadyToVerify
@@ -311,20 +324,18 @@ func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, co
}
updates := map[string]interface{}{
"product_id": currentItem.ProductID,
"container_id": currentItem.ContainerID,
"quantity": currentItem.Quantity,
"price": currentItem.Price,
"sum": currentItem.Sum,
"last_edited_field1": currentItem.LastEditedField1,
"last_edited_field2": currentItem.LastEditedField2,
"is_matched": currentItem.IsMatched,
"product_id": currentItem.ProductID,
"container_id": currentItem.ContainerID,
"quantity": currentItem.Quantity,
"price": currentItem.Price,
"sum": currentItem.Sum,
"is_matched": currentItem.IsMatched,
}
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)
if err != nil {
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("черновик принадлежит другому серверу")
}
if draft.Status == drafts.StatusCompleted {
return "", errors.New("накладная уже отправлена")
}
client, err := s.rmsFactory.GetClientForUser(userID)
if err != nil {
return "", err
}
targetStatus := "NEW"
if server.AutoProcess {
if isProcessed {
targetStatus = "PROCESSED"
}
@@ -373,6 +380,11 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
}
// Если черновик уже был отправлен ранее, передаем RMSInvoiceID для обновления
if draft.RMSInvoiceID != nil {
inv.ID = *draft.RMSInvoiceID
}
for _, dItem := range draft.Items {
if dItem.ProductID == nil {
continue
@@ -405,6 +417,12 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
Price: priceToSend,
Sum: sum,
ContainerID: dItem.ContainerID,
Product: func() catalog.Product {
if dItem.Product != nil {
return *dItem.Product
}
return catalog.Product{}
}(),
}
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)
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)
@@ -434,6 +467,7 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
for _, invoice := range invoices {
if invoice.DocumentNumber == docNum {
draft.RMSInvoiceID = &invoice.ID
draft.DocumentNumber = invoice.DocumentNumber
found = true
break
}
@@ -555,19 +589,20 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID,
}
type UnifiedInvoiceDTO struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
DocumentNumber string `json:"document_number"`
IncomingNumber string `json:"incoming_number"`
DateIncoming time.Time `json:"date_incoming"`
Status string `json:"status"`
TotalSum float64 `json:"total_sum"`
StoreName string `json:"store_name"`
ItemsCount int `json:"items_count"`
CreatedAt time.Time `json:"created_at"`
IsAppCreated bool `json:"is_app_created"`
PhotoURL string `json:"photo_url"`
ItemsPreview string `json:"items_preview"`
ID uuid.UUID `json:"id"`
Type string `json:"type"`
DocumentNumber string `json:"document_number"`
IncomingNumber string `json:"incoming_number"`
DateIncoming time.Time `json:"date_incoming"`
Status string `json:"status"`
TotalSum float64 `json:"total_sum"`
StoreName string `json:"store_name"`
ItemsCount int `json:"items_count"`
CreatedAt time.Time `json:"created_at"`
IsAppCreated bool `json:"is_app_created"`
PhotoURL string `json:"photo_url"`
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) {
@@ -586,7 +621,7 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
return nil, err
}
photoMap, err := s.draftRepo.GetRMSInvoiceIDToPhotoURLMap(server.ID)
linkedDraftsMap, err := s.draftRepo.GetLinkedDraftsMap(server.ID)
if err != nil {
return nil, err
}
@@ -647,9 +682,11 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
isAppCreated := false
photoURL := ""
if url, exists := photoMap[inv.ID]; exists {
var draftID *uuid.UUID
if linkedInfo, exists := linkedDraftsMap[inv.ID]; exists {
isAppCreated = true
photoURL = url
photoURL = linkedInfo.PhotoURL
draftID = &linkedInfo.DraftID
}
var itemsPreview string
@@ -679,6 +716,7 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie
IsAppCreated: isAppCreated,
PhotoURL: photoURL,
ItemsPreview: itemsPreview,
DraftID: draftID,
})
}
@@ -739,6 +777,14 @@ func (s *Service) CreateDraft(userID uuid.UUID) (*drafts.DraftInvoice, error) {
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
}
@@ -967,11 +1013,6 @@ func (s *Service) SaveDraftFull(draftID, userID uuid.UUID, req drafts.UpdateDraf
return err
}
// Проверяем, что черновик не завершен
if draft.Status == drafts.StatusCompleted {
return errors.New("черновик уже отправлен")
}
// Обновляем шапку черновика, если переданы поля
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 {
qty = decimal.NewFromFloat(*itemReq.Quantity)
} else {
qty = currentItem.Quantity
currentItem.Quantity = decimal.NewFromFloat(*itemReq.Quantity)
}
price := decimal.Zero
if itemReq.Price != nil {
price = decimal.NewFromFloat(*itemReq.Price)
} else {
price = currentItem.Price
currentItem.Price = decimal.NewFromFloat(*itemReq.Price)
}
sum := decimal.Zero
if itemReq.Sum != nil {
sum = decimal.NewFromFloat(*itemReq.Sum)
} else {
sum = currentItem.Sum
currentItem.Sum = decimal.NewFromFloat(*itemReq.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 {
draft.Status = drafts.StatusReadyToVerify
@@ -1142,14 +1146,12 @@ func (s *Service) SaveDraftFull(draftID, userID uuid.UUID, req drafts.UpdateDraf
// Сохраняем обновленную позицию
updates := map[string]interface{}{
"product_id": currentItem.ProductID,
"container_id": currentItem.ContainerID,
"quantity": currentItem.Quantity,
"price": currentItem.Price,
"sum": currentItem.Sum,
"last_edited_field1": currentItem.LastEditedField1,
"last_edited_field2": currentItem.LastEditedField2,
"is_matched": currentItem.IsMatched,
"product_id": currentItem.ProductID,
"container_id": currentItem.ContainerID,
"quantity": currentItem.Quantity,
"price": currentItem.Price,
"sum": currentItem.Sum,
"is_matched": currentItem.IsMatched,
}
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
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/shopspring/decimal"
"go.uber.org/zap"
"rmser/internal/domain/account"
"rmser/internal/domain/drafts"
invDomain "rmser/internal/domain/invoices"
"rmser/internal/domain/suppliers"
@@ -19,16 +20,18 @@ type Service struct {
repo invDomain.Repository
draftsRepo drafts.Repository
supplierRepo suppliers.Repository
accountRepo account.Repository
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{
repo: repo,
draftsRepo: draftsRepo,
supplierRepo: supplierRepo,
accountRepo: accountRepo,
rmsFactory: rmsFactory,
}
}
@@ -99,6 +102,13 @@ func (s *Service) SendInvoiceToRMS(req CreateRequestDTO, userID uuid.UUID) (stri
return docNum, nil
}
// InvoiceStatsDTO - DTO для статистики накладных
type InvoiceStatsDTO struct {
Total int64 `json:"total"`
LastMonth int64 `json:"last_month"`
Last24h int64 `json:"last_24h"`
}
// InvoiceDetailsDTO - DTO для ответа на запрос деталей накладной
type InvoiceDetailsDTO struct {
ID uuid.UUID `json:"id"`
@@ -145,7 +155,7 @@ func (s *Service) GetInvoice(id uuid.UUID) (*InvoiceDetailsDTO, error) {
Number: inv.DocumentNumber,
Date: inv.DateIncoming.Format("2006-01-02"),
Status: "COMPLETED", // Для синхронизированных накладных статус всегда COMPLETED
Items: make([]struct {
Items: make([]struct {
Name string `json:"name"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
@@ -166,3 +176,32 @@ func (s *Service) GetInvoice(id uuid.UUID) (*InvoiceDetailsDTO, error) {
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
}

View File

@@ -1,6 +1,7 @@
package recommend
import (
"github.com/google/uuid"
"go.uber.org/zap"
"rmser/internal/domain/recommendations"
@@ -20,56 +21,56 @@ func NewService(repo recommendations.Repository) *Service {
return &Service{repo: repo}
}
// RefreshRecommendations выполняет анализ и сохраняет результаты в БД
func (s *Service) RefreshRecommendations() error {
logger.Log.Info("Запуск пересчета рекомендаций...")
// RefreshRecommendations выполняет анализ и сохраняет результаты в БД для конкретного сервера
func (s *Service) RefreshRecommendations(serverID uuid.UUID) error {
logger.Log.Info("Запуск пересчета рекомендаций...", zap.String("server_id", serverID.String()))
var all []recommendations.Recommendation
// 1. Unused
if unused, err := s.repo.FindUnusedGoods(); err == nil {
if unused, err := s.repo.FindUnusedGoods(serverID); err == nil {
all = append(all, unused...)
} else {
logger.Log.Error("Ошибка unused", zap.Error(err))
}
// 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...)
} else {
logger.Log.Error("Ошибка purchased_unused", zap.Error(err))
}
// 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...)
} else {
logger.Log.Error("Ошибка no_incoming", zap.Error(err))
}
// 4. Usage without Purchase (Расход без прихода) <-- НОВОЕ
if usageNoPurch, err := s.repo.FindUsageWithoutPurchase(AnalyzeDaysNoIncoming); err == nil {
// 4. Usage without Purchase (Расход без прихода)
if usageNoPurch, err := s.repo.FindUsageWithoutPurchase(serverID, AnalyzeDaysNoIncoming); err == nil {
all = append(all, usageNoPurch...)
} else {
logger.Log.Error("Ошибка usage_no_purchase", zap.Error(err))
}
// 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...)
} else {
logger.Log.Error("Ошибка stale", zap.Error(err))
}
// 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...)
} else {
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
}
@@ -77,6 +78,7 @@ func (s *Service) RefreshRecommendations() error {
return nil
}
func (s *Service) GetRecommendations() ([]recommendations.Recommendation, error) {
return s.repo.GetAll()
// GetRecommendations возвращает рекомендации для конкретного сервера
func (s *Service) GetRecommendations(serverID uuid.UUID) ([]recommendations.Recommendation, error) {
return s.repo.GetAll(serverID)
}

View File

@@ -15,6 +15,7 @@ import (
"rmser/internal/domain/recipes"
"rmser/internal/domain/suppliers"
"rmser/internal/infrastructure/rms"
"rmser/pkg/crypto"
"rmser/pkg/logger"
)
@@ -24,17 +25,19 @@ const (
)
type Service struct {
rmsFactory *rms.Factory
accountRepo account.Repository
catalogRepo catalog.Repository
recipeRepo recipes.Repository
invoiceRepo invoices.Repository
opRepo operations.Repository
supplierRepo suppliers.Repository
rmsFactory *rms.Factory
cryptoManager *crypto.CryptoManager
accountRepo account.Repository
catalogRepo catalog.Repository
recipeRepo recipes.Repository
invoiceRepo invoices.Repository
opRepo operations.Repository
supplierRepo suppliers.Repository
}
func NewService(
rmsFactory *rms.Factory,
cryptoManager *crypto.CryptoManager,
accountRepo account.Repository,
catalogRepo catalog.Repository,
recipeRepo recipes.Repository,
@@ -43,16 +46,73 @@ func NewService(
supplierRepo suppliers.Repository,
) *Service {
return &Service{
rmsFactory: rmsFactory,
accountRepo: accountRepo,
catalogRepo: catalogRepo,
recipeRepo: recipeRepo,
invoiceRepo: invoiceRepo,
opRepo: opRepo,
supplierRepo: supplierRepo,
rmsFactory: rmsFactory,
cryptoManager: cryptoManager,
accountRepo: accountRepo,
catalogRepo: catalogRepo,
recipeRepo: recipeRepo,
invoiceRepo: invoiceRepo,
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 запускает полную синхронизацию для конкретного пользователя
func (s *Service) SyncAllData(userID uuid.UUID, force bool) error {
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
return s.syncAllWithClient(client, serverID, force)
}
// syncAllWithClient выполняет синхронизацию с готовым клиентом
func (s *Service) syncAllWithClient(client rms.ClientI, serverID uuid.UUID, force bool) error {
// 2. Справочники
if err := s.syncStores(client, serverID); err != nil {
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))
}
// 7. Складские операции (тяжелый запрос)
// Для MVP можно отключить, если долго грузится
// if err := s.SyncStoreOperations(client, serverID); err != nil {
// logger.Log.Error("Sync Operations failed", zap.Error(err))
// }
// 7. Складские операции
if err := s.SyncStoreOperations(client, serverID); err != nil {
logger.Log.Error("Sync Operations failed", zap.Error(err))
}
logger.Log.Info("Синхронизация завершена", zap.String("user_id", userID.String()))
logger.Log.Info("Синхронизация завершена", zap.String("server_id", serverID.String()))
return nil
}
@@ -236,7 +301,7 @@ func (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID, force bool) er
// SyncStoreOperations публичный, если нужно вызывать отдельно
func (s *Service) SyncStoreOperations(c rms.ClientI, serverID uuid.UUID) error {
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 {
return fmt.Errorf("purchases sync error: %w", err)

View 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()))
}

View File

@@ -203,50 +203,98 @@ type CommitRequestDTO struct {
SupplierID string `json:"supplier_id"`
Comment string `json:"comment"`
IncomingDocNum string `json:"incoming_document_number"`
IsProcessed bool `json:"is_processed"`
}
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"))
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"})
return
}
logger.Log.Info("HANDLER: DraftID parsed", zap.String("draft_id", draftID.String()))
var req CommitRequestDTO
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()})
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)
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"})
return
}
storeID, err := uuid.Parse(req.StoreID)
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"})
return
}
supplierID, err := uuid.Parse(req.SupplierID)
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"})
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 {
logger.Log.Error("HANDLER: UpdateDraftHeader failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update header: " + err.Error()})
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 {
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()})
return
}
logger.Log.Info("HANDLER: Success!", zap.String("doc_num", docNum))
c.JSON(http.StatusOK, gin.H{"status": "completed", "document_number": docNum})
}

View File

@@ -110,3 +110,23 @@ func (h *InvoiceHandler) SyncInvoices(c *gin.Context) {
"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)
}

View File

@@ -4,29 +4,56 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"rmser/internal/domain/account"
"rmser/internal/services/recommend"
"rmser/pkg/logger"
)
type RecommendationsHandler struct {
service *recommend.Service
service *recommend.Service
accountRepo account.Repository
}
func NewRecommendationsHandler(service *recommend.Service) *RecommendationsHandler {
return &RecommendationsHandler{service: service}
func NewRecommendationsHandler(service *recommend.Service, accountRepo account.Repository) *RecommendationsHandler {
return &RecommendationsHandler{
service: service,
accountRepo: accountRepo,
}
}
// GetRecommendations godoc
// @Summary Получить список рекомендаций
// @Description Возвращает сгенерированные рекомендации (проблемные зоны учета)
// @Description Возвращает сгенерированные рекомендации (проблемные зоны учета) для активного сервера
// @Tags recommendations
// @Produce json
// @Success 200 {array} recommendations.Recommendation
// @Failure 500 {object} map[string]string
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 {
logger.Log.Error("Ошибка получения рекомендаций", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

View File

@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -48,13 +49,16 @@ func (h *SettingsHandler) SetNotifier(n Notifier) {
// SettingsResponse - DTO для отдачи настроек
type SettingsResponse struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
DefaultStoreID *string `json:"default_store_id"` // Nullable
RootGroupID *string `json:"root_group_id"` // Nullable
AutoConduct bool `json:"auto_conduct"`
Role string `json:"role"` // OWNER, ADMIN, OPERATOR
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
DefaultStoreID *string `json:"default_store_id"` // Nullable
RootGroupID *string `json:"root_group_id"` // Nullable
AutoConduct bool `json:"auto_conduct"`
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 возвращает настройки активного сервера + роль пользователя
@@ -77,11 +81,14 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
}
resp := SettingsResponse{
ID: server.ID.String(),
Name: server.Name,
BaseURL: server.BaseURL,
AutoConduct: server.AutoProcess,
Role: string(role),
ID: server.ID.String(),
Name: server.Name,
BaseURL: server.BaseURL,
AutoConduct: server.AutoProcess,
Role: string(role),
SyncInterval: server.SyncInterval,
LastSyncAt: server.LastSyncAt,
LastActivityAt: server.LastActivityAt,
}
if server.DefaultStoreID != nil {
@@ -96,16 +103,17 @@ func (h *SettingsHandler) GetSettings(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// UpdateSettingsDTO
// UpdateSettingsDTO - DTO для частичного обновления настроек (PATCH-семантика)
type UpdateSettingsDTO struct {
Name string `json:"name"`
DefaultStoreID string `json:"default_store_id"`
RootGroupID string `json:"root_group_id"`
AutoProcess bool `json:"auto_process"`
AutoConduct bool `json:"auto_conduct"`
Name *string `json:"name"`
DefaultStoreID *string `json:"default_store_id"`
RootGroupID *string `json:"root_group_id"`
AutoProcess *bool `json:"auto_process"` // Legacy для обратной совместимости
AutoConduct *bool `json:"auto_conduct"` // Новое поле
SyncInterval *int `json:"sync_interval,omitempty"` // Интервал синхронизации в минутах (5 - 10080)
}
// UpdateSettings сохраняет настройки
// UpdateSettings сохраняет настройки с PATCH-семантикой
func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
userID := c.MustGet("userID").(uuid.UUID)
@@ -115,6 +123,11 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
return
}
// Логирование полученных данных для отладки
logger.Log.Info("Получен запрос на обновление настроек",
zap.Any("request", req),
)
server, err := h.accountRepo.GetActiveServer(userID)
if err != nil || server == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "No active server"})
@@ -132,31 +145,56 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) {
return
}
if req.Name != "" {
server.Name = req.Name
// Обновление имени (только если передано)
if req.Name != nil {
server.Name = *req.Name
}
if req.AutoConduct {
server.AutoProcess = true
} else {
server.AutoProcess = req.AutoProcess || req.AutoConduct
// Обновление флага авто-проведения
if req.AutoConduct != nil {
server.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 {
server.DefaultStoreID = &uid
// Обновление интервала синхронизации
if req.SyncInterval != nil {
// Валидация диапазона: от 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.DefaultStoreID = nil
server.SyncInterval = *req.SyncInterval
}
if req.RootGroupID != "" {
if uid, err := uuid.Parse(req.RootGroupID); err == nil {
server.RootGroupGUID = &uid
// Обновление DefaultStoreID
if req.DefaultStoreID != nil {
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 {
logger.Log.Error("Failed to save settings", zap.Error(err))

View File

@@ -202,17 +202,17 @@ func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
func (bot *Bot) handleStartCommand(c tele.Context) error {
payload := c.Message().Payload
// Обработка desktop авторизации
if payload != "" && strings.HasPrefix(payload, "auth_") {
sessionID := strings.TrimPrefix(payload, "auth_")
telegramID := c.Sender().ID
logger.Log.Info("Обработка desktop авторизации",
zap.String("session_id", sessionID),
zap.Int64("telegram_id", telegramID),
)
if err := bot.authService.ConfirmDesktopAuth(sessionID, telegramID); err != nil {
logger.Log.Error("Ошибка подтверждения desktop авторизации",
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)
}
if payload != "" && strings.HasPrefix(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)
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))
}
btnAdd := menu.Data(" Добавить сервер", "act_add_server")
btnDel := menu.Data("⚙️ Управление / Удаление", "act_del_server_menu")
btnBack := menu.Data("🔙 Назад", "nav_main")
rows = append(rows, menu.Row(btnAdd, btnDel))
rows = append(rows, menu.Row(btnAdd))
rows = append(rows, menu.Row(btnBack))
menu.Inline(rows...)
txt := fmt.Sprintf("<b>🖥 Ваши серверы (%d):</b>\n\nНажмите на сервер, чтобы сделать его активным.", len(servers))
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)
}
@@ -536,6 +587,131 @@ func (bot *Bot) handleCallback(c tele.Context) error {
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_") {
serverIDStr := strings.TrimPrefix(data, "set_server_")
serverIDStr = strings.TrimSpace(serverIDStr)
@@ -799,6 +975,7 @@ func (bot *Bot) handleText(c tele.Context) error {
userID := c.Sender().ID
state := bot.fsm.GetState(userID)
text := strings.TrimSpace(c.Text())
userDB, _ := bot.accountRepo.GetUserByTelegramID(userID)
if bot.maintenanceMode && !bot.isDev(userID) {
return c.Send("Сервис на обслуживании", tele.ModeHTML)
@@ -888,6 +1065,52 @@ func (bot *Bot) handleText(c tele.Context) error {
ctx.BillingTargetURL = 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

View File

@@ -17,10 +17,12 @@ const (
StateAddServerConfirmName
StateAddServerInputName
StateBillingGiftURL
StateUpdateServerLogin // Обновление логина для существующего сервера
StateUpdateServerPassword // Обновление пароля для существующего сервера
// Состояния редактора черновиков (начиная с 100)
StateDraftEditItemName State = 100 // Ожидание ввода нового названия позиции
StateDraftEditItemQty State = 101 // Ожидание ввода количества
StateDraftEditItemName State = 100 // Ожидание ввода нового названия позиции
StateDraftEditItemQty State = 101 // Ожидание ввода количества
StateDraftEditItemPrice State = 102 // Ожидание ввода цены
)
@@ -34,6 +36,9 @@ type UserContext struct {
TempPassword string
TempServerName string
// Поля для обновления сервера
EditingServerID uuid.UUID // ID редактируемого сервера
// Поля для биллинга
BillingTargetURL string