Files
rmser/internal/services/ocr/service.go

282 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}