2701-есть адекватный порядок для строк черновика и фикс пустого поиска

This commit is contained in:
2026-01-27 08:51:59 +03:00
parent 38a5143902
commit de4bd9c8d7
8 changed files with 148 additions and 5 deletions

View File

@@ -155,6 +155,7 @@ func main() {
api.POST("/drafts/:id/items", draftsHandler.AddDraftItem) api.POST("/drafts/:id/items", draftsHandler.AddDraftItem)
api.DELETE("/drafts/:id/items/:itemId", draftsHandler.DeleteDraftItem) api.DELETE("/drafts/:id/items/:itemId", draftsHandler.DeleteDraftItem)
api.PATCH("/drafts/:id/items/:itemId", draftsHandler.UpdateItem) 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/:id/commit", draftsHandler.CommitDraft)
api.POST("/drafts/container", draftsHandler.AddContainer) api.POST("/drafts/container", draftsHandler.AddContainer)

View File

@@ -59,6 +59,9 @@ type DraftInvoiceItem struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` 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"` 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"` RawName string `gorm:"type:varchar(255);not null" json:"raw_name"`
RawAmount decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_amount"` RawAmount decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_amount"`
RawPrice decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_price"` RawPrice decimal.Decimal `gorm:"type:numeric(19,4)" json:"raw_price"`
@@ -87,6 +90,7 @@ type Repository interface {
CreateItems(items []DraftInvoiceItem) error CreateItems(items []DraftInvoiceItem) error
Update(draft *DraftInvoice) error Update(draft *DraftInvoice) error
UpdateItem(itemID uuid.UUID, updates map[string]interface{}) error UpdateItem(itemID uuid.UUID, updates map[string]interface{}) error
UpdateItemOrder(itemID uuid.UUID, newOrder int) error
CreateItem(item *DraftInvoiceItem) error CreateItem(item *DraftInvoiceItem) error
DeleteItem(itemID uuid.UUID) error DeleteItem(itemID uuid.UUID) error
Delete(id uuid.UUID) error Delete(id uuid.UUID) error

View File

@@ -23,7 +23,7 @@ func (r *pgRepository) GetByID(id uuid.UUID) (*drafts.DraftInvoice, error) {
var draft drafts.DraftInvoice var draft drafts.DraftInvoice
err := r.db. err := r.db.
Preload("Items", func(db *gorm.DB) *gorm.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").
Preload("Items.Product.MainUnit"). Preload("Items.Product.MainUnit").
@@ -98,6 +98,13 @@ func (r *pgRepository) UpdateItem(itemID uuid.UUID, updates map[string]interface
Updates(updates).Error 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 { func (r *pgRepository) Delete(id uuid.UUID) error {
return r.db.Delete(&drafts.DraftInvoice{}, id).Error return r.db.Delete(&drafts.DraftInvoice{}, id).Error
} }

View File

@@ -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) { 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{ newItem := &drafts.DraftInvoiceItem{
ID: uuid.New(), ID: uuid.New(),
DraftID: draftID, DraftID: draftID,
@@ -181,6 +195,7 @@ func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {
IsMatched: false, IsMatched: false,
LastEditedField1: drafts.FieldQuantity, LastEditedField1: drafts.FieldQuantity,
LastEditedField2: drafts.FieldPrice, LastEditedField2: drafts.FieldPrice,
Order: maxOrder + 1,
} }
if err := s.draftRepo.CreateItem(newItem); err != nil { 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 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)
}

View File

@@ -161,7 +161,7 @@ func (s *Service) ProcessDocument(ctx context.Context, userID uuid.UUID, imgData
// 7. Матчинг и сохранение позиций // 7. Матчинг и сохранение позиций
var draftItems []drafts.DraftInvoiceItem var draftItems []drafts.DraftInvoiceItem
for _, rawItem := range rawResult.Items { for i, rawItem := range rawResult.Items {
item := drafts.DraftInvoiceItem{ item := drafts.DraftInvoiceItem{
DraftID: draft.ID, DraftID: draft.ID,
RawName: rawItem.RawName, RawName: rawItem.RawName,
@@ -170,6 +170,7 @@ func (s *Service) ProcessDocument(ctx context.Context, userID uuid.UUID, imgData
Quantity: decimal.NewFromFloat(rawItem.Amount), Quantity: decimal.NewFromFloat(rawItem.Amount),
Price: decimal.NewFromFloat(rawItem.Price), Price: decimal.NewFromFloat(rawItem.Price),
Sum: decimal.NewFromFloat(rawItem.Sum), Sum: decimal.NewFromFloat(rawItem.Sum),
Order: i + 1,
} }
match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName) match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName)

View File

@@ -302,3 +302,31 @@ func (h *DraftsHandler) DeleteDraft(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": newStatus, "id": id.String()}) 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})
}

View File

@@ -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");

View File

@@ -25,6 +25,7 @@ export const CatalogSelect: React.FC<Props> = ({
}) => { }) => {
const [options, setOptions] = useState<SelectOption[]>([]); const [options, setOptions] = useState<SelectOption[]>([]);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [notFound, setNotFound] = useState(false);
const fetchRef = useRef<number | null>(null); const fetchRef = useRef<number | null>(null);
@@ -45,6 +46,7 @@ export const CatalogSelect: React.FC<Props> = ({
const fetchProducts = async (search: string) => { const fetchProducts = async (search: string) => {
if (!search) { if (!search) {
setOptions([]); setOptions([]);
setNotFound(false);
return; return;
} }
setFetching(true); setFetching(true);
@@ -57,8 +59,11 @@ export const CatalogSelect: React.FC<Props> = ({
data: item, data: item,
})); }));
setOptions(newOptions); setOptions(newOptions);
// Показываем "Не найдено" если результатов нет
setNotFound(results.length === 0);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setNotFound(true);
} finally { } finally {
setFetching(false); setFetching(false);
} }
@@ -68,6 +73,8 @@ export const CatalogSelect: React.FC<Props> = ({
if (fetchRef.current !== null) { if (fetchRef.current !== null) {
window.clearTimeout(fetchRef.current); window.clearTimeout(fetchRef.current);
} }
// Сбрасываем notFound при новом поиске
setNotFound(false);
// Запускаем поиск только если введено хотя бы 2 символа // Запускаем поиск только если введено хотя бы 2 символа
if (val.length < 2) { if (val.length < 2) {
return; return;
@@ -93,7 +100,13 @@ export const CatalogSelect: React.FC<Props> = ({
placeholder="Начните вводить название товара..." placeholder="Начните вводить название товара..."
filterOption={false} filterOption={false}
onSearch={handleSearch} onSearch={handleSearch}
notFoundContent={fetching ? <Spin size="small" /> : null} notFoundContent={
fetching ? (
<Spin size="small" />
) : notFound ? (
<div style={{ padding: "8px", color: "#999" }}>Товар не найден</div>
) : null
}
options={options} options={options}
value={value} value={value}
onChange={handleChange} onChange={handleChange}
@@ -101,8 +114,11 @@ export const CatalogSelect: React.FC<Props> = ({
style={{ width: "100%" }} style={{ width: "100%" }}
listHeight={256} listHeight={256}
allowClear allowClear
// При очистке сбрасываем опции, чтобы при следующем клике не вылезал старый товар // При очистке сбрасываем опции и notFound, чтобы при следующем клике не вылезал старый товар
onClear={() => setOptions([])} onClear={() => {
setOptions([]);
setNotFound(false);
}}
// При клике (фокусе), если поле пустое - можно показать дефолтные опции или оставить пустым // При клике (фокусе), если поле пустое - можно показать дефолтные опции или оставить пустым
onFocus={() => { onFocus={() => {
if (!value) setOptions([]); if (!value) setOptions([]);