package ocr import ( "context" "errors" "fmt" "os" "path/filepath" "time" "github.com/google/uuid" "github.com/shopspring/decimal" "rmser/internal/domain/account" "rmser/internal/domain/catalog" "rmser/internal/domain/drafts" "rmser/internal/domain/ocr" "rmser/internal/domain/photos" "rmser/internal/infrastructure/ocr_client" ) type Service struct { ocrRepo ocr.Repository catalogRepo catalog.Repository draftRepo drafts.Repository accountRepo account.Repository photoRepo photos.Repository pyClient *ocr_client.Client storagePath string } func NewService( ocrRepo ocr.Repository, catalogRepo catalog.Repository, draftRepo drafts.Repository, accountRepo account.Repository, photoRepo photos.Repository, pyClient *ocr_client.Client, storagePath string, ) *Service { return &Service{ ocrRepo: ocrRepo, catalogRepo: catalogRepo, draftRepo: draftRepo, accountRepo: accountRepo, photoRepo: photoRepo, pyClient: pyClient, storagePath: storagePath, } } // checkWriteAccess - вспомогательный метод проверки прав func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error { role, err := s.accountRepo.GetUserRole(userID, serverID) if err != nil { return err } if role == account.RoleOperator { return errors.New("access denied: operators cannot modify data") } return nil } // ProcessReceiptImage - Доступно всем (включая Операторов) func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, fmt.Errorf("no active server for user") } serverID := server.ID // 1. Создаем ID для фото и черновика photoID := uuid.New() draftID := uuid.New() fileName := fmt.Sprintf("receipt_%s.jpg", photoID.String()) filePath := filepath.Join(s.storagePath, fileName) // 2. Сохраняем файл if err := os.WriteFile(filePath, imgData, 0644); err != nil { return nil, fmt.Errorf("failed to save image: %w", err) } fileURL := "/uploads/" + fileName // 3. Создаем запись ReceiptPhoto photo := &photos.ReceiptPhoto{ ID: photoID, RMSServerID: serverID, UploadedBy: userID, FilePath: filePath, FileURL: fileURL, FileName: fileName, FileSize: int64(len(imgData)), DraftID: &draftID, // Сразу связываем с будущим черновиком CreatedAt: time.Now(), } if err := s.photoRepo.Create(photo); err != nil { return nil, fmt.Errorf("failed to create photo record: %w", err) } // 4. Создаем черновик draft := &drafts.DraftInvoice{ ID: draftID, UserID: userID, RMSServerID: serverID, Status: drafts.StatusProcessing, StoreID: server.DefaultStoreID, SenderPhotoURL: fileURL, // Оставляем для совместимости, но теперь есть ReceiptPhoto } if err := s.draftRepo.Create(draft); err != nil { return nil, fmt.Errorf("failed to create draft: %w", err) } // 5. Отправляем в 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) } // 6. Матчинг и сохранение позиций 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: decimal.NewFromFloat(rawItem.Amount), Price: decimal.NewFromFloat(rawItem.Price), Sum: decimal.NewFromFloat(rawItem.Sum), } match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) if match != nil { item.IsMatched = true item.ProductID = &match.ProductID item.ContainerID = match.ContainerID } else { s.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName) } draftItems = append(draftItems, item) } draft.Status = drafts.StatusReadyToVerify s.draftRepo.Update(draft) s.draftRepo.CreateItems(draftItems) draft.Items = draftItems return draft, nil } // Добавить структуры в конец файла 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(userID uuid.UUID) ([]ProductForIndex, error) { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, fmt.Errorf("no server") } if err := s.checkWriteAccess(userID, server.ID); err != nil { return nil, err } products, err := s.catalogRepo.GetActiveGoods(server.ID, server.RootGroupGUID) 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 } func (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Product, error) { if len(query) < 2 { return []catalog.Product{}, nil } server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, fmt.Errorf("no server") } // Поиск нужен для матчинга, значит тоже защищаем if err := s.checkWriteAccess(userID, server.ID); err != nil { return nil, err } return s.catalogRepo.Search(server.ID, query, server.RootGroupGUID) } func (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return fmt.Errorf("no server") } if err := s.checkWriteAccess(userID, server.ID); err != nil { return err } return s.ocrRepo.SaveMatch(server.ID, rawName, productID, quantity, containerID) } func (s *Service) DeleteMatch(userID uuid.UUID, rawName string) error { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return fmt.Errorf("no server") } if err := s.checkWriteAccess(userID, server.ID); err != nil { return err } return s.ocrRepo.DeleteMatch(server.ID, rawName) } func (s *Service) GetKnownMatches(userID uuid.UUID) ([]ocr.ProductMatch, error) { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, fmt.Errorf("no server") } if err := s.checkWriteAccess(userID, server.ID); err != nil { return nil, err } return s.ocrRepo.GetAllMatches(server.ID) } func (s *Service) GetUnmatchedItems(userID uuid.UUID) ([]ocr.UnmatchedItem, error) { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, fmt.Errorf("no server") } if err := s.checkWriteAccess(userID, server.ID); err != nil { return nil, err } return s.ocrRepo.GetTopUnmatched(server.ID, 50) } func (s *Service) DiscardUnmatched(userID uuid.UUID, rawName string) error { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return fmt.Errorf("no server") } if err := s.checkWriteAccess(userID, server.ID); err != nil { return err } return s.ocrRepo.DeleteUnmatched(server.ID, rawName) }