mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавил редактируемую сумму и пересчет треугольником
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 требует:
|
||||
// <amount> = кол-во упаковок * вес упаковки (итоговое кол-во в базовых единицах)
|
||||
// <price> = цена за упаковку / вес упаковки (цена за базовую единицу)
|
||||
// <containerId> = 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Props> = ({
|
||||
item,
|
||||
onUpdate,
|
||||
@@ -46,32 +48,129 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
// State Input
|
||||
const [localQuantity, setLocalQuantity] = useState<string | null>(
|
||||
item.quantity?.toString() ?? null
|
||||
);
|
||||
const [localPrice, setLocalPrice] = useState<string | null>(
|
||||
item.price?.toString() ?? null
|
||||
);
|
||||
// --- Локальное состояние значений (строки для удобства ввода) ---
|
||||
const [localQty, setLocalQty] = useState<number | null>(item.quantity);
|
||||
const [localPrice, setLocalPrice] = useState<number | null>(item.price);
|
||||
const [localSum, setLocalSum] = useState<number | null>(item.sum);
|
||||
|
||||
// Sync Effect
|
||||
// --- История редактирования (Stack) ---
|
||||
// Храним 2 последних отредактированных поля.
|
||||
// Инициализируем из пропсов или дефолтно ['quantity', 'price'], чтобы пересчитывалась сумма.
|
||||
const editStack = useRef<FieldType[]>([
|
||||
(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<ProductSearchResult | null>(null);
|
||||
const [addedContainers, setAddedContainers] = useState<
|
||||
@@ -148,53 +247,25 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
const parseToNum = (val: string | null | undefined): number => {
|
||||
if (!val) return 0;
|
||||
return parseFloat(val.replace(",", "."));
|
||||
};
|
||||
|
||||
const getUpdatePayload = (
|
||||
overrides: Partial<UpdateDraftItemRequest>
|
||||
): 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<Props> = ({
|
||||
[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<Props> = ({
|
||||
: item.is_matched
|
||||
? "#b7eb8f"
|
||||
: "#d9d9d9";
|
||||
const uiSum = parseToNum(localQuantity) * parseToNum(localPrice);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -229,7 +299,6 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
<Flex vertical gap={10}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<div style={{ flex: 1 }}>
|
||||
{/* Показываем raw_name только если это OCR строка. Если создана вручную и пустая - плейсхолдер */}
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, lineHeight: 1.2, display: "block" }}
|
||||
@@ -255,7 +324,6 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
>
|
||||
{isUpdating && <SyncOutlined spin style={{ color: "#1890ff" }} />}
|
||||
|
||||
{/* Warning Icon */}
|
||||
{activeWarning && (
|
||||
<WarningFilled
|
||||
style={{ color: "#faad14", fontSize: 16, cursor: "pointer" }}
|
||||
@@ -269,7 +337,6 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
{/* Кнопка удаления */}
|
||||
<Popconfirm
|
||||
title="Удалить строку?"
|
||||
onConfirm={() => onDelete(item.id)}
|
||||
@@ -332,38 +399,44 @@ export const DraftItemRow: React.FC<Props> = ({
|
||||
borderBottomRightRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<InputNumber<string>
|
||||
style={{ width: 60 }}
|
||||
controls={false}
|
||||
placeholder="Кол"
|
||||
stringMode
|
||||
decimalSeparator=","
|
||||
value={localQuantity || ""}
|
||||
onChange={(val) => setLocalQuantity(val)}
|
||||
onBlur={() => handleBlur("quantity")}
|
||||
/>
|
||||
<Text type="secondary">x</Text>
|
||||
<InputNumber<string>
|
||||
<div
|
||||
style={{ display: "flex", gap: 8, alignItems: "center", flex: 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: 70 }}
|
||||
controls={false}
|
||||
placeholder="Кол"
|
||||
min={0}
|
||||
value={localQty}
|
||||
onChange={(val) => handleValueChange("quantity", val)}
|
||||
onBlur={() => handleBlur("quantity")}
|
||||
precision={3}
|
||||
/>
|
||||
<Text type="secondary">x</Text>
|
||||
<InputNumber
|
||||
style={{ width: 80 }}
|
||||
controls={false}
|
||||
placeholder="Цена"
|
||||
stringMode
|
||||
decimalSeparator=","
|
||||
value={localPrice || ""}
|
||||
onChange={(val) => setLocalPrice(val)}
|
||||
min={0}
|
||||
value={localPrice}
|
||||
onChange={(val) => handleValueChange("price", val)}
|
||||
onBlur={() => handleBlur("price")}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{uiSum.toLocaleString("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<Text type="secondary">=</Text>
|
||||
<InputNumber
|
||||
style={{ width: 90, fontWeight: "bold" }}
|
||||
controls={false}
|
||||
placeholder="Сумма"
|
||||
min={0}
|
||||
value={localSum}
|
||||
onChange={(val) => handleValueChange("sum", val)}
|
||||
onBlur={() => handleBlur("sum")}
|
||||
precision={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
@@ -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 для коммита
|
||||
|
||||
Reference in New Issue
Block a user