Files
rmser/internal/services/drafts/service.go
SERTY c8aab42e8e Полноценно редактируются черновики
Добавляются фасовки как в черновике, так и в обучении
Исправил внешний вид
2025-12-17 22:00:21 +03:00

333 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}