mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Добавил черновики накладных и OCR через Яндекс. LLM для расшифровки универсальный
This commit is contained in:
200
internal/services/drafts/service.go
Normal file
200
internal/services/drafts/service.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user