Полноценно редактируются черновики

Добавляются фасовки как в черновике, так и в обучении
Исправил внешний вид
This commit is contained in:
2025-12-17 22:00:21 +03:00
parent e2df2350f7
commit c8aab42e8e
24 changed files with 1313 additions and 433 deletions

View File

@@ -2,6 +2,8 @@ package drafts
import (
"errors"
"fmt"
"strconv"
"time"
"github.com/google/uuid"
@@ -42,6 +44,37 @@ 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)
@@ -60,10 +93,26 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
return s.draftRepo.Update(draft)
}
// UpdateItem обновляет позицию (Без сохранения обучения!)
// UpdateItem обновляет позицию с авто-восстановлением статуса черновика
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
// Мы просто обновляем данные в черновике.
// Сохранение в базу знаний (OCR Matches) произойдет только при отправке накладной.
// 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)
}
@@ -104,8 +153,7 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
for _, dItem := range draft.Items {
if dItem.ProductID == nil {
// Пропускаем нераспознанные или кидаем ошибку?
// Лучше пропустить, чтобы не блокировать отправку частичного документа
continue
break
}
// Расчет суммы (если не задана, считаем)
@@ -114,34 +162,15 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
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)
invItem := invoices.InvoiceItem{
ProductID: *dItem.ProductID,
Amount: dItem.Quantity,
Price: dItem.Price,
Sum: sum,
ContainerID: dItem.ContainerID,
}
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,
})
inv.Items = append(inv.Items, invItem)
}
if len(inv.Items) == 0 {
@@ -198,3 +227,106 @@ func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) {
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
}