Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный

This commit is contained in:
2025-12-17 03:38:24 +03:00
parent fda30276a5
commit e2df2350f7
32 changed files with 1785 additions and 214 deletions

View File

@@ -0,0 +1,200 @@
package drafts
import (
"errors"
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
"go.uber.org/zap"
"rmser/internal/domain/catalog"
"rmser/internal/domain/drafts"
"rmser/internal/domain/invoices"
"rmser/internal/domain/ocr"
"rmser/internal/infrastructure/rms"
"rmser/pkg/logger"
)
type Service struct {
draftRepo drafts.Repository
ocrRepo ocr.Repository
catalogRepo catalog.Repository
rmsClient rms.ClientI
}
func NewService(
draftRepo drafts.Repository,
ocrRepo ocr.Repository,
catalogRepo catalog.Repository,
rmsClient rms.ClientI,
) *Service {
return &Service{
draftRepo: draftRepo,
ocrRepo: ocrRepo,
catalogRepo: catalogRepo,
rmsClient: rmsClient,
}
}
// GetDraft возвращает черновик с позициями
func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) {
return s.draftRepo.GetByID(id)
}
// UpdateDraftHeader обновляет шапку (дата, поставщик, склад, комментарий)
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error {
draft, err := s.draftRepo.GetByID(id)
if err != nil {
return err
}
if draft.Status == drafts.StatusCompleted {
return errors.New("черновик уже отправлен")
}
draft.StoreID = storeID
draft.SupplierID = supplierID
draft.DateIncoming = &date
draft.Comment = comment
return s.draftRepo.Update(draft)
}
// UpdateItem обновляет позицию (Без сохранения обучения!)
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
// Мы просто обновляем данные в черновике.
// Сохранение в базу знаний (OCR Matches) произойдет только при отправке накладной.
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
}
// CommitDraft отправляет накладную в RMS
func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
// 1. Загружаем актуальное состояние черновика
draft, err := s.draftRepo.GetByID(id)
if err != nil {
return "", err
}
if draft.Status == drafts.StatusCompleted {
return "", errors.New("накладная уже отправлена")
}
// Валидация
if draft.StoreID == nil || *draft.StoreID == uuid.Nil {
return "", errors.New("не выбран склад")
}
if draft.SupplierID == nil || *draft.SupplierID == uuid.Nil {
return "", errors.New("не выбран поставщик")
}
if draft.DateIncoming == nil {
return "", errors.New("не выбрана дата")
}
// Сборка Invoice для отправки
inv := invoices.Invoice{
ID: uuid.Nil, // iiko создаст новый
DocumentNumber: draft.DocumentNumber, // Может быть пустой, iiko присвоит
DateIncoming: *draft.DateIncoming,
SupplierID: *draft.SupplierID,
DefaultStoreID: *draft.StoreID,
Status: "NEW",
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
}
for _, dItem := range draft.Items {
if dItem.ProductID == nil {
// Пропускаем нераспознанные или кидаем ошибку?
// Лучше пропустить, чтобы не блокировать отправку частичного документа
continue
}
// Расчет суммы (если не задана, считаем)
sum := dItem.Sum
if sum.IsZero() {
sum = dItem.Quantity.Mul(dItem.Price)
}
// Важный момент с фасовками:
// Клиент RMS (CreateIncomingInvoice) у нас пока не поддерживает отправку container_id в явном виде,
// или мы его обновили? Проверим `internal/infrastructure/rms/client.go`.
// Там используется `IncomingInvoiceImportItemXML`. В ней нет поля ContainerID, но есть `AmountUnit`.
// Если мы хотим передать фасовку, нужно передавать Amount в базовых единицах,
// ЛИБО доработать клиент iiko, чтобы он принимал `amountUnit` (ID фасовки).
// СТРАТЕГИЯ СЕЙЧАС:
// Считаем, что FrontEnd/Service уже пересчитал кол-во в базовые единицы?
// НЕТ. DraftItem хранит Quantity в тех единицах, которые выбрал юзер (фасовках).
// Нам нужно конвертировать в базовые для отправки, если мы не умеем слать фасовки.
// Но погоди, в `ProductContainer` есть `Count` (коэффициент).
finalAmount := dItem.Quantity
if dItem.ContainerID != nil && dItem.Container != nil {
// Если выбрана фасовка, умножаем кол-во упаковок на коэффициент
finalAmount = finalAmount.Mul(dItem.Container.Count)
}
inv.Items = append(inv.Items, invoices.InvoiceItem{
ProductID: *dItem.ProductID,
Amount: finalAmount,
Price: dItem.Price, // Цена обычно за упаковку... А iiko ждет цену за базу?
// RMS API: Если мы шлем в базовых единицах, то и цену надо пересчитать за базовую.
// Price (base) = Price (pack) / Count
// ЛИБО: Мы шлем Sum, а iiko сама посчитает цену. Это надежнее.
Sum: sum,
})
}
if len(inv.Items) == 0 {
return "", errors.New("нет распознанных позиций для отправки")
}
// Отправка
docNum, err := s.rmsClient.CreateIncomingInvoice(inv)
if err != nil {
return "", err
}
// Обновление статуса
draft.Status = drafts.StatusCompleted
// Можно сохранить docNum, если бы было поле в Draft, но у нас есть rms_invoice_id (uuid),
// а возвращается строка номера. Ок, просто меняем статус.
if err := s.draftRepo.Update(draft); err != nil {
logger.Log.Error("Failed to update draft status after commit", zap.Error(err))
}
// 4. ОБУЧЕНИЕ (Deferred Learning)
// Запускаем в горутине, чтобы не задерживать ответ пользователю
go s.learnFromDraft(draft)
return docNum, nil
}
// learnFromDraft сохраняет новые связи на основе подтвержденного черновика
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) {
for _, item := range draft.Items {
// Учимся только если:
// 1. Есть RawName (текст из чека)
// 2. Пользователь (или OCR) выбрал ProductID
if item.RawName != "" && item.ProductID != nil {
// Если нужно запоминать коэффициент (например, всегда 1 или то, что ввел юзер),
// то берем item.Quantity. Но обычно для матчинга мы запоминаем факт связи,
// а дефолтное кол-во ставим 1.
qty := decimal.NewFromFloat(1.0)
err := s.ocrRepo.SaveMatch(item.RawName, *item.ProductID, qty, item.ContainerID)
if err != nil {
logger.Log.Warn("Failed to learn match",
zap.String("raw", item.RawName),
zap.Error(err))
} else {
logger.Log.Info("Learned match", zap.String("raw", item.RawName))
}
}
}
}
// GetActiveStores возвращает список складов
func (s *Service) GetActiveStores() ([]catalog.Store, error) {
return s.catalogRepo.GetActiveStores()
}

