Files
rmser/internal/services/ocr/service.go
SERTY c8aab42e8e Полноценно редактируются черновики
Добавляются фасовки как в черновике, так и в обучении
Исправил внешний вид
2025-12-17 22:00:21 +03:00

206 lines
6.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"
"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)
}