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

Добавляются фасовки как в черновике, так и в обучении
Исправил внешний вид
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

@@ -53,6 +53,8 @@ type Product struct {
type Repository interface {
SaveMeasureUnits(units []MeasureUnit) error
SaveProducts(products []Product) error
SaveContainer(container ProductContainer) error // Добавление фасовки
Search(query string) ([]Product, error)
GetAll() ([]Product, error)
GetActiveGoods() ([]Product, error)
// --- Stores ---

View File

@@ -15,23 +15,27 @@ const (
StatusReadyToVerify = "READY_TO_VERIFY" // Распознано, ждет проверки пользователем
StatusCompleted = "COMPLETED" // Отправлено в RMS
StatusError = "ERROR" // Ошибка обработки
StatusCanceled = "CANCELED" // Пользователь отменил
StatusDeleted = "DELETED" // Пользователь удалил
)
// DraftInvoice - Черновик накладной
type DraftInvoice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ChatID int64 `gorm:"index" json:"chat_id"` // ID чата в Telegram (кто прислал)
SenderPhotoURL string `gorm:"type:text" json:"photo_url"` // Ссылка на фото (если нужно отобразить на фронте)
SenderPhotoURL string `gorm:"type:text" json:"photo_url"` // Ссылка на фото
Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"`
// Данные для отправки в RMS (заполняются пользователем)
// Данные для отправки в RMS
DocumentNumber string `gorm:"type:varchar(100)" json:"document_number"`
DateIncoming *time.Time `json:"date_incoming"`
SupplierID *uuid.UUID `gorm:"type:uuid" json:"supplier_id"`
StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"`
Comment string `gorm:"type:text" json:"comment"`
// Связь с созданной накладной (когда статус COMPLETED)
StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"`
// Связь со складом для Preload
Store *catalog.Store `gorm:"foreignKey:StoreID" json:"store,omitempty"`
Comment string `gorm:"type:text" json:"comment"`
RMSInvoiceID *uuid.UUID `gorm:"type:uuid" json:"rms_invoice_id"`
Items []DraftInvoiceItem `gorm:"foreignKey:DraftID;constraint:OnDelete:CASCADE" json:"items"`
@@ -73,4 +77,5 @@ type Repository interface {
// UpdateItem обновляет конкретную строку (например, при ручном выборе товара)
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
Delete(id uuid.UUID) error
GetActive() ([]DraftInvoice, error)
}

View File

@@ -26,13 +26,14 @@ type Invoice struct {
// InvoiceItem - Позиция накладной
type InvoiceItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
InvoiceID uuid.UUID `gorm:"type:uuid;not null;index"`
ProductID uuid.UUID `gorm:"type:uuid;not null"`
Amount decimal.Decimal `gorm:"type:numeric(19,4);not null"`
Price decimal.Decimal `gorm:"type:numeric(19,4);not null"`
Sum decimal.Decimal `gorm:"type:numeric(19,4);not null"`
VatSum decimal.Decimal `gorm:"type:numeric(19,4)"`
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
InvoiceID uuid.UUID `gorm:"type:uuid;not null;index"`
ProductID uuid.UUID `gorm:"type:uuid;not null"`
ContainerID *uuid.UUID `gorm:"type:uuid"`
Amount decimal.Decimal `gorm:"type:numeric(19,4);not null"`
Price decimal.Decimal `gorm:"type:numeric(19,4);not null"`
Sum decimal.Decimal `gorm:"type:numeric(19,4);not null"`
VatSum decimal.Decimal `gorm:"type:numeric(19,4)"`
Product catalog.Product `gorm:"foreignKey:ProductID"`
}

View File

@@ -132,3 +132,30 @@ func (r *pgRepository) GetActiveStores() ([]catalog.Store, error) {
err := r.db.Where("is_deleted = ?", false).Order("name ASC").Find(&stores).Error
return stores, err
}
// SaveContainer сохраняет или обновляет одну фасовку
func (r *pgRepository) SaveContainer(container catalog.ProductContainer) error {
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).Create(&container).Error
}
// Search ищет товары по названию, артикулу или коду (ILIKE)
func (r *pgRepository) Search(query string) ([]catalog.Product, error) {
var products []catalog.Product
// Оборачиваем в проценты для поиска подстроки
q := "%" + query + "%"
err := r.db.
Preload("MainUnit").
Preload("Containers"). // Обязательно грузим фасовки, они нужны для выбора
Where("is_deleted = ? AND type = ?", false, "GOODS").
Where("name ILIKE ? OR code ILIKE ? OR num ILIKE ?", q, q, q).
Order("name ASC").
Limit(20). // Ограничиваем выдачу, чтобы не перегружать фронт
Find(&products).Error
return products, err
}

View File

@@ -83,3 +83,24 @@ func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, contai
func (r *pgRepository) Delete(id uuid.UUID) error {
return r.db.Delete(&drafts.DraftInvoice{}, id).Error
}
func (r *pgRepository) GetActive() ([]drafts.DraftInvoice, error) {
var list []drafts.DraftInvoice
// Выбираем статусы, которые считаем "активными"
activeStatuses := []string{
drafts.StatusProcessing,
drafts.StatusReadyToVerify,
drafts.StatusError,
drafts.StatusCanceled,
}
err := r.db.
Preload("Items"). // Нужны для подсчета суммы и количества
Preload("Store"). // Нужно для названия склада
Where("status IN ?", activeStatuses).
Order("created_at DESC").
Find(&list).Error
return list, err
}

View File

@@ -38,6 +38,8 @@ type ClientI interface {
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
CreateIncomingInvoice(inv invoices.Invoice) (string, error)
GetProductByID(id uuid.UUID) (*ProductFullDTO, error)
UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)
}
type Client struct {
@@ -571,14 +573,20 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
price, _ := item.Price.Float64()
sum, _ := item.Sum.Float64()
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, IncomingInvoiceImportItemXML{
xmlItem := IncomingInvoiceImportItemXML{
ProductID: item.ProductID.String(),
Amount: amount,
Price: price,
Sum: sum,
Num: i + 1,
Store: inv.DefaultStoreID.String(),
})
}
if item.ContainerID != nil && *item.ContainerID != uuid.Nil {
xmlItem.ContainerId = item.ContainerID.String()
}
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, xmlItem)
}
// 2. Маршалинг в XML
@@ -613,7 +621,6 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
zap.String("url", fullURL),
zap.String("body_payload", string(xmlPayload)),
)
// ----------------------------------------
// 5. Отправка
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload))
@@ -659,3 +666,92 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
return result.DocumentNumber, nil
}
// GetProductByID получает полную структуру товара по ID (через /list?ids=...)
func (c *Client) GetProductByID(id uuid.UUID) (*ProductFullDTO, error) {
// Параметр ids должен быть списком. iiko ожидает ids=UUID
params := map[string]string{
"ids": id.String(),
"includeDeleted": "false",
}
resp, err := c.doRequest("GET", "/resto/api/v2/entities/products/list", params)
if err != nil {
return nil, fmt.Errorf("request error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("rms error code %d", resp.StatusCode)
}
// Ответ - это массив товаров
var products []ProductFullDTO
if err := json.NewDecoder(resp.Body).Decode(&products); err != nil {
return nil, fmt.Errorf("json decode error: %w", err)
}
if len(products) == 0 {
return nil, fmt.Errorf("product not found in rms")
}
return &products[0], nil
}
// UpdateProduct отправляет полную структуру товара на обновление (/update)
func (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error) {
// Маршалим тело
bodyBytes, err := json.Marshal(product)
if err != nil {
return nil, fmt.Errorf("json marshal error: %w", err)
}
// Используем doRequestPost (надо реализовать или вручную, т.к. doRequest у нас GET-ориентирован в текущем коде был прост)
// Расширим логику doRequest или напишем тут, т.к. это POST с JSON body
if err := c.ensureToken(); err != nil {
return nil, err
}
c.mu.RLock()
token := c.token
c.mu.RUnlock()
endpoint := c.baseURL + "/resto/api/v2/entities/products/update?key=" + token
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("update failed (code %d): %s", resp.StatusCode, string(respBody))
}
var result UpdateEntityResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("response unmarshal error: %w", err)
}
if result.Result != "SUCCESS" {
// Собираем ошибки
errMsg := "rms update error: "
for _, e := range result.Errors {
errMsg += fmt.Sprintf("[%s] %s; ", e.Code, e.Value)
}
return nil, fmt.Errorf(errMsg)
}
if result.Response == nil {
return nil, fmt.Errorf("empty response from rms after update")
}
return result.Response, nil
}

