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

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

@@ -160,9 +160,12 @@ func main() {
api.POST("/invoices/send", invoiceHandler.SendInvoice) api.POST("/invoices/send", invoiceHandler.SendInvoice)
// Черновики // Черновики
api.GET("/drafts", draftsHandler.GetDrafts)
api.GET("/drafts/:id", draftsHandler.GetDraft) api.GET("/drafts/:id", draftsHandler.GetDraft)
api.DELETE("/drafts/:id", draftsHandler.DeleteDraft)
api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem) api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem)
api.POST("/drafts/:id/commit", draftsHandler.CommitDraft) api.POST("/drafts/:id/commit", draftsHandler.CommitDraft)
api.POST("/drafts/container", draftsHandler.AddContainer) // Добавление новой фасовки
// Склады // Склады
api.GET("/dictionaries/stores", draftsHandler.GetStores) api.GET("/dictionaries/stores", draftsHandler.GetStores)
@@ -176,6 +179,7 @@ func main() {
api.POST("/ocr/match", ocrHandler.SaveMatch) api.POST("/ocr/match", ocrHandler.SaveMatch)
api.DELETE("/ocr/match", ocrHandler.DeleteMatch) api.DELETE("/ocr/match", ocrHandler.DeleteMatch)
api.GET("/ocr/unmatched", ocrHandler.GetUnmatched) api.GET("/ocr/unmatched", ocrHandler.GetUnmatched)
api.GET("/ocr/search", ocrHandler.SearchProducts)
} }
// Простой хелсчек // Простой хелсчек

View File

