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("Начало синхронизации справочников...") // 1. Склады (INVENTORY_ASSETS) - важно для создания накладных if err := s.SyncStores(); err != nil { logger.Log.Error("Ошибка синхронизации складов", zap.Error(err)) // Не прерываем, идем дальше } // 2. Единицы измерения if err := s.syncMeasureUnits(); err != nil { return err } // 3. Товары logger.Log.Info("Запрос товаров из RMS...") 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 } func (s *Service) syncMeasureUnits() error { logger.Log.Info("Синхронизация единиц измерения...") units, err := s.rmsClient.FetchMeasureUnits() if err != nil { return fmt.Errorf("ошибка получения ед.изм: %w", err) } if err := s.catalogRepo.SaveMeasureUnits(units); err != nil { return fmt.Errorf("ошибка сохранения ед.изм: %w", err) } logger.Log.Info("Единицы измерения обновлены", zap.Int("count", len(units))) return nil } // SyncRecipes загружает техкарты за указанный период (или за последние 30 дней по умолчанию) func (s *Service) SyncRecipes() error { logger.Log.Info("Начало синхронизации техкарт") // RMS требует dateFrom. Берем широкий диапазон, например, с начала года или фиксированную дату, // либо можно сделать конфигурируемым. Для примера берем -3 месяца от текущей даты. // В реальном проде лучше брать дату последнего изменения, если API поддерживает revision, // но V2 API iiko часто требует полной перезагрузки актуальных карт. dateFrom := time.Now().AddDate(0, -3, 0) dateTo := time.Now() // +1 месяц вперед на случай будущих меню recipes, err := s.rmsClient.FetchRecipes(dateFrom, dateTo) if err != nil { return fmt.Errorf("ошибка получения техкарт из RMS: %w", err) } if err := s.recipeRepo.SaveRecipes(recipes); err != nil { return fmt.Errorf("ошибка сохранения техкарт в БД: %w", err) } logger.Log.Info("Синхронизация техкарт завершена", zap.Int("count", len(recipes))) return nil } // SyncInvoices загружает накладные. Если в базе пусто, грузит за последние N дней. func (s *Service) SyncInvoices() error { logger.Log.Info("Начало синхронизации накладных") lastDate, err := s.invoiceRepo.GetLastInvoiceDate() if err != nil { return fmt.Errorf("ошибка получения даты последней накладной: %w", err) } var from time.Time 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 } } // SyncStores загружает список складов func (s *Service) SyncStores() error { logger.Log.Info("Синхронизация складов...") stores, err := s.rmsClient.FetchStores() if err != nil { return fmt.Errorf("ошибка получения складов из RMS: %w", err) } if err := s.catalogRepo.SaveStores(stores); err != nil { return fmt.Errorf("ошибка сохранения складов в БД: %w", err) } logger.Log.Info("Склады обновлены", zap.Int("count", len(stores))) return nil } func (s *Service) SyncStoreOperations() error { dateTo := time.Now() 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 }