View File

@@ -74,6 +74,66 @@ type AssemblyItemDTO struct {
AmountOut float64 `json:"amountOut"`
}
// ProductFullDTO используется для получения (list?ids=...) и обновления (update) товара целиком.
type ProductFullDTO struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
Name string `json:"name"`
Description string `json:"description"`
Num string `json:"num"`
Code string `json:"code"`
Parent *string `json:"parent"` // null или UUID
Modifiers []interface{} `json:"modifiers"` // Оставляем interface{}, чтобы не мапить сложную структуру, если не меняем её
TaxCategory *string `json:"taxCategory"`
Category *string `json:"category"`
AccountingCategory *string `json:"accountingCategory"`
Color map[string]int `json:"color"`
FontColor map[string]int `json:"fontColor"`
FrontImageID *string `json:"frontImageId"`
Position *int `json:"position"`
ModifierSchemaID *string `json:"modifierSchemaId"`
MainUnit string `json:"mainUnit"` // Обязательное поле
ExcludedSections []string `json:"excludedSections"` // Set<UUID>
DefaultSalePrice float64 `json:"defaultSalePrice"`
PlaceType *string `json:"placeType"`
DefaultIncInMenu bool `json:"defaultIncludedInMenu"`
Type string `json:"type"` // GOODS, DISH...
UnitWeight float64 `json:"unitWeight"`
UnitCapacity float64 `json:"unitCapacity"`
StoreBalanceLevels []StoreBalanceLevel `json:"storeBalanceLevels"`
UseBalanceForSell bool `json:"useBalanceForSell"`
Containers []ContainerFullDTO `json:"containers"`
ProductScaleID *string `json:"productScaleId"`
Barcodes []interface{} `json:"barcodes"`
ColdLossPercent float64 `json:"coldLossPercent"`
HotLossPercent float64 `json:"hotLossPercent"`
OuterCode *string `json:"outerEconomicActivityNomenclatureCode"`
AllergenGroups *string `json:"allergenGroups"`
EstPurchasePrice float64 `json:"estimatedPurchasePrice"`
CanSetOpenPrice bool `json:"canSetOpenPrice"`
NotInStoreMovement bool `json:"notInStoreMovement"`
}
type StoreBalanceLevel struct {
StoreID string `json:"storeId"`
MinBalanceLevel *float64 `json:"minBalanceLevel"`
MaxBalanceLevel *float64 `json:"maxBalanceLevel"`
}
type ContainerFullDTO struct {
ID *string `json:"id,omitempty"` // При создании новой фасовки ID пустой/null
Num string `json:"num"` // Порядковый номер? Обычно строка.
Name string `json:"name"`
Count float64 `json:"count"`
MinContainerWeight float64 `json:"minContainerWeight"`
MaxContainerWeight float64 `json:"maxContainerWeight"`
ContainerWeight float64 `json:"containerWeight"`
FullContainerWeight float64 `json:"fullContainerWeight"`
BackwardRecalculation bool `json:"backwardRecalculation"`
Deleted bool `json:"deleted"`
UseInFront bool `json:"useInFront"`
}
// --- XML DTOs (Legacy API) ---
type IncomingInvoiceListXML struct {
@@ -149,15 +209,14 @@ type IncomingInvoiceImportXML struct {
}
type IncomingInvoiceImportItemXML struct {
ProductID string `xml:"product"` // GUID товара
Amount float64 `xml:"amount"` // Кол-во в базовых единицах
Price float64 `xml:"price"` // Цена за единицу
Sum float64 `xml:"sum,omitempty"`
Store string `xml:"store"` // GUID склада
// Поля ниже можно опустить, если iiko должна сама подтянуть их из карточки товара
// или если мы работаем в базовых единицах.
AmountUnit string `xml:"amountUnit,omitempty"` // GUID единицы измерения
Num int `xml:"num,omitempty"` // Номер строки
ProductID string `xml:"product"` // GUID товара
Amount float64 `xml:"amount"` // Кол-во (в фасовках, если указан containerId)
Price float64 `xml:"price"` // Цена за единицу (за фасовку, если указан containerId)
Sum float64 `xml:"sum,omitempty"` // Сумма
Store string `xml:"store"` // GUID склада
ContainerId string `xml:"containerId,omitempty"` // ID фасовки
AmountUnit string `xml:"amountUnit,omitempty"` // GUID единицы измерения (можно опустить, если фасовка)
Num int `xml:"num,omitempty"`
}
// DocumentValidationResult описывает ответ сервера при импорте
@@ -170,3 +229,17 @@ type DocumentValidationResult struct {
ErrorMessage string `xml:"errorMessage"`
AdditionalInfo string `xml:"additionalInfo"`
}
// --- Вспомогательные DTO для ответов (REST) ---
// UpdateEntityResponse - ответ на /save или /update
type UpdateEntityResponse struct {
Result string `json:"result"` // "SUCCESS" or "ERROR"
Response *ProductFullDTO `json:"response"`
Errors []ErrorDTO `json:"errors"`
}
type ErrorDTO struct {
Code string `json:"code"`
Value string `json:"value"`
}

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
}

