mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Полноценно редактируются черновики
Добавляются фасовки как в черновике, так и в обучении Исправил внешний вид
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package ocr
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
@@ -84,11 +83,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
|
||||
item.ProductID = &match.ProductID
|
||||
item.ContainerID = match.ContainerID
|
||||
|
||||
// Важная логика: Если в матче указано ContainerID, то Quantity из чека (например 5 шт)
|
||||
// это 5 коробок. Финальное кол-во (в кг) RMS посчитает сама,
|
||||
// либо мы можем пересчитать тут, если знаем коэффициент.
|
||||
// Пока оставляем Quantity как есть (кол-во упаковок),
|
||||
// так как ContainerID передается в iiko.
|
||||
} else {
|
||||
// Если не нашли - сохраняем в Unmatched для статистики и подсказок
|
||||
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
|
||||
@@ -100,11 +94,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
|
||||
}
|
||||
|
||||
// 4. Сохраняем позиции в БД
|
||||
// Примечание: GORM умеет сохранять вложенные структуры через Update родителя,
|
||||
// но надежнее явно сохранить items, если мы не используем Session FullSaveAssociations.
|
||||
// В данном случае мы уже создали Draft, теперь привяжем к нему items.
|
||||
// Для простоты, так как у нас в Repo нет метода SaveItems,
|
||||
// мы обновим драфт, добавив Items (GORM должен создать их).
|
||||
draft.Status = drafts.StatusReadyToVerify
|
||||
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
@@ -112,18 +101,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
|
||||
}
|
||||
draft.Items = draftItems
|
||||
|
||||
// Используем хак GORM: при обновлении объекта с ассоциациями, он их создаст.
|
||||
// Но надежнее расширить репозиторий. Давай используем Repository Update,
|
||||
// но он у нас обновляет только шапку.
|
||||
// Поэтому лучше расширим draftRepo методом SaveItems или используем прямую запись тут через items?
|
||||
// Сделаем правильно: добавим AddItems в репозиторий прямо сейчас, или воспользуемся тем, что Items сохранятся
|
||||
// если мы сделаем Save через GORM. В нашем Repo метод Create делает Create.
|
||||
// Давайте сделаем SaveItems в репозитории drafts, чтобы было чисто.
|
||||
|
||||
// ВРЕМЕННОЕ РЕШЕНИЕ (чтобы не менять интерфейс снова):
|
||||
// Мы можем создать items через repository, но там нет метода.
|
||||
// Давай я добавлю метод в интерфейс репозитория Drafts в следующем блоке изменений.
|
||||
// Пока предположим, что мы расширили репозиторий.
|
||||
if err := s.draftRepo.CreateItems(draftItems); err != nil {
|
||||
return nil, fmt.Errorf("failed to save items: %w", err)
|
||||
}
|
||||
@@ -218,25 +195,11 @@ func (s *Service) FindKnownMatch(rawName string) (*ocr.ProductMatch, error) {
|
||||
return s.ocrRepo.FindMatch(rawName)
|
||||
}
|
||||
|
||||
// SearchProducts ищет товары в БД по части названия (для ручного выбора в боте)
|
||||
// SearchProducts ищет товары в БД по части названия, коду или артикулу
|
||||
func (s *Service) SearchProducts(query string) ([]catalog.Product, error) {
|
||||
// Этот метод нужно поддержать в репозитории, пока сделаем заглушку или фильтрацию в памяти
|
||||
// Для MVP добавим метод SearchByName в интерфейс репозитория
|
||||
all, err := s.catalogRepo.GetActiveGoods()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if len(query) < 2 {
|
||||
// Слишком короткий запрос, возвращаем пустой список
|
||||
return []catalog.Product{}, nil
|
||||
}
|
||||
|
||||
// Простейший поиск в памяти (для начала хватит)
|
||||
query = strings.ToLower(query)
|
||||
var result []catalog.Product
|
||||
for _, p := range all {
|
||||
if strings.Contains(strings.ToLower(p.Name), query) {
|
||||
result = append(result, p)
|
||||
if len(result) >= 10 { // Ограничим выдачу
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return s.catalogRepo.Search(query)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user