mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
243 lines
9.0 KiB
Go
243 lines
9.0 KiB
Go
package ocr
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"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
|
||
|
||
// Важная логика: Если в матче указано ContainerID, то Quantity из чека (например 5 шт)
|
||
// это 5 коробок. Финальное кол-во (в кг) RMS посчитает сама,
|
||
// либо мы можем пересчитать тут, если знаем коэффициент.
|
||
// Пока оставляем Quantity как есть (кол-во упаковок),
|
||
// так как ContainerID передается в iiko.
|
||
} 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. Сохраняем позиции в БД
|
||
// Примечание: GORM умеет сохранять вложенные структуры через Update родителя,
|
||
// но надежнее явно сохранить items, если мы не используем Session FullSaveAssociations.
|
||
// В данном случае мы уже создали Draft, теперь привяжем к нему items.
|
||
// Для простоты, так как у нас в Repo нет метода SaveItems,
|
||
// мы обновим драфт, добавив Items (GORM должен создать их).
|
||
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
|
||
|
||
// Используем хак GORM: при обновлении объекта с ассоциациями, он их создаст.
|
||
// Но надежнее расширить репозиторий. Давай используем Repository Update,
|
||
// но он у нас обновляет только шапку.
|
||
// Поэтому лучше расширим draftRepo методом SaveItems или используем прямую запись тут через items?
|
||
// Сделаем правильно: добавим AddItems в репозиторий прямо сейчас, или воспользуемся тем, что Items сохранятся
|
||
// если мы сделаем Save через GORM. В нашем Repo метод Create делает Create.
|
||
// Давайте сделаем SaveItems в репозитории drafts, чтобы было чисто.
|
||
|
||
// ВРЕМЕННОЕ РЕШЕНИЕ (чтобы не менять интерфейс снова):
|
||
// Мы можем создать items через repository, но там нет метода.
|
||
// Давай я добавлю метод в интерфейс репозитория Drafts в следующем блоке изменений.
|
||
// Пока предположим, что мы расширили репозиторий.
|
||
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) {
|
||
// Этот метод нужно поддержать в репозитории, пока сделаем заглушку или фильтрацию в памяти
|
||
// Для MVP добавим метод SearchByName в интерфейс репозитория
|
||
all, err := s.catalogRepo.GetActiveGoods()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Простейший поиск в памяти (для начала хватит)
|
||
query = strings.ToLower(query)
|
||
var result []catalog.Product
|
||
for _, p := range all {
|
||
if strings.Contains(strings.ToLower(p.Name), query) {
|
||
result = append(result, p)
|
||
if len(result) >= 10 { // Ограничим выдачу
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|