@@ -1,3 +1,5 @@
name: rmser
services: services:
# 1. База данных PostgreSQL # 1. База данных PostgreSQL
db: db:
@@ -49,7 +51,7 @@ services:
- REDIS_ADDR=redis:6379 - REDIS_ADDR=redis:6379
- OCR_SERVICE_URL=http://ocr:5000 - OCR_SERVICE_URL=http://ocr:5000
# 5. Frontend (React + Nginx) - НОВОЕ # 5. Frontend (React + Nginx)
frontend: frontend:
build: ./rmser-view build: ./rmser-view
container_name: rmser_frontend container_name: rmser_frontend

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ type InvoiceItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"` ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
InvoiceID uuid.UUID `gorm:"type:uuid;not null;index"` InvoiceID uuid.UUID `gorm:"type:uuid;not null;index"`
ProductID uuid.UUID `gorm:"type:uuid;not null"` ProductID uuid.UUID `gorm:"type:uuid;not null"`
ContainerID *uuid.UUID `gorm:"type:uuid"`
Amount decimal.Decimal `gorm:"type:numeric(19,4);not null"` Amount decimal.Decimal `gorm:"type:numeric(19,4);not null"`
Price 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"` Sum decimal.Decimal `gorm:"type:numeric(19,4);not null"`

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 err := r.db.Where("is_deleted = ?", false).Order("name ASC").Find(&stores).Error
return stores, err 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 { func (r *pgRepository) Delete(id uuid.UUID) error {
return r.db.Delete(&drafts.DraftInvoice{}, id).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) FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error) FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
CreateIncomingInvoice(inv invoices.Invoice) (string, error) CreateIncomingInvoice(inv invoices.Invoice) (string, error)
GetProductByID(id uuid.UUID) (*ProductFullDTO, error)
UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)
} }
type Client struct { type Client struct {
@@ -571,14 +573,20 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
price, _ := item.Price.Float64() price, _ := item.Price.Float64()
sum, _ := item.Sum.Float64() sum, _ := item.Sum.Float64()
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, IncomingInvoiceImportItemXML{ xmlItem := IncomingInvoiceImportItemXML{
ProductID: item.ProductID.String(), ProductID: item.ProductID.String(),
Amount: amount, Amount: amount,
Price: price, Price: price,
Sum: sum, Sum: sum,
Num: i + 1, Num: i + 1,
Store: inv.DefaultStoreID.String(), 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 // 2. Маршалинг в XML
@@ -613,7 +621,6 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
zap.String("url", fullURL), zap.String("url", fullURL),
zap.String("body_payload", string(xmlPayload)), zap.String("body_payload", string(xmlPayload)),
) )
// ----------------------------------------
// 5. Отправка // 5. Отправка
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload)) 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 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"` 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) --- // --- XML DTOs (Legacy API) ---
type IncomingInvoiceListXML struct { type IncomingInvoiceListXML struct {
@@ -150,14 +210,13 @@ type IncomingInvoiceImportXML struct {
type IncomingInvoiceImportItemXML struct { type IncomingInvoiceImportItemXML struct {
ProductID string `xml:"product"` // GUID товара ProductID string `xml:"product"` // GUID товара
Amount float64 `xml:"amount"` // Кол-во в базовых единицах Amount float64 `xml:"amount"` // Кол-во (в фасовках, если указан containerId)
Price float64 `xml:"price"` // Цена за единицу Price float64 `xml:"price"` // Цена за единицу (за фасовку, если указан containerId)
Sum float64 `xml:"sum,omitempty"` Sum float64 `xml:"sum,omitempty"` // Сумма
Store string `xml:"store"` // GUID склада Store string `xml:"store"` // GUID склада
// Поля ниже можно опустить, если iiko должна сама подтянуть их из карточки товара ContainerId string `xml:"containerId,omitempty"` // ID фасовки
// или если мы работаем в базовых единицах. AmountUnit string `xml:"amountUnit,omitempty"` // GUID единицы измерения (можно опустить, если фасовка)
AmountUnit string `xml:"amountUnit,omitempty"` // GUID единицы измерения Num int `xml:"num,omitempty"`
Num int `xml:"num,omitempty"` // Номер строки
} }
// DocumentValidationResult описывает ответ сервера при импорте // DocumentValidationResult описывает ответ сервера при импорте
@@ -170,3 +229,17 @@ type DocumentValidationResult struct {
ErrorMessage string `xml:"errorMessage"` ErrorMessage string `xml:"errorMessage"`
AdditionalInfo string `xml:"additionalInfo"` 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 ( import (
"errors" "errors"
"fmt"
"strconv"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -42,6 +44,37 @@ func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) {
return s.draftRepo.GetByID(id) 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 обновляет шапку (дата, поставщик, склад, комментарий) // UpdateDraftHeader обновляет шапку (дата, поставщик, склад, комментарий)
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error { 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) 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) 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 { func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
// Мы просто обновляем данные в черновике. // 1. Проверяем статус черновика для реализации Auto-Restore
// Сохранение в базу знаний (OCR Matches) произойдет только при отправке накладной. 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) 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 { for _, dItem := range draft.Items {
if dItem.ProductID == nil { if dItem.ProductID == nil {
// Пропускаем нераспознанные или кидаем ошибку? // Пропускаем нераспознанные или кидаем ошибку?
// Лучше пропустить, чтобы не блокировать отправку частичного документа break
continue
} }
// Расчет суммы (если не задана, считаем) // Расчет суммы (если не задана, считаем)
@@ -114,34 +162,15 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
sum = dItem.Quantity.Mul(dItem.Price) sum = dItem.Quantity.Mul(dItem.Price)
} }
// Важный момент с фасовками: invItem := invoices.InvoiceItem{
// Клиент RMS (CreateIncomingInvoice) у нас пока не поддерживает отправку container_id в явном виде, ProductID: *dItem.ProductID,
// или мы его обновили? Проверим `internal/infrastructure/rms/client.go`. Amount: dItem.Quantity,
// Там используется `IncomingInvoiceImportItemXML`. В ней нет поля ContainerID, но есть `AmountUnit`. Price: dItem.Price,
// Если мы хотим передать фасовку, нужно передавать Amount в базовых единицах, Sum: sum,
// ЛИБО доработать клиент iiko, чтобы он принимал `amountUnit` (ID фасовки). ContainerID: dItem.ContainerID,
// СТРАТЕГИЯ СЕЙЧАС:
// Считаем, что FrontEnd/Service уже пересчитал кол-во в базовые единицы?
// НЕТ. DraftItem хранит Quantity в тех единицах, которые выбрал юзер (фасовках).
// Нам нужно конвертировать в базовые для отправки, если мы не умеем слать фасовки.
// Но погоди, в `ProductContainer` есть `Count` (коэффициент).
finalAmount := dItem.Quantity
if dItem.ContainerID != nil && dItem.Container != nil {
// Если выбрана фасовка, умножаем кол-во упаковок на коэффициент
finalAmount = finalAmount.Mul(dItem.Container.Count)
} }
inv.Items = append(inv.Items, invoices.InvoiceItem{ inv.Items = append(inv.Items, invItem)
ProductID: *dItem.ProductID,
Amount: finalAmount,
Price: dItem.Price, // Цена обычно за упаковку... А iiko ждет цену за базу?
// RMS API: Если мы шлем в базовых единицах, то и цену надо пересчитать за базовую.
// Price (base) = Price (pack) / Count
// ЛИБО: Мы шлем Sum, а iiko сама посчитает цену. Это надежнее.
Sum: sum,
})
} }
if len(inv.Items) == 0 { if len(inv.Items) == 0 {
@@ -198,3 +227,106 @@ func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) {
func (s *Service) GetActiveStores() ([]catalog.Store, error) { func (s *Service) GetActiveStores() ([]catalog.Store, error) {
return s.catalogRepo.GetActiveStores() 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 ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
@@ -84,11 +83,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
item.ProductID = &match.ProductID item.ProductID = &match.ProductID
item.ContainerID = match.ContainerID item.ContainerID = match.ContainerID
// Важная логика: Если в матче указано ContainerID, то Quantity из чека (например 5 шт)
// это 5 коробок. Финальное кол-во (в кг) RMS посчитает сама,
// либо мы можем пересчитать тут, если знаем коэффициент.
// Пока оставляем Quantity как есть (кол-во упаковок),
// так как ContainerID передается в iiko.
} else { } else {
// Если не нашли - сохраняем в Unmatched для статистики и подсказок // Если не нашли - сохраняем в Unmatched для статистики и подсказок
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil { if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
@@ -100,11 +94,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
} }
// 4. Сохраняем позиции в БД // 4. Сохраняем позиции в БД
// Примечание: GORM умеет сохранять вложенные структуры через Update родителя,
// но надежнее явно сохранить items, если мы не используем Session FullSaveAssociations.
// В данном случае мы уже создали Draft, теперь привяжем к нему items.
// Для простоты, так как у нас в Repo нет метода SaveItems,
// мы обновим драфт, добавив Items (GORM должен создать их).
draft.Status = drafts.StatusReadyToVerify draft.Status = drafts.StatusReadyToVerify
if err := s.draftRepo.Update(draft); err != nil { 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 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 { if err := s.draftRepo.CreateItems(draftItems); err != nil {
return nil, fmt.Errorf("failed to save items: %w", err) 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) return s.ocrRepo.FindMatch(rawName)
} }
// SearchProducts ищет товары в БД по части названия (для ручного выбора в боте) // SearchProducts ищет товары в БД по части названия, коду или артикулу
func (s *Service) SearchProducts(query string) ([]catalog.Product, error) { func (s *Service) SearchProducts(query string) ([]catalog.Product, error) {
// Этот метод нужно поддержать в репозитории, пока сделаем заглушку или фильтрацию в памяти if len(query) < 2 {
// Для MVP добавим метод SearchByName в интерфейс репозитория // Слишком короткий запрос, возвращаем пустой список
all, err := s.catalogRepo.GetActiveGoods() return []catalog.Product{}, nil
if err != nil {
return nil, err
} }
return s.catalogRepo.Search(query)
// Простейший поиск в памяти (для начала хватит)
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
} }

View File

@@ -149,3 +149,129 @@ func (h *DraftsHandler) CommitDraft(c *gin.Context) {
"document_number": docNum, "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"}) 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 возвращает список всех обученных связей // GetMatches возвращает список всех обученных связей
func (h *OCRHandler) GetMatches(c *gin.Context) { func (h *OCRHandler) GetMatches(c *gin.Context) {
matches, err := h.service.GetKnownMatches() matches, err := h.service.GetKnownMatches()

View File

@@ -3,10 +3,8 @@ import { Providers } from './components/layout/Providers';
import { AppLayout } from './components/layout/AppLayout'; import { AppLayout } from './components/layout/AppLayout';
import { Dashboard } from './pages/Dashboard'; import { Dashboard } from './pages/Dashboard';
import { OcrLearning } from './pages/OcrLearning'; import { OcrLearning } from './pages/OcrLearning';
import { InvoiceDraftPage } from './pages/InvoiceDraftPage'; // Импорт import { InvoiceDraftPage } from './pages/InvoiceDraftPage';
import { DraftsList } from './pages/DraftsList';
// Заглушки для списка накладных пока оставим (или можно сделать пустую страницу)
const InvoicesListPage = () => <h2>История накладных (в разработке)</h2>;
function App() { function App() {
return ( return (
@@ -17,11 +15,11 @@ function App() {
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="ocr" element={<OcrLearning />} /> <Route path="ocr" element={<OcrLearning />} />
{/* Роут для черновика. :id - UUID черновика */} {/* Список черновиков */}
<Route path="invoice/:id" element={<InvoiceDraftPage />} /> <Route path="invoices" element={<DraftsList />} />
{/* Страница списка */} {/* Редактирование черновика */}
<Route path="invoices" element={<InvoicesListPage />} /> <Route path="invoice/:id" element={<InvoiceDraftPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>

View File

@@ -0,0 +1,84 @@
import React, { useState } from 'react';
import { Modal, Form, Input, InputNumber, Button, message } from 'antd';
import { api } from '../../services/api';
import type { ProductContainer } from '../../services/types';
interface Props {
visible: boolean;
onCancel: () => void;
productId: string;
productBaseUnit: string;
// Callback возвращает уже полный объект с ID от сервера
onSuccess: (container: ProductContainer) => void;
}
export const CreateContainerModal: React.FC<Props> = ({
visible, onCancel, productId, productBaseUnit, onSuccess
}) => {
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const handleOk = async () => {
try {
const values = await form.validateFields();
setLoading(true);
// 1. Отправляем запрос на БЭКЕНД
const res = await api.createContainer({
product_id: productId,
name: values.name,
count: values.count
});
message.success('Фасовка создана');
// 2. БЭКЕНД вернул ID. Теперь мы собираем объект для UI
// Мы не придумываем ID сами, мы берем res.container_id
const newContainer: ProductContainer = {
id: res.container_id, // <--- ID от сервера
name: values.name,
count: values.count
};
// 3. Возвращаем полный объект родителю
onSuccess(newContainer);
form.resetFields();
} catch {
message.error('Ошибка создания фасовки');
} finally {
setLoading(false);
}
};
return (
<Modal
title="Новая фасовка"
open={visible}
onCancel={onCancel}
footer={[
<Button key="back" onClick={onCancel}>Отмена</Button>,
<Button key="submit" type="primary" loading={loading} onClick={handleOk}>
Создать
</Button>,
]}
>
<Form form={form} layout="vertical">
<Form.Item
name="name"
label="Название"
rules={[{ required: true, message: 'Введите название, например 0.5' }]}
>
<Input placeholder="Например: Бутылка 0.5" />
</Form.Item>
<Form.Item
name="count"
label={`Количество в базовых ед. (${productBaseUnit})`}
rules={[{ required: true, message: 'Введите коэффициент' }]}
>
<InputNumber style={{ width: '100%' }} step={0.001} placeholder="0.5" />
</Form.Item>
</Form>
</Modal>
);
};

View File

@@ -1,162 +1,243 @@
import React, { useMemo } from 'react'; import React, { useMemo, useState, useEffect } from 'react';
import { Card, Flex, InputNumber, Typography, Select, Tag } from 'antd'; import { Card, Flex, InputNumber, Typography, Select, Tag, Button, Divider, Modal } from 'antd';
import { SyncOutlined } from '@ant-design/icons'; import { SyncOutlined, PlusOutlined, WarningFilled } from '@ant-design/icons';
import { CatalogSelect } from '../ocr/CatalogSelect'; import { CatalogSelect } from '../ocr/CatalogSelect';
import type { DraftItem, CatalogItem, UpdateDraftItemRequest } from '../../services/types'; import { CreateContainerModal } from './CreateContainerModal';
import type { DraftItem, UpdateDraftItemRequest, ProductSearchResult, ProductContainer, Recommendation } from '../../services/types';
const { Text } = Typography; const { Text } = Typography;
interface Props { interface Props {
item: DraftItem; item: DraftItem;
catalog: CatalogItem[];
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void; onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
isUpdating: boolean; // Флаг, что конкретно эта строка сейчас сохраняется isUpdating: boolean;
recommendations?: Recommendation[]; // Новый проп
} }
export const DraftItemRow: React.FC<Props> = ({ item, catalog, onUpdate, isUpdating }) => { export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, recommendations = [] }) => {
// 1. Поиск выбранного товара в полном каталоге, чтобы получить доступ к containers const [isModalOpen, setIsModalOpen] = useState(false);
const selectedProductObj = useMemo(() => {
if (!item.product_id) return null; // State Input
return catalog.find(c => c.id === item.product_id || c.ID === item.product_id); const [localQuantity, setLocalQuantity] = useState<string | null>(item.quantity?.toString() ?? null);
}, [item.product_id, catalog]); const [localPrice, setLocalPrice] = useState<string | null>(item.price?.toString() ?? null);
// Sync Effect
useEffect(() => {
const serverQty = item.quantity;
const currentLocal = parseFloat(localQuantity?.replace(',', '.') || '0');
if (Math.abs(serverQty - currentLocal) > 0.001) setLocalQuantity(serverQty.toString().replace('.', ','));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.quantity]);
useEffect(() => {
const serverPrice = item.price;
const currentLocal = parseFloat(localPrice?.replace(',', '.') || '0');
if (Math.abs(serverPrice - currentLocal) > 0.001) setLocalPrice(serverPrice.toString().replace('.', ','));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.price]);
// Product Logic
const [searchedProduct, setSearchedProduct] = useState<ProductSearchResult | null>(null);
const [addedContainers, setAddedContainers] = useState<Record<string, ProductContainer[]>>({});
const activeProduct = useMemo(() => {
if (searchedProduct && searchedProduct.id === item.product_id) return searchedProduct;
return item.product as unknown as ProductSearchResult | undefined;
}, [searchedProduct, item.product, item.product_id]);
const containers = useMemo(() => {
if (!activeProduct) return [];
const baseContainers = activeProduct.containers || [];
const manuallyAdded = addedContainers[activeProduct.id] || [];
const combined = [...baseContainers];
manuallyAdded.forEach(c => {
if (!combined.find(existing => existing.id === c.id)) combined.push(c);
});
return combined;
}, [activeProduct, addedContainers]);
const baseUom = activeProduct?.main_unit?.name || activeProduct?.measure_unit || 'ед.';
// 2. Список фасовок для селекта
const containerOptions = useMemo(() => { const containerOptions = useMemo(() => {
if (!selectedProductObj) return []; if (!activeProduct) return [];
const conts = selectedProductObj.containers || selectedProductObj.Containers || []; const opts = [
const baseUom = selectedProductObj.measure_unit || selectedProductObj.MeasureUnit || 'ед.'; { value: 'BASE_UNIT', label: `Базовая (${baseUom})` },
...containers.map(c => ({
return [
{ value: null, label: `Базовая (${baseUom})` }, // null значит базовая единица
...conts.map(c => ({
value: c.id, value: c.id,
label: c.name // "Коробка" label: `${c.name} (=${Number(c.count)} ${baseUom})`
})) }))
]; ];
}, [selectedProductObj]); if (item.container_id && item.container && !containers.find(c => c.id === item.container_id)) {
opts.push({
value: item.container.id,
label: `${item.container.name} (=${Number(item.container.count)} ${baseUom})`
});
}
return opts;
}, [activeProduct, containers, baseUom, item.container_id, item.container]);
// 3. Хендлеры изменений // --- WARNING LOGIC ---
const activeWarning = useMemo(() => {
if (!item.product_id) return null;
return recommendations.find(r => r.ProductID === item.product_id);
}, [item.product_id, recommendations]);
const handleProductChange = (prodId: string) => { const showWarningModal = () => {
// При смене товара: сбрасываем фасовку, подставляем исходные кол-во/цену, если они были нулями (логика "default") if (!activeWarning) return;
// Но по ТЗ: "При выборе товара автоматически подставлять quantity = raw_amount..." Modal.warning({
// Это лучше делать, передавая эти данные. title: 'Внимание: проблемный товар',
content: (
onUpdate(item.id, { <div>
product_id: prodId, <p><b>{activeWarning.ProductName}</b></p>
container_id: null, // Сброс фасовки <p>{activeWarning.Reason}</p>
quantity: item.quantity || item.raw_amount || 1, <p><Tag color="orange">{activeWarning.Type}</Tag></p>
price: item.price || item.raw_price || 0 </div>
),
okText: 'Понятно',
maskClosable: true
}); });
}; };
const handleContainerChange = (val: string | null) => { // --- Helpers ---
// При смене фасовки просто шлем ID. Сервер сам не пересчитывает цифры, фронт тоже не должен. const parseToNum = (val: string | null | undefined): number => {
// Пользователь сам поправит цену, если она изменилась за упаковку. if (!val) return 0;
onUpdate(item.id, { return parseFloat(val.replace(',', '.'));
container_id: val || null // Antd Select может вернуть undefined, приводим к null
});
}; };
const handleBlur = (field: 'quantity' | 'price', val: number | null) => { const getUpdatePayload = (overrides: Partial<UpdateDraftItemRequest>): UpdateDraftItemRequest => {
// Сохраняем только если значение изменилось и валидно const currentQty = localQuantity !== null ? parseToNum(localQuantity) : item.quantity;
if (val === null) return; const currentPrice = localPrice !== null ? parseToNum(localPrice) : item.price;
if (val === item[field]) return;
onUpdate(item.id, { return {
[field]: val product_id: item.product_id || undefined,
}); container_id: item.container_id,
quantity: currentQty ?? 1,
price: currentPrice ?? 0,
...overrides
};
}; };
// Вычисляем статус цвета // --- Handlers ---
const cardBorderColor = !item.product_id ? '#ffa39e' : item.is_matched ? '#b7eb8f' : '#d9d9d9'; // Красный если нет товара, Зеленый если сматчился сам, Серый если правим const handleProductChange = (prodId: string, productObj?: ProductSearchResult) => {
if (productObj) setSearchedProduct(productObj);
onUpdate(item.id, getUpdatePayload({ product_id: prodId, container_id: null }));
};
const handleContainerChange = (val: string) => {
const newVal = val === 'BASE_UNIT' ? null : val;
onUpdate(item.id, getUpdatePayload({ container_id: newVal }));
};
const handleBlur = (field: 'quantity' | 'price') => {
const localVal = field === 'quantity' ? localQuantity : localPrice;
if (localVal === null) return;
const numVal = parseToNum(localVal);
if (numVal !== item[field]) {
onUpdate(item.id, getUpdatePayload({ [field]: numVal }));
}
};
const handleContainerCreated = (newContainer: ProductContainer) => {
setIsModalOpen(false);
if (activeProduct) {
setAddedContainers(prev => ({ ...prev, [activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer] }));
}
onUpdate(item.id, getUpdatePayload({ container_id: newContainer.id }));
};
const cardBorderColor = !item.product_id ? '#ffa39e' : item.is_matched ? '#b7eb8f' : '#d9d9d9';
const uiSum = parseToNum(localQuantity) * parseToNum(localPrice);
return ( return (
<>
<Card <Card
size="small" size="small"
style={{ style={{ marginBottom: 8, borderLeft: `4px solid ${cardBorderColor}`, background: item.product_id ? '#fff' : '#fff1f0' }}
marginBottom: 8,
borderLeft: `4px solid ${cardBorderColor}`,
background: item.product_id ? '#fff' : '#fff1f0' // Легкий красный фон если не распознан
}}
bodyStyle={{ padding: 12 }} bodyStyle={{ padding: 12 }}
> >
<Flex vertical gap="small"> <Flex vertical gap={10}>
{/* Верхняя строка: Исходное название и статус */}
<Flex justify="space-between" align="start"> <Flex justify="space-between" align="start">
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12, lineHeight: 1.2, display: 'block' }}>{item.raw_name}</Text>
{item.raw_name}
</Text>
{item.raw_amount > 0 && ( {item.raw_amount > 0 && (
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}> <Text type="secondary" style={{ fontSize: 10, display: 'block' }}>(чек: {item.raw_amount} x {item.raw_price})</Text>
(в чеке: {item.raw_amount} x {item.raw_price})
</Text>
)} )}
</div> </div>
<div> <div style={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
{isUpdating && <SyncOutlined spin style={{ color: '#1890ff' }} />} {isUpdating && <SyncOutlined spin style={{ color: '#1890ff' }} />}
{!item.product_id && <Tag color="error">Не найден</Tag>}
{/* Warning Icon */}
{activeWarning && (
<WarningFilled
style={{ color: '#faad14', fontSize: 16, cursor: 'pointer' }}
onClick={showWarningModal}
/>
)}
{!item.product_id && <Tag color="error" style={{ margin: 0 }}>?</Tag>}
</div> </div>
</Flex> </Flex>
{/* Выбор товара */}
<CatalogSelect <CatalogSelect
catalog={catalog}
value={item.product_id || undefined} value={item.product_id || undefined}
onChange={handleProductChange} onChange={handleProductChange}
initialProduct={activeProduct}
/> />
{/* Нижний блок: Фасовка, Кол-во, Цена, Сумма */} {activeProduct && (
<Flex gap={8} align="center">
{/* Если есть фасовки, показываем селект. Если нет - просто лейбл ед. изм */}
<div style={{ flex: 2, minWidth: 90 }}>
{containerOptions.length > 1 ? (
<Select <Select
size="middle"
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder="Ед. изм." placeholder="Выберите единицу измерения"
options={containerOptions} options={containerOptions}
value={item.container_id || null} // null для базовой value={item.container_id || 'BASE_UNIT'}
onChange={handleContainerChange} onChange={handleContainerChange}
disabled={!item.product_id} dropdownRender={(menu) => (
/> <>
) : ( {menu}
<div style={{ padding: '4px 11px', background: '#f5f5f5', borderRadius: 6, fontSize: 13, color: '#888', border: '1px solid #d9d9d9' }}> <Divider style={{ margin: '4px 0' }} />
{selectedProductObj?.measure_unit || 'ед.'} <Button type="text" block icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} style={{ textAlign: 'left' }}>
</div> Добавить фасовку...
</Button>
</>
)} )}
</div>
<InputNumber
style={{ flex: 1.5, minWidth: 60 }}
placeholder="Кол-во"
value={item.quantity}
min={0}
onBlur={(e) => handleBlur('quantity', parseFloat(e.target.value))}
// В Antd onBlur event target value is string
/> />
)}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', minWidth: 60 }}> <div style={{
<InputNumber display: 'flex', alignItems: 'center', justifyContent: 'space-between',
style={{ width: '100%' }} background: '#fafafa', margin: '0 -12px -12px -12px', padding: '8px 12px',
placeholder="Цена" borderTop: '1px solid #f0f0f0', borderBottomLeftRadius: 8, borderBottomRightRadius: 8
value={item.price} }}>
min={0} <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
onBlur={(e) => handleBlur('price', parseFloat(e.target.value))} <InputNumber<string>
style={{ width: 60 }} controls={false} placeholder="Кол" stringMode decimalSeparator=","
value={localQuantity || ''} onChange={(val) => setLocalQuantity(val)} onBlur={() => handleBlur('quantity')}
/>
<Text type="secondary">x</Text>
<InputNumber<string>
style={{ width: 70 }} controls={false} placeholder="Цена" stringMode decimalSeparator=","
value={localPrice || ''} onChange={(val) => setLocalPrice(val)} onBlur={() => handleBlur('price')}
/> />
<Text type="secondary" style={{ fontSize: 10 }}>Цена за ед.</Text>
</div> </div>
</Flex> <div style={{ textAlign: 'right' }}>
<Text strong style={{ fontSize: 16 }}>
{/* Итоговая сумма (расчетная) */} {uiSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2, maximumFractionDigits: 2 })}
<div style={{ textAlign: 'right', marginTop: 4 }}>
<Text strong>
= {(item.quantity * item.price).toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</Text> </Text>
</div> </div>
</div>
</Flex> </Flex>
</Card> </Card>
{activeProduct && (
<CreateContainerModal
visible={isModalOpen}
onCancel={() => setIsModalOpen(false)}
productId={activeProduct.id}
productBaseUnit={baseUom}
onSuccess={handleContainerCreated}
/>
)}
</>
); );
}; };

