package ocr import ( "context" "fmt" "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 } 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. Сохраняем позиции в БД 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) } 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) { if len(query) < 2 { // Слишком короткий запрос, возвращаем пустой список return []catalog.Product{}, nil } return s.catalogRepo.Search(query) }