mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
333 lines
12 KiB
Go
333 lines
12 KiB
Go
package drafts
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"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)
|
||
}
|
||
|
||
// DeleteDraft реализует логику "Отмена -> Удаление"
|
||
func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
|
||
draft, err := s.draftRepo.GetByID(id)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// Сценарий 2: Если уже ОТМЕНЕН -> УДАЛЯЕМ (Soft Delete статусом)
|
||
if draft.Status == drafts.StatusCanceled {
|
||
draft.Status = drafts.StatusDeleted
|
||
if err := s.draftRepo.Update(draft); err != nil {
|
||
return "", err
|
||
}
|
||
logger.Log.Info("Черновик удален (скрыт)", zap.String("id", id.String()))
|
||
return drafts.StatusDeleted, nil
|
||
}
|
||
|
||
// Сценарий 1: Если активен -> ОТМЕНЯЕМ
|
||
// Разрешаем отменять только незавершенные
|
||
if draft.Status != drafts.StatusCompleted && draft.Status != drafts.StatusDeleted {
|
||
draft.Status = drafts.StatusCanceled
|
||
if err := s.draftRepo.Update(draft); err != nil {
|
||
return "", err
|
||
}
|
||
logger.Log.Info("Черновик перемещен в отмененные", zap.String("id", id.String()))
|
||
return drafts.StatusCanceled, nil
|
||
}
|
||
|
||
return draft.Status, nil
|
||
}
|
||
|
||
// 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 {
|
||
// 1. Проверяем статус черновика для реализации Auto-Restore
|
||
draft, err := s.draftRepo.GetByID(draftID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Если черновик был в корзине (CANCELED), возвращаем его в работу
|
||
if draft.Status == drafts.StatusCanceled {
|
||
draft.Status = drafts.StatusReadyToVerify
|
||
if err := s.draftRepo.Update(draft); err != nil {
|
||
logger.Log.Error("Не удалось восстановить статус черновика при редактировании", zap.Error(err))
|
||
// Не прерываем выполнение, пробуем обновить строку
|
||
} else {
|
||
logger.Log.Info("Черновик автоматически восстановлен из отмененных", zap.String("id", draftID.String()))
|
||
}
|
||
}
|
||
|
||
// 2. Обновляем саму строку (существующий вызов репозитория)
|
||
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 {
|
||
// Пропускаем нераспознанные или кидаем ошибку?
|
||
break
|
||
}
|
||
|
||
// Расчет суммы (если не задана, считаем)
|
||
sum := dItem.Sum
|
||
if sum.IsZero() {
|
||
sum = dItem.Quantity.Mul(dItem.Price)
|
||
}
|
||
|
||
invItem := invoices.InvoiceItem{
|
||
ProductID: *dItem.ProductID,
|
||
Amount: dItem.Quantity,
|
||
Price: dItem.Price,
|
||
Sum: sum,
|
||
ContainerID: dItem.ContainerID,
|
||
}
|
||
|
||
inv.Items = append(inv.Items, invItem)
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
// GetActiveDrafts возвращает список черновиков в работе
|
||
func (s *Service) GetActiveDrafts() ([]drafts.DraftInvoice, error) {
|
||
return s.draftRepo.GetActive()
|
||
}
|
||
|
||
// CreateProductContainer создает новую фасовку в iiko и сохраняет её в локальной БД
|
||
// Возвращает UUID созданной фасовки.
|
||
func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
|
||
// 1. Получаем полную карточку товара из iiko
|
||
// Используем инфраструктурный DTO, так как нам нужна полная структура для апдейта
|
||
fullProduct, err := s.rmsClient.GetProductByID(productID)
|
||
if err != nil {
|
||
return uuid.Nil, fmt.Errorf("ошибка получения товара из iiko: %w", err)
|
||
}
|
||
|
||
// 2. Валидация на дубликаты (по имени или коэффициенту)
|
||
// iiko разрешает дубли, но нам это не нужно.
|
||
targetCount, _ := count.Float64()
|
||
for _, c := range fullProduct.Containers {
|
||
if !c.Deleted && (c.Name == name || (c.Count == targetCount)) {
|
||
// Если такая фасовка уже есть, возвращаем её ID
|
||
// (Можно добавить логику обновления имени, но пока просто вернем ID)
|
||
if c.ID != nil && *c.ID != "" {
|
||
return uuid.Parse(*c.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Вычисляем следующий num (iiko использует строки "1", "2"...)
|
||
maxNum := 0
|
||
for _, c := range fullProduct.Containers {
|
||
if n, err := strconv.Atoi(c.Num); err == nil {
|
||
if n > maxNum {
|
||
maxNum = n
|
||
}
|
||
}
|
||
}
|
||
nextNum := strconv.Itoa(maxNum + 1)
|
||
|
||
// 4. Добавляем новую фасовку в список
|
||
newContainerDTO := rms.ContainerFullDTO{
|
||
ID: nil, // Null, чтобы iiko создала новый ID
|
||
Num: nextNum,
|
||
Name: name,
|
||
Count: targetCount,
|
||
UseInFront: true,
|
||
Deleted: false,
|
||
// Остальные поля можно оставить 0/false по умолчанию
|
||
}
|
||
|
||
fullProduct.Containers = append(fullProduct.Containers, newContainerDTO)
|
||
|
||
// 5. Отправляем обновление в iiko
|
||
updatedProduct, err := s.rmsClient.UpdateProduct(*fullProduct)
|
||
if err != nil {
|
||
return uuid.Nil, fmt.Errorf("ошибка обновления товара в iiko: %w", err)
|
||
}
|
||
|
||
// 6. Ищем нашу созданную фасовку в ответе, чтобы получить её ID
|
||
// Ищем по уникальной комбинации Name + Count, которую мы только что отправили
|
||
var createdID uuid.UUID
|
||
found := false
|
||
|
||
for _, c := range updatedProduct.Containers {
|
||
// Сравниваем float с небольшим эпсилоном на всякий случай, хотя JSON должен вернуть точно
|
||
if c.Name == name && c.Count == targetCount && !c.Deleted {
|
||
if c.ID != nil {
|
||
createdID, err = uuid.Parse(*c.ID)
|
||
if err == nil {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
return uuid.Nil, errors.New("фасовка отправлена, но сервер не вернул её ID (возможно, ошибка логики поиска)")
|
||
}
|
||
|
||
// 7. Сохраняем новую фасовку в локальную БД
|
||
newLocalContainer := catalog.ProductContainer{
|
||
ID: createdID,
|
||
ProductID: productID,
|
||
Name: name,
|
||
Count: count,
|
||
}
|
||
|
||
if err := s.catalogRepo.SaveContainer(newLocalContainer); err != nil {
|
||
logger.Log.Error("Ошибка сохранения новой фасовки в локальную БД", zap.Error(err))
|
||
// Не возвращаем ошибку клиенту, так как в iiko она уже создана.
|
||
// Просто в следующем SyncCatalog она подтянется, но лучше иметь её сразу.
|
||
}
|
||
|
||
logger.Log.Info("Создана новая фасовка",
|
||
zap.String("product_id", productID.String()),
|
||
zap.String("container_id", createdID.String()),
|
||
zap.String("name", name),
|
||
zap.String("count", count.String()))
|
||
|
||
return createdID, nil
|
||
}
|