From de4bd9c8d7f8312c5241e3f200cc3a9358b773e9 Mon Sep 17 00:00:00 2001 From: SERTY Date: Tue, 27 Jan 2026 08:51:59 +0300 Subject: [PATCH] =?UTF-8?q?2701-=D0=B5=D1=81=D1=82=D1=8C=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=B5=D0=BA=D0=B2=D0=B0=D1=82=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=8F=D0=B4=D0=BE=D0=BA=20=D0=B4=D0=BB=D1=8F=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=D0=B8=20=D1=84=D0=B8=D0=BA=D1=81=20=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/main.go | 1 + internal/domain/drafts/entity.go | 4 ++ .../repository/drafts/postgres.go | 9 ++- internal/services/drafts/service.go | 67 +++++++++++++++++++ internal/services/ocr/service.go | 3 +- internal/transport/http/handlers/drafts.go | 28 ++++++++ ...00000_add_order_to_draft_invoice_items.sql | 19 ++++++ .../src/components/ocr/CatalogSelect.tsx | 22 +++++- 8 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 migrations/20250127000000_add_order_to_draft_invoice_items.sql diff --git a/cmd/main.go b/cmd/main.go index 503bcbd..9a08458 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -155,6 +155,7 @@ func main() { api.POST("/drafts/:id/items", draftsHandler.AddDraftItem) api.DELETE("/drafts/:id/items/:itemId", draftsHandler.DeleteDraftItem) api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem) + api.PUT("/drafts/:id/items/reorder", draftsHandler.ReorderItems) api.POST("/drafts/:id/commit", draftsHandler.CommitDraft) api.POST("/drafts/container", draftsHandler.AddContainer) diff --git a/internal/domain/drafts/entity.go b/internal/domain/drafts/entity.go index b2b1752..de96a56 100644 --- a/internal/domain/drafts/entity.go +++ b/internal/domain/drafts/entity.go @@ -59,6 +59,9 @@ type DraftInvoiceItem struct { ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` DraftID uuid.UUID `gorm:"type:uuid;not null;index" json:"draft_id"` + // Порядок отображения позиции в черновике + Order int `gorm:"not null;default:0" json:"order"` + RawName string `gorm:"type:varchar(255);not null" json:"raw_name"` RawAmount decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_amount"` RawPrice decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_price"` @@ -87,6 +90,7 @@ type Repository interface { CreateItems(items []DraftInvoiceItem) error Update(draft *DraftInvoice) error UpdateItem(itemID uuid.UUID, updates map[string]interface{}) error + UpdateItemOrder(itemID uuid.UUID, newOrder int) 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 32a73b0..aefa68c 100644 --- a/internal/infrastructure/repository/drafts/postgres.go +++ b/internal/infrastructure/repository/drafts/postgres.go @@ -23,7 +23,7 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) { var draft drafts.DraftInvoice err := r.db. Preload("Items", func(db *gorm.DB) *gorm.DB { - return db.Order("draft_invoice_items.raw_name ASC") + return db.Order("draft_invoice_items.order ASC") }). Preload("Items.Product"). Preload("Items.Product.MainUnit"). @@ -98,6 +98,13 @@ func (r *pgRepository) UpdateItem(itemID uuid.UUID, updates map[string]interface Updates(updates).Error } +// UpdateItemOrder обновляет порядок позиции +func (r *pgRepository) UpdateItemOrder(itemID uuid.UUID, newOrder int) error { + return r.db.Model(&drafts.DraftInvoiceItem{}). + Where("id = ?", itemID). + Update("order", newOrder).Error +} + func (r *pgRepository) Delete(id uuid.UUID) error { return r.db.Delete(&drafts.DraftInvoice{}, id).Error } diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go index 98ebe5a..e520912 100644 --- a/internal/services/drafts/service.go +++ b/internal/services/drafts/service.go @@ -169,6 +169,20 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID } func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) { + // Получаем текущий черновик для определения максимального order + draft, err := s.draftRepo.GetByID(draftID) + if err != nil { + return nil, err + } + + // Находим максимальный order среди существующих позиций + maxOrder := 0 + for _, item := range draft.Items { + if item.Order > maxOrder { + maxOrder = item.Order + } + } + newItem := &drafts.DraftInvoiceItem{ ID: uuid.New(), DraftID: draftID, @@ -181,6 +195,7 @@ func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) { IsMatched: false, LastEditedField1: drafts.FieldQuantity, LastEditedField2: drafts.FieldPrice, + Order: maxOrder + 1, } if err := s.draftRepo.CreateItem(newItem); err != nil { @@ -854,3 +869,55 @@ func (s *Service) SetDraftReadyToVerify(draftID, userID uuid.UUID) (*drafts.Draf return draft, nil } + +// ReorderItem изменяет порядок позиции в черновике +func (s *Service) ReorderItem(draftID, itemID uuid.UUID, newOrder int) error { + // Получаем черновик + draft, err := s.draftRepo.GetByID(draftID) + if err != nil { + return err + } + + // Находим перемещаемый элемент + var targetItem *drafts.DraftInvoiceItem + for _, item := range draft.Items { + if item.ID == itemID { + targetItem = &item + break + } + } + if targetItem == nil { + return fmt.Errorf("item not found") + } + + oldOrder := targetItem.Order + + // Если порядок не изменился, ничего не делаем + if oldOrder == newOrder { + return nil + } + + // Обновляем порядок других элементов + if newOrder < oldOrder { + // Перемещаем вверх: увеличиваем order элементов между newOrder и oldOrder-1 + for _, item := range draft.Items { + if item.Order >= newOrder && item.Order < oldOrder && item.ID != itemID { + if err := s.draftRepo.UpdateItemOrder(item.ID, item.Order+1); err != nil { + return err + } + } + } + } else { + // Перемещаем вниз: уменьшаем order элементов между oldOrder+1 и newOrder + for _, item := range draft.Items { + if item.Order > oldOrder && item.Order <= newOrder && item.ID != itemID { + if err := s.draftRepo.UpdateItemOrder(item.ID, item.Order-1); err != nil { + return err + } + } + } + } + + // Обновляем порядок целевого элемента + return s.draftRepo.UpdateItemOrder(itemID, newOrder) +} diff --git a/internal/services/ocr/service.go b/internal/services/ocr/service.go index 26a8b60..5b8f1dd 100644 --- a/internal/services/ocr/service.go +++ b/internal/services/ocr/service.go @@ -161,7 +161,7 @@ func (s *Service) ProcessDocument(ctx context.Context, userID uuid.UUID, imgData // 7. Матчинг и сохранение позиций var draftItems []drafts.DraftInvoiceItem - for _, rawItem := range rawResult.Items { + for i, rawItem := range rawResult.Items { item := drafts.DraftInvoiceItem{ DraftID: draft.ID, RawName: rawItem.RawName, @@ -170,6 +170,7 @@ func (s *Service) ProcessDocument(ctx context.Context, userID uuid.UUID, imgData Quantity: decimal.NewFromFloat(rawItem.Amount), Price: decimal.NewFromFloat(rawItem.Price), Sum: decimal.NewFromFloat(rawItem.Sum), + Order: i + 1, } match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) diff --git a/internal/transport/http/handlers/drafts.go b/internal/transport/http/handlers/drafts.go index 5660a2d..d313c68 100644 --- a/internal/transport/http/handlers/drafts.go +++ b/internal/transport/http/handlers/drafts.go @@ -302,3 +302,31 @@ func (h *DraftsHandler) DeleteDraft(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": newStatus, "id": id.String()}) } + +// ReorderItems изменяет порядок позиции в черновике +func (h *DraftsHandler) ReorderItems(c *gin.Context) { + type ReorderRequest struct { + ItemID uuid.UUID `json:"item_id" binding:"required"` + NewOrder int `json:"new_order" binding:"required"` + } + + var req ReorderRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Получаем draftID из параметров пути + draftID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"}) + return + } + + if err := h.service.ReorderItem(draftID, req.ItemID, req.NewOrder); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/migrations/20250127000000_add_order_to_draft_invoice_items.sql b/migrations/20250127000000_add_order_to_draft_invoice_items.sql new file mode 100644 index 0000000..4bc86a5 --- /dev/null +++ b/migrations/20250127000000_add_order_to_draft_invoice_items.sql @@ -0,0 +1,19 @@ +-- Добавляем колонку order в таблицу draft_invoice_items +-- Миграция для добавления поля сортировки позиций в черновике накладной + +-- Добавляем колонку order со значением по умолчанию 0 +ALTER TABLE draft_invoice_items ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0; + +-- Инициализируем значения order для существующих записей +-- Сортируем по created_at для сохранения текущего порядка +WITH ordered_items AS ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY draft_id ORDER BY created_at ASC) as row_num + FROM draft_invoice_items +) +UPDATE draft_invoice_items +SET "order" = ordered_items.row_num +FROM ordered_items +WHERE draft_invoice_items.id = ordered_items.id; + +-- Создаем индекс для оптимизации сортировки +CREATE INDEX idx_draft_items_order ON draft_invoice_items(draft_id, "order"); diff --git a/rmser-view/src/components/ocr/CatalogSelect.tsx b/rmser-view/src/components/ocr/CatalogSelect.tsx index a68f945..4dc75b4 100644 --- a/rmser-view/src/components/ocr/CatalogSelect.tsx +++ b/rmser-view/src/components/ocr/CatalogSelect.tsx @@ -25,6 +25,7 @@ export const CatalogSelect: React.FC = ({ }) => { const [options, setOptions] = useState([]); const [fetching, setFetching] = useState(false); + const [notFound, setNotFound] = useState(false); const fetchRef = useRef(null); @@ -45,6 +46,7 @@ export const CatalogSelect: React.FC = ({ const fetchProducts = async (search: string) => { if (!search) { setOptions([]); + setNotFound(false); return; } setFetching(true); @@ -57,8 +59,11 @@ export const CatalogSelect: React.FC = ({ data: item, })); setOptions(newOptions); + // Показываем "Не найдено" если результатов нет + setNotFound(results.length === 0); } catch (e) { console.error(e); + setNotFound(true); } finally { setFetching(false); } @@ -68,6 +73,8 @@ export const CatalogSelect: React.FC = ({ if (fetchRef.current !== null) { window.clearTimeout(fetchRef.current); } + // Сбрасываем notFound при новом поиске + setNotFound(false); // Запускаем поиск только если введено хотя бы 2 символа if (val.length < 2) { return; @@ -93,7 +100,13 @@ export const CatalogSelect: React.FC = ({ placeholder="Начните вводить название товара..." filterOption={false} onSearch={handleSearch} - notFoundContent={fetching ? : null} + notFoundContent={ + fetching ? ( + + ) : notFound ? ( +
Товар не найден
+ ) : null + } options={options} value={value} onChange={handleChange} @@ -101,8 +114,11 @@ export const CatalogSelect: React.FC = ({ style={{ width: "100%" }} listHeight={256} allowClear - // При очистке сбрасываем опции, чтобы при следующем клике не вылезал старый товар - onClear={() => setOptions([])} + // При очистке сбрасываем опции и notFound, чтобы при следующем клике не вылезал старый товар + onClear={() => { + setOptions([]); + setNotFound(false); + }} // При клике (фокусе), если поле пустое - можно показать дефолтные опции или оставить пустым onFocus={() => { if (!value) setOptions([]);