package ocr import ( "context" "fmt" "strings" "github.com/google/uuid" "github.com/shopspring/decimal" "go.uber.org/zap" "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 сервису } func NewService( ocrRepo ocr.Repository, catalogRepo catalog.Repository, draftRepo drafts.Repository, pyClient *ocr_client.Client, ) *Service { return &Service{ ocrRepo: ocrRepo, catalogRepo: catalogRepo, draftRepo: draftRepo, pyClient: pyClient, } } // ProcessReceiptImage - Создает черновик, распознает, сохраняет результаты func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData []byte) (*drafts.DraftInvoice, error) { // 1. Создаем заготовку черновика draft := &drafts.DraftInvoice{ ChatID: chatID, 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 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 var draftItems []drafts.DraftInvoiceItem for _, rawItem := range rawResult.Items { item := drafts.DraftInvoiceItem{ DraftID: draft.ID, 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), } // Пытаемся найти матчинг match, err := s.ocrRepo.FindMatch(rawItem.RawName) if err != nil { logger.Log.Error("db error finding match", zap.Error(err)) } if match != nil { item.IsMatched = true item.ProductID = &match.ProductID item.ContainerID = match.ContainerID // Важная логика: Если в матче указано ContainerID, то Quantity из чека (например 5 шт) // это 5 коробок. Финальное кол-во (в кг) RMS посчитает сама, // либо мы можем пересчитать тут, если знаем коэффициент. // Пока оставляем Quantity как есть (кол-во упаковок), // так как ContainerID передается в iiko. } else { // Если не нашли - сохраняем в Unmatched для статистики и подсказок if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil { logger.Log.Warn("failed to save unmatched", zap.Error(err)) } } draftItems = append(draftItems, item) } // 4. Сохраняем позиции в БД // Примечание: GORM умеет сохранять вложенные структуры через Update родителя, // но надежнее явно сохранить items, если мы не используем Session FullSaveAssociations. // В данном случае мы уже создали Draft, теперь привяжем к нему items. // Для простоты, так как у нас в Repo нет метода SaveItems, // мы обновим драфт, добавив Items (GORM должен создать их). 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 // Используем хак GORM: при обновлении объекта с ассоциациями, он их создаст. // Но надежнее расширить репозиторий. Давай используем Repository Update, // но он у нас обновляет только шапку. // Поэтому лучше расширим draftRepo методом SaveItems или используем прямую запись тут через items? // Сделаем правильно: добавим AddItems в репозиторий прямо сейчас, или воспользуемся тем, что Items сохранятся // если мы сделаем Save через GORM. В нашем Repo метод Create делает Create. // Давайте сделаем SaveItems в репозитории drafts, чтобы было чисто. // ВРЕМЕННОЕ РЕШЕНИЕ (чтобы не менять интерфейс снова): // Мы можем создать items через repository, но там нет метода. // Давай я добавлю метод в интерфейс репозитория Drafts в следующем блоке изменений. // Пока предположим, что мы расширили репозиторий. if err := s.draftRepo.CreateItems(draftItems); err != nil { return nil, fmt.Errorf("failed to save items: %w", err) } 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"` Count float64 `json:"count"` } type ProductForIndex struct { ID string `json:"id"` Name string `json:"name"` Code string `json:"code"` MeasureUnit string `json:"measure_unit"` Containers []ContainerForIndex `json:"containers"` } // GetCatalogForIndexing - возвращает облегченный каталог func (s *Service) GetCatalogForIndexing() ([]ProductForIndex, error) { products, err := s.catalogRepo.GetActiveGoods() if err != nil { return nil, err } result := make([]ProductForIndex, 0, len(products)) for _, p := range products { uom := "" if p.MainUnit != nil { uom = p.MainUnit.Name } var conts []ContainerForIndex for _, c := range p.Containers { cnt, _ := c.Count.Float64() conts = append(conts, ContainerForIndex{ ID: c.ID.String(), Name: c.Name, Count: cnt, }) } result = append(result, ProductForIndex{ ID: p.ID.String(), Name: p.Name, Code: p.Code, MeasureUnit: uom, Containers: conts, }) } 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) { // Этот метод нужно поддержать в репозитории, пока сделаем заглушку или фильтрацию в памяти // Для MVP добавим метод SearchByName в интерфейс репозитория all, err := s.catalogRepo.GetActiveGoods() if err != nil { return nil, err } // Простейший поиск в памяти (для начала хватит) query = strings.ToLower(query) var result []catalog.Product for _, p := range all { if strings.Contains(strings.ToLower(p.Name), query) { result = append(result, p) if len(result) >= 10 { // Ограничим выдачу break } } } return result, nil }