View File

@@ -1,57 +1,86 @@
import React from 'react'; import React from 'react';
import { Layout, Menu, theme } from 'antd'; import { Layout, theme } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { BarChartOutlined, ScanOutlined, FileTextOutlined } from '@ant-design/icons'; import { ScanOutlined, FileTextOutlined, SettingOutlined } from '@ant-design/icons';
const { Header, Content, Footer } = Layout; const { Content } = Layout;
export const AppLayout: React.FC = () => { export const AppLayout: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
// Получаем токены темы (чтобы подстроить AntD под Telegram можно позже настроить ConfigProvider)
const { const {
token: { colorBgContainer, borderRadiusLG }, token: { colorBgContainer, colorPrimary, colorTextSecondary },
} = theme.useToken(); } = theme.useToken();
// Определяем активный пункт меню const path = location.pathname;
const selectedKey = location.pathname === '/' ? 'dashboard' let activeKey = 'invoices';
: location.pathname.startsWith('/ocr') ? 'ocr' if (path.startsWith('/ocr')) activeKey = 'ocr';
: location.pathname.startsWith('/invoices') ? 'invoices' else if (path.startsWith('/settings')) activeKey = 'settings';
: 'dashboard';
const menuItems = [ const menuItems = [
{ key: 'dashboard', icon: <BarChartOutlined />, label: 'Дашборд', onClick: () => navigate('/') }, { key: 'invoices', icon: <FileTextOutlined style={{ fontSize: 20 }} />, label: 'Накладные', path: '/invoices' },
{ key: 'ocr', icon: <ScanOutlined />, label: 'Обучение', onClick: () => navigate('/ocr') }, { key: 'ocr', icon: <ScanOutlined style={{ fontSize: 20 }} />, label: 'Обучение', path: '/ocr' },
{ key: 'invoices', icon: <FileTextOutlined />, label: 'Накладные', onClick: () => navigate('/invoices') }, { key: 'settings', icon: <SettingOutlined style={{ fontSize: 20 }} />, label: 'Настройки', path: '#' },
]; ];
return ( return (
<Layout style={{ minHeight: '100vh' }}> <Layout style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<Header style={{ display: 'flex', alignItems: 'center', padding: 0 }}> {/* Верхнюю шапку (Header) удалили для экономии места */}
<Menu
theme="dark" <Content style={{ padding: '0', flex: 1, overflowY: 'auto', marginBottom: 60 }}>
mode="horizontal" {/* Убрали лишние паддинги вокруг контента для мобилок */}
selectedKeys={[selectedKey]}
items={menuItems}
style={{ flex: 1, minWidth: 0 }}
/>
</Header>
<Content style={{ padding: '16px' }}>
<div <div
style={{ style={{
background: colorBgContainer, background: colorBgContainer,
minHeight: 280, minHeight: '100%',
padding: 24, padding: '12px 12px 80px 12px', // Добавили отступ снизу, чтобы контент не перекрывался меню
borderRadius: borderRadiusLG, borderRadius: 0, // На мобильных скругления углов всего экрана обычно не нужны
}} }}
> >
<Outlet /> <Outlet />
</div> </div>
</Content> </Content>
<Footer style={{ textAlign: 'center', padding: '12px 0' }}>
RMSer ©{new Date().getFullYear()} {/* Нижний Таб-бар */}
</Footer> <div style={{
position: 'fixed',
bottom: 0,
width: '100%',
zIndex: 1000,
background: '#fff',
borderTop: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
padding: '8px 0',
height: 60,
boxShadow: '0 -2px 8px rgba(0,0,0,0.05)'
}}>
{menuItems.map(item => {
const isActive = activeKey === item.key;
return (
<div
key={item.key}
onClick={() => navigate(item.path)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '33%',
cursor: 'pointer',
color: isActive ? colorPrimary : colorTextSecondary
}}
>
{item.icon}
<span style={{ fontSize: 10, marginTop: 2, fontWeight: isActive ? 500 : 400 }}>
{item.label}
</span>
</div>
);
})}
</div>
</Layout> </Layout>
); );
}; };

