mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
282 lines
8.1 KiB
Go
282 lines
8.1 KiB
Go
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)
|
||
}
|