View File

@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"rmser/internal/domain/catalog"
"rmser/internal/domain/drafts"
"rmser/internal/domain/ocr"
"rmser/internal/infrastructure/ocr_client"
"rmser/pkg/logger"
@@ -18,57 +19,116 @@ import (
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, imgData []byte) ([]ProcessedItem, error) {
// 1. Отправляем в Python
// 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)
}
var processed []ProcessedItem
// 3. Обрабатываем результаты и создаем Items
var draftItems []drafts.DraftInvoiceItem
for _, rawItem := range rawResult.Items {
item := ProcessedItem{
RawName: rawItem.RawName,
Amount: decimal.NewFromFloat(rawItem.Amount),
Price: decimal.NewFromFloat(rawItem.Price),
Sum: decimal.NewFromFloat(rawItem.Sum),
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.ProductID = &match.ProductID
item.IsMatched = true
item.MatchSource = "learned"
// Здесь мы могли бы подтянуть quantity/container из матча,
// но пока фронт сам это сделает, запросив /ocr/matches или получив подсказку.
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))
}
}
processed = append(processed, item)
draftItems = append(draftItems, item)
}
return processed, nil
// 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 - результат обработки одной строки чека
@@ -137,6 +197,11 @@ func (s *Service) SaveMapping(rawName string, productID uuid.UUID, quantity deci
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()

View File

@@ -49,14 +49,20 @@ func NewService(
// SyncCatalog загружает номенклатуру и сохраняет в БД
func (s *Service) SyncCatalog() error {
logger.Log.Info("Начало синхронизации каталога...")
logger.Log.Info("Начало синхронизации справочников...")
// 1. Сначала Единицы измерения (чтобы FK не ругался)
// 1. Склады (INVENTORY_ASSETS) - важно для создания накладных
if err := s.SyncStores(); err != nil {
logger.Log.Error("Ошибка синхронизации складов", zap.Error(err))
// Не прерываем, идем дальше
}
// 2. Единицы измерения
if err := s.syncMeasureUnits(); err != nil {
return err
}
// 2. Товары
// 3. Товары
logger.Log.Info("Запрос товаров из RMS...")
products, err := s.rmsClient.FetchCatalog()
if err != nil {
@@ -187,6 +193,22 @@ func classifyOperation(docType string) operations.OperationType {
}
}
// SyncStores загружает список складов
func (s *Service) SyncStores() error {
logger.Log.Info("Синхронизация складов...")
stores, err := s.rmsClient.FetchStores()
if err != nil {
return fmt.Errorf("ошибка получения складов из RMS: %w", err)
}
if err := s.catalogRepo.SaveStores(stores); err != nil {
return fmt.Errorf("ошибка сохранения складов в БД: %w", err)
}
logger.Log.Info("Склады обновлены", zap.Int("count", len(stores)))
return nil
}
func (s *Service) SyncStoreOperations() error {
dateTo := time.Now()
dateFrom := dateTo.AddDate(0, 0, -30)