View File

@@ -1,13 +1,13 @@
import React, { useState, useMemo } from 'react'; // Убрали useEffect import React, { useState, useMemo } from 'react';
import { Card, Button, Flex, AutoComplete, InputNumber, Typography, Select } from 'antd'; import { Card, Button, Flex, AutoComplete, InputNumber, Typography, Select } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { CatalogSelect } from './CatalogSelect'; import { CatalogSelect } from './CatalogSelect';
import type { CatalogItem, UnmatchedItem } from '../../services/types'; import type { CatalogItem, UnmatchedItem, ProductSearchResult } from '../../services/types';
const { Text } = Typography; const { Text } = Typography;
interface Props { interface Props {
catalog: CatalogItem[]; catalog: CatalogItem[]; // Оставляем для совместимости, но CatalogSelect его больше не использует
unmatched?: UnmatchedItem[]; unmatched?: UnmatchedItem[];
onSave: (rawName: string, productId: string, quantity: number, containerId?: string) => void; onSave: (rawName: string, productId: string, quantity: number, containerId?: string) => void;
isLoading: boolean; isLoading: boolean;
@@ -16,6 +16,9 @@ interface Props {
export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave, isLoading }) => { export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave, isLoading }) => {
const [rawName, setRawName] = useState(''); const [rawName, setRawName] = useState('');
const [selectedProduct, setSelectedProduct] = useState<string | undefined>(undefined); const [selectedProduct, setSelectedProduct] = useState<string | undefined>(undefined);
// Сохраняем полный объект товара, полученный из поиска, чтобы иметь доступ к containers
const [selectedProductData, setSelectedProductData] = useState<ProductSearchResult | undefined>(undefined);
const [quantity, setQuantity] = useState<number | null>(1); const [quantity, setQuantity] = useState<number | null>(1);
const [selectedContainer, setSelectedContainer] = useState<string | null>(null); const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
@@ -26,23 +29,31 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
})); }));
}, [unmatched]); }, [unmatched]);
const selectedCatalogItem = useMemo(() => { // Вычисляем активный товар: либо из результатов поиска, либо ищем в старом каталоге (если он передан)
if (!selectedProduct) return null; const activeProduct = useMemo(() => {
return catalog.find(item => item.id === selectedProduct || item.ID === selectedProduct); if (selectedProductData) return selectedProductData;
}, [selectedProduct, catalog]); if (selectedProduct && catalog.length > 0) {
// Приводим типы, так как CatalogItem расширяет ProductSearchResult
return catalog.find(item => item.id === selectedProduct) as unknown as ProductSearchResult;
}
return null;
}, [selectedProduct, selectedProductData, catalog]);
// Хендлер смены товара: сразу сбрасываем фасовку // Хендлер смены товара: принимаем и ID, и объект
const handleProductChange = (val: string) => { const handleProductChange = (val: string, productObj?: ProductSearchResult) => {
setSelectedProduct(val); setSelectedProduct(val);
if (productObj) {
setSelectedProductData(productObj);
}
setSelectedContainer(null); setSelectedContainer(null);
}; };
// Мемоизируем список контейнеров, чтобы он был стабильной зависимостью
const containers = useMemo(() => { const containers = useMemo(() => {
return selectedCatalogItem?.containers || selectedCatalogItem?.Containers || []; return activeProduct?.containers || [];
}, [selectedCatalogItem]); }, [activeProduct]);
const baseUom = selectedCatalogItem?.measure_unit || selectedCatalogItem?.MeasureUnit || 'ед.'; // Берем единицу измерения с учетом новой структуры (main_unit)
const baseUom = activeProduct?.main_unit?.name || activeProduct?.measure_unit || 'ед.';
const currentUomName = useMemo(() => { const currentUomName = useMemo(() => {
if (selectedContainer) { if (selectedContainer) {
@@ -58,6 +69,7 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
setRawName(''); setRawName('');
setSelectedProduct(undefined); setSelectedProduct(undefined);
setSelectedProductData(undefined);
setQuantity(1); setQuantity(1);
setSelectedContainer(null); setSelectedContainer(null);
} }
@@ -85,9 +97,9 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
<div> <div>
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Товар в iiko:</div> <div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Товар в iiko:</div>
<CatalogSelect <CatalogSelect
catalog={catalog} // Удален проп catalog={catalog}, так как компонент теперь ищет товары сам
value={selectedProduct} value={selectedProduct}
onChange={handleProductChange} // Используем новый хендлер onChange={handleProductChange}
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
@@ -103,7 +115,7 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
{ value: null, label: `Базовая единица (${baseUom})` }, { value: null, label: `Базовая единица (${baseUom})` },
...containers.map(c => ({ ...containers.map(c => ({
value: c.id, value: c.id,
label: `${c.name} (=${c.count} ${baseUom})` label: `${c.name} (=${Number(c.count)} ${baseUom})`
})) }))
]} ]}
/> />

