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

309 lines
9.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"
)
// DevNotifier - интерфейс для уведомления разработчиков
type DevNotifier interface {
NotifyDevs(devIDs []int64, photoPath string, serverName string, serverID string)
}
type Service struct {
ocrRepo ocr.Repository
catalogRepo catalog.Repository
draftRepo drafts.Repository
accountRepo account.Repository
photoRepo photos.Repository
pyClient *ocr_client.Client
storagePath string
notifier DevNotifier
devIDs []int64
}
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,
}
}
// SetNotifier - устанавливает notifier для уведомлений разработчиков
func (s *Service) SetNotifier(n DevNotifier) {
s.notifier = n
}
// SetDevIDs - устанавливает список ID разработчиков для уведомлений
func (s *Service) SetDevIDs(ids []int64) {
s.devIDs = ids
}
// 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, serverID.String(), fileName)
// 2. Создаем директорию если не существует
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return nil, fmt.Errorf("failed to create directory: %w", err)
}
// 3. Сохраняем файл
if err := os.WriteFile(filePath, imgData, 0644); err != nil {
return nil, fmt.Errorf("failed to save image: %w", err)
}
fileURL := "/uploads/" + fileName
// 4. Создаем запись 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)
}
// 5. Создаем черновик
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)
}
// Уведомляем разработчиков если devIDs заданы
if len(s.devIDs) > 0 && s.notifier != nil {
s.notifier.NotifyDevs(s.devIDs, filePath, server.Name, serverID.String())
}
// 6. Отправляем в 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)
}