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/ocr" "rmser/internal/infrastructure/ocr_client" "rmser/pkg/logger" ) type Service struct { ocrRepo ocr.Repository catalogRepo catalog.Repository pyClient *ocr_client.Client // Клиент к Python сервису } func NewService( ocrRepo ocr.Repository, catalogRepo catalog.Repository, pyClient *ocr_client.Client, ) *Service { return &Service{ ocrRepo: ocrRepo, catalogRepo: catalogRepo, pyClient: pyClient, } } // ProcessReceiptImage - основной метод: Картинка -> Распознавание -> Матчинг func (s *Service) ProcessReceiptImage(ctx context.Context, imgData []byte) ([]ProcessedItem, error) { // 1. Отправляем в Python rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg") if err != nil { return nil, fmt.Errorf("python ocr error: %w", err) } var processed []ProcessedItem // 2. Обрабатываем каждую строку for _, rawItem := range rawResult.Items { item := ProcessedItem{ RawName: rawItem.RawName, Amount: decimal.NewFromFloat(rawItem.Amount), Price: decimal.NewFromFloat(rawItem.Price), Sum: decimal.NewFromFloat(rawItem.Sum), } // 3. Ищем соответствие // Сначала проверяем таблицу ручного обучения (product_matches) matchID, err := s.ocrRepo.FindMatch(rawItem.RawName) if err != nil { logger.Log.Error("db error finding match", zap.Error(err)) } if matchID != nil { // Нашли в обучении item.ProductID = matchID item.IsMatched = true item.MatchSource = "learned" } else { // Если не нашли, пробуем найти точное совпадение по имени в каталоге (на всякий случай) // (В реальном проекте тут может быть нечеткий поиск, но пока точный) // TODO: Добавить метод FindByName в репозиторий каталога, если нужно } processed = append(processed, item) } return processed, 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" } // ProductForIndex DTO для внешнего сервиса type ProductForIndex struct { ID string `json:"id"` Name string `json:"name"` Code string `json:"code"` } // 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 { result = append(result, ProductForIndex{ ID: p.ID.String(), Name: p.Name, Code: p.Code, }) } return result, nil } // SaveMapping сохраняет связь "Текст из чека" -> "Наш товар" func (s *Service) SaveMapping(rawName string, productID uuid.UUID) error { return s.ocrRepo.SaveMatch(rawName, productID) } // FindKnownMatch ищет, знаем ли мы уже этот товар func (s *Service) FindKnownMatch(rawName string) (*uuid.UUID, 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 }