mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Перевел на multi-tenant
Добавил поставщиков Накладные успешно создаются из фронта
This commit is contained in:
@@ -10,72 +10,89 @@ import (
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/account"
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/drafts"
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/domain/ocr"
|
||||
"rmser/internal/domain/suppliers"
|
||||
"rmser/internal/infrastructure/rms"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
draftRepo drafts.Repository
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
rmsClient rms.ClientI
|
||||
draftRepo drafts.Repository
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
accountRepo account.Repository
|
||||
supplierRepo suppliers.Repository
|
||||
rmsFactory *rms.Factory
|
||||
}
|
||||
|
||||
func NewService(
|
||||
draftRepo drafts.Repository,
|
||||
ocrRepo ocr.Repository,
|
||||
catalogRepo catalog.Repository,
|
||||
rmsClient rms.ClientI,
|
||||
accountRepo account.Repository,
|
||||
supplierRepo suppliers.Repository,
|
||||
rmsFactory *rms.Factory,
|
||||
) *Service {
|
||||
return &Service{
|
||||
draftRepo: draftRepo,
|
||||
ocrRepo: ocrRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
rmsClient: rmsClient,
|
||||
draftRepo: draftRepo,
|
||||
ocrRepo: ocrRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
accountRepo: accountRepo,
|
||||
supplierRepo: supplierRepo,
|
||||
rmsFactory: rmsFactory,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDraft возвращает черновик с позициями
|
||||
func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||
return s.draftRepo.GetByID(id)
|
||||
func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||
// TODO: Проверить что userID совпадает с draft.UserID
|
||||
return s.draftRepo.GetByID(draftID)
|
||||
}
|
||||
|
||||
func (s *Service) GetActiveDrafts(userID uuid.UUID) ([]drafts.DraftInvoice, error) {
|
||||
return s.draftRepo.GetActive(userID)
|
||||
}
|
||||
|
||||
// GetDictionaries возвращает Склады и Поставщиков для пользователя
|
||||
func (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, error) {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("active server not found")
|
||||
}
|
||||
|
||||
stores, _ := s.catalogRepo.GetActiveStores(server.ID)
|
||||
|
||||
// Ранжированные поставщики (топ за 90 дней)
|
||||
suppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90)
|
||||
|
||||
return map[string]interface{}{
|
||||
"stores": stores,
|
||||
"suppliers": suppliersList,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteDraft реализует логику "Отмена -> Удаление"
|
||||
func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
|
||||
// Без изменений логики, только вызов репо
|
||||
draft, err := s.draftRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Сценарий 2: Если уже ОТМЕНЕН -> УДАЛЯЕМ (Soft Delete статусом)
|
||||
if draft.Status == drafts.StatusCanceled {
|
||||
draft.Status = drafts.StatusDeleted
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logger.Log.Info("Черновик удален (скрыт)", zap.String("id", id.String()))
|
||||
s.draftRepo.Update(draft)
|
||||
return drafts.StatusDeleted, nil
|
||||
}
|
||||
|
||||
// Сценарий 1: Если активен -> ОТМЕНЯЕМ
|
||||
// Разрешаем отменять только незавершенные
|
||||
if draft.Status != drafts.StatusCompleted && draft.Status != drafts.StatusDeleted {
|
||||
if draft.Status != drafts.StatusCompleted {
|
||||
draft.Status = drafts.StatusCanceled
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logger.Log.Info("Черновик перемещен в отмененные", zap.String("id", id.String()))
|
||||
s.draftRepo.Update(draft)
|
||||
return drafts.StatusCanceled, nil
|
||||
}
|
||||
|
||||
return draft.Status, nil
|
||||
}
|
||||
|
||||
// UpdateDraftHeader обновляет шапку (дата, поставщик, склад, комментарий)
|
||||
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error {
|
||||
draft, err := s.draftRepo.GetByID(id)
|
||||
if err != nil {
|
||||
@@ -84,65 +101,46 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
|
||||
if draft.Status == drafts.StatusCompleted {
|
||||
return errors.New("черновик уже отправлен")
|
||||
}
|
||||
|
||||
draft.StoreID = storeID
|
||||
draft.SupplierID = supplierID
|
||||
draft.DateIncoming = &date
|
||||
draft.Comment = comment
|
||||
|
||||
return s.draftRepo.Update(draft)
|
||||
}
|
||||
|
||||
// UpdateItem обновляет позицию с авто-восстановлением статуса черновика
|
||||
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||
// 1. Проверяем статус черновика для реализации Auto-Restore
|
||||
draft, err := s.draftRepo.GetByID(draftID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Если черновик был в корзине (CANCELED), возвращаем его в работу
|
||||
if draft.Status == drafts.StatusCanceled {
|
||||
draft.Status = drafts.StatusReadyToVerify
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
logger.Log.Error("Не удалось восстановить статус черновика при редактировании", zap.Error(err))
|
||||
// Не прерываем выполнение, пробуем обновить строку
|
||||
} else {
|
||||
logger.Log.Info("Черновик автоматически восстановлен из отмененных", zap.String("id", draftID.String()))
|
||||
}
|
||||
s.draftRepo.Update(draft)
|
||||
}
|
||||
|
||||
// 2. Обновляем саму строку (существующий вызов репозитория)
|
||||
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
|
||||
}
|
||||
|
||||
// CommitDraft отправляет накладную в RMS
|
||||
func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
||||
// 1. Загружаем актуальное состояние черновика
|
||||
draft, err := s.draftRepo.GetByID(id)
|
||||
// CommitDraft отправляет накладную
|
||||
func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||||
// 1. Клиент для пользователя
|
||||
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 2. Черновик
|
||||
draft, err := s.draftRepo.GetByID(draftID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if draft.Status == drafts.StatusCompleted {
|
||||
return "", errors.New("накладная уже отправлена")
|
||||
}
|
||||
|
||||
// Валидация
|
||||
if draft.StoreID == nil || *draft.StoreID == uuid.Nil {
|
||||
return "", errors.New("не выбран склад")
|
||||
}
|
||||
if draft.SupplierID == nil || *draft.SupplierID == uuid.Nil {
|
||||
return "", errors.New("не выбран поставщик")
|
||||
}
|
||||
if draft.DateIncoming == nil {
|
||||
return "", errors.New("не выбрана дата")
|
||||
}
|
||||
|
||||
// Сборка Invoice для отправки
|
||||
// 3. Сборка Invoice
|
||||
inv := invoices.Invoice{
|
||||
ID: uuid.Nil, // iiko создаст новый
|
||||
DocumentNumber: draft.DocumentNumber, // Может быть пустой, iiko присвоит
|
||||
ID: uuid.Nil,
|
||||
DocumentNumber: draft.DocumentNumber,
|
||||
DateIncoming: *draft.DateIncoming,
|
||||
SupplierID: *draft.SupplierID,
|
||||
DefaultStoreID: *draft.StoreID,
|
||||
@@ -152,11 +150,10 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
||||
|
||||
for _, dItem := range draft.Items {
|
||||
if dItem.ProductID == nil {
|
||||
// Пропускаем нераспознанные или кидаем ошибку?
|
||||
break
|
||||
continue // Skip unrecognized
|
||||
}
|
||||
|
||||
// Расчет суммы (если не задана, считаем)
|
||||
// Если суммы нет, считаем
|
||||
sum := dItem.Sum
|
||||
if sum.IsZero() {
|
||||
sum = dItem.Quantity.Mul(dItem.Price)
|
||||
@@ -169,7 +166,6 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
||||
Sum: sum,
|
||||
ContainerID: dItem.ContainerID,
|
||||
}
|
||||
|
||||
inv.Items = append(inv.Items, invItem)
|
||||
}
|
||||
|
||||
@@ -177,86 +173,64 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
||||
return "", errors.New("нет распознанных позиций для отправки")
|
||||
}
|
||||
|
||||
// Отправка
|
||||
docNum, err := s.rmsClient.CreateIncomingInvoice(inv)
|
||||
// 4. Отправка в RMS
|
||||
docNum, err := client.CreateIncomingInvoice(inv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Обновление статуса
|
||||
// 5. Обновление статуса
|
||||
draft.Status = drafts.StatusCompleted
|
||||
// Можно сохранить docNum, если бы было поле в Draft, но у нас есть rms_invoice_id (uuid),
|
||||
// а возвращается строка номера. Ок, просто меняем статус.
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
logger.Log.Error("Failed to update draft status after commit", zap.Error(err))
|
||||
}
|
||||
s.draftRepo.Update(draft)
|
||||
|
||||
// 4. ОБУЧЕНИЕ (Deferred Learning)
|
||||
// Запускаем в горутине, чтобы не задерживать ответ пользователю
|
||||
go s.learnFromDraft(draft)
|
||||
// 6. БИЛЛИНГ: Увеличиваем счетчик накладных
|
||||
server, _ := s.accountRepo.GetActiveServer(userID)
|
||||
if server != nil {
|
||||
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
|
||||
logger.Log.Error("Billing increment failed", zap.Error(err))
|
||||
}
|
||||
// 7. Обучение (передаем ID сервера для сохранения маппинга)
|
||||
go s.learnFromDraft(draft, server.ID)
|
||||
}
|
||||
|
||||
return docNum, nil
|
||||
}
|
||||
|
||||
// learnFromDraft сохраняет новые связи на основе подтвержденного черновика
|
||||
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) {
|
||||
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice, serverID uuid.UUID) {
|
||||
for _, item := range draft.Items {
|
||||
// Учимся только если:
|
||||
// 1. Есть RawName (текст из чека)
|
||||
// 2. Пользователь (или OCR) выбрал ProductID
|
||||
if item.RawName != "" && item.ProductID != nil {
|
||||
|
||||
// Если нужно запоминать коэффициент (например, всегда 1 или то, что ввел юзер),
|
||||
// то берем item.Quantity. Но обычно для матчинга мы запоминаем факт связи,
|
||||
// а дефолтное кол-во ставим 1.
|
||||
qty := decimal.NewFromFloat(1.0)
|
||||
|
||||
err := s.ocrRepo.SaveMatch(item.RawName, *item.ProductID, qty, item.ContainerID)
|
||||
err := s.ocrRepo.SaveMatch(serverID, item.RawName, *item.ProductID, qty, item.ContainerID)
|
||||
if err != nil {
|
||||
logger.Log.Warn("Failed to learn match",
|
||||
zap.String("raw", item.RawName),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
logger.Log.Info("Learned match", zap.String("raw", item.RawName))
|
||||
logger.Log.Warn("Failed to learn match", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetActiveStores возвращает список складов
|
||||
func (s *Service) GetActiveStores() ([]catalog.Store, error) {
|
||||
return s.catalogRepo.GetActiveStores()
|
||||
}
|
||||
|
||||
// GetActiveDrafts возвращает список черновиков в работе
|
||||
func (s *Service) GetActiveDrafts() ([]drafts.DraftInvoice, error) {
|
||||
return s.draftRepo.GetActive()
|
||||
}
|
||||
|
||||
// CreateProductContainer создает новую фасовку в iiko и сохраняет её в локальной БД
|
||||
// Возвращает UUID созданной фасовки.
|
||||
func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
|
||||
// 1. Получаем полную карточку товара из iiko
|
||||
// Используем инфраструктурный DTO, так как нам нужна полная структура для апдейта
|
||||
fullProduct, err := s.rmsClient.GetProductByID(productID)
|
||||
func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
|
||||
client, err := s.rmsFactory.GetClientForUser(userID)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("ошибка получения товара из iiko: %w", err)
|
||||
return uuid.Nil, err
|
||||
}
|
||||
server, _ := s.accountRepo.GetActiveServer(userID) // нужен ServerID для сохранения в локальную БД
|
||||
|
||||
fullProduct, err := client.GetProductByID(productID)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("error fetching product: %w", err)
|
||||
}
|
||||
|
||||
// 2. Валидация на дубликаты (по имени или коэффициенту)
|
||||
// iiko разрешает дубли, но нам это не нужно.
|
||||
// Валидация на дубли
|
||||
targetCount, _ := count.Float64()
|
||||
for _, c := range fullProduct.Containers {
|
||||
if !c.Deleted && (c.Name == name || (c.Count == targetCount)) {
|
||||
// Если такая фасовка уже есть, возвращаем её ID
|
||||
// (Можно добавить логику обновления имени, но пока просто вернем ID)
|
||||
if c.ID != nil && *c.ID != "" {
|
||||
return uuid.Parse(*c.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Вычисляем следующий num (iiko использует строки "1", "2"...)
|
||||
// Next Num
|
||||
maxNum := 0
|
||||
for _, c := range fullProduct.Containers {
|
||||
if n, err := strconv.Atoi(c.Num); err == nil {
|
||||
@@ -267,32 +241,27 @@ func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count
|
||||
}
|
||||
nextNum := strconv.Itoa(maxNum + 1)
|
||||
|
||||
// 4. Добавляем новую фасовку в список
|
||||
// Add
|
||||
newContainerDTO := rms.ContainerFullDTO{
|
||||
ID: nil, // Null, чтобы iiko создала новый ID
|
||||
ID: nil,
|
||||
Num: nextNum,
|
||||
Name: name,
|
||||
Count: targetCount,
|
||||
UseInFront: true,
|
||||
Deleted: false,
|
||||
// Остальные поля можно оставить 0/false по умолчанию
|
||||
}
|
||||
|
||||
fullProduct.Containers = append(fullProduct.Containers, newContainerDTO)
|
||||
|
||||
// 5. Отправляем обновление в iiko
|
||||
updatedProduct, err := s.rmsClient.UpdateProduct(*fullProduct)
|
||||
// Update RMS
|
||||
updatedProduct, err := client.UpdateProduct(*fullProduct)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("ошибка обновления товара в iiko: %w", err)
|
||||
return uuid.Nil, fmt.Errorf("error updating product: %w", err)
|
||||
}
|
||||
|
||||
// 6. Ищем нашу созданную фасовку в ответе, чтобы получить её ID
|
||||
// Ищем по уникальной комбинации Name + Count, которую мы только что отправили
|
||||
// Find created ID
|
||||
var createdID uuid.UUID
|
||||
found := false
|
||||
|
||||
for _, c := range updatedProduct.Containers {
|
||||
// Сравниваем float с небольшим эпсилоном на всякий случай, хотя JSON должен вернуть точно
|
||||
if c.Name == name && c.Count == targetCount && !c.Deleted {
|
||||
if c.ID != nil {
|
||||
createdID, err = uuid.Parse(*c.ID)
|
||||
@@ -305,28 +274,18 @@ func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count
|
||||
}
|
||||
|
||||
if !found {
|
||||
return uuid.Nil, errors.New("фасовка отправлена, но сервер не вернул её ID (возможно, ошибка логики поиска)")
|
||||
return uuid.Nil, errors.New("container created but id not found")
|
||||
}
|
||||
|
||||
// 7. Сохраняем новую фасовку в локальную БД
|
||||
// Save Local
|
||||
newLocalContainer := catalog.ProductContainer{
|
||||
ID: createdID,
|
||||
ProductID: productID,
|
||||
Name: name,
|
||||
Count: count,
|
||||
ID: createdID,
|
||||
RMSServerID: server.ID, // <-- NEW
|
||||
ProductID: productID,
|
||||
Name: name,
|
||||
Count: count,
|
||||
}
|
||||
|
||||
if err := s.catalogRepo.SaveContainer(newLocalContainer); err != nil {
|
||||
logger.Log.Error("Ошибка сохранения новой фасовки в локальную БД", zap.Error(err))
|
||||
// Не возвращаем ошибку клиенту, так как в iiko она уже создана.
|
||||
// Просто в следующем SyncCatalog она подтянется, но лучше иметь её сразу.
|
||||
}
|
||||
|
||||
logger.Log.Info("Создана новая фасовка",
|
||||
zap.String("product_id", productID.String()),
|
||||
zap.String("container_id", createdID.String()),
|
||||
zap.String("name", name),
|
||||
zap.String("count", count.String()))
|
||||
s.catalogRepo.SaveContainer(newLocalContainer)
|
||||
|
||||
return createdID, nil
|
||||
}
|
||||
|
||||
@@ -6,58 +6,66 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"rmser/internal/domain/account"
|
||||
"rmser/internal/domain/catalog"
|
||||
"rmser/internal/domain/drafts"
|
||||
"rmser/internal/domain/ocr"
|
||||
"rmser/internal/infrastructure/ocr_client"
|
||||
"rmser/pkg/logger"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
ocrRepo ocr.Repository
|
||||
catalogRepo catalog.Repository
|
||||
draftRepo drafts.Repository
|
||||
pyClient *ocr_client.Client // Клиент к Python сервису
|
||||
accountRepo account.Repository // <-- NEW
|
||||
pyClient *ocr_client.Client
|
||||
}
|
||||
|
||||
func NewService(
|
||||
ocrRepo ocr.Repository,
|
||||
catalogRepo catalog.Repository,
|
||||
draftRepo drafts.Repository,
|
||||
accountRepo account.Repository, // <-- NEW
|
||||
pyClient *ocr_client.Client,
|
||||
) *Service {
|
||||
return &Service{
|
||||
ocrRepo: ocrRepo,
|
||||
catalogRepo: catalogRepo,
|
||||
draftRepo: draftRepo,
|
||||
accountRepo: accountRepo,
|
||||
pyClient: pyClient,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessReceiptImage - Создает черновик, распознает, сохраняет результаты
|
||||
func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData []byte) (*drafts.DraftInvoice, error) {
|
||||
// 1. Создаем заготовку черновика
|
||||
// ProcessReceiptImage
|
||||
func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {
|
||||
// 1. Получаем активный сервер для UserID
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no active server for user")
|
||||
}
|
||||
serverID := server.ID
|
||||
|
||||
// 2. Создаем черновик
|
||||
draft := &drafts.DraftInvoice{
|
||||
ChatID: chatID,
|
||||
Status: drafts.StatusProcessing,
|
||||
UserID: userID, // <-- Исправлено с ChatID на UserID
|
||||
RMSServerID: serverID, // <-- NEW
|
||||
Status: drafts.StatusProcessing,
|
||||
}
|
||||
if err := s.draftRepo.Create(draft); err != nil {
|
||||
return nil, fmt.Errorf("failed to create draft: %w", err)
|
||||
}
|
||||
logger.Log.Info("Создан черновик", zap.String("draft_id", draft.ID.String()))
|
||||
|
||||
// 2. Отправляем в Python OCR
|
||||
// 3. Отправляем в Python OCR
|
||||
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
|
||||
if err != nil {
|
||||
// Ставим статус ошибки
|
||||
draft.Status = drafts.StatusError
|
||||
_ = s.draftRepo.Update(draft)
|
||||
return nil, fmt.Errorf("python ocr error: %w", err)
|
||||
}
|
||||
|
||||
// 3. Обрабатываем результаты и создаем Items
|
||||
// 4. Матчинг (с учетом ServerID)
|
||||
var draftItems []drafts.DraftInvoiceItem
|
||||
|
||||
for _, rawItem := range rawResult.Items {
|
||||
@@ -66,60 +74,33 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
|
||||
RawName: rawItem.RawName,
|
||||
RawAmount: decimal.NewFromFloat(rawItem.Amount),
|
||||
RawPrice: decimal.NewFromFloat(rawItem.Price),
|
||||
// Quantity/Price по умолчанию берем как Raw, если не будет пересчета
|
||||
Quantity: decimal.NewFromFloat(rawItem.Amount),
|
||||
Price: decimal.NewFromFloat(rawItem.Price),
|
||||
Sum: decimal.NewFromFloat(rawItem.Sum),
|
||||
Quantity: decimal.NewFromFloat(rawItem.Amount),
|
||||
Price: decimal.NewFromFloat(rawItem.Price),
|
||||
Sum: decimal.NewFromFloat(rawItem.Sum),
|
||||
}
|
||||
|
||||
// Пытаемся найти матчинг
|
||||
match, err := s.ocrRepo.FindMatch(rawItem.RawName)
|
||||
if err != nil {
|
||||
logger.Log.Error("db error finding match", zap.Error(err))
|
||||
}
|
||||
match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) // <-- ServerID
|
||||
|
||||
if match != nil {
|
||||
item.IsMatched = true
|
||||
item.ProductID = &match.ProductID
|
||||
item.ContainerID = match.ContainerID
|
||||
|
||||
} else {
|
||||
// Если не нашли - сохраняем в Unmatched для статистики и подсказок
|
||||
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
|
||||
logger.Log.Warn("failed to save unmatched", zap.Error(err))
|
||||
}
|
||||
s.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName) // <-- ServerID
|
||||
}
|
||||
|
||||
draftItems = append(draftItems, item)
|
||||
}
|
||||
|
||||
// 4. Сохраняем позиции в БД
|
||||
// 5. Сохраняем
|
||||
draft.Status = drafts.StatusReadyToVerify
|
||||
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
return nil, fmt.Errorf("failed to update draft status: %w", err)
|
||||
}
|
||||
draft.Items = draftItems
|
||||
|
||||
if err := s.draftRepo.CreateItems(draftItems); err != nil {
|
||||
return nil, fmt.Errorf("failed to save items: %w", err)
|
||||
}
|
||||
s.draftRepo.Update(draft)
|
||||
s.draftRepo.CreateItems(draftItems)
|
||||
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
// ProcessedItem - результат обработки одной строки чека
|
||||
type ProcessedItem struct {
|
||||
RawName string
|
||||
Amount decimal.Decimal
|
||||
Price decimal.Decimal
|
||||
Sum decimal.Decimal
|
||||
|
||||
IsMatched bool
|
||||
ProductID *uuid.UUID
|
||||
MatchSource string // "learned", "auto", "manual"
|
||||
}
|
||||
|
||||
// Добавить структуры в конец файла
|
||||
type ContainerForIndex struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -134,9 +115,14 @@ type ProductForIndex struct {
|
||||
Containers []ContainerForIndex `json:"containers"`
|
||||
}
|
||||
|
||||
// GetCatalogForIndexing - возвращает облегченный каталог
|
||||
func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
|
||||
products, err := s.catalogRepo.GetActiveGoods()
|
||||
// GetCatalogForIndexing
|
||||
func (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, error) {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
|
||||
products, err := s.catalogRepo.GetActiveGoods(server.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -169,37 +155,45 @@ func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveMapping сохраняет привязку с количеством и фасовкой
|
||||
func (s *Service) SaveMapping(rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
||||
return s.ocrRepo.SaveMatch(rawName, productID, quantity, containerID)
|
||||
}
|
||||
|
||||
// DeleteMatch удаляет ошибочную привязку
|
||||
func (s *Service) DeleteMatch(rawName string) error {
|
||||
return s.ocrRepo.DeleteMatch(rawName)
|
||||
}
|
||||
|
||||
// GetKnownMatches возвращает список всех обученных связей
|
||||
func (s *Service) GetKnownMatches() ([]ocr.ProductMatch, error) {
|
||||
return s.ocrRepo.GetAllMatches()
|
||||
}
|
||||
|
||||
// GetUnmatchedItems возвращает список частых нераспознанных строк
|
||||
func (s *Service) GetUnmatchedItems() ([]ocr.UnmatchedItem, error) {
|
||||
// Берем топ 50 нераспознанных
|
||||
return s.ocrRepo.GetTopUnmatched(50)
|
||||
}
|
||||
|
||||
// FindKnownMatch ищет, знаем ли мы уже этот товар
|
||||
func (s *Service) FindKnownMatch(rawName string) (*ocr.ProductMatch, error) {
|
||||
return s.ocrRepo.FindMatch(rawName)
|
||||
}
|
||||
|
||||
// SearchProducts ищет товары в БД по части названия, коду или артикулу
|
||||
func (s *Service) SearchProducts(query string) ([]catalog.Product, error) {
|
||||
func (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Product, error) {
|
||||
if len(query) < 2 {
|
||||
// Слишком короткий запрос, возвращаем пустой список
|
||||
return []catalog.Product{}, nil
|
||||
}
|
||||
return s.catalogRepo.Search(query)
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
return s.catalogRepo.Search(server.ID, query)
|
||||
}
|
||||
|
||||
func (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return fmt.Errorf("no server")
|
||||
}
|
||||
return s.ocrRepo.SaveMatch(server.ID, rawName, productID, quantity, containerID)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteMatch(userID uuid.UUID, rawName string) error {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return fmt.Errorf("no server")
|
||||
}
|
||||
return s.ocrRepo.DeleteMatch(server.ID, rawName)
|
||||
}
|
||||
|
||||
func (s *Service) GetKnownMatches(userID uuid.UUID) ([]ocr.ProductMatch, error) {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
return s.ocrRepo.GetAllMatches(server.ID)
|
||||
}
|
||||
|
||||
func (s *Service) GetUnmatchedItems(userID uuid.UUID) ([]ocr.UnmatchedItem, error) {
|
||||
server, err := s.accountRepo.GetActiveServer(userID)
|
||||
if err != nil || server == nil {
|
||||
return nil, fmt.Errorf("no server")
|
||||
}
|
||||
return s.ocrRepo.GetTopUnmatched(server.ID, 50)
|
||||
}
|
||||
|
||||
@@ -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