mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
245 lines
9.4 KiB
Go
245 lines
9.4 KiB
Go
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
|
||
}
|