Добавил черновики накладных и 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()
}