mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Полноценно редактируются черновики
Добавляются фасовки как в черновике, так и в обучении Исправил внешний вид
This commit is contained in:
@@ -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 ---
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user