From c8aab42e8ef4206fa6cca77662dc14e7a2f69bb0 Mon Sep 17 00:00:00 2001 From: SERTY Date: Wed, 17 Dec 2025 22:00:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BB=D0=BD=D0=BE=D1=86=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D1=83=D1=8E=D1=82=D1=81=D1=8F=20=D1=87=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D0=BA=D0=B8=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D1=8F=D1=8E=D1=82=D1=81=D1=8F=20=D1=84=D0=B0=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D0=BA=D0=B0=D0=BA=20=D0=B2=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=B5,=20=D1=82=D0=B0?= =?UTF-8?q?=D0=BA=20=D0=B8=20=D0=B2=20=D0=BE=D0=B1=D1=83=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B8=D0=B9=20=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/main.go | 4 + docker-compose.yml | 4 +- internal/domain/catalog/entity.go | 2 + internal/domain/drafts/entity.go | 15 +- internal/domain/invoices/entity.go | 15 +- .../repository/catalog/postgres.go | 27 ++ .../repository/drafts/postgres.go | 21 ++ internal/infrastructure/rms/client.go | 102 +++++- internal/infrastructure/rms/dto.go | 91 ++++- internal/services/drafts/service.go | 194 ++++++++-- internal/services/ocr/service.go | 47 +-- internal/transport/http/handlers/drafts.go | 126 +++++++ internal/transport/http/handlers/ocr.go | 20 ++ rmser-view/src/App.tsx | 14 +- .../invoices/CreateContainerModal.tsx | 84 +++++ .../src/components/invoices/DraftItemRow.tsx | 337 +++++++++++------- .../src/components/layout/AppLayout.tsx | 89 +++-- .../src/components/ocr/AddMatchForm.tsx | 44 ++- .../src/components/ocr/CatalogSelect.tsx | 104 ++++-- rmser-view/src/components/ocr/MatchList.tsx | 20 +- rmser-view/src/pages/DraftsList.tsx | 86 +++++ rmser-view/src/pages/InvoiceDraftPage.tsx | 188 ++++++---- rmser-view/src/services/api.ts | 61 +++- rmser-view/src/services/types.ts | 51 ++- 24 files changed, 1313 insertions(+), 433 deletions(-) create mode 100644 rmser-view/src/components/invoices/CreateContainerModal.tsx create mode 100644 rmser-view/src/pages/DraftsList.tsx diff --git a/cmd/main.go b/cmd/main.go index 4d83176..a6ab6de 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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) } // Простой хелсчек diff --git a/docker-compose.yml b/docker-compose.yml index 03b79a7..9b3ae8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/internal/domain/catalog/entity.go b/internal/domain/catalog/entity.go index cb63163..7284991 100644 --- a/internal/domain/catalog/entity.go +++ b/internal/domain/catalog/entity.go @@ -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 --- diff --git a/internal/domain/drafts/entity.go b/internal/domain/drafts/entity.go index a999289..66fa893 100644 --- a/internal/domain/drafts/entity.go +++ b/internal/domain/drafts/entity.go @@ -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) } diff --git a/internal/domain/invoices/entity.go b/internal/domain/invoices/entity.go index d368f08..f9de9bd 100644 --- a/internal/domain/invoices/entity.go +++ b/internal/domain/invoices/entity.go @@ -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"` } diff --git a/internal/infrastructure/repository/catalog/postgres.go b/internal/infrastructure/repository/catalog/postgres.go index 3092ac3..74c3d4f 100644 --- a/internal/infrastructure/repository/catalog/postgres.go +++ b/internal/infrastructure/repository/catalog/postgres.go @@ -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 +} diff --git a/internal/infrastructure/repository/drafts/postgres.go b/internal/infrastructure/repository/drafts/postgres.go index 67572dc..58b9097 100644 --- a/internal/infrastructure/repository/drafts/postgres.go +++ b/internal/infrastructure/repository/drafts/postgres.go @@ -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 +} diff --git a/internal/infrastructure/rms/client.go b/internal/infrastructure/rms/client.go index 71953c1..49bf73c 100644 --- a/internal/infrastructure/rms/client.go +++ b/internal/infrastructure/rms/client.go @@ -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 +} diff --git a/internal/infrastructure/rms/dto.go b/internal/infrastructure/rms/dto.go index ece2e20..7945abd 100644 --- a/internal/infrastructure/rms/dto.go +++ b/internal/infrastructure/rms/dto.go @@ -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 + 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"` +} diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go index 7390e98..459c51d 100644 --- a/internal/services/drafts/service.go +++ b/internal/services/drafts/service.go @@ -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 +} diff --git a/internal/services/ocr/service.go b/internal/services/ocr/service.go index 58426ec..a712383 100644 --- a/internal/services/ocr/service.go +++ b/internal/services/ocr/service.go @@ -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) } diff --git a/internal/transport/http/handlers/drafts.go b/internal/transport/http/handlers/drafts.go index f726694..799bfb0 100644 --- a/internal/transport/http/handlers/drafts.go +++ b/internal/transport/http/handlers/drafts.go @@ -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(), + }) +} \ No newline at end of file diff --git a/internal/transport/http/handlers/ocr.go b/internal/transport/http/handlers/ocr.go index 4174d94..cf4fb63 100644 --- a/internal/transport/http/handlers/ocr.go +++ b/internal/transport/http/handlers/ocr.go @@ -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() diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx index dc52661..b810a5a 100644 --- a/rmser-view/src/App.tsx +++ b/rmser-view/src/App.tsx @@ -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 = () =>

