package drafts import ( "errors" "fmt" "strconv" "time" "github.com/google/uuid" "github.com/shopspring/decimal" "go.uber.org/zap" "rmser/internal/domain/catalog" "rmser/internal/domain/drafts" "rmser/internal/domain/invoices" "rmser/internal/domain/ocr" "rmser/internal/infrastructure/rms" "rmser/pkg/logger" ) type Service struct { draftRepo drafts.Repository ocrRepo ocr.Repository catalogRepo catalog.Repository rmsClient rms.ClientI } func NewService( draftRepo drafts.Repository, ocrRepo ocr.Repository, catalogRepo catalog.Repository, rmsClient rms.ClientI, ) *Service { return &Service{ draftRepo: draftRepo, ocrRepo: ocrRepo, catalogRepo: catalogRepo, rmsClient: rmsClient, } } // GetDraft возвращает черновик с позициями func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) { return s.draftRepo.GetByID(id) } // 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())) return drafts.StatusDeleted, nil } // Сценарий 1: Если активен -> ОТМЕНЯЕМ // Разрешаем отменять только незавершенные if draft.Status != drafts.StatusCompleted && draft.Status != drafts.StatusDeleted { draft.Status = drafts.StatusCanceled if err := s.draftRepo.Update(draft); err != nil { return "", err } logger.Log.Info("Черновик перемещен в отмененные", zap.String("id", id.String())) 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 { return err } 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())) } } // 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) 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 для отправки inv := invoices.Invoice{ ID: uuid.Nil, // iiko создаст новый DocumentNumber: draft.DocumentNumber, // Может быть пустой, iiko присвоит DateIncoming: *draft.DateIncoming, SupplierID: *draft.SupplierID, DefaultStoreID: *draft.StoreID, Status: "NEW", Items: make([]invoices.InvoiceItem, 0, len(draft.Items)), } for _, dItem := range draft.Items { if dItem.ProductID == nil { // Пропускаем нераспознанные или кидаем ошибку? break } // Расчет суммы (если не задана, считаем) sum := dItem.Sum if sum.IsZero() { sum = dItem.Quantity.Mul(dItem.Price) } invItem := invoices.InvoiceItem{ ProductID: *dItem.ProductID, Amount: dItem.Quantity, Price: dItem.Price, Sum: sum, ContainerID: dItem.ContainerID, } inv.Items = append(inv.Items, invItem) } if len(inv.Items) == 0 { return "", errors.New("нет распознанных позиций для отправки") } // Отправка docNum, err := s.rmsClient.CreateIncomingInvoice(inv) if err != nil { return "", err } // Обновление статуса 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)) } // 4. ОБУЧЕНИЕ (Deferred Learning) // Запускаем в горутине, чтобы не задерживать ответ пользователю go s.learnFromDraft(draft) return docNum, nil } // learnFromDraft сохраняет новые связи на основе подтвержденного черновика func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) { 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) 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)) } } } } // 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) if err != nil { return uuid.Nil, fmt.Errorf("ошибка получения товара из iiko: %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"...) maxNum := 0 for _, c := range fullProduct.Containers { if n, err := strconv.Atoi(c.Num); err == nil { if n > maxNum { maxNum = n } } } nextNum := strconv.Itoa(maxNum + 1) // 4. Добавляем новую фасовку в список newContainerDTO := rms.ContainerFullDTO{ ID: nil, // Null, чтобы iiko создала новый ID 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) if err != nil { return uuid.Nil, fmt.Errorf("ошибка обновления товара в iiko: %w", err) } // 6. Ищем нашу созданную фасовку в ответе, чтобы получить её ID // Ищем по уникальной комбинации Name + Count, которую мы только что отправили 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) if err == nil { found = true break } } } } if !found { return uuid.Nil, errors.New("фасовка отправлена, но сервер не вернул её ID (возможно, ошибка логики поиска)") } // 7. Сохраняем новую фасовку в локальную БД newLocalContainer := catalog.ProductContainer{ ID: createdID, 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())) return createdID, nil }