diff --git a/cmd/main.go b/cmd/main.go index 4f061bb..4795ef3 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -88,6 +88,7 @@ func main() { draftsHandler := handlers.NewDraftsHandler(draftsService) ocrHandler := handlers.NewOCRHandler(ocrService) recommendHandler := handlers.NewRecommendationsHandler(recService) + settingsHandler := handlers.NewSettingsHandler(accountRepo, catalogRepo) // 8. Telegram Bot (Передаем syncService) if cfg.Telegram.Token != "" { @@ -120,12 +121,20 @@ func main() { api.GET("/drafts", draftsHandler.GetDrafts) api.GET("/drafts/:id", draftsHandler.GetDraft) api.DELETE("/drafts/:id", draftsHandler.DeleteDraft) + // Items CRUD + api.POST("/drafts/:id/items", draftsHandler.AddDraftItem) + api.DELETE("/drafts/:id/items/:itemId", draftsHandler.DeleteDraftItem) api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem) api.POST("/drafts/:id/commit", draftsHandler.CommitDraft) api.POST("/drafts/container", draftsHandler.AddContainer) + // Settings + api.GET("/settings", settingsHandler.GetSettings) + api.POST("/settings", settingsHandler.UpdateSettings) + // Dictionaries api.GET("/dictionaries", draftsHandler.GetDictionaries) + api.GET("/dictionaries/groups", settingsHandler.GetGroupsTree) api.GET("/dictionaries/stores", draftsHandler.GetStores) // Recommendations diff --git a/internal/domain/account/entity.go b/internal/domain/account/entity.go index 1b62a59..3ccb1c4 100644 --- a/internal/domain/account/entity.go +++ b/internal/domain/account/entity.go @@ -36,6 +36,10 @@ type RMSServer struct { Login string `gorm:"type:varchar(100);not null" json:"login"` EncryptedPassword string `gorm:"type:text;not null" json:"-"` // Пароль храним зашифрованным + DefaultStoreID *uuid.UUID `gorm:"type:uuid" json:"default_store_id"` // Склад для подстановки + RootGroupGUID *uuid.UUID `gorm:"type:uuid" json:"root_group_guid"` // ID корневой папки для поиска товаров + AutoProcess bool `gorm:"default:false" json:"auto_process"` // Пытаться сразу проводить накладную + // Billing / Stats InvoiceCount int `gorm:"default:0" json:"invoice_count"` // Счетчик успешно отправленных накладных diff --git a/internal/domain/catalog/entity.go b/internal/domain/catalog/entity.go index d76f2f0..99e106c 100644 --- a/internal/domain/catalog/entity.go +++ b/internal/domain/catalog/entity.go @@ -58,8 +58,10 @@ type Repository interface { SaveProducts(products []Product) error SaveContainer(container ProductContainer) error - Search(serverID uuid.UUID, query string) ([]Product, error) - GetActiveGoods(serverID uuid.UUID) ([]Product, error) + Search(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]Product, error) + GetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]Product, error) + + GetGroups(serverID uuid.UUID) ([]Product, error) SaveStores(stores []Store) error GetActiveStores(serverID uuid.UUID) ([]Store, error) diff --git a/internal/domain/drafts/entity.go b/internal/domain/drafts/entity.go index 4b95b15..97c782a 100644 --- a/internal/domain/drafts/entity.go +++ b/internal/domain/drafts/entity.go @@ -70,6 +70,8 @@ type Repository interface { Update(draft *DraftInvoice) error CreateItems(items []DraftInvoiceItem) error UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error + CreateItem(item *DraftInvoiceItem) error + DeleteItem(itemID uuid.UUID) error Delete(id uuid.UUID) error GetActive(userID uuid.UUID) ([]DraftInvoice, error) } diff --git a/internal/domain/invoices/entity.go b/internal/domain/invoices/entity.go index 4b08148..361b8fe 100644 --- a/internal/domain/invoices/entity.go +++ b/internal/domain/invoices/entity.go @@ -18,6 +18,7 @@ type Invoice struct { SupplierID uuid.UUID `gorm:"type:uuid;index"` DefaultStoreID uuid.UUID `gorm:"type:uuid;index"` Status string `gorm:"type:varchar(50)"` + Comment string `gorm:"type:text"` Items []InvoiceItem `gorm:"foreignKey:InvoiceID;constraint:OnDelete:CASCADE"` diff --git a/internal/infrastructure/repository/account/postgres.go b/internal/infrastructure/repository/account/postgres.go index b9981e4..c80e8c6 100644 --- a/internal/infrastructure/repository/account/postgres.go +++ b/internal/infrastructure/repository/account/postgres.go @@ -100,9 +100,11 @@ func (r *pgRepository) GetActiveServer(userID uuid.UUID) (*account.RMSServer, er return &server, nil } +// GetAllServers возвращает ВСЕ серверы пользователя, чтобы можно было переключаться func (r *pgRepository) GetAllServers(userID uuid.UUID) ([]account.RMSServer, error) { var servers []account.RMSServer - err := r.db.Where("user_id = ? AND is_active = ?", userID, true).Find(&servers).Error + // Убрали фильтр AND is_active = true, теперь возвращает весь список + err := r.db.Where("user_id = ?", userID).Find(&servers).Error return servers, err } diff --git a/internal/infrastructure/repository/catalog/postgres.go b/internal/infrastructure/repository/catalog/postgres.go index a46dea5..8162101 100644 --- a/internal/infrastructure/repository/catalog/postgres.go +++ b/internal/infrastructure/repository/catalog/postgres.go @@ -86,14 +86,25 @@ func (r *pgRepository) GetAll() ([]catalog.Product, error) { return nil, nil } -func (r *pgRepository) GetActiveGoods(serverID uuid.UUID) ([]catalog.Product, error) { +func (r *pgRepository) GetActiveGoods(serverID uuid.UUID, rootGroupID *uuid.UUID) ([]catalog.Product, error) { var products []catalog.Product - err := r.db. - Preload("MainUnit"). - Preload("Containers"). - Where("rms_server_id = ? AND is_deleted = ? AND type IN ?", serverID, false, []string{"GOODS"}). - Order("name ASC"). - Find(&products).Error + db := r.db.Preload("MainUnit").Preload("Containers"). + Where("rms_server_id = ? AND is_deleted = ? AND type IN ?", serverID, false, []string{"GOODS"}) + + if rootGroupID != nil && *rootGroupID != uuid.Nil { + // Используем Recursive CTE для поиска всех дочерних элементов папки + subQuery := r.db.Raw(` + WITH RECURSIVE subnodes AS ( + SELECT id FROM products WHERE id = ? + UNION ALL + SELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id + ) + SELECT id FROM subnodes + `, rootGroupID) + db = db.Where("id IN (?)", subQuery) + } + + err := db.Order("name ASC").Find(&products).Error return products, err } @@ -103,19 +114,27 @@ func (r *pgRepository) GetActiveStores(serverID uuid.UUID) ([]catalog.Store, err return stores, err } -func (r *pgRepository) Search(serverID uuid.UUID, query string) ([]catalog.Product, error) { +func (r *pgRepository) Search(serverID uuid.UUID, query string, rootGroupID *uuid.UUID) ([]catalog.Product, error) { var products []catalog.Product q := "%" + query + "%" - err := r.db. - Preload("MainUnit"). - Preload("Containers"). + db := r.db.Preload("MainUnit").Preload("Containers"). Where("rms_server_id = ? AND is_deleted = ? AND type = ?", serverID, false, "GOODS"). - Where("name ILIKE ? OR code ILIKE ? OR num ILIKE ?", q, q, q). - Order("name ASC"). - Limit(20). - Find(&products).Error + Where("(name ILIKE ? OR code ILIKE ? OR num ILIKE ?)", q, q, q) + if rootGroupID != nil && *rootGroupID != uuid.Nil { + subQuery := r.db.Raw(` + WITH RECURSIVE subnodes AS ( + SELECT id FROM products WHERE id = ? + UNION ALL + SELECT p.id FROM products p INNER JOIN subnodes s ON p.parent_id = s.id + ) + SELECT id FROM subnodes + `, rootGroupID) + db = db.Where("id IN (?)", subQuery) + } + + err := db.Order("name ASC").Limit(20).Find(&products).Error return products, err } @@ -176,3 +195,12 @@ func (r *pgRepository) CountStores(serverID uuid.UUID) (int64, error) { Count(&count).Error return count, err } + +func (r *pgRepository) GetGroups(serverID uuid.UUID) ([]catalog.Product, error) { + var groups []catalog.Product + // iiko присылает группы с типом "GROUP" + err := r.db.Where("rms_server_id = ? AND type = ? AND is_deleted = ?", serverID, "GROUP", false). + Order("name ASC"). + Find(&groups).Error + return groups, err +} diff --git a/internal/infrastructure/repository/drafts/postgres.go b/internal/infrastructure/repository/drafts/postgres.go index cc7b2bf..9fc308f 100644 --- a/internal/infrastructure/repository/drafts/postgres.go +++ b/internal/infrastructure/repository/drafts/postgres.go @@ -60,6 +60,14 @@ func (r *pgRepository) CreateItems(items []drafts.DraftInvoiceItem) error { return r.db.CreateInBatches(items, 100).Error } +func (r *pgRepository) CreateItem(item *drafts.DraftInvoiceItem) error { + return r.db.Create(item).Error +} + +func (r *pgRepository) DeleteItem(itemID uuid.UUID) error { + return r.db.Delete(&drafts.DraftInvoiceItem{}, itemID).Error +} + func (r *pgRepository) UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error { sum := qty.Mul(price) isMatched := productID != nil diff --git a/internal/infrastructure/rms/client.go b/internal/infrastructure/rms/client.go index 8aa2072..7014a91 100644 --- a/internal/infrastructure/rms/client.go +++ b/internal/infrastructure/rms/client.go @@ -557,13 +557,26 @@ func (c *Client) FetchStoreOperations(presetID string, from, to time.Time) ([]St // CreateIncomingInvoice отправляет накладную в iiko func (c *Client) CreateIncomingInvoice(inv invoices.Invoice) (string, error) { // 1. Маппинг Domain -> XML DTO + + // Статус по умолчанию NEW, если не передан + status := inv.Status + if status == "" { + status = "NEW" + } + + // Комментарий по умолчанию, если пустой + comment := inv.Comment + if comment == "" { + comment = "Loaded via RMSER OCR" + } + reqDTO := IncomingInvoiceImportXML{ DocumentNumber: inv.DocumentNumber, DateIncoming: inv.DateIncoming.Format("02.01.2006"), DefaultStore: inv.DefaultStoreID.String(), Supplier: inv.SupplierID.String(), - Status: "NEW", - Comment: "Loaded via RMSER OCR", + Status: status, + Comment: comment, } if inv.ID != uuid.Nil { @@ -758,6 +771,49 @@ func (c *Client) UpdateProduct(product ProductFullDTO) (*ProductFullDTO, error) return result.Response, nil } +// GetServerInfo пытается получить информацию о сервере (имя, версия) без авторизации. +// Использует endpoint /resto/getServerMonitoringInfo.jsp +func GetServerInfo(baseURL string) (*ServerMonitoringInfoDTO, error) { + // Формируем URL. Убираем слэш в конце, если есть. + url := strings.TrimRight(baseURL, "/") + "/resto/getServerMonitoringInfo.jsp" + + logger.Log.Info("RMS: Requesting server info", zap.String("url", url)) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(url) + if err != nil { + logger.Log.Error("RMS: Monitoring connection failed", zap.Error(err)) + return nil, fmt.Errorf("connection error: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body error: %w", err) + } + + logger.Log.Info("RMS: Monitoring Response", + zap.Int("status", resp.StatusCode), + zap.String("body", string(bodyBytes))) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code %d", resp.StatusCode) + } + + var info ServerMonitoringInfoDTO + + // Пробуем JSON (так как в логе пришел JSON) + if err := json.Unmarshal(bodyBytes, &info); err != nil { + // Если вдруг JSON не прошел, можно попробовать XML как фоллбек (для старых версий) + logger.Log.Warn("RMS: JSON decode failed, trying XML...", zap.Error(err)) + if xmlErr := xml.Unmarshal(bodyBytes, &info); xmlErr != nil { + return nil, fmt.Errorf("decode error (json & xml failed): %w", err) + } + } + + return &info, nil +} + // FetchSuppliers загружает список поставщиков через XML API func (c *Client) FetchSuppliers() ([]suppliers.Supplier, error) { // Endpoint /resto/api/suppliers diff --git a/internal/infrastructure/rms/dto.go b/internal/infrastructure/rms/dto.go index 89162ff..7d0cbfc 100644 --- a/internal/infrastructure/rms/dto.go +++ b/internal/infrastructure/rms/dto.go @@ -244,6 +244,13 @@ type ErrorDTO struct { Value string `json:"value"` } +// ServerMonitoringInfoDTO используется для парсинга ответа мониторинга +// iiko может отдавать JSON, поэтому ставим json теги. +type ServerMonitoringInfoDTO struct { + ServerName string `json:"serverName" xml:"serverName"` + Version string `json:"version" xml:"version"` +} + // --- Suppliers XML (Legacy API /resto/api/suppliers) --- type SuppliersListXML struct { diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go index 4a06981..7339de0 100644 --- a/internal/services/drafts/service.go +++ b/internal/services/drafts/service.go @@ -108,6 +108,56 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID return s.draftRepo.Update(draft) } +// AddItem добавляет пустую строку в черновик +func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) { + // Проверка статуса драфта (можно добавить) + + newItem := &drafts.DraftInvoiceItem{ + ID: uuid.New(), + DraftID: draftID, + RawName: "Новая позиция", + RawAmount: decimal.NewFromFloat(1), + RawPrice: decimal.Zero, + Quantity: decimal.NewFromFloat(1), + Price: decimal.Zero, + Sum: decimal.Zero, + IsMatched: false, + } + + if err := s.draftRepo.CreateItem(newItem); err != nil { + return nil, err + } + return newItem, nil +} + +// DeleteItem удаляет строку и возвращает обновленную сумму черновика +func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) { + // 1. Удаляем + if err := s.draftRepo.DeleteItem(itemID); err != nil { + return 0, err + } + + // 2. Получаем драфт заново для пересчета суммы + // Это самый надежный способ, чем считать в памяти + draft, err := s.draftRepo.GetByID(draftID) + if err != nil { + return 0, err + } + + // 3. Считаем сумму + var totalSum decimal.Decimal + for _, item := range draft.Items { + if !item.Sum.IsZero() { + totalSum = totalSum.Add(item.Sum) + } else { + totalSum = totalSum.Add(item.Quantity.Mul(item.Price)) + } + } + + sumFloat, _ := totalSum.Float64() + return sumFloat, nil +} + func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error { draft, err := s.draftRepo.GetByID(draftID) if err != nil { @@ -137,6 +187,16 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { return "", errors.New("накладная уже отправлена") } + server, err := s.accountRepo.GetActiveServer(userID) + if err != nil { + return "", fmt.Errorf("active server not found: %w", err) + } + + targetStatus := "NEW" + if server.AutoProcess { + targetStatus = "PROCESSED" + } + // 3. Сборка Invoice inv := invoices.Invoice{ ID: uuid.Nil, @@ -144,7 +204,8 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { DateIncoming: *draft.DateIncoming, SupplierID: *draft.SupplierID, DefaultStoreID: *draft.StoreID, - Status: "NEW", + Status: targetStatus, // <-- Передаем статус из настроек + Comment: draft.Comment, // <-- Передаем комментарий из черновика Items: make([]invoices.InvoiceItem, 0, len(draft.Items)), } @@ -179,19 +240,15 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { return "", err } - // 5. Обновление статуса + // 5. Обновление статуса черновика draft.Status = drafts.StatusCompleted s.draftRepo.Update(draft) - // 6. БИЛЛИНГ: Увеличиваем счетчик накладных - server, _ := s.accountRepo.GetActiveServer(userID) - if server != nil { - if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil { - logger.Log.Error("Billing increment failed", zap.Error(err)) - } - // 7. Обучение (передаем ID сервера для сохранения маппинга) - go s.learnFromDraft(draft, server.ID) + // 6. БИЛЛИНГ и Обучение + if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil { + logger.Log.Error("Billing increment failed", zap.Error(err)) } + go s.learnFromDraft(draft, server.ID) return docNum, nil } diff --git a/internal/services/ocr/service.go b/internal/services/ocr/service.go index 64cb3a7..f43d310 100644 --- a/internal/services/ocr/service.go +++ b/internal/services/ocr/service.go @@ -49,9 +49,10 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img // 2. Создаем черновик draft := &drafts.DraftInvoice{ - UserID: userID, // <-- Исправлено с ChatID на UserID - RMSServerID: serverID, // <-- NEW + UserID: userID, + RMSServerID: serverID, Status: drafts.StatusProcessing, + StoreID: server.DefaultStoreID, } if err := s.draftRepo.Create(draft); err != nil { return nil, fmt.Errorf("failed to create draft: %w", err) @@ -79,7 +80,7 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img Sum: decimal.NewFromFloat(rawItem.Sum), } - match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) // <-- ServerID + match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) if match != nil { item.IsMatched = true @@ -97,6 +98,8 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img s.draftRepo.Update(draft) s.draftRepo.CreateItems(draftItems) + draft.Items = draftItems + return draft, nil } @@ -122,7 +125,7 @@ func (s *Service) GetCatalogForIndexing(userID uuid.UUID) ([]ProductForIndex, er return nil, fmt.Errorf("no server") } - products, err := s.catalogRepo.GetActiveGoods(server.ID) + products, err := s.catalogRepo.GetActiveGoods(server.ID, server.RootGroupGUID) if err != nil { return nil, err } @@ -163,7 +166,7 @@ func (s *Service) SearchProducts(userID uuid.UUID, query string) ([]catalog.Prod if err != nil || server == nil { return nil, fmt.Errorf("no server") } - return s.catalogRepo.Search(server.ID, query) + return s.catalogRepo.Search(server.ID, query, server.RootGroupGUID) } func (s *Service) SaveMapping(userID uuid.UUID, rawName string, productID uuid.UUID, quantity decimal.Decimal, containerID *uuid.UUID) error { diff --git a/internal/transport/http/handlers/drafts.go b/internal/transport/http/handlers/drafts.go index 3cde6b1..72eb698 100644 --- a/internal/transport/http/handlers/drafts.go +++ b/internal/transport/http/handlers/drafts.go @@ -77,6 +77,51 @@ type UpdateItemDTO struct { Price float64 `json:"price"` } +// AddDraftItem - POST /api/drafts/:id/items +func (h *DraftsHandler) AddDraftItem(c *gin.Context) { + draftID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"}) + return + } + + item, err := h.service.AddItem(draftID) + if err != nil { + logger.Log.Error("Failed to add item", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, item) +} + +// DeleteDraftItem - DELETE /api/drafts/:id/items/:itemId +func (h *DraftsHandler) DeleteDraftItem(c *gin.Context) { + draftID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"}) + return + } + itemID, err := uuid.Parse(c.Param("itemId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid item id"}) + return + } + + newTotal, err := h.service.DeleteItem(draftID, itemID) + if err != nil { + logger.Log.Error("Failed to delete item", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "deleted", + "id": itemID.String(), + "total_sum": newTotal, + }) +} + func (h *DraftsHandler) UpdateItem(c *gin.Context) { // userID := c.MustGet("userID").(uuid.UUID) // Пока не используется в UpdateItem, но можно добавить проверку владельца draftID, _ := uuid.Parse(c.Param("id")) diff --git a/internal/transport/http/handlers/settings.go b/internal/transport/http/handlers/settings.go new file mode 100644 index 0000000..6e0eaac --- /dev/null +++ b/internal/transport/http/handlers/settings.go @@ -0,0 +1,158 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "rmser/internal/domain/account" + "rmser/internal/domain/catalog" + "rmser/pkg/logger" +) + +type SettingsHandler struct { + accountRepo account.Repository + catalogRepo catalog.Repository +} + +func NewSettingsHandler(accRepo account.Repository, catRepo catalog.Repository) *SettingsHandler { + return &SettingsHandler{ + accountRepo: accRepo, + catalogRepo: catRepo, + } +} + +// GetSettings возвращает настройки активного сервера +func (h *SettingsHandler) GetSettings(c *gin.Context) { + userID := c.MustGet("userID").(uuid.UUID) + + server, err := h.accountRepo.GetActiveServer(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if server == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "No active server"}) + return + } + + c.JSON(http.StatusOK, server) +} + +// UpdateSettingsDTO +type UpdateSettingsDTO struct { + Name string `json:"name"` + DefaultStoreID string `json:"default_store_id"` + RootGroupID string `json:"root_group_id"` + AutoProcess bool `json:"auto_process"` +} + +// UpdateSettings сохраняет настройки +func (h *SettingsHandler) UpdateSettings(c *gin.Context) { + userID := c.MustGet("userID").(uuid.UUID) + + var req UpdateSettingsDTO + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + server, err := h.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "No active server"}) + return + } + + // Обновляем поля + if req.Name != "" { + server.Name = req.Name + } + server.AutoProcess = req.AutoProcess + + if req.DefaultStoreID != "" { + if uid, err := uuid.Parse(req.DefaultStoreID); err == nil { + server.DefaultStoreID = &uid + } + } else { + server.DefaultStoreID = nil + } + + // Теперь правильно ловим ID группы + if req.RootGroupID != "" { + if uid, err := uuid.Parse(req.RootGroupID); err == nil { + server.RootGroupGUID = &uid + } + } else { + server.RootGroupGUID = nil + } + + if err := h.accountRepo.SaveServer(server); err != nil { + logger.Log.Error("Failed to save settings", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, server) +} + +// --- Group Tree Logic --- + +type GroupNode struct { + Key string `json:"key"` // ID for Ant Design TreeSelect + Value string `json:"value"` // ID value + Title string `json:"title"` // Name + Children []*GroupNode `json:"children"` // Sub-groups +} + +// GetGroupsTree возвращает иерархию групп +func (h *SettingsHandler) GetGroupsTree(c *gin.Context) { + userID := c.MustGet("userID").(uuid.UUID) + server, err := h.accountRepo.GetActiveServer(userID) + if err != nil || server == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "No active server"}) + return + } + + groups, err := h.catalogRepo.GetGroups(server.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + tree := buildTree(groups) + c.JSON(http.StatusOK, tree) +} + +func buildTree(flat []catalog.Product) []*GroupNode { + // 1. Map ID -> Node + nodeMap := make(map[uuid.UUID]*GroupNode) + for _, g := range flat { + nodeMap[g.ID] = &GroupNode{ + Key: g.ID.String(), + Value: g.ID.String(), + Title: g.Name, + Children: make([]*GroupNode, 0), + } + } + + var roots []*GroupNode + + // 2. Build Hierarchy + for _, g := range flat { + node := nodeMap[g.ID] + if g.ParentID != nil { + if parent, exists := nodeMap[*g.ParentID]; exists { + parent.Children = append(parent.Children, node) + } else { + // Если родителя нет в списке (например, он удален или мы выбрали подмножество), + // считаем узлом верхнего уровня + roots = append(roots, node) + } + } else { + roots = append(roots, node) + } + } + return roots +} diff --git a/internal/transport/telegram/bot.go b/internal/transport/telegram/bot.go index feb2bfd..ce3cc82 100644 --- a/internal/transport/telegram/bot.go +++ b/internal/transport/telegram/bot.go @@ -141,6 +141,9 @@ func (bot *Bot) initHandlers() { // Actions Callbacks bot.b.Handle(&tele.Btn{Unique: "act_add_server"}, bot.startAddServerFlow) bot.b.Handle(&tele.Btn{Unique: "act_sync"}, bot.triggerSync) + bot.b.Handle(&tele.Btn{Unique: "act_del_server_menu"}, bot.renderDeleteServerMenu) + bot.b.Handle(&tele.Btn{Unique: "confirm_name_yes"}, bot.handleConfirmNameYes) + bot.b.Handle(&tele.Btn{Unique: "confirm_name_no"}, bot.handleConfirmNameNo) bot.b.Handle(&tele.Btn{Unique: "act_deposit"}, func(c tele.Context) error { return c.Respond(&tele.CallbackResponse{Text: "Функция пополнения в разработке 🛠"}) }) @@ -151,6 +154,7 @@ func (bot *Bot) initHandlers() { // Input Handlers bot.b.Handle(tele.OnText, bot.handleText) bot.b.Handle(tele.OnPhoto, bot.handlePhoto) + } func (bot *Bot) Start() { @@ -205,9 +209,10 @@ func (bot *Bot) renderServersMenu(c tele.Context) error { } btnAdd := menu.Data("➕ Добавить сервер", "act_add_server") + btnDel := menu.Data("🗑 Удалить", "act_del_server_menu") btnBack := menu.Data("🔙 Назад", "nav_main") - rows = append(rows, menu.Row(btnAdd)) + rows = append(rows, menu.Row(btnAdd, btnDel)) rows = append(rows, menu.Row(btnBack)) menu.Inline(rows...) @@ -264,18 +269,21 @@ func (bot *Bot) renderBalanceMenu(c tele.Context) error { func (bot *Bot) handleCallback(c tele.Context) error { data := c.Callback().Data + // FIX: Telebot v3 добавляет префикс '\f' к Unique ID кнопки. + // Нам нужно удалить его, чтобы корректно парсить строку. + if len(data) > 0 && data[0] == '\f' { + data = data[1:] + } + // Обработка выбора сервера "set_server_..." if strings.HasPrefix(data, "set_server_") { serverIDStr := strings.TrimPrefix(data, "set_server_") - // Удаляем лишние пробелы/символы, которые telebot иногда добавляет (уникальный префикс \f) serverIDStr = strings.TrimSpace(serverIDStr) - // Telebot v3: Callback data is prefixed with \f followed by unique id. - // But here we use 'data' which is the payload. - // NOTE: data variable contains what we passed in .Data() second arg. - // Split by | just in case middleware adds something, but usually raw string is fine. - parts := strings.Split(serverIDStr, "|") // Защита от старых форматов - serverIDStr = parts[0] + // Защита от старых форматов с разделителем | + if idx := strings.Index(serverIDStr, "|"); idx != -1 { + serverIDStr = serverIDStr[:idx] + } userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) @@ -290,30 +298,74 @@ func (bot *Bot) handleCallback(c tele.Context) error { } if !found { + logger.Log.Warn("User tried to select unknown server", + zap.Int64("user_tg_id", c.Sender().ID), + zap.String("server_id_req", serverIDStr)) return c.Respond(&tele.CallbackResponse{Text: "Сервер не найден или доступ запрещен"}) } // 2. Делаем активным - // Важно: нужно спарсить UUID - // Telebot sometimes sends garbage if Unique is not handled properly. - // But we handle OnCallback generally. - - // Fix: В Telebot 3 Data() возвращает payload как есть. - // Но лучше быть аккуратным. - - if err := bot.accountRepo.SetActiveServer(userDB.ID, parseUUID(serverIDStr)); err != nil { + targetID := parseUUID(serverIDStr) + if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil { logger.Log.Error("Failed to set active server", zap.Error(err)) return c.Respond(&tele.CallbackResponse{Text: "Ошибка смены сервера"}) } - // 3. Сбрасываем кэш фабрики клиентов (чтобы при следующем запросе создался клиент с новыми кредами, если бы они поменялись, - // но тут меняется сам сервер, так что Factory.GetClientForUser просто возьмет другой сервер) - // Для надежности можно ничего не делать, Factory сама разберется. - + // 3. Успех c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"}) return bot.renderServersMenu(c) // Перерисовываем меню } + // --- ЛОГИКА УДАЛЕНИЯ (новая) --- + if strings.HasPrefix(data, "do_del_server_") { + serverIDStr := strings.TrimPrefix(data, "do_del_server_") + serverIDStr = strings.TrimSpace(serverIDStr) + + // Очистка от мусора + if idx := strings.Index(serverIDStr, "|"); idx != -1 { + serverIDStr = serverIDStr[:idx] + } + + targetID := parseUUID(serverIDStr) + if targetID == uuid.Nil { + return c.Respond(&tele.CallbackResponse{Text: "Некорректный ID"}) + } + + // 1. Проверяем, активен ли он сейчас + // Нам нужно знать это ДО удаления, чтобы переключить активность + // Но проще удалить, а потом проверить, остался ли активный сервер + + // Удаляем + if err := bot.accountRepo.DeleteServer(targetID); err != nil { + logger.Log.Error("Failed to delete server", zap.Error(err)) + return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"}) + } + + // Сбрасываем кэш клиента в фабрике + bot.rmsFactory.ClearCache(targetID) + + // 2. Проверяем, есть ли активный сервер у пользователя + userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) + active, err := bot.accountRepo.GetActiveServer(userDB.ID) + + // Если активного нет (мы удалили активный) или ошибка - назначаем новый + if active == nil || err != nil { + all, _ := bot.accountRepo.GetAllServers(userDB.ID) + if len(all) > 0 { + // Делаем активным первый попавшийся + _ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID) + c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Активным назначен " + all[0].Name}) + } else { + c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Список пуст."}) + } + } else { + c.Respond(&tele.CallbackResponse{Text: "Сервер удален"}) + } + + // Возвращаемся в меню удаления (обновляем список) + return bot.renderDeleteServerMenu(c) + } + return nil } @@ -366,55 +418,65 @@ func (bot *Bot) handleText(c tele.Context) error { ctx.TempURL = strings.TrimRight(text, "/") ctx.State = StateAddServerLogin }) - return c.Send("👤 Введите логин пользователя iiko:") + return c.Send("👤 Введите логин пользователя iiko:") case StateAddServerLogin: bot.fsm.UpdateContext(userID, func(ctx *UserContext) { ctx.TempLogin = text ctx.State = StateAddServerPassword }) - return c.Send("🔑 Введите пароль:") + return c.Send("🔑 Введите пароль:") case StateAddServerPassword: password := text ctx := bot.fsm.GetContext(userID) msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...") - // Check connection + // 1. Проверяем авторизацию (креды) tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password) if err := tempClient.Auth(); err != nil { bot.b.Delete(msg) - return c.Send(fmt.Sprintf("❌ Ошибка: %v\nПопробуйте ввести пароль снова или начните сначала /add_server", err)) + return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err)) } - // Save - encPass, _ := bot.cryptoManager.Encrypt(password) - userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "") - - newServer := &account.RMSServer{ - UserID: userDB.ID, - Name: "iiko Server " + time.Now().Format("15:04"), // Генерируем имя, чтобы не спрашивать лишнего - BaseURL: ctx.TempURL, - Login: ctx.TempLogin, - EncryptedPassword: encPass, - IsActive: true, // Сразу делаем активным + // 2. Пробуем узнать имя сервера + var detectedName string + info, err := rms.GetServerInfo(ctx.TempURL) + if err == nil && info.ServerName != "" { + detectedName = info.ServerName } - // Сначала сохраняем, потом делаем активным (через репо сохранения) - if err := bot.accountRepo.SaveServer(newServer); err != nil { - return c.Send("Ошибка БД: " + err.Error()) - } - // Устанавливаем активным (сбрасывая другие) - bot.accountRepo.SetActiveServer(userDB.ID, newServer.ID) - - bot.fsm.Reset(userID) bot.b.Delete(msg) - c.Send("✅ Сервер добавлен и выбран активным!", tele.ModeHTML) - // Auto-sync - go bot.syncService.SyncAllData(userDB.ID) + // Сохраняем пароль во временный контекст, он нам пригодится при финальном сохранении + bot.fsm.UpdateContext(userID, func(uCtx *UserContext) { + uCtx.TempPassword = password + uCtx.TempServerName = detectedName + }) - return bot.renderMainMenu(c) + // Если имя нашли - предлагаем выбор + if detectedName != "" { + bot.fsm.SetState(userID, StateAddServerConfirmName) + + menu := &tele.ReplyMarkup{} + btnYes := menu.Data("✅ Да, использовать это имя", "confirm_name_yes") + btnNo := menu.Data("✏️ Ввести другое", "confirm_name_no") + menu.Inline(menu.Row(btnYes), menu.Row(btnNo)) + + return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: %s.\nИспользовать его?", detectedName), menu, tele.ModeHTML) + } + + // Если имя не нашли - просим ввести вручную + bot.fsm.SetState(userID, StateAddServerInputName) + return c.Send("🏷 Введите название для этого сервера (для вашего удобства):") + + case StateAddServerInputName: + // Пользователь ввел свое название + name := text + if len(name) < 3 { + return c.Send("⚠️ Название слишком короткое.") + } + return bot.saveServerFinal(c, userID, name) } return nil @@ -488,3 +550,78 @@ func parseUUID(s string) uuid.UUID { id, _ := uuid.Parse(s) return id } + +func (bot *Bot) handleConfirmNameYes(c tele.Context) error { + userID := c.Sender().ID + ctx := bot.fsm.GetContext(userID) + if ctx.State != StateAddServerConfirmName { + return c.Respond() + } + return bot.saveServerFinal(c, userID, ctx.TempServerName) +} + +func (bot *Bot) handleConfirmNameNo(c tele.Context) error { + userID := c.Sender().ID + bot.fsm.SetState(userID, StateAddServerInputName) + return c.EditOrSend("🏷 Хорошо, введите желаемое название:") +} + +// saveServerFinal - общая логика сохранения в БД +func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error { + ctx := bot.fsm.GetContext(userID) + + encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword) + userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "") + + newServer := &account.RMSServer{ + UserID: userDB.ID, + Name: serverName, + BaseURL: ctx.TempURL, + Login: ctx.TempLogin, + EncryptedPassword: encPass, + IsActive: true, + } + + if err := bot.accountRepo.SaveServer(newServer); err != nil { + return c.Send("Ошибка сохранения в БД: " + err.Error()) + } + + bot.accountRepo.SetActiveServer(userDB.ID, newServer.ID) + bot.fsm.Reset(userID) + + c.Send(fmt.Sprintf("✅ Сервер %s успешно добавлен!", serverName), tele.ModeHTML) + + // Auto-sync + go bot.syncService.SyncAllData(userDB.ID) + + return bot.renderMainMenu(c) +} + +func (bot *Bot) renderDeleteServerMenu(c tele.Context) error { + userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) + servers, err := bot.accountRepo.GetAllServers(userDB.ID) + if err != nil { + return c.Send("Ошибка БД: " + err.Error()) + } + + if len(servers) == 0 { + return c.Respond(&tele.CallbackResponse{Text: "Список серверов пуст"}) + } + + menu := &tele.ReplyMarkup{} + var rows []tele.Row + + for _, s := range servers { + // Кнопка удаления для каждого сервера + // Префикс do_del_server_ + btn := menu.Data(fmt.Sprintf("❌ %s", s.Name), "do_del_server_"+s.ID.String()) + rows = append(rows, menu.Row(btn)) + } + + btnBack := menu.Data("🔙 Назад к списку", "nav_servers") + rows = append(rows, menu.Row(btnBack)) + + menu.Inline(rows...) + + return c.EditOrSend("🗑 Удаление сервера\n\nНажмите на сервер, который хотите удалить.\nЭто действие нельзя отменить.", menu, tele.ModeHTML) +} diff --git a/internal/transport/telegram/fsm.go b/internal/transport/telegram/fsm.go index 66cd1c7..2c825d6 100644 --- a/internal/transport/telegram/fsm.go +++ b/internal/transport/telegram/fsm.go @@ -10,14 +10,17 @@ const ( StateAddServerURL StateAddServerLogin StateAddServerPassword + StateAddServerConfirmName + StateAddServerInputName ) // UserContext хранит временные данные в процессе диалога type UserContext struct { - State State - TempURL string - TempLogin string - TempPassword string + State State + TempURL string + TempLogin string + TempPassword string + TempServerName string } // StateManager управляет состояниями diff --git a/rmser-view/src/App.tsx b/rmser-view/src/App.tsx index d82a153..c900fe5 100644 --- a/rmser-view/src/App.tsx +++ b/rmser-view/src/App.tsx @@ -1,16 +1,25 @@ -import { useEffect, useState } from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; -import { Result, Button } from 'antd'; -import { Providers } from './components/layout/Providers'; -import { AppLayout } from './components/layout/AppLayout'; -import { OcrLearning } from './pages/OcrLearning'; -import { InvoiceDraftPage } from './pages/InvoiceDraftPage'; -import { DraftsList } from './pages/DraftsList'; -import { UNAUTHORIZED_EVENT } from './services/api'; +import { useEffect, useState } from "react"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { Result, Button } from "antd"; +import { Providers } from "./components/layout/Providers"; +import { AppLayout } from "./components/layout/AppLayout"; +import { OcrLearning } from "./pages/OcrLearning"; +import { InvoiceDraftPage } from "./pages/InvoiceDraftPage"; +import { DraftsList } from "./pages/DraftsList"; +import { SettingsPage } from "./pages/SettingsPage"; +import { UNAUTHORIZED_EVENT } from "./services/api"; // Компонент заглушки для 401 ошибки const UnauthorizedScreen = () => ( -
+
{ const handleUnauthorized = () => setIsUnauthorized(true); - + // Подписываемся на событие из api.ts window.addEventListener(UNAUTHORIZED_EVENT, handleUnauthorized); @@ -48,12 +57,13 @@ function App() { }> {/* Если Dashboard удален, можно сделать редирект на invoices */} - } /> - + } /> + } /> } /> } /> - + } /> + } /> @@ -62,4 +72,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/rmser-view/src/components/invoices/DraftItemRow.tsx b/rmser-view/src/components/invoices/DraftItemRow.tsx index 4eb1782..079f543 100644 --- a/rmser-view/src/components/invoices/DraftItemRow.tsx +++ b/rmser-view/src/components/invoices/DraftItemRow.tsx @@ -1,48 +1,86 @@ -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 { CreateContainerModal } from './CreateContainerModal'; -import type { DraftItem, UpdateDraftItemRequest, ProductSearchResult, ProductContainer, Recommendation } from '../../services/types'; +import React, { useMemo, useState, useEffect } from "react"; +import { + Card, + Flex, + InputNumber, + Typography, + Select, + Tag, + Button, + Divider, + Modal, + Popconfirm, +} from "antd"; +import { + SyncOutlined, + PlusOutlined, + WarningFilled, + DeleteOutlined, +} from "@ant-design/icons"; +import { CatalogSelect } from "../ocr/CatalogSelect"; +import { CreateContainerModal } from "./CreateContainerModal"; +import type { + DraftItem, + UpdateDraftItemRequest, + ProductSearchResult, + ProductContainer, + Recommendation, +} from "../../services/types"; const { Text } = Typography; interface Props { item: DraftItem; onUpdate: (itemId: string, changes: UpdateDraftItemRequest) => void; + onDelete: (itemId: string) => void; isUpdating: boolean; - recommendations?: Recommendation[]; // Новый проп + recommendations?: Recommendation[]; } -export const DraftItemRow: React.FC = ({ item, onUpdate, isUpdating, recommendations = [] }) => { +export const DraftItemRow: React.FC = ({ + item, + onUpdate, + onDelete, + 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); + 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('.', ',')); + 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('.', ',')); + 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 [searchedProduct, setSearchedProduct] = + useState(null); + const [addedContainers, setAddedContainers] = useState< + Record + >({}); const activeProduct = useMemo(() => { - if (searchedProduct && searchedProduct.id === item.product_id) return searchedProduct; + if (searchedProduct && searchedProduct.id === item.product_id) + return searchedProduct; return item.product as unknown as ProductSearchResult | undefined; }, [searchedProduct, item.product, item.product_id]); @@ -51,28 +89,35 @@ export const DraftItemRow: React.FC = ({ item, onUpdate, isUpdating, reco 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); + 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 || 'ед.'; + const baseUom = + activeProduct?.main_unit?.name || activeProduct?.measure_unit || "ед."; const containerOptions = useMemo(() => { if (!activeProduct) return []; const opts = [ - { value: 'BASE_UNIT', label: `Базовая (${baseUom})` }, - ...containers.map(c => ({ + { value: "BASE_UNIT", label: `Базовая (${baseUom})` }, + ...containers.map((c) => ({ value: c.id, - label: `${c.name} (=${Number(c.count)} ${baseUom})` - })) + label: `${c.name} (=${Number(c.count)} ${baseUom})`, + })), ]; - 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})` - }); + 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]); @@ -80,123 +125,193 @@ export const DraftItemRow: React.FC = ({ item, onUpdate, isUpdating, reco // --- WARNING LOGIC --- const activeWarning = useMemo(() => { if (!item.product_id) return null; - return recommendations.find(r => r.ProductID === item.product_id); + return recommendations.find((r) => r.ProductID === item.product_id); }, [item.product_id, recommendations]); const showWarningModal = () => { if (!activeWarning) return; Modal.warning({ - title: 'Внимание: проблемный товар', + title: "Внимание: проблемный товар", content: (
-

{activeWarning.ProductName}

+

+ {activeWarning.ProductName} +

{activeWarning.Reason}

-

{activeWarning.Type}

+

+ {activeWarning.Type} +

), - okText: 'Понятно', - maskClosable: true + okText: "Понятно", + maskClosable: true, }); }; // --- Helpers --- const parseToNum = (val: string | null | undefined): number => { if (!val) return 0; - return parseFloat(val.replace(',', '.')); + return parseFloat(val.replace(",", ".")); }; - const getUpdatePayload = (overrides: Partial): UpdateDraftItemRequest => { - const currentQty = localQuantity !== null ? parseToNum(localQuantity) : item.quantity; - const currentPrice = localPrice !== null ? parseToNum(localPrice) : item.price; + const getUpdatePayload = ( + overrides: Partial + ): UpdateDraftItemRequest => { + const currentQty = + localQuantity !== null ? parseToNum(localQuantity) : item.quantity; + const currentPrice = + localPrice !== null ? parseToNum(localPrice) : item.price; return { - product_id: item.product_id || undefined, + product_id: item.product_id || undefined, container_id: item.container_id, quantity: currentQty ?? 1, price: currentPrice ?? 0, - ...overrides + ...overrides, }; }; // --- Handlers --- - const handleProductChange = (prodId: string, productObj?: ProductSearchResult) => { + const handleProductChange = ( + prodId: string, + productObj?: ProductSearchResult + ) => { if (productObj) setSearchedProduct(productObj); - onUpdate(item.id, getUpdatePayload({ product_id: prodId, container_id: null })); + onUpdate( + item.id, + getUpdatePayload({ product_id: prodId, container_id: null }) + ); }; const handleContainerChange = (val: string) => { - const newVal = val === 'BASE_UNIT' ? null : val; + 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; + 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 })); + onUpdate(item.id, getUpdatePayload({ [field]: numVal })); } }; const handleContainerCreated = (newContainer: ProductContainer) => { setIsModalOpen(false); if (activeProduct) { - setAddedContainers(prev => ({ ...prev, [activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer] })); + 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 cardBorderColor = !item.product_id + ? "#ffa39e" + : item.is_matched + ? "#b7eb8f" + : "#d9d9d9"; const uiSum = parseToNum(localQuantity) * parseToNum(localPrice); return ( <> -
- {item.raw_name} + {/* Показываем raw_name только если это OCR строка. Если создана вручную и пустая - плейсхолдер */} + + {item.raw_name || "Новая позиция"} + {item.raw_amount > 0 && ( - (чек: {item.raw_amount} x {item.raw_price}) + + (чек: {item.raw_amount} x {item.raw_price}) + )}
-
- {isUpdating && } - +
+ {isUpdating && } + {/* Warning Icon */} {activeWarning && ( - )} - - {!item.product_id && ?} + + {!item.product_id && ( + + ? + + )} + + {/* Кнопка удаления */} + onDelete(item.id)} + okText="Да" + cancelText="Нет" + placement="left" + > +
- {activeProduct && ( ({ label: s.name, value: s.id }))} - size="middle" - /> - - - - {/* Поле Поставщика (Обязательное) */} - -