View File

@@ -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)
}

View File

@@ -149,3 +149,129 @@ func (h *DraftsHandler) CommitDraft(c *gin.Context) {
"document_number": docNum,
})
}
// AddContainerRequestDTO - запрос на создание фасовки
type AddContainerRequestDTO struct {
ProductID string `json:"product_id" binding:"required"`
Name string `json:"name" binding:"required"`
Count float64 `json:"count" binding:"required,gt=0"`
}
// AddContainer создает новую фасовку для товара
func (h *DraftsHandler) AddContainer(c *gin.Context) {
var req AddContainerRequestDTO
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pID, err := uuid.Parse(req.ProductID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product_id"})
return
}
// Конвертация float64 -> decimal
countDec := decimal.NewFromFloat(req.Count)
// Вызов сервиса
newID, err := h.service.CreateProductContainer(pID, req.Name, countDec)
if err != nil {
logger.Log.Error("Failed to create container", zap.Error(err))
// Можно возвращать 502, если ошибка от RMS
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "created",
"container_id": newID.String(),
})
}
// DraftListItemDTO - структура элемента списка
type DraftListItemDTO struct {
ID string `json:"id"`
DocumentNumber string `json:"document_number"`
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
Status string `json:"status"`
ItemsCount int `json:"items_count"`
TotalSum float64 `json:"total_sum"`
StoreName string `json:"store_name"`
CreatedAt string `json:"created_at"`
}
// GetDrafts возвращает список активных черновиков
func (h *DraftsHandler) GetDrafts(c *gin.Context) {
list, err := h.service.GetActiveDrafts()
if err != nil {
logger.Log.Error("Failed to fetch drafts", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response := make([]DraftListItemDTO, 0, len(list))
for _, d := range list {
// Расчет суммы
var totalSum decimal.Decimal
for _, item := range d.Items {
// Если item.Sum посчитана - берем её, иначе (qty * price)
if !item.Sum.IsZero() {
totalSum = totalSum.Add(item.Sum)
} else {
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
}
}
sumFloat, _ := totalSum.Float64()
// Форматирование даты
dateStr := ""
if d.DateIncoming != nil {
dateStr = d.DateIncoming.Format("2006-01-02")
}
// Имя склада
storeName := ""
if d.Store != nil {
storeName = d.Store.Name
}
response = append(response, DraftListItemDTO{
ID: d.ID.String(),
DocumentNumber: d.DocumentNumber,
DateIncoming: dateStr,
Status: d.Status,
ItemsCount: len(d.Items),
TotalSum: sumFloat,
StoreName: storeName,
CreatedAt: d.CreatedAt.Format(time.RFC3339),
})
}
c.JSON(http.StatusOK, response)
}
// DeleteDraft обрабатывает запрос на удаление/отмену
func (h *DraftsHandler) DeleteDraft(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
newStatus, err := h.service.DeleteDraft(id)
if err != nil {
logger.Log.Error("Failed to delete draft", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Возвращаем новый статус, чтобы фронтенд знал, удалился он совсем или стал CANCELED
c.JSON(http.StatusOK, gin.H{
"status": newStatus,
"id": id.String(),
})
}

View File

@@ -92,6 +92,26 @@ func (h *OCRHandler) DeleteMatch(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// SearchProducts ищет товары (для автокомплита)
func (h *OCRHandler) SearchProducts(c *gin.Context) {
query := c.Query("q") // ?q=молоко
if query == "" {
c.JSON(http.StatusOK, []interface{}{})
return
}
products, err := h.service.SearchProducts(query)
if err != nil {
logger.Log.Error("Search error", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Отдаем на фронт упрощенную структуру или полную, в зависимости от нужд.
// Product entity уже содержит JSON теги, так что можно отдать напрямую.
c.JSON(http.StatusOK, products)
}
// GetMatches возвращает список всех обученных связей
func (h *OCRHandler) GetMatches(c *gin.Context) {
matches, err := h.service.GetKnownMatches()