View File

@@ -1,56 +1,92 @@
import React, { useMemo } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Select } from 'antd'; import { Select, Spin } from 'antd';
import type { CatalogItem } from '../../services/types'; import { api } from '../../services/api';
import type { CatalogItem, ProductSearchResult } from '../../services/types';
interface Props { interface Props {
catalog: CatalogItem[];
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string, productObj?: ProductSearchResult) => void;
disabled?: boolean; disabled?: boolean;
initialProduct?: CatalogItem | ProductSearchResult;
} }
export const CatalogSelect: React.FC<Props> = ({ catalog, value, onChange, disabled }) => { // Интерфейс для элемента выпадающего списка
const options = useMemo(() => { interface SelectOption {
return catalog.map((item) => { label: string;
const name = item.name || item.Name || 'Неизвестный товар'; value: string;
// Гарантируем строку. Если ID нет, будет пустая строка, которую мы отфильтруем. data: ProductSearchResult;
const id = item.id || item.ID || ''; }
const code = item.code || item.Code || '';
// const uom = item.measure_unit || item.MeasureUnit || ''; // Можно добавить в label
return { export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, initialProduct }) => {
const [options, setOptions] = useState<SelectOption[]>([]);
const [fetching, setFetching] = useState(false);
const fetchRef = useRef<number | null>(null);
useEffect(() => {
if (initialProduct && initialProduct.id === value) {
const name = initialProduct.name;
const code = initialProduct.code;
setOptions([{
label: code ? `${name} [${code}]` : name, label: code ? `${name} [${code}]` : name,
value: id, value: initialProduct.id,
code: code, data: initialProduct as ProductSearchResult
name: name, }]);
}
}, [initialProduct, value]);
const fetchProducts = async (search: string) => {
if (!search) return;
setFetching(true);
setOptions([]);
try {
const results = await api.searchProducts(search);
const newOptions = results.map(item => ({
label: item.code ? `${item.name} [${item.code}]` : item.name,
value: item.id,
data: item
}));
setOptions(newOptions);
} catch (e) {
console.error(e);
} finally {
setFetching(false);
}
}; };
})
// TypeScript Predicate: явно говорим компилятору, что после фильтра value точно string (и не пустая)
.filter((opt): opt is { label: string; value: string; code: string; name: string } => !!opt.value);
}, [catalog]);
const filterOption = (input: string, option?: { label: string; value: string; code: string; name: string }) => { const handleSearch = (val: string) => {
if (!option) return false; if (fetchRef.current !== null) {
window.clearTimeout(fetchRef.current);
}
fetchRef.current = window.setTimeout(() => {
fetchProducts(val);
}, 500);
};
const search = input.toLowerCase(); // Исправлено: добавлен | undefined для option
const name = (option.name || '').toLowerCase(); const handleChange = (val: string, option: SelectOption | SelectOption[] | undefined) => {
const code = (option.code || '').toLowerCase(); if (onChange) {
// В single mode option - это один объект или undefined
return name.includes(search) || code.includes(search); const opt = Array.isArray(option) ? option[0] : option;
onChange(val, opt?.data);
}
}; };
return ( return (
<Select <Select
showSearch showSearch
placeholder="Выберите товар из iiko" placeholder="Начните вводить название товара..."
optionFilterProp="children" filterOption={false}
filterOption={filterOption} onSearch={handleSearch}
notFoundContent={fetching ? <Spin size="small" /> : null}
options={options} options={options}
value={value} value={value}
onChange={onChange} onChange={handleChange}
disabled={disabled} disabled={disabled}
style={{ width: '100%' }} style={{ width: '100%' }}
listHeight={256} listHeight={256}
allowClear
/> />
); );
}; };

View File

