mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
Полноценно редактируются черновики
Добавляются фасовки как в черновике, так и в обучении Исправил внешний вид
This commit is contained in:
@@ -160,9 +160,12 @@ func main() {
|
||||
api.POST("/invoices/send", invoiceHandler.SendInvoice)
|
||||
|
||||
// Черновики
|
||||
api.GET("/drafts", draftsHandler.GetDrafts)
|
||||
api.GET("/drafts/:id", draftsHandler.GetDraft)
|
||||
api.DELETE("/drafts/:id", draftsHandler.DeleteDraft)
|
||||
api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem)
|
||||
api.POST("/drafts/:id/commit", draftsHandler.CommitDraft)
|
||||
api.POST("/drafts/container", draftsHandler.AddContainer) // Добавление новой фасовки
|
||||
|
||||
// Склады
|
||||
api.GET("/dictionaries/stores", draftsHandler.GetStores)
|
||||
@@ -176,6 +179,7 @@ func main() {
|
||||
api.POST("/ocr/match", ocrHandler.SaveMatch)
|
||||
api.DELETE("/ocr/match", ocrHandler.DeleteMatch)
|
||||
api.GET("/ocr/unmatched", ocrHandler.GetUnmatched)
|
||||
api.GET("/ocr/search", ocrHandler.SearchProducts)
|
||||
}
|
||||
|
||||
// Простой хелсчек
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
|
||||
name: rmser
|
||||
services:
|
||||
# 1. База данных PostgreSQL
|
||||
db:
|
||||
@@ -49,7 +51,7 @@ services:
|
||||
- REDIS_ADDR=redis:6379
|
||||
- OCR_SERVICE_URL=http://ocr:5000
|
||||
|
||||
# 5. Frontend (React + Nginx) - НОВОЕ
|
||||
# 5. Frontend (React + Nginx)
|
||||
frontend:
|
||||
build: ./rmser-view
|
||||
container_name: rmser_frontend
|
||||
|
||||
@@ -53,6 +53,8 @@ type Product struct {
|
||||
type Repository interface {
|
||||
SaveMeasureUnits(units []MeasureUnit) error
|
||||
SaveProducts(products []Product) error
|
||||
SaveContainer(container ProductContainer) error // Добавление фасовки
|
||||
Search(query string) ([]Product, error)
|
||||
GetAll() ([]Product, error)
|
||||
GetActiveGoods() ([]Product, error)
|
||||
// --- Stores ---
|
||||
|
||||
@@ -15,23 +15,27 @@ const (
|
||||
StatusReadyToVerify = "READY_TO_VERIFY" // Распознано, ждет проверки пользователем
|
||||
StatusCompleted = "COMPLETED" // Отправлено в RMS
|
||||
StatusError = "ERROR" // Ошибка обработки
|
||||
StatusCanceled = "CANCELED" // Пользователь отменил
|
||||
StatusDeleted = "DELETED" // Пользователь удалил
|
||||
)
|
||||
|
||||
// DraftInvoice - Черновик накладной
|
||||
type DraftInvoice struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ChatID int64 `gorm:"index" json:"chat_id"` // ID чата в Telegram (кто прислал)
|
||||
SenderPhotoURL string `gorm:"type:text" json:"photo_url"` // Ссылка на фото (если нужно отобразить на фронте)
|
||||
SenderPhotoURL string `gorm:"type:text" json:"photo_url"` // Ссылка на фото
|
||||
Status string `gorm:"type:varchar(50);default:'PROCESSING'" json:"status"`
|
||||
|
||||
// Данные для отправки в RMS (заполняются пользователем)
|
||||
// Данные для отправки в RMS
|
||||
DocumentNumber string `gorm:"type:varchar(100)" json:"document_number"`
|
||||
DateIncoming *time.Time `json:"date_incoming"`
|
||||
SupplierID *uuid.UUID `gorm:"type:uuid" json:"supplier_id"`
|
||||
StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"`
|
||||
Comment string `gorm:"type:text" json:"comment"`
|
||||
|
||||
// Связь с созданной накладной (когда статус COMPLETED)
|
||||
StoreID *uuid.UUID `gorm:"type:uuid" json:"store_id"`
|
||||
// Связь со складом для Preload
|
||||
Store *catalog.Store `gorm:"foreignKey:StoreID" json:"store,omitempty"`
|
||||
|
||||
Comment string `gorm:"type:text" json:"comment"`
|
||||
RMSInvoiceID *uuid.UUID `gorm:"type:uuid" json:"rms_invoice_id"`
|
||||
|
||||
Items []DraftInvoiceItem `gorm:"foreignKey:DraftID;constraint:OnDelete:CASCADE" json:"items"`
|
||||
@@ -73,4 +77,5 @@ type Repository interface {
|
||||
// UpdateItem обновляет конкретную строку (например, при ручном выборе товара)
|
||||
UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error
|
||||
Delete(id uuid.UUID) error
|
||||
GetActive() ([]DraftInvoice, error)
|
||||
}
|
||||
|
||||
@@ -26,13 +26,14 @@ type Invoice struct {
|
||||
|
||||
// InvoiceItem - Позиция накладной
|
||||
type InvoiceItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||
InvoiceID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null"`
|
||||
Amount decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
Price decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
Sum decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
VatSum decimal.Decimal `gorm:"type:numeric(19,4)"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()"`
|
||||
InvoiceID uuid.UUID `gorm:"type:uuid;not null;index"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null"`
|
||||
ContainerID *uuid.UUID `gorm:"type:uuid"`
|
||||
Amount decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
Price decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
Sum decimal.Decimal `gorm:"type:numeric(19,4);not null"`
|
||||
VatSum decimal.Decimal `gorm:"type:numeric(19,4)"`
|
||||
|
||||
Product catalog.Product `gorm:"foreignKey:ProductID"`
|
||||
}
|
||||
|
||||
@@ -132,3 +132,30 @@ func (r *pgRepository) GetActiveStores() ([]catalog.Store, error) {
|
||||
err := r.db.Where("is_deleted = ?", false).Order("name ASC").Find(&stores).Error
|
||||
return stores, err
|
||||
}
|
||||
|
||||
// SaveContainer сохраняет или обновляет одну фасовку
|
||||
func (r *pgRepository) SaveContainer(container catalog.ProductContainer) error {
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
UpdateAll: true,
|
||||
}).Create(&container).Error
|
||||
}
|
||||
|
||||
// Search ищет товары по названию, артикулу или коду (ILIKE)
|
||||
func (r *pgRepository) Search(query string) ([]catalog.Product, error) {
|
||||
var products []catalog.Product
|
||||
|
||||
// Оборачиваем в проценты для поиска подстроки
|
||||
q := "%" + query + "%"
|
||||
|
||||
err := r.db.
|
||||
Preload("MainUnit").
|
||||
Preload("Containers"). // Обязательно грузим фасовки, они нужны для выбора
|
||||
Where("is_deleted = ? AND type = ?", false, "GOODS").
|
||||
Where("name ILIKE ? OR code ILIKE ? OR num ILIKE ?", q, q, q).
|
||||
Order("name ASC").
|
||||
Limit(20). // Ограничиваем выдачу, чтобы не перегружать фронт
|
||||
Find(&products).Error
|
||||
|
||||
return products, err
|
||||
}
|
||||
|
||||
@@ -83,3 +83,24 @@ func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, contai
|
||||
func (r *pgRepository) Delete(id uuid.UUID) error {
|
||||
return r.db.Delete(&drafts.DraftInvoice{}, id).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetActive() ([]drafts.DraftInvoice, error) {
|
||||
var list []drafts.DraftInvoice
|
||||
|
||||
// Выбираем статусы, которые считаем "активными"
|
||||
activeStatuses := []string{
|
||||
drafts.StatusProcessing,
|
||||
drafts.StatusReadyToVerify,
|
||||
drafts.StatusError,
|
||||
drafts.StatusCanceled,
|
||||
}
|
||||
|
||||
err := r.db.
|
||||
Preload("Items"). // Нужны для подсчета суммы и количества
|
||||
Preload("Store"). // Нужно для названия склада
|
||||
Where("status IN ?", activeStatuses).
|
||||
Order("created_at DESC").
|
||||
Find(&list).Error
|
||||
|
||||
return list, err
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ type ClientI interface {
|
||||
FetchInvoices(from, to time.Time) ([]invoices.Invoice, error)
|
||||
FetchStoreOperations(presetID string, from, to time.Time) ([]StoreReportItemXML, error)
|
||||
CreateIncomingInvoice(inv invoices.Invoice) (string, error)
|
||||
GetProductByID(id uuid.UUID) (*ProductFullDTO, error)
|
||||
UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
@@ -571,14 +573,20 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
||||
price, _ := item.Price.Float64()
|
||||
sum, _ := item.Sum.Float64()
|
||||
|
||||
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, IncomingInvoiceImportItemXML{
|
||||
xmlItem := IncomingInvoiceImportItemXML{
|
||||
ProductID: item.ProductID.String(),
|
||||
Amount: amount,
|
||||
Price: price,
|
||||
Sum: sum,
|
||||
Num: i + 1,
|
||||
Store: inv.DefaultStoreID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
if item.ContainerID != nil && *item.ContainerID != uuid.Nil {
|
||||
xmlItem.ContainerId = item.ContainerID.String()
|
||||
}
|
||||
|
||||
reqDTO.ItemsWrapper.Items = append(reqDTO.ItemsWrapper.Items, xmlItem)
|
||||
}
|
||||
|
||||
// 2. Маршалинг в XML
|
||||
@@ -613,7 +621,6 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
||||
zap.String("url", fullURL),
|
||||
zap.String("body_payload", string(xmlPayload)),
|
||||
)
|
||||
// ----------------------------------------
|
||||
|
||||
// 5. Отправка
|
||||
req, err := http.NewRequest("POST", fullURL, bytes.NewReader(xmlPayload))
|
||||
@@ -659,3 +666,92 @@ func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) {
|
||||
|
||||
return result.DocumentNumber, nil
|
||||
}
|
||||
|
||||
// GetProductByID получает полную структуру товара по ID (через /list?ids=...)
|
||||
func (c *Client) GetProductByID(id uuid.UUID) (*ProductFullDTO, error) {
|
||||
// Параметр ids должен быть списком. iiko ожидает ids=UUID
|
||||
params := map[string]string{
|
||||
"ids": id.String(),
|
||||
"includeDeleted": "false",
|
||||
}
|
||||
|
||||
resp, err := c.doRequest("GET", "/resto/api/v2/entities/products/list", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request error: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("rms error code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Ответ - это массив товаров
|
||||
var products []ProductFullDTO
|
||||
if err := json.NewDecoder(resp.Body).Decode(&products); err != nil {
|
||||
return nil, fmt.Errorf("json decode error: %w", err)
|
||||
}
|
||||
|
||||
if len(products) == 0 {
|
||||
return nil, fmt.Errorf("product not found in rms")
|
||||
}
|
||||
|
||||
return &products[0], nil
|
||||
}
|
||||
|
||||
// UpdateProduct отправляет полную структуру товара на обновление (/update)
|
||||
func (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error) {
|
||||
// Маршалим тело
|
||||
bodyBytes, err := json.Marshal(product)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("json marshal error: %w", err)
|
||||
}
|
||||
|
||||
// Используем doRequestPost (надо реализовать или вручную, т.к. doRequest у нас GET-ориентирован в текущем коде был прост)
|
||||
// Расширим логику doRequest или напишем тут, т.к. это POST с JSON body
|
||||
if err := c.ensureToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.mu.RLock()
|
||||
token := c.token
|
||||
c.mu.RUnlock()
|
||||
|
||||
endpoint := c.baseURL + "/resto/api/v2/entities/products/update?key=" + token
|
||||
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("update failed (code %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result UpdateEntityResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("response unmarshal error: %w", err)
|
||||
}
|
||||
|
||||
if result.Result != "SUCCESS" {
|
||||
// Собираем ошибки
|
||||
errMsg := "rms update error: "
|
||||
for _, e := range result.Errors {
|
||||
errMsg += fmt.Sprintf("[%s] %s; ", e.Code, e.Value)
|
||||
}
|
||||
return nil, fmt.Errorf(errMsg)
|
||||
}
|
||||
|
||||
if result.Response == nil {
|
||||
return nil, fmt.Errorf("empty response from rms after update")
|
||||
}
|
||||
|
||||
return result.Response, nil
|
||||
}
|
||||
|
||||
@@ -74,6 +74,66 @@ type AssemblyItemDTO struct {
|
||||
AmountOut float64 `json:"amountOut"`
|
||||
}
|
||||
|
||||
// ProductFullDTO используется для получения (list?ids=...) и обновления (update) товара целиком.
|
||||
type ProductFullDTO struct {
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Num string `json:"num"`
|
||||
Code string `json:"code"`
|
||||
Parent *string `json:"parent"` // null или UUID
|
||||
Modifiers []interface{} `json:"modifiers"` // Оставляем interface{}, чтобы не мапить сложную структуру, если не меняем её
|
||||
TaxCategory *string `json:"taxCategory"`
|
||||
Category *string `json:"category"`
|
||||
AccountingCategory *string `json:"accountingCategory"`
|
||||
Color map[string]int `json:"color"`
|
||||
FontColor map[string]int `json:"fontColor"`
|
||||
FrontImageID *string `json:"frontImageId"`
|
||||
Position *int `json:"position"`
|
||||
ModifierSchemaID *string `json:"modifierSchemaId"`
|
||||
MainUnit string `json:"mainUnit"` // Обязательное поле
|
||||
ExcludedSections []string `json:"excludedSections"` // Set<UUID>
|
||||
DefaultSalePrice float64 `json:"defaultSalePrice"`
|
||||
PlaceType *string `json:"placeType"`
|
||||
DefaultIncInMenu bool `json:"defaultIncludedInMenu"`
|
||||
Type string `json:"type"` // GOODS, DISH...
|
||||
UnitWeight float64 `json:"unitWeight"`
|
||||
UnitCapacity float64 `json:"unitCapacity"`
|
||||
StoreBalanceLevels []StoreBalanceLevel `json:"storeBalanceLevels"`
|
||||
UseBalanceForSell bool `json:"useBalanceForSell"`
|
||||
Containers []ContainerFullDTO `json:"containers"`
|
||||
ProductScaleID *string `json:"productScaleId"`
|
||||
Barcodes []interface{} `json:"barcodes"`
|
||||
ColdLossPercent float64 `json:"coldLossPercent"`
|
||||
HotLossPercent float64 `json:"hotLossPercent"`
|
||||
OuterCode *string `json:"outerEconomicActivityNomenclatureCode"`
|
||||
AllergenGroups *string `json:"allergenGroups"`
|
||||
EstPurchasePrice float64 `json:"estimatedPurchasePrice"`
|
||||
CanSetOpenPrice bool `json:"canSetOpenPrice"`
|
||||
NotInStoreMovement bool `json:"notInStoreMovement"`
|
||||
}
|
||||
|
||||
type StoreBalanceLevel struct {
|
||||
StoreID string `json:"storeId"`
|
||||
MinBalanceLevel *float64 `json:"minBalanceLevel"`
|
||||
MaxBalanceLevel *float64 `json:"maxBalanceLevel"`
|
||||
}
|
||||
|
||||
type ContainerFullDTO struct {
|
||||
ID *string `json:"id,omitempty"` // При создании новой фасовки ID пустой/null
|
||||
Num string `json:"num"` // Порядковый номер? Обычно строка.
|
||||
Name string `json:"name"`
|
||||
Count float64 `json:"count"`
|
||||
MinContainerWeight float64 `json:"minContainerWeight"`
|
||||
MaxContainerWeight float64 `json:"maxContainerWeight"`
|
||||
ContainerWeight float64 `json:"containerWeight"`
|
||||
FullContainerWeight float64 `json:"fullContainerWeight"`
|
||||
BackwardRecalculation bool `json:"backwardRecalculation"`
|
||||
Deleted bool `json:"deleted"`
|
||||
UseInFront bool `json:"useInFront"`
|
||||
}
|
||||
|
||||
// --- XML DTOs (Legacy API) ---
|
||||
|
||||
type IncomingInvoiceListXML struct {
|
||||
@@ -149,15 +209,14 @@ type IncomingInvoiceImportXML struct {
|
||||
}
|
||||
|
||||
type IncomingInvoiceImportItemXML struct {
|
||||
ProductID string `xml:"product"` // GUID товара
|
||||
Amount float64 `xml:"amount"` // Кол-во в базовых единицах
|
||||
Price float64 `xml:"price"` // Цена за единицу
|
||||
Sum float64 `xml:"sum,omitempty"`
|
||||
Store string `xml:"store"` // GUID склада
|
||||
// Поля ниже можно опустить, если iiko должна сама подтянуть их из карточки товара
|
||||
// или если мы работаем в базовых единицах.
|
||||
AmountUnit string `xml:"amountUnit,omitempty"` // GUID единицы измерения
|
||||
Num int `xml:"num,omitempty"` // Номер строки
|
||||
ProductID string `xml:"product"` // GUID товара
|
||||
Amount float64 `xml:"amount"` // Кол-во (в фасовках, если указан containerId)
|
||||
Price float64 `xml:"price"` // Цена за единицу (за фасовку, если указан containerId)
|
||||
Sum float64 `xml:"sum,omitempty"` // Сумма
|
||||
Store string `xml:"store"` // GUID склада
|
||||
ContainerId string `xml:"containerId,omitempty"` // ID фасовки
|
||||
AmountUnit string `xml:"amountUnit,omitempty"` // GUID единицы измерения (можно опустить, если фасовка)
|
||||
Num int `xml:"num,omitempty"`
|
||||
}
|
||||
|
||||
// DocumentValidationResult описывает ответ сервера при импорте
|
||||
@@ -170,3 +229,17 @@ type DocumentValidationResult struct {
|
||||
ErrorMessage string `xml:"errorMessage"`
|
||||
AdditionalInfo string `xml:"additionalInfo"`
|
||||
}
|
||||
|
||||
// --- Вспомогательные DTO для ответов (REST) ---
|
||||
|
||||
// UpdateEntityResponse - ответ на /save или /update
|
||||
type UpdateEntityResponse struct {
|
||||
Result string `json:"result"` // "SUCCESS" or "ERROR"
|
||||
Response *ProductFullDTO `json:"response"`
|
||||
Errors []ErrorDTO `json:"errors"`
|
||||
}
|
||||
|
||||
type ErrorDTO struct {
|
||||
Code string `json:"code"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package drafts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -42,6 +44,37 @@ func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) {
|
||||
return s.draftRepo.GetByID(id)
|
||||
}
|
||||
|
||||
// DeleteDraft реализует логику "Отмена -> Удаление"
|
||||
func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
|
||||
draft, err := s.draftRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Сценарий 2: Если уже ОТМЕНЕН -> УДАЛЯЕМ (Soft Delete статусом)
|
||||
if draft.Status == drafts.StatusCanceled {
|
||||
draft.Status = drafts.StatusDeleted
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logger.Log.Info("Черновик удален (скрыт)", zap.String("id", id.String()))
|
||||
return drafts.StatusDeleted, nil
|
||||
}
|
||||
|
||||
// Сценарий 1: Если активен -> ОТМЕНЯЕМ
|
||||
// Разрешаем отменять только незавершенные
|
||||
if draft.Status != drafts.StatusCompleted && draft.Status != drafts.StatusDeleted {
|
||||
draft.Status = drafts.StatusCanceled
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logger.Log.Info("Черновик перемещен в отмененные", zap.String("id", id.String()))
|
||||
return drafts.StatusCanceled, nil
|
||||
}
|
||||
|
||||
return draft.Status, nil
|
||||
}
|
||||
|
||||
// UpdateDraftHeader обновляет шапку (дата, поставщик, склад, комментарий)
|
||||
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error {
|
||||
draft, err := s.draftRepo.GetByID(id)
|
||||
@@ -60,10 +93,26 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
|
||||
return s.draftRepo.Update(draft)
|
||||
}
|
||||
|
||||
// UpdateItem обновляет позицию (Без сохранения обучения!)
|
||||
// UpdateItem обновляет позицию с авто-восстановлением статуса черновика
|
||||
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||||
// Мы просто обновляем данные в черновике.
|
||||
// Сохранение в базу знаний (OCR Matches) произойдет только при отправке накладной.
|
||||
// 1. Проверяем статус черновика для реализации Auto-Restore
|
||||
draft, err := s.draftRepo.GetByID(draftID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Если черновик был в корзине (CANCELED), возвращаем его в работу
|
||||
if draft.Status == drafts.StatusCanceled {
|
||||
draft.Status = drafts.StatusReadyToVerify
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
logger.Log.Error("Не удалось восстановить статус черновика при редактировании", zap.Error(err))
|
||||
// Не прерываем выполнение, пробуем обновить строку
|
||||
} else {
|
||||
logger.Log.Info("Черновик автоматически восстановлен из отмененных", zap.String("id", draftID.String()))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Обновляем саму строку (существующий вызов репозитория)
|
||||
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
|
||||
}
|
||||
|
||||
@@ -104,8 +153,7 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
||||
for _, dItem := range draft.Items {
|
||||
if dItem.ProductID == nil {
|
||||
// Пропускаем нераспознанные или кидаем ошибку?
|
||||
// Лучше пропустить, чтобы не блокировать отправку частичного документа
|
||||
continue
|
||||
break
|
||||
}
|
||||
|
||||
// Расчет суммы (если не задана, считаем)
|
||||
@@ -114,34 +162,15 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
|
||||
sum = dItem.Quantity.Mul(dItem.Price)
|
||||
}
|
||||
|
||||
// Важный момент с фасовками:
|
||||
// Клиент RMS (CreateIncomingInvoice) у нас пока не поддерживает отправку container_id в явном виде,
|
||||
// или мы его обновили? Проверим `internal/infrastructure/rms/client.go`.
|
||||
// Там используется `IncomingInvoiceImportItemXML`. В ней нет поля ContainerID, но есть `AmountUnit`.
|
||||
// Если мы хотим передать фасовку, нужно передавать Amount в базовых единицах,
|
||||
// ЛИБО доработать клиент iiko, чтобы он принимал `amountUnit` (ID фасовки).
|
||||
|
||||
// СТРАТЕГИЯ СЕЙЧАС:
|
||||
// Считаем, что FrontEnd/Service уже пересчитал кол-во в базовые единицы?
|
||||
// НЕТ. DraftItem хранит Quantity в тех единицах, которые выбрал юзер (фасовках).
|
||||
// Нам нужно конвертировать в базовые для отправки, если мы не умеем слать фасовки.
|
||||
|
||||
// Но погоди, в `ProductContainer` есть `Count` (коэффициент).
|
||||
finalAmount := dItem.Quantity
|
||||
if dItem.ContainerID != nil && dItem.Container != nil {
|
||||
// Если выбрана фасовка, умножаем кол-во упаковок на коэффициент
|
||||
finalAmount = finalAmount.Mul(dItem.Container.Count)
|
||||
invItem := invoices.InvoiceItem{
|
||||
ProductID: *dItem.ProductID,
|
||||
Amount: dItem.Quantity,
|
||||
Price: dItem.Price,
|
||||
Sum: sum,
|
||||
ContainerID: dItem.ContainerID,
|
||||
}
|
||||
|
||||
inv.Items = append(inv.Items, invoices.InvoiceItem{
|
||||
ProductID: *dItem.ProductID,
|
||||
Amount: finalAmount,
|
||||
Price: dItem.Price, // Цена обычно за упаковку... А iiko ждет цену за базу?
|
||||
// RMS API: Если мы шлем в базовых единицах, то и цену надо пересчитать за базовую.
|
||||
// Price (base) = Price (pack) / Count
|
||||
// ЛИБО: Мы шлем Sum, а iiko сама посчитает цену. Это надежнее.
|
||||
Sum: sum,
|
||||
})
|
||||
inv.Items = append(inv.Items, invItem)
|
||||
}
|
||||
|
||||
if len(inv.Items) == 0 {
|
||||
@@ -198,3 +227,106 @@ func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) {
|
||||
func (s *Service) GetActiveStores() ([]catalog.Store, error) {
|
||||
return s.catalogRepo.GetActiveStores()
|
||||
}
|
||||
|
||||
// GetActiveDrafts возвращает список черновиков в работе
|
||||
func (s *Service) GetActiveDrafts() ([]drafts.DraftInvoice, error) {
|
||||
return s.draftRepo.GetActive()
|
||||
}
|
||||
|
||||
// CreateProductContainer создает новую фасовку в iiko и сохраняет её в локальной БД
|
||||
// Возвращает UUID созданной фасовки.
|
||||
func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
|
||||
// 1. Получаем полную карточку товара из iiko
|
||||
// Используем инфраструктурный DTO, так как нам нужна полная структура для апдейта
|
||||
fullProduct, err := s.rmsClient.GetProductByID(productID)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("ошибка получения товара из iiko: %w", err)
|
||||
}
|
||||
|
||||
// 2. Валидация на дубликаты (по имени или коэффициенту)
|
||||
// iiko разрешает дубли, но нам это не нужно.
|
||||
targetCount, _ := count.Float64()
|
||||
for _, c := range fullProduct.Containers {
|
||||
if !c.Deleted && (c.Name == name || (c.Count == targetCount)) {
|
||||
// Если такая фасовка уже есть, возвращаем её ID
|
||||
// (Можно добавить логику обновления имени, но пока просто вернем ID)
|
||||
if c.ID != nil && *c.ID != "" {
|
||||
return uuid.Parse(*c.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Вычисляем следующий num (iiko использует строки "1", "2"...)
|
||||
maxNum := 0
|
||||
for _, c := range fullProduct.Containers {
|
||||
if n, err := strconv.Atoi(c.Num); err == nil {
|
||||
if n > maxNum {
|
||||
maxNum = n
|
||||
}
|
||||
}
|
||||
}
|
||||
nextNum := strconv.Itoa(maxNum + 1)
|
||||
|
||||
// 4. Добавляем новую фасовку в список
|
||||
newContainerDTO := rms.ContainerFullDTO{
|
||||
ID: nil, // Null, чтобы iiko создала новый ID
|
||||
Num: nextNum,
|
||||
Name: name,
|
||||
Count: targetCount,
|
||||
UseInFront: true,
|
||||
Deleted: false,
|
||||
// Остальные поля можно оставить 0/false по умолчанию
|
||||
}
|
||||
|
||||
fullProduct.Containers = append(fullProduct.Containers, newContainerDTO)
|
||||
|
||||
// 5. Отправляем обновление в iiko
|
||||
updatedProduct, err := s.rmsClient.UpdateProduct(*fullProduct)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("ошибка обновления товара в iiko: %w", err)
|
||||
}
|
||||
|
||||
// 6. Ищем нашу созданную фасовку в ответе, чтобы получить её ID
|
||||
// Ищем по уникальной комбинации Name + Count, которую мы только что отправили
|
||||
var createdID uuid.UUID
|
||||
found := false
|
||||
|
||||
for _, c := range updatedProduct.Containers {
|
||||
// Сравниваем float с небольшим эпсилоном на всякий случай, хотя JSON должен вернуть точно
|
||||
if c.Name == name && c.Count == targetCount && !c.Deleted {
|
||||
if c.ID != nil {
|
||||
createdID, err = uuid.Parse(*c.ID)
|
||||
if err == nil {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return uuid.Nil, errors.New("фасовка отправлена, но сервер не вернул её ID (возможно, ошибка логики поиска)")
|
||||
}
|
||||
|
||||
// 7. Сохраняем новую фасовку в локальную БД
|
||||
newLocalContainer := catalog.ProductContainer{
|
||||
ID: createdID,
|
||||
ProductID: productID,
|
||||
Name: name,
|
||||
Count: count,
|
||||
}
|
||||
|
||||
if err := s.catalogRepo.SaveContainer(newLocalContainer); err != nil {
|
||||
logger.Log.Error("Ошибка сохранения новой фасовки в локальную БД", zap.Error(err))
|
||||
// Не возвращаем ошибку клиенту, так как в iiko она уже создана.
|
||||
// Просто в следующем SyncCatalog она подтянется, но лучше иметь её сразу.
|
||||
}
|
||||
|
||||
logger.Log.Info("Создана новая фасовка",
|
||||
zap.String("product_id", productID.String()),
|
||||
zap.String("container_id", createdID.String()),
|
||||
zap.String("name", name),
|
||||
zap.String("count", count.String()))
|
||||
|
||||
return createdID, nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package ocr
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shopspring/decimal"
|
||||
@@ -84,11 +83,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
|
||||
item.ProductID = &match.ProductID
|
||||
item.ContainerID = match.ContainerID
|
||||
|
||||
// Важная логика: Если в матче указано ContainerID, то Quantity из чека (например 5 шт)
|
||||
// это 5 коробок. Финальное кол-во (в кг) RMS посчитает сама,
|
||||
// либо мы можем пересчитать тут, если знаем коэффициент.
|
||||
// Пока оставляем Quantity как есть (кол-во упаковок),
|
||||
// так как ContainerID передается в iiko.
|
||||
} else {
|
||||
// Если не нашли - сохраняем в Unmatched для статистики и подсказок
|
||||
if err := s.ocrRepo.UpsertUnmatched(rawItem.RawName); err != nil {
|
||||
@@ -100,11 +94,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
|
||||
}
|
||||
|
||||
// 4. Сохраняем позиции в БД
|
||||
// Примечание: GORM умеет сохранять вложенные структуры через Update родителя,
|
||||
// но надежнее явно сохранить items, если мы не используем Session FullSaveAssociations.
|
||||
// В данном случае мы уже создали Draft, теперь привяжем к нему items.
|
||||
// Для простоты, так как у нас в Repo нет метода SaveItems,
|
||||
// мы обновим драфт, добавив Items (GORM должен создать их).
|
||||
draft.Status = drafts.StatusReadyToVerify
|
||||
|
||||
if err := s.draftRepo.Update(draft); err != nil {
|
||||
@@ -112,18 +101,6 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, chatID int64, imgData
|
||||
}
|
||||
draft.Items = draftItems
|
||||
|
||||
// Используем хак GORM: при обновлении объекта с ассоциациями, он их создаст.
|
||||
// Но надежнее расширить репозиторий. Давай используем Repository Update,
|
||||
// но он у нас обновляет только шапку.
|
||||
// Поэтому лучше расширим draftRepo методом SaveItems или используем прямую запись тут через items?
|
||||
// Сделаем правильно: добавим AddItems в репозиторий прямо сейчас, или воспользуемся тем, что Items сохранятся
|
||||
// если мы сделаем Save через GORM. В нашем Repo метод Create делает Create.
|
||||
// Давайте сделаем SaveItems в репозитории drafts, чтобы было чисто.
|
||||
|
||||
// ВРЕМЕННОЕ РЕШЕНИЕ (чтобы не менять интерфейс снова):
|
||||
// Мы можем создать items через repository, но там нет метода.
|
||||
// Давай я добавлю метод в интерфейс репозитория Drafts в следующем блоке изменений.
|
||||
// Пока предположим, что мы расширили репозиторий.
|
||||
if err := s.draftRepo.CreateItems(draftItems); err != nil {
|
||||
return nil, fmt.Errorf("failed to save items: %w", err)
|
||||
}
|
||||
@@ -218,25 +195,11 @@ func (s *Service) FindKnownMatch(rawName string) (*ocr.ProductMatch, error) {
|
||||
return s.ocrRepo.FindMatch(rawName)
|
||||
}
|
||||
|
||||
// SearchProducts ищет товары в БД по части названия (для ручного выбора в боте)
|
||||
// SearchProducts ищет товары в БД по части названия, коду или артикулу
|
||||
func (s *Service) SearchProducts(query string) ([]catalog.Product, error) {
|
||||
// Этот метод нужно поддержать в репозитории, пока сделаем заглушку или фильтрацию в памяти
|
||||
// Для MVP добавим метод SearchByName в интерфейс репозитория
|
||||
all, err := s.catalogRepo.GetActiveGoods()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if len(query) < 2 {
|
||||
// Слишком короткий запрос, возвращаем пустой список
|
||||
return []catalog.Product{}, nil
|
||||
}
|
||||
|
||||
// Простейший поиск в памяти (для начала хватит)
|
||||
query = strings.ToLower(query)
|
||||
var result []catalog.Product
|
||||
for _, p := range all {
|
||||
if strings.Contains(strings.ToLower(p.Name), query) {
|
||||
result = append(result, p)
|
||||
if len(result) >= 10 { // Ограничим выдачу
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
return s.catalogRepo.Search(query)
|
||||
}
|
||||
|
||||
@@ -149,3 +149,129 @@ func (h *DraftsHandler) CommitDraft(c *gin.Context) {
|
||||
"document_number": docNum,
|
||||
})
|
||||
}
|
||||
|
||||
// AddContainerRequestDTO - запрос на создание фасовки
|
||||
type AddContainerRequestDTO struct {
|
||||
ProductID string `json:"product_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Count float64 `json:"count" binding:"required,gt=0"`
|
||||
}
|
||||
|
||||
// AddContainer создает новую фасовку для товара
|
||||
func (h *DraftsHandler) AddContainer(c *gin.Context) {
|
||||
var req AddContainerRequestDTO
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pID, err := uuid.Parse(req.ProductID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid product_id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Конвертация float64 -> decimal
|
||||
countDec := decimal.NewFromFloat(req.Count)
|
||||
|
||||
// Вызов сервиса
|
||||
newID, err := h.service.CreateProductContainer(pID, req.Name, countDec)
|
||||
if err != nil {
|
||||
logger.Log.Error("Failed to create container", zap.Error(err))
|
||||
// Можно возвращать 502, если ошибка от RMS
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "created",
|
||||
"container_id": newID.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// DraftListItemDTO - структура элемента списка
|
||||
type DraftListItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
DocumentNumber string `json:"document_number"`
|
||||
DateIncoming string `json:"date_incoming"` // YYYY-MM-DD
|
||||
Status string `json:"status"`
|
||||
ItemsCount int `json:"items_count"`
|
||||
TotalSum float64 `json:"total_sum"`
|
||||
StoreName string `json:"store_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetDrafts возвращает список активных черновиков
|
||||
func (h *DraftsHandler) GetDrafts(c *gin.Context) {
|
||||
list, err := h.service.GetActiveDrafts()
|
||||
if err != nil {
|
||||
logger.Log.Error("Failed to fetch drafts", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]DraftListItemDTO, 0, len(list))
|
||||
|
||||
for _, d := range list {
|
||||
// Расчет суммы
|
||||
var totalSum decimal.Decimal
|
||||
for _, item := range d.Items {
|
||||
// Если item.Sum посчитана - берем её, иначе (qty * price)
|
||||
if !item.Sum.IsZero() {
|
||||
totalSum = totalSum.Add(item.Sum)
|
||||
} else {
|
||||
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
|
||||
}
|
||||
}
|
||||
|
||||
sumFloat, _ := totalSum.Float64()
|
||||
|
||||
// Форматирование даты
|
||||
dateStr := ""
|
||||
if d.DateIncoming != nil {
|
||||
dateStr = d.DateIncoming.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Имя склада
|
||||
storeName := ""
|
||||
if d.Store != nil {
|
||||
storeName = d.Store.Name
|
||||
}
|
||||
|
||||
response = append(response, DraftListItemDTO{
|
||||
ID: d.ID.String(),
|
||||
DocumentNumber: d.DocumentNumber,
|
||||
DateIncoming: dateStr,
|
||||
Status: d.Status,
|
||||
ItemsCount: len(d.Items),
|
||||
TotalSum: sumFloat,
|
||||
StoreName: storeName,
|
||||
CreatedAt: d.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteDraft обрабатывает запрос на удаление/отмену
|
||||
func (h *DraftsHandler) DeleteDraft(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
newStatus, err := h.service.DeleteDraft(id)
|
||||
if err != nil {
|
||||
logger.Log.Error("Failed to delete draft", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Возвращаем новый статус, чтобы фронтенд знал, удалился он совсем или стал CANCELED
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": newStatus,
|
||||
"id": id.String(),
|
||||
})
|
||||
}
|
||||
@@ -92,6 +92,26 @@ func (h *OCRHandler) DeleteMatch(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
|
||||
// SearchProducts ищет товары (для автокомплита)
|
||||
func (h *OCRHandler) SearchProducts(c *gin.Context) {
|
||||
query := c.Query("q") // ?q=молоко
|
||||
if query == "" {
|
||||
c.JSON(http.StatusOK, []interface{}{})
|
||||
return
|
||||
}
|
||||
|
||||
products, err := h.service.SearchProducts(query)
|
||||
if err != nil {
|
||||
logger.Log.Error("Search error", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Отдаем на фронт упрощенную структуру или полную, в зависимости от нужд.
|
||||
// Product entity уже содержит JSON теги, так что можно отдать напрямую.
|
||||
c.JSON(http.StatusOK, products)
|
||||
}
|
||||
|
||||
// GetMatches возвращает список всех обученных связей
|
||||
func (h *OCRHandler) GetMatches(c *gin.Context) {
|
||||
matches, err := h.service.GetKnownMatches()
|
||||
|
||||
@@ -3,10 +3,8 @@ import { Providers } from './components/layout/Providers';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { OcrLearning } from './pages/OcrLearning';
|
||||
import { InvoiceDraftPage } from './pages/InvoiceDraftPage'; // Импорт
|
||||
|
||||
// Заглушки для списка накладных пока оставим (или можно сделать пустую страницу)
|
||||
const InvoicesListPage = () => <h2>История накладных (в разработке)</h2>;
|
||||
import { InvoiceDraftPage } from './pages/InvoiceDraftPage';
|
||||
import { DraftsList } from './pages/DraftsList';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -17,11 +15,11 @@ function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<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>
|
||||
|
||||
84
rmser-view/src/components/invoices/CreateContainerModal.tsx
Normal file
84
rmser-view/src/components/invoices/CreateContainerModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,162 +1,243 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, Flex, InputNumber, Typography, Select, Tag } from 'antd';
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Card, Flex, InputNumber, Typography, Select, Tag, Button, Divider, Modal } from 'antd';
|
||||
import { SyncOutlined, PlusOutlined, WarningFilled } from '@ant-design/icons';
|
||||
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;
|
||||
|
||||
interface Props {
|
||||
item: DraftItem;
|
||||
catalog: CatalogItem[];
|
||||
onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void;
|
||||
isUpdating: boolean; // Флаг, что конкретно эта строка сейчас сохраняется
|
||||
isUpdating: boolean;
|
||||
recommendations?: Recommendation[]; // Новый проп
|
||||
}
|
||||
|
||||
export const DraftItemRow: React.FC<Props> = ({ item, catalog, onUpdate, isUpdating }) => {
|
||||
// 1. Поиск выбранного товара в полном каталоге, чтобы получить доступ к containers
|
||||
const selectedProductObj = useMemo(() => {
|
||||
if (!item.product_id) return null;
|
||||
return catalog.find(c => c.id === item.product_id || c.ID === item.product_id);
|
||||
}, [item.product_id, catalog]);
|
||||
export const DraftItemRow: React.FC<Props> = ({ item, onUpdate, isUpdating, recommendations = [] }) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// State Input
|
||||
const [localQuantity, setLocalQuantity] = useState<string | null>(item.quantity?.toString() ?? null);
|
||||
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(() => {
|
||||
if (!selectedProductObj) return [];
|
||||
const conts = selectedProductObj.containers || selectedProductObj.Containers || [];
|
||||
const baseUom = selectedProductObj.measure_unit || selectedProductObj.MeasureUnit || 'ед.';
|
||||
|
||||
return [
|
||||
{ value: null, label: `Базовая (${baseUom})` }, // null значит базовая единица
|
||||
...conts.map(c => ({
|
||||
if (!activeProduct) return [];
|
||||
const opts = [
|
||||
{ value: 'BASE_UNIT', label: `Базовая (${baseUom})` },
|
||||
...containers.map(c => ({
|
||||
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) => {
|
||||
// При смене товара: сбрасываем фасовку, подставляем исходные кол-во/цену, если они были нулями (логика "default")
|
||||
// Но по ТЗ: "При выборе товара автоматически подставлять quantity = raw_amount..."
|
||||
// Это лучше делать, передавая эти данные.
|
||||
|
||||
onUpdate(item.id, {
|
||||
product_id: prodId,
|
||||
container_id: null, // Сброс фасовки
|
||||
quantity: item.quantity || item.raw_amount || 1,
|
||||
price: item.price || item.raw_price || 0
|
||||
const showWarningModal = () => {
|
||||
if (!activeWarning) return;
|
||||
Modal.warning({
|
||||
title: 'Внимание: проблемный товар',
|
||||
content: (
|
||||
<div>
|
||||
<p><b>{activeWarning.ProductName}</b></p>
|
||||
<p>{activeWarning.Reason}</p>
|
||||
<p><Tag color="orange">{activeWarning.Type}</Tag></p>
|
||||
</div>
|
||||
),
|
||||
okText: 'Понятно',
|
||||
maskClosable: true
|
||||
});
|
||||
};
|
||||
|
||||
const handleContainerChange = (val: string | null) => {
|
||||
// При смене фасовки просто шлем ID. Сервер сам не пересчитывает цифры, фронт тоже не должен.
|
||||
// Пользователь сам поправит цену, если она изменилась за упаковку.
|
||||
onUpdate(item.id, {
|
||||
container_id: val || null // Antd Select может вернуть undefined, приводим к null
|
||||
});
|
||||
// --- Helpers ---
|
||||
const parseToNum = (val: string | null | undefined): number => {
|
||||
if (!val) return 0;
|
||||
return parseFloat(val.replace(',', '.'));
|
||||
};
|
||||
|
||||
const handleBlur = (field: 'quantity' | 'price', val: number | null) => {
|
||||
// Сохраняем только если значение изменилось и валидно
|
||||
if (val === null) return;
|
||||
if (val === item[field]) return;
|
||||
const getUpdatePayload = (overrides: Partial<UpdateDraftItemRequest>): UpdateDraftItemRequest => {
|
||||
const currentQty = localQuantity !== null ? parseToNum(localQuantity) : item.quantity;
|
||||
const currentPrice = localPrice !== null ? parseToNum(localPrice) : item.price;
|
||||
|
||||
onUpdate(item.id, {
|
||||
[field]: val
|
||||
});
|
||||
return {
|
||||
product_id: item.product_id || undefined,
|
||||
container_id: item.container_id,
|
||||
quantity: currentQty ?? 1,
|
||||
price: currentPrice ?? 0,
|
||||
...overrides
|
||||
};
|
||||
};
|
||||
|
||||
// Вычисляем статус цвета
|
||||
const cardBorderColor = !item.product_id ? '#ffa39e' : item.is_matched ? '#b7eb8f' : '#d9d9d9'; // Красный если нет товара, Зеленый если сматчился сам, Серый если правим
|
||||
// --- Handlers ---
|
||||
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 (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderLeft: `4px solid ${cardBorderColor}`,
|
||||
background: item.product_id ? '#fff' : '#fff1f0' // Легкий красный фон если не распознан
|
||||
}}
|
||||
bodyStyle={{ padding: 12 }}
|
||||
>
|
||||
<Flex vertical gap="small">
|
||||
{/* Верхняя строка: Исходное название и статус */}
|
||||
<Flex justify="space-between" align="start">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.raw_name}
|
||||
</Text>
|
||||
{item.raw_amount > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
|
||||
(в чеке: {item.raw_amount} x {item.raw_price})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{isUpdating && <SyncOutlined spin style={{ color: '#1890ff' }} />}
|
||||
{!item.product_id && <Tag color="error">Не найден</Tag>}
|
||||
</div>
|
||||
</Flex>
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginBottom: 8, borderLeft: `4px solid ${cardBorderColor}`, background: item.product_id ? '#fff' : '#fff1f0' }}
|
||||
bodyStyle={{ padding: 12 }}
|
||||
>
|
||||
<Flex vertical gap={10}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.2, display: 'block' }}>{item.raw_name}</Text>
|
||||
{item.raw_amount > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 10, display: 'block' }}>(чек: {item.raw_amount} x {item.raw_price})</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{isUpdating && <SyncOutlined spin style={{ color: '#1890ff' }} />}
|
||||
|
||||
{/* Выбор товара */}
|
||||
<CatalogSelect
|
||||
catalog={catalog}
|
||||
value={item.product_id || undefined}
|
||||
onChange={handleProductChange}
|
||||
/>
|
||||
|
||||
{/* Нижний блок: Фасовка, Кол-во, Цена, Сумма */}
|
||||
<Flex gap={8} align="center">
|
||||
{/* Если есть фасовки, показываем селект. Если нет - просто лейбл ед. изм */}
|
||||
<div style={{ flex: 2, minWidth: 90 }}>
|
||||
{containerOptions.length > 1 ? (
|
||||
<Select
|
||||
size="middle"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Ед. изм."
|
||||
options={containerOptions}
|
||||
value={item.container_id || null} // null для базовой
|
||||
onChange={handleContainerChange}
|
||||
disabled={!item.product_id}
|
||||
{/* Warning Icon */}
|
||||
{activeWarning && (
|
||||
<WarningFilled
|
||||
style={{ color: '#faad14', fontSize: 16, cursor: 'pointer' }}
|
||||
onClick={showWarningModal}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '4px 11px', background: '#f5f5f5', borderRadius: 6, fontSize: 13, color: '#888', border: '1px solid #d9d9d9' }}>
|
||||
{selectedProductObj?.measure_unit || 'ед.'}
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
{!item.product_id && <Tag color="error" style={{ margin: 0 }}>?</Tag>}
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<CatalogSelect
|
||||
value={item.product_id || undefined}
|
||||
onChange={handleProductChange}
|
||||
initialProduct={activeProduct}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', minWidth: 60 }}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Цена"
|
||||
value={item.price}
|
||||
min={0}
|
||||
onBlur={(e) => handleBlur('price', parseFloat(e.target.value))}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 10 }}>Цена за ед.</Text>
|
||||
{activeProduct && (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Выберите единицу измерения"
|
||||
options={containerOptions}
|
||||
value={item.container_id || 'BASE_UNIT'}
|
||||
onChange={handleContainerChange}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: '4px 0' }} />
|
||||
<Button type="text" block icon={<PlusOutlined />} onClick={() => setIsModalOpen(true)} style={{ textAlign: 'left' }}>
|
||||
Добавить фасовку...
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
background: '#fafafa', margin: '0 -12px -12px -12px', padding: '8px 12px',
|
||||
borderTop: '1px solid #f0f0f0', borderBottomLeftRadius: 8, borderBottomRightRadius: 8
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<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')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{uiSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
{/* Итоговая сумма (расчетная) */}
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<Text strong>
|
||||
= {(item.quantity * item.price).toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
</Flex>
|
||||
</Card>
|
||||
{activeProduct && (
|
||||
<CreateContainerModal
|
||||
visible={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
productId={activeProduct.id}
|
||||
productBaseUnit={baseUom}
|
||||
onSuccess={handleContainerCreated}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,57 +1,86 @@
|
||||
import React from 'react';
|
||||
import { Layout, Menu, theme } from 'antd';
|
||||
import { Layout, theme } from 'antd';
|
||||
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 = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Получаем токены темы (чтобы подстроить AntD под Telegram можно позже настроить ConfigProvider)
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
token: { colorBgContainer, colorPrimary, colorTextSecondary },
|
||||
} = theme.useToken();
|
||||
|
||||
// Определяем активный пункт меню
|
||||
const selectedKey = location.pathname === '/' ? 'dashboard'
|
||||
: location.pathname.startsWith('/ocr') ? 'ocr'
|
||||
: location.pathname.startsWith('/invoices') ? 'invoices'
|
||||
: 'dashboard';
|
||||
const path = location.pathname;
|
||||
let activeKey = 'invoices';
|
||||
if (path.startsWith('/ocr')) activeKey = 'ocr';
|
||||
else if (path.startsWith('/settings')) activeKey = 'settings';
|
||||
|
||||
const menuItems = [
|
||||
{ key: 'dashboard', icon: <BarChartOutlined />, label: 'Дашборд', onClick: () => navigate('/') },
|
||||
{ key: 'ocr', icon: <ScanOutlined />, label: 'Обучение', onClick: () => navigate('/ocr') },
|
||||
{ key: 'invoices', icon: <FileTextOutlined />, label: 'Накладные', onClick: () => navigate('/invoices') },
|
||||
{ key: 'invoices', icon: <FileTextOutlined style={{ fontSize: 20 }} />, label: 'Накладные', path: '/invoices' },
|
||||
{ key: 'ocr', icon: <ScanOutlined style={{ fontSize: 20 }} />, label: 'Обучение', path: '/ocr' },
|
||||
{ key: 'settings', icon: <SettingOutlined style={{ fontSize: 20 }} />, label: 'Настройки', path: '#' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Header style={{ display: 'flex', alignItems: 'center', padding: 0 }}>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
</Header>
|
||||
<Content style={{ padding: '16px' }}>
|
||||
<Layout style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Верхнюю шапку (Header) удалили для экономии места */}
|
||||
|
||||
<Content style={{ padding: '0', flex: 1, overflowY: 'auto', marginBottom: 60 }}>
|
||||
{/* Убрали лишние паддинги вокруг контента для мобилок */}
|
||||
<div
|
||||
style={{
|
||||
background: colorBgContainer,
|
||||
minHeight: 280,
|
||||
padding: 24,
|
||||
borderRadius: borderRadiusLG,
|
||||
minHeight: '100%',
|
||||
padding: '12px 12px 80px 12px', // Добавили отступ снизу, чтобы контент не перекрывался меню
|
||||
borderRadius: 0, // На мобильных скругления углов всего экрана обычно не нужны
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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 { PlusOutlined } from '@ant-design/icons';
|
||||
import { CatalogSelect } from './CatalogSelect';
|
||||
import type { CatalogItem, UnmatchedItem } from '../../services/types';
|
||||
import type { CatalogItem, UnmatchedItem, ProductSearchResult } from '../../services/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
catalog: CatalogItem[];
|
||||
catalog: CatalogItem[]; // Оставляем для совместимости, но CatalogSelect его больше не использует
|
||||
unmatched?: UnmatchedItem[];
|
||||
onSave: (rawName: string, productId: string, quantity: number, containerId?: string) => void;
|
||||
isLoading: boolean;
|
||||
@@ -16,6 +16,9 @@ interface Props {
|
||||
export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave, isLoading }) => {
|
||||
const [rawName, setRawName] = useState('');
|
||||
const [selectedProduct, setSelectedProduct] = useState<string | undefined>(undefined);
|
||||
// Сохраняем полный объект товара, полученный из поиска, чтобы иметь доступ к containers
|
||||
const [selectedProductData, setSelectedProductData] = useState<ProductSearchResult | undefined>(undefined);
|
||||
|
||||
const [quantity, setQuantity] = useState<number | null>(1);
|
||||
const [selectedContainer, setSelectedContainer] = useState<string | null>(null);
|
||||
|
||||
@@ -26,23 +29,31 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
|
||||
}));
|
||||
}, [unmatched]);
|
||||
|
||||
const selectedCatalogItem = useMemo(() => {
|
||||
if (!selectedProduct) return null;
|
||||
return catalog.find(item => item.id === selectedProduct || item.ID === selectedProduct);
|
||||
}, [selectedProduct, catalog]);
|
||||
// Вычисляем активный товар: либо из результатов поиска, либо ищем в старом каталоге (если он передан)
|
||||
const activeProduct = useMemo(() => {
|
||||
if (selectedProductData) return selectedProductData;
|
||||
if (selectedProduct && catalog.length > 0) {
|
||||
// Приводим типы, так как CatalogItem расширяет ProductSearchResult
|
||||
return catalog.find(item => item.id === selectedProduct) as unknown as ProductSearchResult;
|
||||
}
|
||||
return null;
|
||||
}, [selectedProduct, selectedProductData, catalog]);
|
||||
|
||||
// Хендлер смены товара: сразу сбрасываем фасовку
|
||||
const handleProductChange = (val: string) => {
|
||||
// Хендлер смены товара: принимаем и ID, и объект
|
||||
const handleProductChange = (val: string, productObj?: ProductSearchResult) => {
|
||||
setSelectedProduct(val);
|
||||
if (productObj) {
|
||||
setSelectedProductData(productObj);
|
||||
}
|
||||
setSelectedContainer(null);
|
||||
};
|
||||
|
||||
// Мемоизируем список контейнеров, чтобы он был стабильной зависимостью
|
||||
const containers = useMemo(() => {
|
||||
return selectedCatalogItem?.containers || selectedCatalogItem?.Containers || [];
|
||||
}, [selectedCatalogItem]);
|
||||
return activeProduct?.containers || [];
|
||||
}, [activeProduct]);
|
||||
|
||||
const baseUom = selectedCatalogItem?.measure_unit || selectedCatalogItem?.MeasureUnit || 'ед.';
|
||||
// Берем единицу измерения с учетом новой структуры (main_unit)
|
||||
const baseUom = activeProduct?.main_unit?.name || activeProduct?.measure_unit || 'ед.';
|
||||
|
||||
const currentUomName = useMemo(() => {
|
||||
if (selectedContainer) {
|
||||
@@ -58,6 +69,7 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
|
||||
|
||||
setRawName('');
|
||||
setSelectedProduct(undefined);
|
||||
setSelectedProductData(undefined);
|
||||
setQuantity(1);
|
||||
setSelectedContainer(null);
|
||||
}
|
||||
@@ -85,9 +97,9 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>Товар в iiko:</div>
|
||||
<CatalogSelect
|
||||
catalog={catalog}
|
||||
// Удален проп catalog={catalog}, так как компонент теперь ищет товары сам
|
||||
value={selectedProduct}
|
||||
onChange={handleProductChange} // Используем новый хендлер
|
||||
onChange={handleProductChange}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -103,7 +115,7 @@ export const AddMatchForm: React.FC<Props> = ({ catalog, unmatched = [], onSave,
|
||||
{ value: null, label: `Базовая единица (${baseUom})` },
|
||||
...containers.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.name} (=${c.count} ${baseUom})`
|
||||
label: `${c.name} (=${Number(c.count)} ${baseUom})`
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,56 +1,92 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import type { CatalogItem } from '../../services/types';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { api } from '../../services/api';
|
||||
import type { CatalogItem, ProductSearchResult } from '../../services/types';
|
||||
|
||||
interface Props {
|
||||
catalog: CatalogItem[];
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onChange?: (value: string, productObj?: ProductSearchResult) => void;
|
||||
disabled?: boolean;
|
||||
initialProduct?: CatalogItem | ProductSearchResult;
|
||||
}
|
||||
|
||||
export const CatalogSelect: React.FC<Props> = ({ catalog, value, onChange, disabled }) => {
|
||||
const options = useMemo(() => {
|
||||
return catalog.map((item) => {
|
||||
const name = item.name || item.Name || 'Неизвестный товар';
|
||||
// Гарантируем строку. Если ID нет, будет пустая строка, которую мы отфильтруем.
|
||||
const id = item.id || item.ID || '';
|
||||
const code = item.code || item.Code || '';
|
||||
// const uom = item.measure_unit || item.MeasureUnit || ''; // Можно добавить в label
|
||||
// Интерфейс для элемента выпадающего списка
|
||||
interface SelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
data: ProductSearchResult;
|
||||
}
|
||||
|
||||
return {
|
||||
label: code ? `${name} [${code}]` : name,
|
||||
value: id,
|
||||
code: code,
|
||||
name: name,
|
||||
};
|
||||
})
|
||||
// TypeScript Predicate: явно говорим компилятору, что после фильтра value точно string (и не пустая)
|
||||
.filter((opt): opt is { label: string; value: string; code: string; name: string } => !!opt.value);
|
||||
}, [catalog]);
|
||||
export const CatalogSelect: React.FC<Props> = ({ value, onChange, disabled, initialProduct }) => {
|
||||
const [options, setOptions] = useState<SelectOption[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
|
||||
const filterOption = (input: string, option?: { label: string; value: string; code: string; name: string }) => {
|
||||
if (!option) return false;
|
||||
const fetchRef = useRef<number | null>(null);
|
||||
|
||||
const search = input.toLowerCase();
|
||||
const name = (option.name || '').toLowerCase();
|
||||
const code = (option.code || '').toLowerCase();
|
||||
useEffect(() => {
|
||||
if (initialProduct && initialProduct.id === value) {
|
||||
const name = initialProduct.name;
|
||||
const code = initialProduct.code;
|
||||
setOptions([{
|
||||
label: code ? `${name} [${code}]` : name,
|
||||
value: initialProduct.id,
|
||||
data: initialProduct as ProductSearchResult
|
||||
}]);
|
||||
}
|
||||
}, [initialProduct, value]);
|
||||
|
||||
return name.includes(search) || code.includes(search);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (val: string) => {
|
||||
if (fetchRef.current !== null) {
|
||||
window.clearTimeout(fetchRef.current);
|
||||
}
|
||||
fetchRef.current = window.setTimeout(() => {
|
||||
fetchProducts(val);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Исправлено: добавлен | undefined для option
|
||||
const handleChange = (val: string, option: SelectOption | SelectOption[] | undefined) => {
|
||||
if (onChange) {
|
||||
// В single mode option - это один объект или undefined
|
||||
const opt = Array.isArray(option) ? option[0] : option;
|
||||
onChange(val, opt?.data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Выберите товар из iiko"
|
||||
optionFilterProp="children"
|
||||
filterOption={filterOption}
|
||||
placeholder="Начните вводить название товара..."
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
notFoundContent={fetching ? <Spin size="small" /> : null}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%' }}
|
||||
listHeight={256}
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -13,9 +13,9 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
|
||||
const filteredData = matches.filter(item => {
|
||||
const raw = (item.raw_name || item.RawName || '').toLowerCase();
|
||||
const prod = item.product || item.Product;
|
||||
const prodName = (prod?.name || prod?.Name || '').toLowerCase();
|
||||
const raw = (item.raw_name || '').toLowerCase();
|
||||
const prod = item.product;
|
||||
const prodName = (prod?.name || '').toLowerCase();
|
||||
const search = searchText.toLowerCase();
|
||||
return raw.includes(search) || prodName.includes(search);
|
||||
});
|
||||
@@ -37,14 +37,14 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
|
||||
locale={{ emptyText: <Empty description="Нет данных" /> }}
|
||||
pagination={{ pageSize: 10, size: "small", simple: true }}
|
||||
renderItem={(item) => {
|
||||
// Унификация полей
|
||||
const rawName = item.raw_name || item.RawName || 'Без названия';
|
||||
const product = item.product || item.Product;
|
||||
const productName = product?.name || product?.Name || 'Товар не найден';
|
||||
const qty = item.quantity || item.Quantity || 1;
|
||||
// Унификация полей (только snake_case)
|
||||
const rawName = item.raw_name || 'Без названия';
|
||||
const product = item.product;
|
||||
const productName = product?.name || 'Товар не найден';
|
||||
const qty = item.quantity || 1;
|
||||
|
||||
// Логика отображения Единицы или Фасовки
|
||||
const container = item.container || item.Container;
|
||||
const container = item.container;
|
||||
let displayUnit = '';
|
||||
|
||||
if (container) {
|
||||
@@ -52,7 +52,7 @@ export const MatchList: React.FC<Props> = ({ matches }) => {
|
||||
displayUnit = container.name;
|
||||
} else {
|
||||
// Иначе базовая ед.: "кг"
|
||||
displayUnit = product?.measure_unit || product?.MeasureUnit || 'ед.';
|
||||
displayUnit = product?.measure_unit || 'ед.';
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
86
rmser-view/src/pages/DraftsList.tsx
Normal file
86
rmser-view/src/pages/DraftsList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -3,16 +3,17 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Spin, Alert, Button, Form, Select, DatePicker, Input,
|
||||
Typography, message, Row, Col, Affix
|
||||
Typography, message, Row, Col, Affix, Modal, Tag
|
||||
} from 'antd';
|
||||
import { ArrowLeftOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { ArrowLeftOutlined, CheckOutlined, DeleteOutlined, ExclamationCircleFilled, RestOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../services/api';
|
||||
import { DraftItemRow } from '../components/invoices/DraftItemRow';
|
||||
import type { UpdateDraftItemRequest, CommitDraftRequest } from '../services/types';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
const { confirm } = Modal;
|
||||
|
||||
export const InvoiceDraftPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -20,27 +21,26 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Локальное состояние для отслеживания какие строки сейчас обновляются
|
||||
const [updatingItems, setUpdatingItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// 1. Загрузка справочников
|
||||
// --- ЗАПРОСЫ ---
|
||||
const storesQuery = useQuery({ queryKey: ['stores'], queryFn: api.getStores });
|
||||
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({
|
||||
queryKey: ['draft', id],
|
||||
queryFn: () => api.getDraft(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
// Продолжаем опрашивать, пока статус PROCESSING, чтобы подтянуть новые товары, если они долетают
|
||||
return status === 'PROCESSING' ? 3000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Мутация обновления строки
|
||||
const draft = draftQuery.data;
|
||||
|
||||
// ... (МУТАЦИИ оставляем без изменений) ...
|
||||
const updateItemMutation = useMutation({
|
||||
mutationFn: (vars: { itemId: string; payload: UpdateDraftItemRequest }) =>
|
||||
api.updateDraftItem(id!, vars.itemId, vars.payload),
|
||||
@@ -62,7 +62,6 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Мутация коммита
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: (payload: CommitDraftRequest) => api.commitDraft(id!, payload),
|
||||
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(() => {
|
||||
if (draft) {
|
||||
// Проверяем, не менял ли пользователь уже поля, чтобы не перезатирать их при поллинге
|
||||
const currentValues = form.getFieldsValue();
|
||||
if (!currentValues.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.date_incoming) {
|
||||
form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
|
||||
}
|
||||
if (!currentValues.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.date_incoming) form.setFieldValue('date_incoming', draft.date_incoming ? dayjs(draft.date_incoming) : dayjs());
|
||||
}
|
||||
}, [draft, form]);
|
||||
|
||||
// Вычисляемые данные для UI
|
||||
// --- ХЕЛПЕРЫ ---
|
||||
const totalSum = useMemo(() => {
|
||||
// Добавил Number(), так как API возвращает строки ("250"), а нам нужны числа
|
||||
return draft?.items.reduce((acc, item) => acc + (Number(item.quantity) * Number(item.price)), 0) || 0;
|
||||
}, [draft?.items]);
|
||||
|
||||
@@ -116,10 +117,9 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (invalidItemsCount > 0) {
|
||||
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров! Удалите их или сопоставьте.`);
|
||||
message.warning(`Осталось ${invalidItemsCount} нераспознанных товаров!`);
|
||||
return;
|
||||
}
|
||||
|
||||
commitMutation.mutate({
|
||||
date_incoming: values.date_incoming.format('YYYY-MM-DD'),
|
||||
store_id: values.store_id,
|
||||
@@ -127,115 +127,147 @@ export const InvoiceDraftPage: React.FC = () => {
|
||||
comment: values.comment || '',
|
||||
});
|
||||
} catch {
|
||||
message.error('Заполните обязательные поля в шапке (Склад, Поставщик)');
|
||||
message.error('Заполните обязательные поля');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Рендер ---
|
||||
const isCanceled = draft?.status === 'CANCELED';
|
||||
|
||||
// Показываем спиннер ТОЛЬКО если данных нет вообще, или статус PROCESSING и список пуст.
|
||||
// Если статус PROCESSING, но items уже пришли — показываем интерфейс.
|
||||
const handleDelete = () => {
|
||||
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));
|
||||
|
||||
if (showSpinner) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 50 }}>
|
||||
<Spin size="large" tip="Обработка чека..." />
|
||||
<div style={{ marginTop: 16, color: '#888' }}>ИИ читает ваш чек, подождите...</div>
|
||||
</div>
|
||||
);
|
||||
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
if (draftQuery.isError || !draft) {
|
||||
return <Alert type="error" message="Ошибка загрузки черновика" description="Попробуйте обновить страницу" />;
|
||||
return <Alert type="error" message="Ошибка загрузки черновика" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 80 }}>
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')} />
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Черновик {draft.document_number ? `№${draft.document_number}` : ''}
|
||||
{draft.status === 'PROCESSING' && <Spin size="small" style={{ marginLeft: 8 }} />}
|
||||
</Title>
|
||||
<div style={{ paddingBottom: 60 }}>
|
||||
{/* Header: Уплотненный, без переноса слов */}
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/invoices')} size="small" />
|
||||
|
||||
{/* Контейнер заголовка и бейджа */}
|
||||
<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>
|
||||
|
||||
<Button
|
||||
danger={isCanceled}
|
||||
type={isCanceled ? 'primary' : 'default'}
|
||||
icon={isCanceled ? <DeleteOutlined /> : <RestOutlined />}
|
||||
onClick={handleDelete}
|
||||
loading={deleteDraftMutation.isPending}
|
||||
size="small"
|
||||
>
|
||||
{isCanceled ? 'Удалить' : 'Отмена'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#fff', padding: 16, borderRadius: 8, marginBottom: 16 }}>
|
||||
{/* 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() }}>
|
||||
<Row gutter={12}>
|
||||
<Row gutter={10}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
|
||||
<Form.Item label="Дата" name="date_incoming" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
|
||||
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" size="middle" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<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
|
||||
placeholder="Куда?"
|
||||
loading={storesQuery.isLoading}
|
||||
options={storesQuery.data?.map(s => ({ label: s.name, value: s.id }))}
|
||||
size="middle"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true, message: 'Выберите поставщика' }]}>
|
||||
<Form.Item label="Поставщик" name="supplier_id" rules={[{ required: true }]} style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
placeholder="От кого?"
|
||||
loading={suppliersQuery.isLoading}
|
||||
options={suppliersQuery.data?.map(s => ({ label: s.name, value: s.id }))}
|
||||
size="middle"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Комментарий" name="comment">
|
||||
<TextArea rows={1} placeholder="Прим: Довоз за пятницу" />
|
||||
<Form.Item label="Комментарий" name="comment" style={{ marginBottom: 0 }}>
|
||||
<TextArea rows={1} placeholder="Комментарий..." style={{ fontSize: 13 }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={5} style={{ margin: 0 }}>Позиции ({draft.items.length})</Title>
|
||||
{invalidItemsCount > 0 && <Text type="danger">{invalidItemsCount} нераспознано</Text>}
|
||||
{/* Items Header */}
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 4px' }}>
|
||||
<Text strong>Позиции ({draft.items.length})</Text>
|
||||
{invalidItemsCount > 0 && <Text type="danger" style={{ fontSize: 12 }}>{invalidItemsCount} нераспознано</Text>}
|
||||
</div>
|
||||
|
||||
{/* Items List */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{draft.items.map(item => (
|
||||
<DraftItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
catalog={catalogQuery.data || []}
|
||||
onUpdate={handleItemUpdate}
|
||||
isUpdating={updatingItems.has(item.id)}
|
||||
recommendations={recommendationsQuery.data || []}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Affix offsetBottom={0}>
|
||||
{/* Footer Actions */}
|
||||
<Affix offsetBottom={60} /* Высота нижнего меню */>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: '12px 16px',
|
||||
padding: '8px 16px',
|
||||
borderTop: '1px solid #eee',
|
||||
boxShadow: '0 -2px 10px rgba(0,0,0,0.05)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
borderRadius: '8px 8px 0 0' // Скругление сверху
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#888' }}>Итого:</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: 11, color: '#888', lineHeight: 1 }}>Итого:</span>
|
||||
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff', lineHeight: 1.2 }}>
|
||||
{totalSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleCommit}
|
||||
loading={commitMutation.isPending}
|
||||
disabled={invalidItemsCount > 0}
|
||||
disabled={invalidItemsCount > 0 || isCanceled}
|
||||
style={{ height: 40, padding: '0 24px' }}
|
||||
>
|
||||
Отправить в iiko
|
||||
{isCanceled ? 'Восстановить' : 'Отправить'}
|
||||
</Button>
|
||||
</div>
|
||||
</Affix>
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios';
|
||||
import type {
|
||||
CatalogItem,
|
||||
CreateInvoiceRequest,
|
||||
MatchRequest, // Используем новый тип
|
||||
MatchRequest,
|
||||
HealthResponse,
|
||||
InvoiceResponse,
|
||||
ProductMatch,
|
||||
@@ -12,7 +12,11 @@ import type {
|
||||
Supplier,
|
||||
DraftInvoice,
|
||||
UpdateDraftItemRequest,
|
||||
CommitDraftRequest
|
||||
CommitDraftRequest,
|
||||
// Новые типы
|
||||
ProductSearchResult,
|
||||
AddContainerRequest,
|
||||
AddContainerResponse
|
||||
} from './types';
|
||||
|
||||
// Базовый URL
|
||||
@@ -33,7 +37,7 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Мок поставщиков (так как эндпоинта пока нет)
|
||||
// Мок поставщиков
|
||||
const MOCK_SUPPLIERS: Supplier[] = [
|
||||
{ id: '00000000-0000-0000-0000-000000000001', 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' },
|
||||
];
|
||||
|
||||
// интерфейс для списка (краткий)
|
||||
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 = {
|
||||
checkHealth: async (): Promise<HealthResponse> => {
|
||||
const { data } = await apiClient.get<HealthResponse>('/health');
|
||||
@@ -52,11 +67,28 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Оставляем для совместимости со старыми компонентами (если используются),
|
||||
// но в Draft Flow будем использовать поиск.
|
||||
getCatalogItems: async (): Promise<CatalogItem[]> => {
|
||||
const { data } = await apiClient.get<CatalogItem[]>('/ocr/catalog');
|
||||
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[]> => {
|
||||
const { data } = await apiClient.get<ProductMatch[]>('/ocr/matches');
|
||||
return data;
|
||||
@@ -67,7 +99,6 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// Обновили тип аргумента payload
|
||||
createMatch: async (payload: MatchRequest): Promise<{ status: string }> => {
|
||||
const { data } = await apiClient.post<{ status: string }>('/ocr/match', payload);
|
||||
return data;
|
||||
@@ -77,39 +108,41 @@ export const api = {
|
||||
const { data } = await apiClient.post<InvoiceResponse>('/invoices/send', payload);
|
||||
return data;
|
||||
},
|
||||
// Получить список складов
|
||||
|
||||
getStores: async (): Promise<Store[]> => {
|
||||
const { data } = await apiClient.get<Store[]>('/dictionaries/stores');
|
||||
return data;
|
||||
},
|
||||
|
||||
// Получить список поставщиков (Mock)
|
||||
getSuppliers: async (): Promise<Supplier[]> => {
|
||||
// Имитация асинхронности
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(MOCK_SUPPLIERS), 300);
|
||||
});
|
||||
},
|
||||
|
||||
// Получить черновик
|
||||
getDraft: async (id: string): Promise<DraftInvoice> => {
|
||||
const { data } = await apiClient.get<DraftInvoice>(`/drafts/${id}`);
|
||||
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> => {
|
||||
// Бэкенд возвращает обновленный черновик целиком (обычно) или обновленный item.
|
||||
// Предположим, что возвращается обновленный Item или просто 200 OK.
|
||||
// Но для React Query удобно возвращать данные.
|
||||
// Если бэк возвращает только item, типизацию нужно уточнить. Пока ждем DraftInvoice или any.
|
||||
const { data } = await apiClient.patch<DraftInvoice>(`/drafts/${draftId}/items/${itemId}`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Зафиксировать черновик
|
||||
commitDraft: async (draftId: string, payload: CommitDraftRequest): Promise<{ document_number: string }> => {
|
||||
const { data } = await apiClient.post<{ document_number: string }>(`/drafts/${draftId}/commit`, payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Отменить/Удалить черновик
|
||||
deleteDraft: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/drafts/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -10,15 +10,36 @@ export interface ProductContainer {
|
||||
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;
|
||||
name: string;
|
||||
code: string;
|
||||
measure_unit: string; // "кг", "л"
|
||||
containers: ProductContainer[]; // Массив фасовок
|
||||
num?: string;
|
||||
|
||||
// Fallback (на всякий случай)
|
||||
// Обновляем структуру единицы измерения
|
||||
main_unit?: MainUnit;
|
||||
measure_unit?: string; // Оставим для совместимости, но брать будем из main_unit.name
|
||||
|
||||
containers: ProductContainer[];
|
||||
}
|
||||
|
||||
// Совместимость с CatalogItem (чтобы не ломать старый код, если он где-то используется)
|
||||
export interface CatalogItem extends ProductSearchResult {
|
||||
// Fallback поля
|
||||
ID?: UUID;
|
||||
Name?: string;
|
||||
Code?: string;
|
||||
@@ -32,11 +53,10 @@ export interface MatchRequest {
|
||||
raw_name: string;
|
||||
product_id: UUID;
|
||||
quantity: number;
|
||||
container_id?: UUID; // Новое поле
|
||||
container_id?: UUID;
|
||||
}
|
||||
|
||||
export interface ProductMatch {
|
||||
// snake_case (v2.0)
|
||||
raw_name: string;
|
||||
product_id: UUID;
|
||||
product?: CatalogItem;
|
||||
@@ -44,14 +64,6 @@ export interface ProductMatch {
|
||||
container_id?: UUID;
|
||||
container?: ProductContainer;
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Остальные типы (без изменений) ---
|
||||
// --- Остальные типы ---
|
||||
|
||||
export interface Recommendation {
|
||||
ID: UUID;
|
||||
@@ -111,7 +123,7 @@ export interface Supplier {
|
||||
|
||||
// --- Черновик Накладной (Draft) ---
|
||||
|
||||
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR';
|
||||
export type DraftStatus = 'PROCESSING' | 'READY_TO_VERIFY' | 'COMPLETED' | 'ERROR' | 'CANCELED';
|
||||
|
||||
export interface DraftItem {
|
||||
id: UUID;
|
||||
@@ -161,3 +173,8 @@ export interface CommitDraftRequest {
|
||||
supplier_id: UUID;
|
||||
comment: string;
|
||||
}
|
||||
export interface MainUnit {
|
||||
id: UUID;
|
||||
name: string; // "кг"
|
||||
code: string;
|
||||
}
|
||||
Reference in New Issue
Block a user