mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Перевел на multi-tenant
Добавил поставщиков Накладные успешно создаются из фронта
This commit is contained in:
@@ -4,263 +4,238 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/account"
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/domain/operations"
|
||||
"rmser/internal/domain/recipes"
|
||||
"rmser/internal/domain/suppliers"
|
||||
"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
|
||||
rmsFactory *rms.Factory
|
||||
accountRepo account.Repository
|
||||
catalogRepo catalog.Repository
|
||||
recipeRepo recipes.Repository
|
||||
invoiceRepo invoices.Repository
|
||||
opRepo operations.Repository
|
||||
supplierRepo suppliers.Repository
|
||||
}
|
||||
|
||||
func NewService(
|
||||
rmsClient rms.ClientI,
|
||||
rmsFactory *rms.Factory,
|
||||
accountRepo account.Repository,
|
||||
catalogRepo catalog.Repository,
|
||||
recipeRepo recipes.Repository,
|
||||
invoiceRepo invoices.Repository,
|
||||
opRepo operations.Repository,
|
||||
supplierRepo suppliers.Repository,
|
||||
) *Service {
|
||||
return &Service{
|
||||
rmsClient: rmsClient,
|
||||
catalogRepo: catalogRepo,
|
||||
recipeRepo: recipeRepo,
|
||||
invoiceRepo: invoiceRepo,
|
||||
opRepo: opRepo,
|
||||
rmsFactory: rmsFactory,
|
||||
accountRepo: accountRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
recipeRepo: recipeRepo,
|
||||
invoiceRepo: invoiceRepo,
|
||||
opRepo: opRepo,
|
||||
supplierRepo: supplierRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// SyncCatalog загружает номенклатуру и сохраняет в БД
|
||||
func (s *Service) SyncCatalog() error {
|
||||
logger.Log.Info("Начало синхронизации справочников...")
|
||||
// SyncAllData запускает полную синхронизацию для конкретного пользователя
|
||||
func (s *Service) SyncAllData(userID uuid.UUID) error {
|
||||
logger.Log.Info("Запуск полной синхронизации", zap.String("user_id", userID.String()))
|
||||
|
||||
// 1. Склады (INVENTORY_ASSETS) - важно для создания накладных
|
||||
if err := s.SyncStores(); err != nil {
|
||||
logger.Log.Error("Ошибка синхронизации складов", zap.Error(err))
|
||||
// Не прерываем, идем дальше
|
||||
// 1. Получаем клиент и инфо о сервере
|
||||
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return fmt.Errorf("active server not found for user %s", userID)
|
||||
}
|
||||
serverID := server.ID
|
||||
|
||||
// 2. Справочники
|
||||
if err := s.syncStores(client, serverID); err != nil {
|
||||
logger.Log.Error("Sync Stores failed", zap.Error(err))
|
||||
}
|
||||
if err := s.syncMeasureUnits(client, serverID); err != nil {
|
||||
logger.Log.Error("Sync Units failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// 2. Единицы измерения
|
||||
if err := s.syncMeasureUnits(); err != nil {
|
||||
// 3. Поставщики
|
||||
if err := s.syncSuppliers(client, serverID); err != nil {
|
||||
logger.Log.Error("Sync Suppliers failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// 4. Товары
|
||||
if err := s.syncProducts(client, serverID); err != nil {
|
||||
logger.Log.Error("Sync Products failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// 5. Техкарты (тяжелый запрос)
|
||||
if err := s.syncRecipes(client, serverID); err != nil {
|
||||
logger.Log.Error("Sync Recipes failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// 6. Накладные (история)
|
||||
if err := s.syncInvoices(client, serverID); err != nil {
|
||||
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))
|
||||
// }
|
||||
|
||||
logger.Log.Info("Синхронизация завершена", zap.String("user_id", userID.String()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) syncSuppliers(c rms.ClientI, serverID uuid.UUID) error {
|
||||
list, err := c.FetchSuppliers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Проставляем ServerID
|
||||
for i := range list {
|
||||
list[i].RMSServerID = serverID
|
||||
}
|
||||
return s.supplierRepo.SaveBatch(list)
|
||||
}
|
||||
|
||||
func (s *Service) syncStores(c rms.ClientI, serverID uuid.UUID) error {
|
||||
stores, err := c.FetchStores()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range stores {
|
||||
stores[i].RMSServerID = serverID
|
||||
}
|
||||
return s.catalogRepo.SaveStores(stores)
|
||||
}
|
||||
|
||||
func (s *Service) syncMeasureUnits(c rms.ClientI, serverID uuid.UUID) error {
|
||||
units, err := c.FetchMeasureUnits()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range units {
|
||||
units[i].RMSServerID = serverID
|
||||
}
|
||||
return s.catalogRepo.SaveMeasureUnits(units)
|
||||
}
|
||||
|
||||
func (s *Service) syncProducts(c rms.ClientI, serverID uuid.UUID) error {
|
||||
products, err := c.FetchCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Важно: Проставляем ID рекурсивно и в фасовки
|
||||
for i := range products {
|
||||
products[i].RMSServerID = serverID
|
||||
for j := range products[i].Containers {
|
||||
products[i].Containers[j].RMSServerID = serverID
|
||||
}
|
||||
}
|
||||
return s.catalogRepo.SaveProducts(products)
|
||||
}
|
||||
|
||||
func (s *Service) syncRecipes(c rms.ClientI, serverID uuid.UUID) error {
|
||||
dateFrom := time.Now().AddDate(0, -3, 0) // За 3 месяца
|
||||
dateTo := time.Now()
|
||||
recipesList, err := c.FetchRecipes(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Товары
|
||||
logger.Log.Info("Запрос товаров из RMS...")
|
||||
products, err := s.rmsClient.FetchCatalog()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения каталога из RMS: %w", err)
|
||||
for i := range recipesList {
|
||||
recipesList[i].RMSServerID = serverID
|
||||
for j := range recipesList[i].Items {
|
||||
recipesList[i].Items[j].RMSServerID = serverID
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.catalogRepo.SaveProducts(products); err != nil {
|
||||
return fmt.Errorf("ошибка сохранения продуктов в БД: %w", err)
|
||||
}
|
||||
|
||||
logger.Log.Info("Синхронизация номенклатуры завершена", zap.Int("count", len(products)))
|
||||
return nil
|
||||
return s.recipeRepo.SaveRecipes(recipesList)
|
||||
}
|
||||
|
||||
func (s *Service) syncMeasureUnits() error {
|
||||
logger.Log.Info("Синхронизация единиц измерения...")
|
||||
units, err := s.rmsClient.FetchMeasureUnits()
|
||||
func (s *Service) syncInvoices(c rms.ClientI, serverID uuid.UUID) error {
|
||||
lastDate, err := s.invoiceRepo.GetLastInvoiceDate(serverID)
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
var from time.Time
|
||||
to := time.Now()
|
||||
|
||||
if lastDate != nil {
|
||||
// Берем следующий день после последней загрузки или тот же день, чтобы обновить изменения
|
||||
from = *lastDate
|
||||
} else {
|
||||
// Дефолтная загрузка за 30 дней назад
|
||||
from = time.Now().AddDate(0, 0, -30)
|
||||
from = time.Now().AddDate(0, 0, -45) // 45 дней по дефолту
|
||||
}
|
||||
|
||||
logger.Log.Info("Запрос накладных", zap.Time("from", from), zap.Time("to", to))
|
||||
|
||||
invoices, err := s.rmsClient.FetchInvoices(from, to)
|
||||
invs, err := c.FetchInvoices(from, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ошибка получения накладных из RMS: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if len(invoices) == 0 {
|
||||
logger.Log.Info("Новых накладных не найдено")
|
||||
return nil
|
||||
for i := range invs {
|
||||
invs[i].RMSServerID = serverID
|
||||
// В Items пока не добавляли ServerID
|
||||
}
|
||||
|
||||
if err := s.invoiceRepo.SaveInvoices(invoices); err != nil {
|
||||
return fmt.Errorf("ошибка сохранения накладных в БД: %w", err)
|
||||
if len(invs) > 0 {
|
||||
return s.invoiceRepo.SaveInvoices(invs)
|
||||
}
|
||||
|
||||
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 {
|
||||
// SyncStoreOperations публичный, если нужно вызывать отдельно
|
||||
func (s *Service) SyncStoreOperations(c rms.ClientI, serverID uuid.UUID) 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)
|
||||
if err := s.syncReport(c, serverID, PresetPurchases, operations.OpTypePurchase, dateFrom, dateTo); err != nil {
|
||||
return fmt.Errorf("purchases sync error: %w", err)
|
||||
}
|
||||
|
||||
// 2. Синхронизируем Расход (PresetUsage)
|
||||
if err := s.syncReport(PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {
|
||||
return fmt.Errorf("ошибка синхронизации расхода: %w", err)
|
||||
if err := s.syncReport(c, serverID, PresetUsage, operations.OpTypeUsage, dateFrom, dateTo); err != nil {
|
||||
return fmt.Errorf("usage sync error: %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)
|
||||
func (s *Service) syncReport(c rms.ClientI, serverID uuid.UUID, presetID string, targetOpType operations.OperationType, from, to time.Time) error {
|
||||
items, err := c.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 {
|
||||
if realOpType == operations.OpTypeUnknown || realOpType != targetOpType {
|
||||
continue
|
||||
}
|
||||
|
||||
ops = append(ops, operations.StoreOperation{
|
||||
RMSServerID: serverID,
|
||||
ProductID: pID,
|
||||
OpType: realOpType,
|
||||
DocumentType: item.DocumentType,
|
||||
@@ -274,13 +249,59 @@ func (s *Service) syncReport(presetID string, targetOpType operations.OperationT
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.opRepo.SaveOperations(ops, targetOpType, from, to); err != nil {
|
||||
return err
|
||||
return s.opRepo.SaveOperations(ops, serverID, targetOpType, from, to)
|
||||
}
|
||||
|
||||
func classifyOperation(docType string) operations.OperationType {
|
||||
switch docType {
|
||||
case "INCOMING_INVOICE", "INCOMING_SERVICE":
|
||||
return operations.OpTypePurchase
|
||||
case "SALES_DOCUMENT", "WRITEOFF_DOCUMENT", "OUTGOING_INVOICE", "SESSION_ACCEPTANCE", "DISASSEMBLE_DOCUMENT":
|
||||
return operations.OpTypeUsage
|
||||
default:
|
||||
return operations.OpTypeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем структуру для возврата статистики
|
||||
type SyncStats struct {
|
||||
ServerName string
|
||||
ProductsCount int64
|
||||
StoresCount int64
|
||||
SuppliersCount int64
|
||||
InvoicesLast30 int64
|
||||
LastInvoice *time.Time
|
||||
}
|
||||
|
||||
// GetSyncStats собирает информацию о данных текущего сервера
|
||||
func (s *Service) GetSyncStats(userID uuid.UUID) (*SyncStats, error) {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("нет активного сервера")
|
||||
}
|
||||
|
||||
logger.Log.Info("Отчет сохранен",
|
||||
zap.String("op_type", string(targetOpType)),
|
||||
zap.Int("received", len(items)),
|
||||
zap.Int("saved", len(ops)))
|
||||
return nil
|
||||
stats := &SyncStats{
|
||||
ServerName: server.Name,
|
||||
}
|
||||
|
||||
// Параллельный запуск не обязателен, запросы Count очень быстрые
|
||||
if cnt, err := s.catalogRepo.CountGoods(server.ID); err == nil {
|
||||
stats.ProductsCount = cnt
|
||||
}
|
||||
|
||||
if cnt, err := s.catalogRepo.CountStores(server.ID); err == nil {
|
||||
stats.StoresCount = cnt
|
||||
}
|
||||
|
||||
if cnt, err := s.supplierRepo.Count(server.ID); err == nil {
|
||||
stats.SuppliersCount = cnt
|
||||
}
|
||||
|
||||
if cnt, err := s.invoiceRepo.CountRecent(server.ID, 30); err == nil {
|
||||
stats.InvoicesLast30 = cnt
|
||||
}
|
||||
|
||||
stats.LastInvoice, _ = s.invoiceRepo.GetLastInvoiceDate(server.ID)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user