@@ -13,9 +13,9 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
const [searchText, setSearchText] = React.useState(''); const [searchText, setSearchText] = React.useState('');
const filteredData = matches.filter(item => { const filteredData = matches.filter(item => {
const raw = (item.raw_name || item.RawName || '').toLowerCase(); const raw = (item.raw_name || '').toLowerCase();
const prod = item.product || item.Product; const prod = item.product;
const prodName = (prod?.name || prod?.Name || '').toLowerCase(); const prodName = (prod?.name || '').toLowerCase();
const search = searchText.toLowerCase(); const search = searchText.toLowerCase();
return raw.includes(search) || prodName.includes(search); return raw.includes(search) || prodName.includes(search);
}); });
@@ -37,14 +37,14 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
locale={{ emptyText: <Empty description="Нет данных" /> }} locale={{ emptyText: <Empty description="Нет данных" /> }}
pagination={{ pageSize: 10, size: "small", simple: true }} pagination={{ pageSize: 10, size: "small", simple: true }}
renderItem={(item) => { renderItem={(item) => {
// Унификация полей // Унификация полей (только snake_case)
const rawName = item.raw_name || item.RawName || 'Без названия'; const rawName = item.raw_name || 'Без названия';
const product = item.product || item.Product; const product = item.product;
const productName = product?.name || product?.Name || 'Товар не найден'; const productName = product?.name || 'Товар не найден';
const qty = item.quantity || item.Quantity || 1; const qty = item.quantity || 1;
// Логика отображения Единицы или Фасовки // Логика отображения Единицы или Фасовки
const container = item.container || item.Container; const container = item.container;
let displayUnit = ''; let displayUnit = '';
if (container) { if (container) {
@@ -52,7 +52,7 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
displayUnit = container.name; displayUnit = container.name;
} else { } else {
// Иначе базовая ед.: "кг" // Иначе базовая ед.: "кг"
displayUnit = product?.measure_unit || product?.MeasureUnit || 'ед.'; displayUnit = product?.measure_unit || 'ед.';
} }
return ( return (

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { List, Typography, Tag, Spin, Empty } from 'antd';
import { useNavigate } from 'react-router-dom';
import { ArrowRightOutlined } from '@ant-design/icons';
import { api } from '../services/api';
const { Title, Text } = Typography;
export const DraftsList: React.FC = () => {
const navigate = useNavigate();
const { data: drafts, isLoading, isError } = useQuery({
queryKey: ['drafts'],
queryFn: api.getDrafts,
refetchOnWindowFocus: true
});
const getStatusTag = (status: string) => {
switch (status) {
case 'PROCESSING': return <Tag color="blue">Обработка</Tag>;
case 'READY_TO_VERIFY': return <Tag color="orange">Проверка</Tag>;
case 'COMPLETED': return <Tag color="green">Готово</Tag>;
case 'ERROR': return <Tag color="red">Ошибка</Tag>;
case 'CANCELED': return <Tag color="default" style={{ color: '#999' }}>Отменен</Tag>;
default: return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return <div style={{ textAlign: 'center', padding: 40 }}><Spin size="large" /></div>;
}
if (isError) {
return <div style={{ padding: 20, textAlign: 'center' }}>Ошибка загрузки списка</div>;
}
return (
<div style={{ padding: '0 16px 20px' }}>
<Title level={4} style={{ marginTop: 16, marginBottom: 16 }}>Черновики накладных</Title>
{(!drafts || drafts.length === 0) ? (
<Empty description="Нет активных черновиков" />
) : (
<List
itemLayout="horizontal"
dataSource={drafts}
renderItem={(item) => (
<List.Item
style={{
background: '#fff',
padding: 12,
marginBottom: 8,
borderRadius: 8,
cursor: 'pointer',
opacity: item.status === 'CANCELED' ? 0.6 : 1 // Делаем отмененные бледными
}}
onClick={() => navigate(`/invoice/${item.id}`)}
>
<div style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text strong style={{ fontSize: 16, textDecoration: item.status === 'CANCELED' ? 'line-through' : 'none' }}>
{item.document_number || 'Без номера'}
</Text>
{getStatusTag(item.status)}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#888', fontSize: 13 }}>
<span>{new Date(item.date_incoming).toLocaleDateString()}</span>
<span>{item.items_count} поз.</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6, alignItems: 'center' }}>
<Text strong>
{item.total_sum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
</Text>
<ArrowRightOutlined style={{ color: '#1890ff' }} />
</div>
</div>
</List.Item>
)}
/>
)}
</div>
);
};

View File

@@ -3,16 +3,17 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
Spin, Alert, Button, Form, Select, DatePicker, Input, Spin, Alert, Button, Form, Select, DatePicker, Input,
Typography, message, Row, Col, Affix Typography, message, Row, Col, Affix, Modal, Tag
} from 'antd'; } from 'antd';
import { ArrowLeftOutlined, CheckOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined, CheckOutlined, DeleteOutlined, ExclamationCircleFilled, RestOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { api } from '../services/api'; import { api } from '../services/api';
import { DraftItemRow } from '../components/invoices/DraftItemRow'; import { DraftItemRow } from '../components/invoices/DraftItemRow';
import type { UpdateDraftItemRequest, CommitDraftRequest } from '../services/types'; import type { UpdateDraftItemRequest, CommitDraftRequest } from '../services/types';
const { Title, Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
const { confirm } = Modal;
export const InvoiceDraftPage: React.FC = () => { export const InvoiceDraftPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -20,27 +21,26 @@ export const InvoiceDraftPage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [form] = Form.useForm(); const [form] = Form.useForm();
// Локальное состояние для отслеживания какие строки сейчас обновляются
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set()); const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
// 1. Загрузка справочников // --- ЗАПРОСЫ ---
const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores }); const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores });
const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers }); const suppliersQuery = useQuery({ queryKey: ['suppliers'], queryFn: api.getSuppliers });
const catalogQuery = useQuery({ queryKey: ['catalog'], queryFn: api.getCatalogItems, staleTime: 1000 * 60 * 10 }); const recommendationsQuery = useQuery({ queryKey: ['recommendations'], queryFn: api.getRecommendations });
// 2. Загрузка черновика
const draftQuery = useQuery({ const draftQuery = useQuery({
queryKey: ['draft', id], queryKey: ['draft', id],
queryFn: () => api.getDraft(id!), queryFn: () => api.getDraft(id!),
enabled: !!id, enabled: !!id,
refetchInterval: (query) => { refetchInterval: (query) => {
const status = query.state.data?.status; const status = query.state.data?.status;
// Продолжаем опрашивать, пока статус PROCESSING, чтобы подтянуть новые товары, если они долетают
return status === 'PROCESSING' ? 3000 : false; return status === 'PROCESSING' ? 3000 : false;
}, },
}); });
// 3. Мутация обновления строки const draft = draftQuery.data;
// ... (МУТАЦИИ оставляем без изменений) ...
const updateItemMutation = useMutation({ const updateItemMutation = useMutation({
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) => mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
api.updateDraftItem(id!, vars.itemId, vars.payload), api.updateDraftItem(id!, vars.itemId, vars.payload),
@@ -62,7 +62,6 @@ export const InvoiceDraftPage: React.FC = () => {
} }
}); });
// 4. Мутация коммита
const commitMutation = useMutation({ const commitMutation = useMutation({
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload), mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
onSuccess: (data) => { onSuccess: (data) => {
@@ -74,33 +73,35 @@ export const InvoiceDraftPage: React.FC = () => {
} }
}); });
const draft = draftQuery.data; const deleteDraftMutation = useMutation({
mutationFn: () => api.deleteDraft(id!),
onSuccess: () => {
if (draft?.status === 'CANCELED') {
message.info('Черновик удален окончательно');
navigate('/invoices');
} else {
message.warning('Черновик отменен');
queryClient.invalidateQueries({ queryKey: ['draft', id] });
}
},
onError: () => {
message.error('Ошибка при удалении');
}
});
// Инициализация формы. // --- ЭФФЕКТЫ ---
// Убрали проверку status !== 'PROCESSING', чтобы форма заполнялась сразу, как пришли данные.
useEffect(() => { useEffect(() => {
if (draft) { if (draft) {
// Проверяем, не менял ли пользователь уже поля, чтобы не перезатирать их при поллинге
const currentValues = form.getFieldsValue(); const currentValues = form.getFieldsValue();
if (!currentValues.store_id && draft.store_id) { if (!currentValues.store_id && draft.store_id) form.setFieldValue('store_id', draft.store_id);
form.setFieldValue('store_id', draft.store_id); if (!currentValues.supplier_id && draft.supplier_id) form.setFieldValue('supplier_id', draft.supplier_id);
} if (!currentValues.comment && draft.comment) form.setFieldValue('comment', draft.comment);
if (!currentValues.supplier_id && draft.supplier_id) { if (!currentValues.date_incoming) form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
form.setFieldValue('supplier_id', draft.supplier_id);
}
if (!currentValues.comment && draft.comment) {
form.setFieldValue('comment', draft.comment);
}
// Дату ставим, если её нет в форме
if (!currentValues.date_incoming) {
form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
}
} }
}, [draft, form]); }, [draft, form]);
// Вычисляемые данные для UI // --- ХЕЛПЕРЫ ---
const totalSum = useMemo(() => { const totalSum = useMemo(() => {
// Добавил Number(), так как API возвращает строки ("250"), а нам нужны числа
return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0; return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0;
}, [draft?.items]); }, [draft?.items]);
@@ -116,10 +117,9 @@ export const InvoiceDraftPage: React.FC = () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
if (invalidItemsCount > 0) { if (invalidItemsCount > 0) {
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров! Удалите их или сопоставьте.`); message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`);
return; return;
} }
commitMutation.mutate({ commitMutation.mutate({
date_incoming: values.date_incoming.format('YYYY-MM-DD'), date_incoming: values.date_incoming.format('YYYY-MM-DD'),
store_id: values.store_id, store_id: values.store_id,
@@ -127,115 +127,147 @@ export const InvoiceDraftPage: React.FC = () => {
comment: values.comment || '', comment: values.comment || '',
}); });
} catch { } catch {
message.error('Заполните обязательные поля в шапке (Склад, Поставщик)'); message.error('Заполните обязательные поля');
} }
}; };
// --- Рендер --- const isCanceled = draft?.status === 'CANCELED';
// Показываем спиннер ТОЛЬКО если данных нет вообще, или статус PROCESSING и список пуст. const handleDelete = () => {
// Если статус PROCESSING, но items уже пришли — показываем интерфейс. confirm({
title: isCanceled ? 'Удалить окончательно?' : 'Отменить черновик?',
icon: <ExclamationCircleFilled style={{ color: 'red' }} />,
content: isCanceled
? 'Черновик пропадет из списка навсегда.'
: 'Черновик получит статус "Отменен", но останется в списке.',
okText: isCanceled ? 'Удалить навсегда' : 'Отменить',
okType: 'danger',
cancelText: 'Назад',
onOk() {
deleteDraftMutation.mutate();
},
});
};
// --- RENDER ---
const showSpinner = draftQuery.isLoading || (draft?.status === 'PROCESSING' && (!draft?.items || draft.items.length === 0)); const showSpinner = draftQuery.isLoading || (draft?.status === 'PROCESSING' && (!draft?.items || draft.items.length === 0));
if (showSpinner) { if (showSpinner) {
return ( return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
<div style={{ textAlign: 'center', padding: 50 }}>
<Spin size="large" tip="Обработка чека..." />
<div style={{ marginTop: 16, color: '#888' }}>ИИ читает ваш чек, подождите...</div>
</div>
);
} }
if (draftQuery.isError || !draft) { if (draftQuery.isError || !draft) {
return <Alert type="error" message="Ошибка загрузки черновика" description="Попробуйте обновить страницу" />; return <Alert type="error" message="Ошибка загрузки черновика" />;
} }
return ( return (
<div style={{ paddingBottom: 80 }}> <div style={{ paddingBottom: 60 }}>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}> {/* Header: Уплотненный, без переноса слов */}
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')} /> <div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<Title level={4} style={{ margin: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
Черновик {draft.document_number ? `${draft.document_number}` : ''} <Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/invoices')} size="small" />
{draft.status === 'PROCESSING' && <Spin size="small" style={{ marginLeft: 8 }} />}
</Title> {/* Контейнер заголовка и бейджа */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 18, fontWeight: 'bold', whiteSpace: 'nowrap' }}>
{draft.document_number ? `${draft.document_number}` : 'Черновик'}
</span>
{draft.status === 'PROCESSING' && <Spin size="small" />}
{isCanceled && <Tag color="red" style={{ margin: 0 }}>ОТМЕНЕН</Tag>}
</div>
</div> </div>
<div style={{ background: '#fff', padding: 16, borderRadius: 8, marginBottom: 16 }}> <Button
danger={isCanceled}
type={isCanceled ? 'primary' : 'default'}
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
onClick={handleDelete}
loading={deleteDraftMutation.isPending}
size="small"
>
{isCanceled ? 'Удалить' : 'Отмена'}
</Button>
</div>
{/* Form: Compact margins */}
<div style={{ background: '#fff', padding: 12, borderRadius: 8, marginBottom: 12, opacity: isCanceled ? 0.6 : 1 }}>
<Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}> <Form form={form} layout="vertical" initialValues={{ date_incoming: dayjs() }}>
<Row gutter={12}> <Row gutter={10}>
<Col span={12}> <Col span={12}>
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]}> <Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" /> <DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" size="middle" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="Склад" name="store_id" rules={[{ required: true, message: 'Выберите склад' }]}> <Form.Item label="Склад" name="store_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<Select <Select
placeholder="Куда?" placeholder="Куда?"
loading={storesQuery.isLoading} loading={storesQuery.isLoading}
options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))} options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))}
size="middle"
/> />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]}>
<Select <Select
placeholder="От кого?" placeholder="От кого?"
loading={suppliersQuery.isLoading} loading={suppliersQuery.isLoading}
options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))} options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))}
size="middle"
/> />
</Form.Item> </Form.Item>
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
<Form.Item label="Комментарий" name="comment"> <TextArea rows={1} placeholder="Комментарий..." style={{ fontSize: 13 }} />
<TextArea rows={1} placeholder="Прим: Довоз за пятницу" />
</Form.Item> </Form.Item>
</Form> </Form>
</div> </div>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> {/* Items Header */}
<Title level={5} style={{ margin: 0 }}>Позиции ({draft.items.length})</Title> <div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 4px' }}>
{invalidItemsCount > 0 && <Text type="danger">{invalidItemsCount} нераспознано</Text>} <Text strong>Позиции ({draft.items.length})</Text>
{invalidItemsCount > 0 && <Text type="danger" style={{ fontSize: 12 }}>{invalidItemsCount} нераспознано</Text>}
</div> </div>
{/* Items List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{draft.items.map(item => ( {draft.items.map(item => (
<DraftItemRow <DraftItemRow
key={item.id} key={item.id}
item={item} item={item}
catalog={catalogQuery.data || []}
onUpdate={handleItemUpdate} onUpdate={handleItemUpdate}
isUpdating={updatingItems.has(item.id)} isUpdating={updatingItems.has(item.id)}
recommendations={recommendationsQuery.data || []}
/> />
))} ))}
</div> </div>
<Affix offsetBottom={0}> {/* Footer Actions */}
<Affix offsetBottom={60} /* Высота нижнего меню */>
<div style={{ <div style={{
background: '#fff', background: '#fff',
padding: '12px 16px', padding: '8px 16px',
borderTop: '1px solid #eee', borderTop: '1px solid #eee',
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)', boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
display: 'flex', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
justifyContent: 'space-between', borderRadius: '8px 8px 0 0' // Скругление сверху
alignItems: 'center'
}}> }}>
<div> <div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 12, color: '#888' }}>Итого:</div> <span style={{ fontSize: 11, color: '#888', lineHeight: 1 }}>Итого:</span>
<div style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}> <span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff', lineHeight: 1.2 }}>
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })} {totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 })}
</div> </span>
</div> </div>
<Button <Button
type="primary" type="primary"
size="large"
icon={<CheckOutlined />} icon={<CheckOutlined />}
onClick={handleCommit} onClick={handleCommit}
loading={commitMutation.isPending} loading={commitMutation.isPending}
disabled={invalidItemsCount > 0} disabled={invalidItemsCount > 0 || isCanceled}
style={{ height: 40, padding: '0 24px' }}
> >
Отправить в iiko {isCanceled ? 'Восстановить' : 'Отправить'}
</Button> </Button>
</div> </div>
</Affix> </Affix>

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
import type { import type {
CatalogItem, CatalogItem,
CreateInvoiceRequest, CreateInvoiceRequest,
MatchRequest, // Используем новый тип MatchRequest,
HealthResponse, HealthResponse,
InvoiceResponse, InvoiceResponse,
ProductMatch, ProductMatch,
@@ -12,7 +12,11 @@ import type {
Supplier, Supplier,
DraftInvoice, DraftInvoice,
UpdateDraftItemRequest, UpdateDraftItemRequest,
CommitDraftRequest CommitDraftRequest,
// Новые типы
ProductSearchResult,
AddContainerRequest,
AddContainerResponse
} from './types'; } from './types';
// Базовый URL // Базовый URL
@@ -33,7 +37,7 @@ apiClient.interceptors.response.use(
} }
); );
// Мок поставщиков (так как эндпоинта пока нет) // Мок поставщиков
const MOCK_SUPPLIERS: Supplier[] = [ const MOCK_SUPPLIERS: Supplier[] = [
{ id: '00000000-0000-0000-0000-000000000001', name: 'ООО "Рога и Копыта"' }, { id: '00000000-0000-0000-0000-000000000001', name: 'ООО "Рога и Копыта"' },
{ id: '00000000-0000-0000-0000-000000000002', name: 'ИП Иванов (Овощи)' }, { id: '00000000-0000-0000-0000-000000000002', name: 'ИП Иванов (Овощи)' },
@@ -41,6 +45,17 @@ const MOCK_SUPPLIERS: Supplier[] = [
{ id: '00000000-0000-0000-0000-000000000004', name: 'Simple Wine' }, { id: '00000000-0000-0000-0000-000000000004', name: 'Simple Wine' },
]; ];
// интерфейс для списка (краткий)
export interface DraftSummary {
id: string;
document_number: string;
date_incoming: string;
status: string;
items_count: number;
total_sum: number;
store_name?: string;
}
export const api = { export const api = {
checkHealth: async (): Promise<HealthResponse> => { checkHealth: async (): Promise<HealthResponse> => {
const { data } = await apiClient.get<HealthResponse>('/health'); const { data } = await apiClient.get<HealthResponse>('/health');
@@ -52,11 +67,28 @@ export const api = {
return data; return data;
}, },
// Оставляем для совместимости со старыми компонентами (если используются),
// но в Draft Flow будем использовать поиск.
getCatalogItems: async (): Promise<CatalogItem[]> => { getCatalogItems: async (): Promise<CatalogItem[]> => {
const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog'); const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog');
return data; return data;
}, },
// Поиск товаров ---
searchProducts: async (query: string): Promise<ProductSearchResult[]> => {
const { data } = await apiClient.get<ProductSearchResult[]>('/ocr/search', {
params: { q: query }
});
return data;
},
// Создание фасовки ---
createContainer: async (payload: AddContainerRequest): Promise<AddContainerResponse> => {
// Внимание: URL эндпоинта взят из вашего ТЗ (/drafts/container)
const { data } = await apiClient.post<AddContainerResponse>('/drafts/container', payload);
return data;
},
getMatches: async (): Promise<ProductMatch[]> => { getMatches: async (): Promise<ProductMatch[]> => {
const { data } = await apiClient.get<ProductMatch[]>('/ocr/matches'); const { data } = await apiClient.get<ProductMatch[]>('/ocr/matches');
return data; return data;
@@ -67,7 +99,6 @@ export const api = {
return data; return data;
}, },
// Обновили тип аргумента payload
createMatch: async (payload: MatchRequest): Promise<{ status: string }> => { createMatch: async (payload: MatchRequest): Promise<{ status: string }> => {
const { data } = await apiClient.post<{ status: string }>('/ocr/match', payload); const { data } = await apiClient.post<{ status: string }>('/ocr/match', payload);
return data; return data;
@@ -77,39 +108,41 @@ export const api = {
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload); const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
return data; return data;
}, },
// Получить список складов
getStores: async (): Promise<Store[]> => { getStores: async (): Promise<Store[]> => {
const { data } = await apiClient.get<Store[]>('/dictionaries/stores'); const { data } = await apiClient.get<Store[]>('/dictionaries/stores');
return data; return data;
}, },
// Получить список поставщиков (Mock)
getSuppliers: async (): Promise<Supplier[]> => { getSuppliers: async (): Promise<Supplier[]> => {
// Имитация асинхронности
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => resolve(MOCK_SUPPLIERS), 300); setTimeout(() => resolve(MOCK_SUPPLIERS), 300);
}); });
}, },
// Получить черновик
getDraft: async (id: string): Promise<DraftInvoice> => { getDraft: async (id: string): Promise<DraftInvoice> => {
const { data } = await apiClient.get<DraftInvoice>(`/drafts/${id}`); const { data } = await apiClient.get<DraftInvoice>(`/drafts/${id}`);
return data; return data;
}, },
// Обновить строку черновика (и обучить модель) // Получить список черновиков
getDrafts: async (): Promise<DraftSummary[]> => {
const { data } = await apiClient.get<DraftSummary[]>('/drafts');
return data;
},
updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => { updateDraftItem: async (draftId: string, itemId: string, payload: UpdateDraftItemRequest): Promise<DraftInvoice> => {
// Бэкенд возвращает обновленный черновик целиком (обычно) или обновленный item.
// Предположим, что возвращается обновленный Item или просто 200 OK.
// Но для React Query удобно возвращать данные.
// Если бэк возвращает только item, типизацию нужно уточнить. Пока ждем DraftInvoice или any.
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload); const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
return data; return data;
}, },
// Зафиксировать черновик
commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => { commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload); const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
return data; return data;
}, },
// Отменить/Удалить черновик
deleteDraft: async (id: string): Promise<void> => {
await apiClient.delete(`/drafts/${id}`);
},
}; };

