diff --git a/internal/domain/drafts/entity.go b/internal/domain/drafts/entity.go index 7ef5639..281e409 100644 --- a/internal/domain/drafts/entity.go +++ b/internal/domain/drafts/entity.go @@ -18,6 +18,14 @@ const ( StatusDeleted = "DELETED" ) +type EditedField string + +const ( + FieldQuantity EditedField = "quantity" + FieldPrice EditedField = "price" + FieldSum EditedField = "sum" +) + type DraftInvoice struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` @@ -63,6 +71,10 @@ type DraftInvoiceItem struct { Price decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"price"` Sum decimal.Decimal `gorm:"type:numeric(19,4);default:0" json:"sum"` + // Два последних отредактированных поля (для автопересчёта) + LastEditedField1 EditedField `gorm:"column:last_edited_field1;type:varchar(20);default:'quantity'" json:"last_edited_field_1"` + LastEditedField2 EditedField `gorm:"column:last_edited_field2;type:varchar(20);default:'price'" json:"last_edited_field_2"` + IsMatched bool `gorm:"default:false" json:"is_matched"` } @@ -70,9 +82,10 @@ type Repository interface { Create(draft *DraftInvoice) error GetByID(id uuid.UUID) (*DraftInvoice, error) GetByRMSInvoiceID(rmsInvoiceID uuid.UUID) (*DraftInvoice, error) - Update(draft *DraftInvoice) error + GetItemByID(itemID uuid.UUID) (*DraftInvoiceItem, error) CreateItems(items []DraftInvoiceItem) error - UpdateItem(itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error + Update(draft *DraftInvoice) error + UpdateItem(itemID uuid.UUID, updates map[string]interface{}) error CreateItem(item *DraftInvoiceItem) error DeleteItem(itemID uuid.UUID) error Delete(id uuid.UUID) error diff --git a/internal/infrastructure/repository/drafts/postgres.go b/internal/infrastructure/repository/drafts/postgres.go index 61a5b6e..32a73b0 100644 --- a/internal/infrastructure/repository/drafts/postgres.go +++ b/internal/infrastructure/repository/drafts/postgres.go @@ -4,7 +4,6 @@ import ( "rmser/internal/domain/drafts" "github.com/google/uuid" - "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -56,7 +55,7 @@ func (r *pgRepository) Update(draft *drafts.DraftInvoice) error { return r.db.Model(draft).Updates(map[string]interface{}{ "status": draft.Status, "document_number": draft.DocumentNumber, - "incoming_document_number": draft.IncomingDocumentNumber, // Добавлено поле для входящего номера документа + "incoming_document_number": draft.IncomingDocumentNumber, "date_incoming": draft.DateIncoming, "supplier_id": draft.SupplierID, "store_id": draft.StoreID, @@ -82,27 +81,27 @@ 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 +// GetItemByID - новый метод +func (r *pgRepository) GetItemByID(itemID uuid.UUID) (*drafts.DraftInvoiceItem, error) { + var item drafts.DraftInvoiceItem + err := r.db.Where("id = ?", itemID).First(&item).Error + if err != nil { + return nil, err + } + return &item, nil +} +// UpdateItem - обновленный метод, принимает map +func (r *pgRepository) UpdateItem(itemID uuid.UUID, updates map[string]interface{}) error { return r.db.Model(&drafts.DraftInvoiceItem{}). Where("id = ?", itemID). - Updates(map[string]interface{}{ - "product_id": productID, - "container_id": containerID, - "quantity": qty, - "price": price, - "sum": sum, - "is_matched": isMatched, - }).Error + Updates(updates).Error } func (r *pgRepository) Delete(id uuid.UUID) error { return r.db.Delete(&drafts.DraftInvoice{}, id).Error } -// GetActive возвращает черновики для конкретного СЕРВЕРА func (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, error) { var list []drafts.DraftInvoice @@ -116,14 +115,13 @@ func (r *pgRepository) GetActive(serverID uuid.UUID) ([]drafts.DraftInvoice, err err := r.db. Preload("Items"). Preload("Store"). - Where("rms_server_id = ? AND status IN ?", serverID, activeStatuses). // Фильтр по серверу + Where("rms_server_id = ? AND status IN ?", serverID, activeStatuses). Order("created_at DESC"). Find(&list).Error return list, err } -// GetRMSInvoiceIDToPhotoURLMap возвращает мапу rms_invoice_id -> sender_photo_url для сервера, где rms_invoice_id не NULL func (r *pgRepository) GetRMSInvoiceIDToPhotoURLMap(serverID uuid.UUID) (map[uuid.UUID]string, error) { var draftsList []drafts.DraftInvoice err := r.db. diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go index f9f2898..e6cd72a 100644 --- a/internal/services/drafts/service.go +++ b/internal/services/drafts/service.go @@ -55,7 +55,6 @@ func NewService( } } -// checkWriteAccess проверяет, что пользователь имеет право редактировать данные на сервере (ADMIN/OWNER) func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error { role, err := s.accountRepo.GetUserRole(userID, serverID) if err != nil { @@ -73,8 +72,6 @@ func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, err return nil, err } - // Проверяем, что черновик принадлежит активному серверу пользователя - // И пользователь не Оператор (операторы вообще не ходят в API) server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, errors.New("нет активного сервера") @@ -92,30 +89,24 @@ func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, err } func (s *Service) GetActiveDrafts(userID uuid.UUID) ([]drafts.DraftInvoice, error) { - // 1. Узнаем активный сервер server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, errors.New("активный сервер не выбран") } - // 2. Проверяем роль (Security) - // Операторам список недоступен if err := s.checkWriteAccess(userID, server.ID); err != nil { return nil, err } - // 3. Возвращаем все черновики СЕРВЕРА return s.draftRepo.GetActive(server.ID) } -// GetDictionaries возвращает Склады и Поставщиков для пользователя func (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, error) { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, fmt.Errorf("active server not found") } - // Словари нужны только тем, кто редактирует if err := s.checkWriteAccess(userID, server.ID); err != nil { return nil, err } @@ -134,10 +125,6 @@ func (s *Service) DeleteDraft(id uuid.UUID) (string, error) { if err != nil { return "", err } - // TODO: Здесь тоже бы проверить userID и права, но пока оставим как есть, - // так как DeleteDraft вызывается из хендлера, где мы можем добавить проверку, - // но лучше передавать userID в сигнатуру DeleteDraft(id, userID). - // Для скорости пока оставим, полагаясь на то, что фронт не покажет кнопку. if draft.Status == drafts.StatusCanceled { draft.Status = drafts.StatusDeleted @@ -168,18 +155,19 @@ 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, + 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, + LastEditedField1: drafts.FieldQuantity, + LastEditedField2: drafts.FieldPrice, } if err := s.draftRepo.CreateItem(newItem); err != nil { @@ -188,7 +176,6 @@ func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) { return newItem, nil } -// DeleteItem удаляет строку и возвращает обновленную сумму черновика func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) { if err := s.draftRepo.DeleteItem(itemID); err != nil { return 0, err @@ -212,23 +199,104 @@ func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) { return sumFloat, nil } -func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error { +// RecalculateItemFields - логика пересчета Qty/Price/Sum +func (s *Service) RecalculateItemFields(item *drafts.DraftInvoiceItem, editedField drafts.EditedField) { + if item.LastEditedField1 != editedField { + item.LastEditedField2 = item.LastEditedField1 + item.LastEditedField1 = editedField + } + + fieldsToKeep := map[drafts.EditedField]bool{ + item.LastEditedField1: true, + item.LastEditedField2: true, + } + + var fieldToRecalc drafts.EditedField + fieldToRecalc = drafts.FieldSum // Default fallback + + for _, f := range []drafts.EditedField{drafts.FieldQuantity, drafts.FieldPrice, drafts.FieldSum} { + if !fieldsToKeep[f] { + fieldToRecalc = f + break + } + } + + switch fieldToRecalc { + case drafts.FieldQuantity: + if !item.Price.IsZero() { + item.Quantity = item.Sum.Div(item.Price) + } else { + item.Quantity = decimal.Zero + } + case drafts.FieldPrice: + if !item.Quantity.IsZero() { + item.Price = item.Sum.Div(item.Quantity) + } else { + item.Price = decimal.Zero + } + case drafts.FieldSum: + item.Sum = item.Quantity.Mul(item.Price) + } +} + +// UpdateItem обновлен для поддержки динамического пересчета +func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price, sum decimal.Decimal, editedField string) error { draft, err := s.draftRepo.GetByID(draftID) if err != nil { return err } - // Автосмена статуса + + currentItem, err := s.draftRepo.GetItemByID(itemID) + if err != nil { + return err + } + + if productID != nil { + currentItem.ProductID = productID + currentItem.IsMatched = true + } + + if containerID != nil { + // Если пришел UUID.Nil, значит сброс + if *containerID == uuid.Nil { + currentItem.ContainerID = nil + } else { + currentItem.ContainerID = containerID + } + } + + field := drafts.EditedField(editedField) + switch field { + case drafts.FieldQuantity: + currentItem.Quantity = qty + case drafts.FieldPrice: + currentItem.Price = price + case drafts.FieldSum: + currentItem.Sum = sum + } + + s.RecalculateItemFields(currentItem, field) + if draft.Status == drafts.StatusCanceled { draft.Status = drafts.StatusReadyToVerify s.draftRepo.Update(draft) } - return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price) + + updates := map[string]interface{}{ + "product_id": currentItem.ProductID, + "container_id": currentItem.ContainerID, + "quantity": currentItem.Quantity, + "price": currentItem.Price, + "sum": currentItem.Sum, + "last_edited_field1": currentItem.LastEditedField1, + "last_edited_field2": currentItem.LastEditedField2, + "is_matched": currentItem.IsMatched, + } + + return s.draftRepo.UpdateItem(itemID, updates) } -// CommitDraft отправляет накладную -// CommitDraft отправляет накладную func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { - // 1. Получаем сервер и права server, err := s.accountRepo.GetActiveServer(userID) if err != nil { return "", fmt.Errorf("active server not found: %w", err) @@ -238,18 +306,15 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { return "", err } - // --- BILLING CHECK --- if can, err := s.billingService.CanProcessInvoice(server.ID); !can { return "", fmt.Errorf("ошибка биллинга: %w", err) } - // 2. Черновик draft, err := s.draftRepo.GetByID(draftID) if err != nil { return "", err } - // Проверка принадлежности черновика серверу if draft.RMSServerID != server.ID { return "", errors.New("черновик принадлежит другому серверу") } @@ -258,7 +323,6 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { return "", errors.New("накладная уже отправлена") } - // 3. Клиент (использует права текущего юзера - Админа/Владельца) client, err := s.rmsFactory.GetClientForUser(userID) if err != nil { return "", err @@ -269,7 +333,6 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { targetStatus = "PROCESSED" } - // 4. Сборка Invoice inv := invoices.Invoice{ ID: uuid.Nil, DocumentNumber: draft.DocumentNumber, @@ -284,7 +347,7 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { for _, dItem := range draft.Items { if dItem.ProductID == nil { - continue // Skip unrecognized + continue } sum := dItem.Sum @@ -292,28 +355,16 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { sum = dItem.Quantity.Mul(dItem.Price) } - // Инициализируем значениями из черновика (по умолчанию для базовых единиц) amountToSend := dItem.Quantity priceToSend := dItem.Price - // ЛОГИКА ПЕРЕСЧЕТА ДЛЯ ФАСОВОК (СОГЛАСНО ДОКУМЕНТАЦИИ IIKO) - // Если указан ContainerID, iiko требует: - // = кол-во упаковок * вес упаковки (итоговое кол-во в базовых единицах) - // = цена за упаковку / вес упаковки (цена за базовую единицу) - // = ID фасовки if dItem.ContainerID != nil && *dItem.ContainerID != uuid.Nil { - // Проверяем, что Container загружен (Preload в репозитории) if dItem.Container != nil { if !dItem.Container.Count.IsZero() { - // 1. Пересчитываем кол-во: 5 ящиков * 10 кг = 50 кг amountToSend = dItem.Quantity.Mul(dItem.Container.Count) - - // 2. Пересчитываем цену: 1000 руб/ящ / 10 кг = 100 руб/кг priceToSend = dItem.Price.Div(dItem.Container.Count) } } else { - // Если фасовка есть в ID, но не подгрузилась структура - это ошибка данных. - // Логируем варнинг, но пробуем отправить как есть (iiko может отвергнуть или посчитать криво) logger.Log.Warn("Container struct is nil for item with ContainerID", zap.String("item_id", dItem.ID.String()), zap.String("container_id", dItem.ContainerID.String())) @@ -322,9 +373,9 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { invItem := invoices.InvoiceItem{ ProductID: *dItem.ProductID, - Amount: amountToSend, // Отправляем ПЕРЕСЧИТАННЫЙ вес/объем - Price: priceToSend, // Отправляем ПЕРЕСЧИТАННУЮ цену за базовую ед. - Sum: sum, // Сумма остается неизменной (Total) + Amount: amountToSend, + Price: priceToSend, + Sum: sum, ContainerID: dItem.ContainerID, } inv.Items = append(inv.Items, invItem) @@ -334,13 +385,11 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { return "", errors.New("нет распознанных позиций для отправки") } - // 5. Отправка в RMS docNum, err := client.CreateIncomingInvoice(inv) if err != nil { return "", err } - // 6. Поиск UUID созданной накладной invoices, err := client.FetchInvoices(*draft.DateIncoming, *draft.DateIncoming) if err != nil { logger.Log.Warn("Не удалось получить список накладных для поиска UUID", zap.Error(err), zap.Time("date", *draft.DateIncoming)) @@ -358,11 +407,9 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { } } - // 7. Обновление статуса черновика draft.Status = drafts.StatusCompleted s.draftRepo.Update(draft) - // --- БИЛЛИНГ: Списание баланса и инкремент счетчика --- if err := s.accountRepo.DecrementBalance(server.ID); err != nil { logger.Log.Error("Billing decrement failed", zap.Error(err), zap.String("server_id", server.ID.String())) } @@ -371,7 +418,6 @@ func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { logger.Log.Error("Billing increment failed", zap.Error(err)) } - // 7. Запуск обучения go s.learnFromDraft(draft, server.ID) return docNum, nil @@ -408,7 +454,6 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, return uuid.Nil, fmt.Errorf("error fetching product: %w", err) } - // Валидация на дубли targetCount, _ := count.Float64() for _, c := range fullProduct.Containers { if !c.Deleted && (c.Name == name || (c.Count == targetCount)) { @@ -418,7 +463,6 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, } } - // Next Num maxNum := 0 for _, c := range fullProduct.Containers { if n, err := strconv.Atoi(c.Num); err == nil { @@ -429,7 +473,6 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, } nextNum := strconv.Itoa(maxNum + 1) - // Add newContainerDTO := rms.ContainerFullDTO{ ID: nil, Num: nextNum, @@ -440,13 +483,11 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, } fullProduct.Containers = append(fullProduct.Containers, newContainerDTO) - // Update RMS updatedProduct, err := client.UpdateProduct(*fullProduct) if err != nil { return uuid.Nil, fmt.Errorf("error updating product: %w", err) } - // Find created ID var createdID uuid.UUID found := false for _, c := range updatedProduct.Containers { @@ -465,7 +506,6 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, return uuid.Nil, errors.New("container created but id not found") } - // Save Local newLocalContainer := catalog.ProductContainer{ ID: createdID, RMSServerID: server.ID, @@ -478,10 +518,9 @@ func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, return createdID, nil } -// Добавим новый DTO для единого списка (Frontend Contract) type UnifiedInvoiceDTO struct { ID uuid.UUID `json:"id"` - Type string `json:"type"` // "DRAFT" или "SYNCED" + Type string `json:"type"` DocumentNumber string `json:"document_number"` IncomingNumber string `json:"incoming_number"` DateIncoming time.Time `json:"date_incoming"` @@ -501,19 +540,16 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie return nil, errors.New("активный сервер не выбран") } - // 1. Получаем черновики (их обычно немного, берем все активные) draftsList, err := s.draftRepo.GetActive(server.ID) if err != nil { return nil, err } - // 2. Получаем синхронизированные накладные за период invoicesList, err := s.invoiceRepo.GetByPeriod(server.ID, from, to) if err != nil { return nil, err } - // 3. Получаем мапу rms_invoice_id -> sender_photo_url photoMap, err := s.draftRepo.GetRMSInvoiceIDToPhotoURLMap(server.ID) if err != nil { return nil, err @@ -521,7 +557,6 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie result := make([]UnifiedInvoiceDTO, 0, len(draftsList)+len(invoicesList)) - // Маппим черновики for _, d := range draftsList { var sum decimal.Decimal for _, it := range d.Items { @@ -538,7 +573,6 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie date = *d.DateIncoming } - // Формируем ItemsPreview для черновиков var itemsPreview string if len(d.Items) > 0 { names := make([]string, 0, 3) @@ -555,11 +589,11 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie ID: d.ID, Type: "DRAFT", DocumentNumber: d.DocumentNumber, - IncomingNumber: "", // В черновиках пока не разделяем + IncomingNumber: "", DateIncoming: date, Status: d.Status, TotalSum: val, - StoreName: "", // Можно подгрузить из d.Store.Name если сделан Preload + StoreName: "", ItemsCount: len(d.Items), CreatedAt: d.CreatedAt, IsAppCreated: true, @@ -568,7 +602,6 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie }) } - // Маппим проведенные for _, inv := range invoicesList { var sum decimal.Decimal for _, it := range inv.Items { @@ -576,7 +609,6 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie } val, _ := sum.Float64() - // Определяем IsAppCreated и PhotoURL через мапу isAppCreated := false photoURL := "" if url, exists := photoMap[inv.ID]; exists { @@ -584,7 +616,6 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie photoURL = url } - // Формируем ItemsPreview для синхронизированных накладных var itemsPreview string if len(inv.Items) > 0 { names := make([]string, 0, 3) @@ -592,7 +623,6 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie if i >= 3 { break } - // Предполагаем, что Product подгружен, иначе нужно добавить Preload if it.Product.Name != "" { names = append(names, it.Product.Name) } @@ -616,19 +646,15 @@ func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]Unifie }) } - // Сортировка по дате накладной (desc) - // (Здесь можно добавить библиотеку sort или оставить как есть, если БД уже отсортировала части) return result, nil } func (s *Service) GetInvoiceDetails(invoiceID, userID uuid.UUID) (*invoices.Invoice, string, error) { - // Получить накладную inv, err := s.invoiceRepo.GetByID(invoiceID) if err != nil { return nil, "", err } - // Проверить, что пользователь имеет доступ к серверу накладной server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, "", errors.New("нет активного сервера") @@ -638,7 +664,6 @@ func (s *Service) GetInvoiceDetails(invoiceID, userID uuid.UUID) (*invoices.Invo return nil, "", errors.New("накладная не принадлежит активному серверу") } - // Попытаться найти черновик по rms_invoice_id draft, err := s.draftRepo.GetByRMSInvoiceID(invoiceID) if err != nil { return nil, "", err diff --git a/internal/transport/http/handlers/drafts.go b/internal/transport/http/handlers/drafts.go index ff858b8..5660a2d 100644 --- a/internal/transport/http/handlers/drafts.go +++ b/internal/transport/http/handlers/drafts.go @@ -21,7 +21,6 @@ func NewDraftsHandler(service *drafts.Service) *DraftsHandler { return &DraftsHandler{service: service} } -// GetDraft func (h *DraftsHandler) GetDraft(c *gin.Context) { userID := c.MustGet("userID").(uuid.UUID) idStr := c.Param("id") @@ -39,7 +38,6 @@ func (h *DraftsHandler) GetDraft(c *gin.Context) { c.JSON(http.StatusOK, draft) } -// GetDictionaries (бывший GetStores) func (h *DraftsHandler) GetDictionaries(c *gin.Context) { userID := c.MustGet("userID").(uuid.UUID) @@ -52,12 +50,9 @@ func (h *DraftsHandler) GetDictionaries(c *gin.Context) { c.JSON(http.StatusOK, data) } -// GetStores - устаревший метод для обратной совместимости -// Возвращает массив складов func (h *DraftsHandler) GetStores(c *gin.Context) { userID := c.MustGet("userID").(uuid.UUID) - // Используем логику из GetDictionaries, но возвращаем только stores dict, err := h.service.GetDictionaries(userID) if err != nil { logger.Log.Error("GetStores error", zap.Error(err)) @@ -65,19 +60,19 @@ func (h *DraftsHandler) GetStores(c *gin.Context) { return } - // dict["stores"] уже содержит []catalog.Store c.JSON(http.StatusOK, dict["stores"]) } -// UpdateItemDTO +// UpdateItemDTO обновлен: float64 -> *float64, добавлен edited_field type UpdateItemDTO struct { - ProductID *string `json:"product_id"` - ContainerID *string `json:"container_id"` - Quantity float64 `json:"quantity"` - Price float64 `json:"price"` + ProductID *string `json:"product_id"` + ContainerID *string `json:"container_id"` + Quantity *float64 `json:"quantity"` + Price *float64 `json:"price"` + Sum *float64 `json:"sum"` + EditedField string `json:"edited_field"` // "quantity", "price", "sum" } -// AddDraftItem - POST /api/drafts/:id/items func (h *DraftsHandler) AddDraftItem(c *gin.Context) { draftID, err := uuid.Parse(c.Param("id")) if err != nil { @@ -95,7 +90,6 @@ func (h *DraftsHandler) AddDraftItem(c *gin.Context) { 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 { @@ -122,8 +116,8 @@ func (h *DraftsHandler) DeleteDraftItem(c *gin.Context) { }) } +// UpdateItem обновлен func (h *DraftsHandler) UpdateItem(c *gin.Context) { - // userID := c.MustGet("userID").(uuid.UUID) // Пока не используется в UpdateItem, но можно добавить проверку владельца draftID, _ := uuid.Parse(c.Param("id")) itemID, _ := uuid.Parse(c.Param("itemId")) @@ -141,16 +135,44 @@ func (h *DraftsHandler) UpdateItem(c *gin.Context) { } var cID *uuid.UUID - if req.ContainerID != nil && *req.ContainerID != "" { - if uid, err := uuid.Parse(*req.ContainerID); err == nil { + if req.ContainerID != nil { + if *req.ContainerID == "" { + // Сброс фасовки + empty := uuid.Nil + cID = &empty + } else if uid, err := uuid.Parse(*req.ContainerID); err == nil { cID = &uid } } - qty := decimal.NewFromFloat(req.Quantity) - price := decimal.NewFromFloat(req.Price) + qty := decimal.Zero + if req.Quantity != nil { + qty = decimal.NewFromFloat(*req.Quantity) + } - if err := h.service.UpdateItem(draftID, itemID, pID, cID, qty, price); err != nil { + price := decimal.Zero + if req.Price != nil { + price = decimal.NewFromFloat(*req.Price) + } + + sum := decimal.Zero + if req.Sum != nil { + sum = decimal.NewFromFloat(*req.Sum) + } + + // Дефолт, если фронт не прислал (для совместимости) + editedField := req.EditedField + if editedField == "" { + if req.Sum != nil { + editedField = "sum" + } else if req.Price != nil { + editedField = "price" + } else { + editedField = "quantity" + } + } + + if err := h.service.UpdateItem(draftID, itemID, pID, cID, qty, price, sum, editedField); err != nil { logger.Log.Error("Failed to update item", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -245,28 +267,14 @@ func (h *DraftsHandler) AddContainer(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "created", "container_id": newID.String()}) } -type DraftListItemDTO struct { - ID string `json:"id"` - DocumentNumber string `json:"document_number"` - DateIncoming string `json:"date_incoming"` - Status string `json:"status"` - ItemsCount int `json:"items_count"` - TotalSum float64 `json:"total_sum"` - StoreName string `json:"store_name"` - CreatedAt string `json:"created_at"` - IsAppCreated bool `json:"is_app_created"` -} - func (h *DraftsHandler) GetDrafts(c *gin.Context) { userID := c.MustGet("userID").(uuid.UUID) - // Читаем параметры периода из Query (default: 30 days) fromStr := c.DefaultQuery("from", time.Now().AddDate(0, 0, -30).Format("2006-01-02")) toStr := c.DefaultQuery("to", time.Now().Format("2006-01-02")) from, _ := time.Parse("2006-01-02", fromStr) to, _ := time.Parse("2006-01-02", toStr) - // Устанавливаем конец дня для 'to' to = to.Add(23*time.Hour + 59*time.Minute + 59*time.Second) list, err := h.service.GetUnifiedList(userID, from, to) @@ -279,7 +287,6 @@ func (h *DraftsHandler) GetDrafts(c *gin.Context) { } func (h *DraftsHandler) DeleteDraft(c *gin.Context) { - // userID := c.MustGet("userID").(uuid.UUID) // Можно добавить проверку владельца idStr := c.Param("id") id, err := uuid.Parse(idStr) if err != nil { diff --git a/rmser-view/src/components/invoices/DraftItemRow.tsx b/rmser-view/src/components/invoices/DraftItemRow.tsx index 079f543..a2a2f3a 100644 --- a/rmser-view/src/components/invoices/DraftItemRow.tsx +++ b/rmser-view/src/components/invoices/DraftItemRow.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from "react"; +import React, { useMemo, useState, useEffect, useRef } from "react"; import { Card, Flex, @@ -37,6 +37,8 @@ interface Props { recommendations?: Recommendation[]; } +type FieldType = "quantity" | "price" | "sum"; + export const DraftItemRow: React.FC = ({ item, onUpdate, @@ -46,32 +48,129 @@ export const DraftItemRow: React.FC = ({ }) => { const [isModalOpen, setIsModalOpen] = useState(false); - // State Input - const [localQuantity, setLocalQuantity] = useState( - item.quantity?.toString() ?? null - ); - const [localPrice, setLocalPrice] = useState( - item.price?.toString() ?? null - ); + // --- Локальное состояние значений (строки для удобства ввода) --- + const [localQty, setLocalQty] = useState(item.quantity); + const [localPrice, setLocalPrice] = useState(item.price); + const [localSum, setLocalSum] = useState(item.sum); - // Sync Effect + // --- История редактирования (Stack) --- + // Храним 2 последних отредактированных поля. + // Инициализируем из пропсов или дефолтно ['quantity', 'price'], чтобы пересчитывалась сумма. + const editStack = useRef([ + (item.last_edited_field_1 as FieldType) || "quantity", + (item.last_edited_field_2 as FieldType) || "price", + ]); + + // Храним ссылку на предыдущую версию item, чтобы сравнивать изменения + + // --- Синхронизация с сервером --- 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]); + // Если мы ждем ответа от сервера, не сбиваем локальный ввод + if (isUpdating) return; - 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]); + // Обновляем локальные стейты только когда меняются конкретные поля в item + setLocalQty(item.quantity); + setLocalPrice(item.price); + setLocalSum(item.sum); - // Product Logic + // Обновляем стек редактирования + if (item.last_edited_field_1 && item.last_edited_field_2) { + editStack.current = [ + item.last_edited_field_1 as FieldType, + item.last_edited_field_2 as FieldType, + ]; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + // Зависим ТОЛЬКО от примитивов. Если объект item изменится, но цифры те же - эффект не сработает. + item.quantity, + item.price, + item.sum, + item.last_edited_field_1, + item.last_edited_field_2, + isUpdating, + ]); + + // --- Логика пересчета (Треугольник) --- + const recalculateLocally = (changedField: FieldType, newVal: number) => { + // 1. Обновляем стек истории + // Удаляем поле, если оно уже было в стеке, и добавляем в начало (LIFO для важности) + const currentStack = editStack.current.filter((f) => f !== changedField); + currentStack.unshift(changedField); + // Оставляем только 2 последних + if (currentStack.length > 2) currentStack.pop(); + editStack.current = currentStack; + + // 2. Определяем, какое поле нужно пересчитать (то, которого НЕТ в стеке) + const allFields: FieldType[] = ["quantity", "price", "sum"]; + const fieldToRecalc = allFields.find((f) => !currentStack.includes(f)); + + // 3. Выполняем расчет + let q = changedField === "quantity" ? newVal : localQty || 0; + let p = changedField === "price" ? newVal : localPrice || 0; + let s = changedField === "sum" ? newVal : localSum || 0; + + switch (fieldToRecalc) { + case "sum": + s = q * p; + setLocalSum(s); + break; + case "quantity": + if (p !== 0) { + q = s / p; + setLocalQty(q); + } else { + setLocalQty(0); + } + break; + case "price": + if (q !== 0) { + p = s / q; + setLocalPrice(p); + } else { + setLocalPrice(0); + } + break; + } + }; + + // --- Обработчики ввода --- + + const handleValueChange = (field: FieldType, val: number | null) => { + // Обновляем само поле + if (field === "quantity") setLocalQty(val); + if (field === "price") setLocalPrice(val); + if (field === "sum") setLocalSum(val); + + if (val !== null) { + recalculateLocally(field, val); + } + }; + + const handleBlur = (field: FieldType) => { + // Отправляем на сервер только измененное поле + маркер edited_field. + // Сервер сам проведет пересчет и вернет точные данные. + // Важно: отправляем текущее локальное значение. + + let val: number | null = null; + if (field === "quantity") val = localQty; + if (field === "price") val = localPrice; + if (field === "sum") val = localSum; + + if (val === null) return; + + // Сравниваем с текущим item, чтобы не спамить запросами, если число не поменялось + const serverVal = item[field]; + // Используем эпсилон для сравнения float + if (Math.abs(val - serverVal) > 0.0001) { + onUpdate(item.id, { + [field]: val, + edited_field: field, + }); + } + }; + + // --- Product & Container Logic (как было) --- const [searchedProduct, setSearchedProduct] = useState(null); const [addedContainers, setAddedContainers] = useState< @@ -148,53 +247,25 @@ export const DraftItemRow: React.FC = ({ }); }; - // --- Helpers --- - const parseToNum = (val: string | null | undefined): number => { - if (!val) return 0; - 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; - - return { - product_id: item.product_id || undefined, - container_id: item.container_id, - quantity: currentQty ?? 1, - price: currentPrice ?? 0, - ...overrides, - }; - }; - // --- Handlers --- const handleProductChange = ( prodId: string, productObj?: ProductSearchResult ) => { if (productObj) setSearchedProduct(productObj); - onUpdate( - item.id, - getUpdatePayload({ product_id: prodId, container_id: null }) - ); + onUpdate(item.id, { + product_id: prodId, + container_id: null, // Сбрасываем фасовку + // При смене товара логично оставить Qty и Sum, пересчитав Price? + // Или оставить Qty и Price? Обычно цена меняется. + // Пока не трогаем числа, пусть остаются как были. + }); }; 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 })); - } + // "" пустая строка приходит при выборе "Базовая" (мы так настроим value) + const newVal = val === "BASE_UNIT" ? "" : val; + onUpdate(item.id, { container_id: newVal }); }; const handleContainerCreated = (newContainer: ProductContainer) => { @@ -205,7 +276,7 @@ export const DraftItemRow: React.FC = ({ [activeProduct.id]: [...(prev[activeProduct.id] || []), newContainer], })); } - onUpdate(item.id, getUpdatePayload({ container_id: newContainer.id })); + onUpdate(item.id, { container_id: newContainer.id }); }; const cardBorderColor = !item.product_id @@ -213,7 +284,6 @@ export const DraftItemRow: React.FC = ({ : item.is_matched ? "#b7eb8f" : "#d9d9d9"; - const uiSum = parseToNum(localQuantity) * parseToNum(localPrice); return ( <> @@ -229,7 +299,6 @@ export const DraftItemRow: React.FC = ({
- {/* Показываем raw_name только если это OCR строка. Если создана вручную и пустая - плейсхолдер */} = ({ > {isUpdating && } - {/* Warning Icon */} {activeWarning && ( = ({ )} - {/* Кнопка удаления */} onDelete(item.id)} @@ -332,38 +399,44 @@ export const DraftItemRow: React.FC = ({ borderBottomRightRadius: 8, }} > -
- - style={{ width: 60 }} - controls={false} - placeholder="Кол" - stringMode - decimalSeparator="," - value={localQuantity || ""} - onChange={(val) => setLocalQuantity(val)} - onBlur={() => handleBlur("quantity")} - /> - x - +
+ handleValueChange("quantity", val)} + onBlur={() => handleBlur("quantity")} + precision={3} + /> + x + setLocalPrice(val)} + min={0} + value={localPrice} + onChange={(val) => handleValueChange("price", val)} onBlur={() => handleBlur("price")} + precision={2} />
-
- - {uiSum.toLocaleString("ru-RU", { - style: "currency", - currency: "RUB", - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} - + +
+ = + handleValueChange("sum", val)} + onBlur={() => handleBlur("sum")} + precision={2} + />
diff --git a/rmser-view/src/services/types.ts b/rmser-view/src/services/types.ts index d5c0790..fb58190 100644 --- a/rmser-view/src/services/types.ts +++ b/rmser-view/src/services/types.ts @@ -185,8 +185,12 @@ export interface DraftItem { // Мета-данные is_matched: boolean; - product?: CatalogItem; // Развернутый объект для UI - container?: ProductContainer; // Развернутый объект для UI + product?: CatalogItem; + container?: ProductContainer; + + // Поля для синхронизации состояния (опционально, если бэкенд их отдает) + last_edited_field_1?: string; + last_edited_field_2?: string; } // --- Список Черновиков (Summary) --- @@ -218,9 +222,11 @@ export interface DraftInvoice { // DTO для обновления строки export interface UpdateDraftItemRequest { product_id?: UUID; - container_id?: UUID | null; // null если сбросили фасовку + container_id?: UUID | null; quantity?: number; price?: number; + sum?: number; + edited_field?: string; // ('quantity' | 'price' | 'sum') } // DTO для коммита