package drafts import ( "errors" "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) } // 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 { // Мы просто обновляем данные в черновике. // Сохранение в базу знаний (OCR Matches) произойдет только при отправке накладной. 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 { // Пропускаем нераспознанные или кидаем ошибку? // Лучше пропустить, чтобы не блокировать отправку частичного документа continue } // Расчет суммы (если не задана, считаем) sum := dItem.Sum if sum.IsZero() { sum = dItem.Quantity.Mul(dItem.Price) } // Важный момент с фасовками: // Клиент RMS (CreateIncomingInvoice) у нас пока не поддерживает отправку container_id в явном виде, // или мы его обновили? Проверим `internal/infrastructure/rms/client.go`. // Там используется `IncomingInvoiceImportItemXML`. В ней нет поля ContainerID, но есть `AmountUnit`. // Если мы хотим передать фасовку, нужно передавать Amount в базовых единицах, // ЛИБО доработать клиент iiko, чтобы он принимал `amountUnit` (ID фасовки). // СТРАТЕГИЯ СЕЙЧАС: // Считаем, что FrontEnd/Service уже пересчитал кол-во в базовые единицы? // НЕТ. DraftItem хранит Quantity в тех единицах, которые выбрал юзер (фасовках). // Нам нужно конвертировать в базовые для отправки, если мы не умеем слать фасовки. // Но погоди, в `ProductContainer` есть `Count` (коэффициент). finalAmount := dItem.Quantity if dItem.ContainerID != nil && dItem.Container != nil { // Если выбрана фасовка, умножаем кол-во упаковок на коэффициент finalAmount = finalAmount.Mul(dItem.Container.Count) } inv.Items = append(inv.Items, invoices.InvoiceItem{ ProductID: *dItem.ProductID, Amount: finalAmount, Price: dItem.Price, // Цена обычно за упаковку... А iiko ждет цену за базу? // RMS API: Если мы шлем в базовых единицах, то и цену надо пересчитать за базовую. // Price (base) = Price (pack) / Count // ЛИБО: Мы шлем Sum, а iiko сама посчитает цену. Это надежнее. Sum: sum, }) } 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() }