View File

@@ -10,15 +10,36 @@ export interface ProductContainer {
count: number; // 0.180 count: number; // 0.180
} }
export interface CatalogItem { // Запрос на создание фасовки
// Основные поля (snake_case) export interface AddContainerRequest {
product_id: UUID;
name: string; // "Бутылка 0.75"
count: number; // 0.75
}
// Ответ на создание фасовки
export interface AddContainerResponse {
status: string;
container_id: UUID;
}
// Результат поиска товара
export interface ProductSearchResult {
id: UUID; id: UUID;
name: string; name: string;
code: string; code: string;
measure_unit: string; // "кг", "л" num?: string;
containers: ProductContainer[]; // Массив фасовок
// Fallback (на всякий случай) // Обновляем структуру единицы измерения
main_unit?: MainUnit;
measure_unit?: string; // Оставим для совместимости, но брать будем из main_unit.name
containers: ProductContainer[];
}
// Совместимость с CatalogItem (чтобы не ломать старый код, если он где-то используется)
export interface CatalogItem extends ProductSearchResult {
// Fallback поля
ID?: UUID; ID?: UUID;
Name?: string; Name?: string;
Code?: string; Code?: string;
@@ -32,11 +53,10 @@ export interface MatchRequest {
raw_name: string; raw_name: string;
product_id: UUID; product_id: UUID;
quantity: number; quantity: number;
container_id?: UUID; // Новое поле container_id?: UUID;
} }
export interface ProductMatch { export interface ProductMatch {
// snake_case (v2.0)
raw_name: string; raw_name: string;
product_id: UUID; product_id: UUID;
product?: CatalogItem; product?: CatalogItem;
@@ -44,14 +64,6 @@ export interface ProductMatch {
container_id?: UUID; container_id?: UUID;
container?: ProductContainer; container?: ProductContainer;
updated_at: string; updated_at: string;
// Fallback
RawName?: string;
ProductID?: UUID;
Product?: CatalogItem;
Quantity?: number;
ContainerId?: UUID;
Container?: ProductContainer;
} }
// --- Нераспознанное --- // --- Нераспознанное ---
@@ -62,7 +74,7 @@ export interface UnmatchedItem {
last_seen: string; last_seen: string;
} }
// --- Остальные типы (без изменений) --- // --- Остальные типы ---
export interface Recommendation { export interface Recommendation {
ID: UUID; ID: UUID;
@@ -111,7 +123,7 @@ export interface Supplier {
// --- Черновик Накладной (Draft) --- // --- Черновик Накладной (Draft) ---
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR'; export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED';
export interface DraftItem { export interface DraftItem {
id: UUID; id: UUID;
@@ -161,3 +173,8 @@ export interface CommitDraftRequest {
supplier_id: UUID; supplier_id: UUID;
comment: string; comment: string;
} }
export interface MainUnit {
id: UUID;
name: string; // "кг"
code: string;
}