Files
rmser/internal/services/sync/service.go
2025-11-29 08:40:24 +03:00

245 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sync
import (
"fmt"
"time"
"go.uber.org/zap"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"rmser/internal/domain/catalog"
"rmser/internal/domain/invoices"
"rmser/internal/domain/operations"
"rmser/internal/domain/recipes"
"rmser/internal/infrastructure/rms"
"rmser/pkg/logger"
)
const (
// Пресеты от пользователя
PresetPurchases = "1a3297e1-cb05-55dc-98a7-c13f13bc85a7" // Закупки
PresetUsage = "24d9402e-2d01-eca1-ebeb-7981f7d1cb86" // Расход
)
type Service struct {
rmsClient rms.ClientI
catalogRepo catalog.Repository
recipeRepo recipes.Repository
invoiceRepo invoices.Repository
opRepo operations.Repository
}
func NewService(
rmsClient rms.ClientI,
catalogRepo catalog.Repository,
recipeRepo recipes.Repository,
invoiceRepo invoices.Repository,
opRepo operations.Repository,
) *Service {
return &Service{
rmsClient: rmsClient,
catalogRepo: catalogRepo,
recipeRepo: recipeRepo,
invoiceRepo: invoiceRepo,
opRepo: opRepo,
}
}
// SyncCatalog загружает номенклатуру и сохраняет в БД
func (s *Service) SyncCatalog() error {
logger.Log.Info("Начало синхронизации номенклатуры")
products, err := s.rmsClient.FetchCatalog()
if err != nil {
return fmt.Errorf("ошибка получения каталога из RMS: %w", err)
}
if err := s.catalogRepo.SaveProducts(products); err != nil {
return fmt.Errorf("ошибка сохранения продуктов в БД: %w", err)
}
logger.Log.Info("Синхронизация номенклатуры завершена", zap.Int("count", len(products)))
return nil
}
// SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию)
func (s *Service) SyncRecipes() error {
logger.Log.Info("Начало синхронизации техкарт")
// RMS требует dateFrom. Берем широкий диапазон, например, с начала года или фиксированную дату,
// либо можно сделать конфигурируемым. Для примера берем -3 месяца от текущей даты.
// В реальном проде лучше брать дату последнего изменения, если API поддерживает revision,
// но V2 API iiko часто требует полной перезагрузки актуальных карт.
dateFrom := time.Now().AddDate(0, -3, 0)
dateTo := time.Now() // +1 месяц вперед на случай будущих меню
recipes, err := s.rmsClient.FetchRecipes(dateFrom, dateTo)
if err != nil {
return fmt.Errorf("ошибка получения техкарт из RMS: %w", err)
}
if err := s.recipeRepo.SaveRecipes(recipes); err != nil {
return fmt.Errorf("ошибка сохранения техкарт в БД: %w", err)
}
logger.Log.Info("Синхронизация техкарт завершена", zap.Int("count", len(recipes)))
return nil
}
// SyncInvoices загружает накладные. Если в базе пусто, грузит за последние N дней.
func (s *Service) SyncInvoices() error {
logger.Log.Info("Начало синхронизации накладных")
lastDate, err := s.invoiceRepo.GetLastInvoiceDate()
if err != nil {
return fmt.Errorf("ошибка получения даты последней накладной: %w", err)
}
var from time.Time
to := time.Now()
if lastDate != nil {
// Берем следующий день после последней загрузки или тот же день, чтобы обновить изменения
from = *lastDate
} else {
// Дефолтная загрузка за 30 дней назад
from = time.Now().AddDate(0, 0, -30)
}
logger.Log.Info("Запрос накладных", zap.Time("from", from), zap.Time("to", to))
invoices, err := s.rmsClient.FetchInvoices(from, to)
if err != nil {
return fmt.Errorf("ошибка получения накладных из RMS: %w", err)
}
if len(invoices) == 0 {
logger.Log.Info("Новых накладных не найдено")
return nil
}
if err := s.invoiceRepo.SaveInvoices(invoices); err != nil {
return fmt.Errorf("ошибка сохранения накладных в БД: %w", err)
}
logger.Log.Info("Синхронизация накладных завершена", zap.Int("count", len(invoices)))
return nil
}
// classifyOperation определяет тип операции на основе DocumentType
func classifyOperation(docType string) operations.OperationType {
switch docType {
// === ПРИХОД (PURCHASE) ===
case "INCOMING_INVOICE": // Приходная накладная
return operations.OpTypePurchase
case "INCOMING_SERVICE": // Акт приема услуг (редко товары, но бывает)
return operations.OpTypePurchase
// === РАСХОД (USAGE) ===
case "SALES_DOCUMENT": // Акт реализации (продажа)
return operations.OpTypeUsage
case "WRITEOFF_DOCUMENT": // Акт списания (порча, проработки)
return operations.OpTypeUsage
case "OUTGOING_INVOICE": // Расходная накладная
return operations.OpTypeUsage
case "SESSION_ACCEPTANCE": // Принятие смены (иногда агрегирует продажи)
return operations.OpTypeUsage
case "DISASSEMBLE_DOCUMENT": // Акт разбора (расход целого)
return operations.OpTypeUsage
// === Спорные/Игнорируемые ===
// RETURNED_INVOICE (Возвратная накладная) - технически это уменьшение прихода,
// но для рекомендаций "что мы покупаем" лучше обрабатывать отдельно или как минус-purchase.
// Пока отнесем к UNKNOWN, чтобы не портить статистику чистого прихода,
// либо можно считать как Purchase с отрицательным Amount (если XML дает минус).
case "RETURNED_INVOICE":
return operations.OpTypeUnknown
case "INTERNAL_TRANSFER":
return operations.OpTypeUnknown // Перемещение нас не интересует в рамках рекомендаций "купил/продал"
case "INCOMING_INVENTORY":
return operations.OpTypeUnknown // Инвентаризация
default:
return operations.OpTypeUnknown
}
}
func (s *Service) SyncStoreOperations() error {
dateTo := time.Now()
dateFrom := dateTo.AddDate(0, 0, -30)
// 1. Синхронизируем Закупки (PresetPurchases)
// Мы передаем OpTypePurchase, чтобы репозиторий знал, какую "полку" очистить перед записью.
if err := s.syncReport(PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {
return fmt.Errorf("ошибка синхронизации закупок: %w", err)
}
// 2. Синхронизируем Расход (PresetUsage)
if err := s.syncReport(PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {
return fmt.Errorf("ошибка синхронизации расхода: %w", err)
}
return nil
}
func (s *Service) syncReport(presetID string, targetOpType operations.OperationType, from, to time.Time) error {
logger.Log.Info("Запрос отчета RMS", zap.String("preset", presetID))
items, err := s.rmsClient.FetchStoreOperations(presetID, from, to)
if err != nil {
return err
}
var ops []operations.StoreOperation
for _, item := range items {
// 1. Валидация товара
pID, err := uuid.Parse(item.ProductID)
if err != nil {
continue
}
// 2. Определение реального типа операции
realOpType := classifyOperation(item.DocumentType)
// 3. Фильтрация "мусора"
// Если мы грузим отчет "Закупки", но туда попало "Перемещение" (из-за кривого пресета),
// мы это пропустим. Либо если документ неизвестного типа.
if realOpType == operations.OpTypeUnknown {
continue
}
// Важно: Мы сохраняем только то, что соответствует целевому типу этапа синхронизации.
// Если в пресете "Закупки" попалась "Реализация", мы не должны писать её в "Закупки",
// и не должны писать в "Расход" (так как мы сейчас чистим "Закупки").
if realOpType != targetOpType {
continue
}
ops = append(ops, operations.StoreOperation{
ProductID: pID,
OpType: realOpType,
DocumentType: item.DocumentType,
TransactionType: item.TransactionType,
DocumentNumber: item.DocumentNum,
Amount: decimal.NewFromFloat(item.Amount),
Sum: decimal.NewFromFloat(item.Sum),
Cost: decimal.NewFromFloat(item.Cost),
PeriodFrom: from,
PeriodTo: to,
})
}
if err := s.opRepo.SaveOperations(ops, targetOpType, from, to); err != nil {
return err
}
logger.Log.Info("Отчет сохранен",
zap.String("op_type", string(targetOpType)),
zap.Int("received", len(items)),
zap.Int("saved", len(ops)))
return nil
}