mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
201 lines
7.8 KiB
Go
201 lines
7.8 KiB
Go
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()
|
||
}
|