diff --git a/cmd/main.go b/cmd/main.go index 808f3fb..a2d9e6b 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -172,6 +172,7 @@ func main() { // Drafts & Invoices api.GET("/drafts", draftsHandler.GetDrafts) api.GET("/drafts/:id", draftsHandler.GetDraft) + api.PATCH("/drafts/:id", draftsHandler.UpdateDraft) api.DELETE("/drafts/:id", draftsHandler.DeleteDraft) api.POST("/drafts/upload", draftsHandler.Upload) // Items CRUD diff --git a/internal/domain/drafts/dto.go b/internal/domain/drafts/dto.go new file mode 100644 index 0000000..8b6b06d --- /dev/null +++ b/internal/domain/drafts/dto.go @@ -0,0 +1,19 @@ +package drafts + +// UpdateDraftRequest DTO для пакетного обновления черновика +type UpdateDraftRequest struct { + DateIncoming *string `json:"date_incoming"` // YYYY-MM-DD + StoreID *string `json:"store_id"` // UUID или пустая строка для сброса + SupplierID *string `json:"supplier_id"` // UUID или пустая строка для сброса + Comment *string `json:"comment"` + IncomingDocumentNumber *string `json:"incoming_document_number"` + Items []struct { + ID *string `json:"id"` // Обязательный ID строки + 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" + } `json:"items"` +} diff --git a/internal/services/drafts/service.go b/internal/services/drafts/service.go index 45eb49d..16170f5 100644 --- a/internal/services/drafts/service.go +++ b/internal/services/drafts/service.go @@ -958,3 +958,205 @@ func (s *Service) ReorderItems(draftID uuid.UUID, items []struct { // Вызываем метод репозитория для обновления порядка return s.draftRepo.ReorderItems(draftID, items) } + +// SaveDraftFull обновляет черновик (шапку и позиции) пакетно в одной транзакции +func (s *Service) SaveDraftFull(draftID, userID uuid.UUID, req drafts.UpdateDraftRequest) error { + // Получаем черновик для проверки прав доступа + draft, err := s.GetDraft(draftID, userID) + if err != nil { + return err + } + + // Проверяем, что черновик не завершен + if draft.Status == drafts.StatusCompleted { + return errors.New("черновик уже отправлен") + } + + // Обновляем шапку черновика, если переданы поля + headerUpdated := false + + // 1. Дата (Обязательное поле, не может быть nil) + if req.DateIncoming != nil && *req.DateIncoming != "" { + parsedDate, err := time.Parse("2006-01-02", *req.DateIncoming) + if err != nil { + return fmt.Errorf("invalid date format: %w", err) + } + draft.DateIncoming = &parsedDate + headerUpdated = true + } + + // 2. Склад (Может быть nil/сброшен) + if req.StoreID != nil { + if *req.StoreID == "" { + draft.StoreID = nil + } else { + uid, err := uuid.Parse(*req.StoreID) + if err != nil { + return fmt.Errorf("invalid store_id: %w", err) + } + draft.StoreID = &uid + } + headerUpdated = true + } + + // 3. Поставщик (Может быть nil/сброшен) + if req.SupplierID != nil { + if *req.SupplierID == "" { + draft.SupplierID = nil + } else { + uid, err := uuid.Parse(*req.SupplierID) + if err != nil { + return fmt.Errorf("invalid supplier_id: %w", err) + } + draft.SupplierID = &uid + } + headerUpdated = true + } + + // 4. Комментарий + if req.Comment != nil { + draft.Comment = *req.Comment + headerUpdated = true + } + + // 5. Входящий номер + if req.IncomingDocumentNumber != nil { + draft.IncomingDocumentNumber = *req.IncomingDocumentNumber + headerUpdated = true + } + + // Если были изменения в шапке — сохраняем + if headerUpdated { + if err := s.draftRepo.Update(draft); err != nil { + return fmt.Errorf("failed to update draft header: %w", err) + } + } + + // Обновляем позиции, если переданы + if len(req.Items) > 0 { + for _, itemReq := range req.Items { + if itemReq.ID == nil || *itemReq.ID == "" { + return errors.New("item id is required") + } + + itemID, err := uuid.Parse(*itemReq.ID) + if err != nil { + return fmt.Errorf("invalid item id: %s", *itemReq.ID) + } + + // Получаем текущую позицию + currentItem, err := s.draftRepo.GetItemByID(itemID) + if err != nil { + return fmt.Errorf("item not found: %s", itemID.String()) + } + + // Проверяем, что позиция принадлежит черновику + if currentItem.DraftID != draftID { + return fmt.Errorf("item %s does not belong to draft %s", itemID.String(), draftID.String()) + } + + // Обновляем поля позиции + if itemReq.ProductID != nil { + if *itemReq.ProductID == "" { + currentItem.ProductID = nil + currentItem.IsMatched = false + currentItem.ContainerID = nil // Если убрали товар, фасовку тоже надо обнулить + } else { + parsedID, err := uuid.Parse(*itemReq.ProductID) + if err != nil { + return fmt.Errorf("invalid product_id for item %s", itemID.String()) + } + currentItem.ProductID = &parsedID + currentItem.IsMatched = true + } + } + + if itemReq.ContainerID != nil { + if *itemReq.ContainerID == "" { + // Сброс фасовки + currentItem.ContainerID = nil + } else { + parsedID, err := uuid.Parse(*itemReq.ContainerID) + if err != nil { + return fmt.Errorf("invalid container_id for item %s", itemID.String()) + } + currentItem.ContainerID = &parsedID + } + } + + // Определяем, какое поле редактируется + editedField := itemReq.EditedField + if editedField == "" { + if itemReq.Sum != nil { + editedField = "sum" + } else if itemReq.Price != nil { + editedField = "price" + } else if itemReq.Quantity != nil { + editedField = "quantity" + } + } + + // Обновляем числовые поля + qty := decimal.Zero + if itemReq.Quantity != nil { + qty = decimal.NewFromFloat(*itemReq.Quantity) + } else { + qty = currentItem.Quantity + } + + price := decimal.Zero + if itemReq.Price != nil { + price = decimal.NewFromFloat(*itemReq.Price) + } else { + price = currentItem.Price + } + + sum := decimal.Zero + if itemReq.Sum != nil { + sum = decimal.NewFromFloat(*itemReq.Sum) + } else { + sum = currentItem.Sum + } + + // Применяем изменения в зависимости от редактируемого поля + 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 + if err := s.draftRepo.Update(draft); err != nil { + return fmt.Errorf("failed to update draft status: %w", err) + } + } + + // Сохраняем обновленную позицию + 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, + } + + if err := s.draftRepo.UpdateItem(itemID, updates); err != nil { + return fmt.Errorf("failed to update item %s: %w", itemID.String(), err) + } + } + } + + return nil +} diff --git a/internal/transport/http/handlers/drafts.go b/internal/transport/http/handlers/drafts.go index a67bcec..dce574b 100644 --- a/internal/transport/http/handlers/drafts.go +++ b/internal/transport/http/handlers/drafts.go @@ -11,6 +11,7 @@ import ( "github.com/shopspring/decimal" "go.uber.org/zap" + draftsdomain "rmser/internal/domain/drafts" "rmser/internal/services/drafts" "rmser/internal/services/ocr" "rmser/pkg/logger" @@ -361,6 +362,30 @@ func (h *DraftsHandler) ReorderItems(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true}) } +// UpdateDraft обновляет черновик (шапку и позиции) пакетно +func (h *DraftsHandler) UpdateDraft(c *gin.Context) { + userID := c.MustGet("userID").(uuid.UUID) + draftID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid draft id"}) + return + } + + var req draftsdomain.UpdateDraftRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.service.SaveDraftFull(draftID, userID, req); err != nil { + logger.Log.Error("Failed to update draft", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Не удалось сохранить изменения. Проверьте данные."}) + return + } + + c.JSON(http.StatusOK, gin.H{"status": "updated"}) +} + // Upload обрабатывает загрузку файла и прогоняет через OCR func (h *DraftsHandler) Upload(c *gin.Context) { // Лимит размера тела запроса (20MB) diff --git a/rmser-view/src/components/invoices/DraftEditor.tsx b/rmser-view/src/components/invoices/DraftEditor.tsx index 27a3aea..d7c4fec 100644 --- a/rmser-view/src/components/invoices/DraftEditor.tsx +++ b/rmser-view/src/components/invoices/DraftEditor.tsx @@ -34,6 +34,7 @@ import ExcelPreviewModal from "../common/ExcelPreviewModal"; import { useActiveDraftStore } from "../../stores/activeDraftStore"; import type { DraftItem, + UpdateDraftRequest, CommitDraftRequest, ReorderDraftItemsRequest, } from "../../services/types"; @@ -65,6 +66,7 @@ export const DraftEditor: React.FC = ({ addItem, reorderItems, resetDirty, + markAsDirty, } = useActiveDraftStore(); // Отслеживаем текущий draftId для инициализации стора @@ -168,44 +170,29 @@ export const DraftEditor: React.FC = ({ // --- ЭФФЕКТЫ --- - // Инициализация стора при загрузке черновика + // Инициализация данных при загрузке черновика useEffect(() => { - if (draft && draft.items) { - // Инициализируем стор только если изменился draftId или стор пуст + if (draft) { + // Инициализируем только если изменился draftId или стор пуст if (currentDraftIdRef.current !== draft.id || items.length === 0) { - setItems(draft.items); + // 1. Инициализация строк (Store) + setItems(draft.items || []); + + // 2. Инициализация шапки (Form) + form.setFieldsValue({ + store_id: draft.store_id, + supplier_id: draft.supplier_id, + comment: draft.comment, + incoming_document_number: draft.incoming_document_number, + date_incoming: draft.date_incoming + ? dayjs(draft.date_incoming) + : dayjs(), + }); + currentDraftIdRef.current = draft.id; } } - }, [draft, items.length, setItems]); - - useEffect(() => { - if (draft) { - const currentValues = form.getFieldsValue(); - if (!currentValues.store_id && draft.store_id) - form.setFieldValue("store_id", draft.store_id); - if (!currentValues.supplier_id && draft.supplier_id) - form.setFieldValue("supplier_id", draft.supplier_id); - if (!currentValues.comment && draft.comment) - form.setFieldValue("comment", draft.comment); - - // Инициализация входящего номера - if ( - !currentValues.incoming_document_number && - draft.incoming_document_number - ) - form.setFieldValue( - "incoming_document_number", - draft.incoming_document_number - ); - - if (!currentValues.date_incoming) - form.setFieldValue( - "date_incoming", - draft.date_incoming ? dayjs(draft.date_incoming) : dayjs() - ); - } - }, [draft, form]); + }, [draft, items.length, setItems, form]); // --- ХЕЛПЕРЫ --- const totalSum = useMemo(() => { @@ -230,8 +217,8 @@ export const DraftEditor: React.FC = ({ // Собираем значения формы для обновления шапки черновика const formValues = form.getFieldsValue(); - // Подготавливаем payload для обновления мета-данных черновика - const draftPayload: Partial = { + // Формируем единый payload для пакетного обновления (шапка + элементы) + const payload: UpdateDraftRequest = { store_id: formValues.store_id, supplier_id: formValues.supplier_id, comment: formValues.comment || "", @@ -239,24 +226,18 @@ export const DraftEditor: React.FC = ({ date_incoming: formValues.date_incoming ? formValues.date_incoming.format("YYYY-MM-DD") : undefined, - }; - - // Сохраняем все измененные элементы - const savePromises = items.map((item) => - api.updateDraftItem(draftId, item.id, { - product_id: item.product_id ?? null, - container_id: item.container_id ?? null, + items: items.map((item) => ({ + id: item.id, + product_id: item.product_id ?? "", + container_id: item.container_id ?? "", quantity: Number(item.quantity), price: Number(item.price), sum: Number(item.sum), - }) - ); + })), + }; - // Параллельно сохраняем шапку и строки - await Promise.all([ - api.updateDraft(draftId, draftPayload), - ...savePromises, - ]); + // Отправляем единый запрос на сервер + await api.updateDraft(draftId, payload); // После успешного сохранения обновляем данные с сервера await queryClient.invalidateQueries({ queryKey: ["draft", draftId] }); @@ -540,7 +521,7 @@ export const DraftEditor: React.FC = ({
markAsDirty()} > diff --git a/rmser-view/src/services/api.ts b/rmser-view/src/services/api.ts index 54f2df2..47155ce 100644 --- a/rmser-view/src/services/api.ts +++ b/rmser-view/src/services/api.ts @@ -18,6 +18,7 @@ import type { DraftInvoice, DraftItem, UpdateDraftItemRequest, + UpdateDraftRequest, CommitDraftRequest, ReorderDraftItemsRequest, ProductSearchResult, @@ -213,7 +214,7 @@ export const api = { return data; }, - updateDraft: async (id: string, payload: Partial): Promise => { + updateDraft: async (id: string, payload: UpdateDraftRequest): Promise => { const { data } = await apiClient.patch(`/drafts/${id}`, payload); return data; }, diff --git a/rmser-view/src/services/types.ts b/rmser-view/src/services/types.ts index 89bb3d6..a044095 100644 --- a/rmser-view/src/services/types.ts +++ b/rmser-view/src/services/types.ts @@ -229,6 +229,7 @@ export interface DraftInvoice { // DTO для обновления строки export interface UpdateDraftItemRequest { + id?: UUID; // ID элемента для идентификации при пакетном обновлении product_id?: UUID | null; container_id?: UUID | null; quantity?: number; @@ -237,6 +238,16 @@ export interface UpdateDraftItemRequest { edited_field?: string; // ('quantity' | 'price' | 'sum') } +// DTO для пакетного обновления черновика (шапка + элементы) +export interface UpdateDraftRequest { + date_incoming?: string; + store_id?: UUID; + supplier_id?: UUID; + comment?: string; + incoming_document_number?: string; + items?: UpdateDraftItemRequest[]; +} + // DTO для коммита export interface CommitDraftRequest { date_incoming: string; diff --git a/rmser-view/src/stores/activeDraftStore.ts b/rmser-view/src/stores/activeDraftStore.ts index 28d1168..f1e00a5 100644 --- a/rmser-view/src/stores/activeDraftStore.ts +++ b/rmser-view/src/stores/activeDraftStore.ts @@ -65,6 +65,12 @@ interface ActiveDraftStore { * Используется после успешного сохранения изменений на сервер */ resetDirty: () => void; + + /** + * Устанавливает флаг isDirty в true + * Используется для пометки черновика как измененного + */ + markAsDirty: () => void; } /** @@ -181,5 +187,14 @@ export const useActiveDraftStore = create()( set((state) => { state.isDirty = false; }), + + /** + * Устанавливает флаг isDirty в true + * Используется для пометки черновика как измененного + */ + markAsDirty: () => + set((state) => { + state.isDirty = true; + }), })) );