История накладных (в разработке)

; +import { InvoiceDraftPage } from './pages/InvoiceDraftPage'; +import { DraftsList } from './pages/DraftsList'; function App() { return ( @@ -17,11 +15,11 @@ function App() { } /> } /> - {/* Роут для черновика. :id - UUID черновика */} - } /> + {/* Список черновиков */} + } /> - {/* Страница списка */} - } /> + {/* Редактирование черновика */} + } /> } /> diff --git a/rmser-view/src/components/invoices/CreateContainerModal.tsx b/rmser-view/src/components/invoices/CreateContainerModal.tsx new file mode 100644 index 0000000..523acad --- /dev/null +++ b/rmser-view/src/components/invoices/CreateContainerModal.tsx @@ -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 = ({ + 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 ( + Отмена, + , + ]} + > +
+ + + + + + +
+
+ ); +}; \ No newline at end of file diff --git a/rmser-view/src/components/invoices/DraftItemRow.tsx b/rmser-view/src/components/invoices/DraftItemRow.tsx index 6c22de1..4eb1782 100644 --- a/rmser-view/src/components/invoices/DraftItemRow.tsx +++ b/rmser-view/src/components/invoices/DraftItemRow.tsx @@ -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 = ({ 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 = ({ item, onUpdate, isUpdating, recommendations = [] }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + + // State Input + const [localQuantity, setLocalQuantity] = useState(item.quantity?.toString() ?? null); + const [localPrice, setLocalPrice] = useState(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(null); + const [addedContainers, setAddedContainers] = useState>({}); + + 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: ( +
+

{activeWarning.ProductName}

+

{activeWarning.Reason}

+

{activeWarning.Type}

+
+ ), + 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 => { + 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 ( - - - {/* Верхняя строка: Исходное название и статус */} - -
- - {item.raw_name} - - {item.raw_amount > 0 && ( - - (в чеке: {item.raw_amount} x {item.raw_price}) - - )} -
-
- {isUpdating && } - {!item.product_id && Не найден} -
-
- - {/* Выбор товара */} - - - {/* Нижний блок: Фасовка, Кол-во, Цена, Сумма */} - - {/* Если есть фасовки, показываем селект. Если нет - просто лейбл ед. изм */} -
- {containerOptions.length > 1 ? ( - ( + <> + {menu} + + + + )} + /> + )} + +
+
+ + style={{ width: 60 }} controls={false} placeholder="Кол" stringMode decimalSeparator="," + value={localQuantity || ''} onChange={(val) => setLocalQuantity(val)} onBlur={() => handleBlur('quantity')} + /> + x + + style={{ width: 70 }} controls={false} placeholder="Цена" stringMode decimalSeparator="," + value={localPrice || ''} onChange={(val) => setLocalPrice(val)} onBlur={() => handleBlur('price')} + /> +
+
+ + {uiSum.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2, maximumFractionDigits: 2 })} + +
+ - {/* Итоговая сумма (расчетная) */} -
- - = {(item.quantity * item.price).toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })} - -
- - - + {activeProduct && ( + setIsModalOpen(false)} + productId={activeProduct.id} + productBaseUnit={baseUom} + onSuccess={handleContainerCreated} + /> + )} + ); }; \ No newline at end of file diff --git a/rmser-view/src/components/layout/AppLayout.tsx b/rmser-view/src/components/layout/AppLayout.tsx index 15aab8e..c0acbd3 100644 --- a/rmser-view/src/components/layout/AppLayout.tsx +++ b/rmser-view/src/components/layout/AppLayout.tsx @@ -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: , label: 'Дашборд', onClick: () => navigate('/') }, - { key: 'ocr', icon: , label: 'Обучение', onClick: () => navigate('/ocr') }, - { key: 'invoices', icon: , label: 'Накладные', onClick: () => navigate('/invoices') }, + { key: 'invoices', icon: , label: 'Накладные', path: '/invoices' }, + { key: 'ocr', icon: , label: 'Обучение', path: '/ocr' }, + { key: 'settings', icon: , label: 'Настройки', path: '#' }, ]; return ( - -
- -
- + + {/* Верхнюю шапку (Header) удалили для экономии места */} + + + {/* Убрали лишние паддинги вокруг контента для мобилок */}
-
- RMSer ©{new Date().getFullYear()} -
+ + {/* Нижний Таб-бар */} +
+ {menuItems.map(item => { + const isActive = activeKey === item.key; + return ( +
navigate(item.path)} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + width: '33%', + cursor: 'pointer', + color: isActive ? colorPrimary : colorTextSecondary + }} + > + {item.icon} + + {item.label} + +
+ ); + })} +
); }; \ No newline at end of file diff --git a/rmser-view/src/components/ocr/AddMatchForm.tsx b/rmser-view/src/components/ocr/AddMatchForm.tsx index d9c9147..8dfc69a 100644 --- a/rmser-view/src/components/ocr/AddMatchForm.tsx +++ b/rmser-view/src/components/ocr/AddMatchForm.tsx @@ -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 = ({ catalog, unmatched = [], onSave, isLoading }) => { const [rawName, setRawName] = useState(''); const [selectedProduct, setSelectedProduct] = useState(undefined); + // Сохраняем полный объект товара, полученный из поиска, чтобы иметь доступ к containers + const [selectedProductData, setSelectedProductData] = useState(undefined); + const [quantity, setQuantity] = useState(1); const [selectedContainer, setSelectedContainer] = useState(null); @@ -26,23 +29,31 @@ export const AddMatchForm: React.FC = ({ 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 = ({ catalog, unmatched = [], onSave, setRawName(''); setSelectedProduct(undefined); + setSelectedProductData(undefined); setQuantity(1); setSelectedContainer(null); } @@ -85,9 +97,9 @@ export const AddMatchForm: React.FC = ({ catalog, unmatched = [], onSave,
Товар в iiko:
@@ -103,7 +115,7 @@ export const AddMatchForm: React.FC = ({ 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})` })) ]} /> diff --git a/rmser-view/src/components/ocr/CatalogSelect.tsx b/rmser-view/src/components/ocr/CatalogSelect.tsx index 3d11d8b..0fa619e 100644 --- a/rmser-view/src/components/ocr/CatalogSelect.tsx +++ b/rmser-view/src/components/ocr/CatalogSelect.tsx @@ -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 = ({ 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 = ({ value, onChange, disabled, initialProduct }) => { + const [options, setOptions] = useState([]); + const [fetching, setFetching] = useState(false); + + const fetchRef = useRef(null); - const filterOption = (input: string, option?: { label: string; value: string; code: string; name: string }) => { - if (!option) return false; - - 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 ( ({ label: s.name, value: s.id }))} + size="middle" /> - - +