package drafts import ( "errors" "fmt" "strconv" "time" "github.com/google/uuid" "github.com/shopspring/decimal" "go.uber.org/zap" "rmser/internal/domain/account" "rmser/internal/domain/catalog" "rmser/internal/domain/drafts" "rmser/internal/domain/invoices" "rmser/internal/domain/ocr" "rmser/internal/domain/suppliers" "rmser/internal/infrastructure/rms" "rmser/pkg/logger" ) type Service struct { draftRepo drafts.Repository ocrRepo ocr.Repository catalogRepo catalog.Repository accountRepo account.Repository supplierRepo suppliers.Repository rmsFactory *rms.Factory } func NewService( draftRepo drafts.Repository, ocrRepo ocr.Repository, catalogRepo catalog.Repository, accountRepo account.Repository, supplierRepo suppliers.Repository, rmsFactory *rms.Factory, ) *Service { return &Service{ draftRepo: draftRepo, ocrRepo: ocrRepo, catalogRepo: catalogRepo, accountRepo: accountRepo, supplierRepo: supplierRepo, rmsFactory: rmsFactory, } } // checkWriteAccess проверяет, что пользователь имеет право редактировать данные на сервере (ADMIN/OWNER) func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error { role, err := s.accountRepo.GetUserRole(userID, serverID) if err != nil { return err } if role == account.RoleOperator { return errors.New("доступ запрещен: оператор не может редактировать данные") } return nil } func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) { draft, err := s.draftRepo.GetByID(draftID) if err != nil { return nil, err } // Проверяем, что черновик принадлежит активному серверу пользователя // И пользователь не Оператор (операторы вообще не ходят в API) server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, errors.New("нет активного сервера") } if draft.RMSServerID != server.ID { return nil, errors.New("черновик не принадлежит активному серверу") } if err := s.checkWriteAccess(userID, server.ID); err != nil { return nil, err } return draft, nil } 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 } stores, _ := s.catalogRepo.GetActiveStores(server.ID) suppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90) return map[string]interface{}{ "stores": stores, "suppliers": suppliersList, }, nil } func (s *Service) DeleteDraft(id uuid.UUID) (string, error) { draft, err := s.draftRepo.GetByID(id) if err != nil { return "", err } // TODO: Здесь тоже бы проверить userID и права, но пока оставим как есть, // так как DeleteDraft вызывается из хендлера, где мы можем добавить проверку, // но лучше передавать userID в сигнатуру DeleteDraft(id, userID). // Для скорости пока оставим, полагаясь на то, что фронт не покажет кнопку. if draft.Status == drafts.StatusCanceled { draft.Status = drafts.StatusDeleted s.draftRepo.Update(draft) return drafts.StatusDeleted, nil } if draft.Status != drafts.StatusCompleted { draft.Status = drafts.StatusCanceled s.draftRepo.Update(draft) return drafts.StatusCanceled, nil } return draft.Status, nil } func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error { draft, err := s.draftRepo.GetByID(id) if err != nil { return err } if draft.Status == drafts.StatusCompleted { return errors.New("черновик уже отправлен") } draft.StoreID = storeID draft.SupplierID = supplierID draft.DateIncoming = &date draft.Comment = comment 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, } if err := s.draftRepo.CreateItem(newItem); err != nil { return nil, err } 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 } draft, err := s.draftRepo.GetByID(draftID) if err != nil { return 0, err } var totalSum decimal.Decimal for _, item := range draft.Items { if !item.Sum.IsZero() { totalSum = totalSum.Add(item.Sum) } else { totalSum = totalSum.Add(item.Quantity.Mul(item.Price)) } } sumFloat, _ := totalSum.Float64() return sumFloat, nil } func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error { draft, err := s.draftRepo.GetByID(draftID) if err != nil { return err } // Автосмена статуса if draft.Status == drafts.StatusCanceled { draft.Status = drafts.StatusReadyToVerify s.draftRepo.Update(draft) } return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price) } // 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) } if err := s.checkWriteAccess(userID, server.ID); err != nil { return "", err } // 2. Черновик draft, err := s.draftRepo.GetByID(draftID) if err != nil { return "", err } // Проверка принадлежности черновика серверу if draft.RMSServerID != server.ID { return "", errors.New("черновик принадлежит другому серверу") } if draft.Status == drafts.StatusCompleted { return "", errors.New("накладная уже отправлена") } // 3. Клиент (использует права текущего юзера - Админа/Владельца) client, err := s.rmsFactory.GetClientForUser(userID) if err != nil { return "", err } targetStatus := "NEW" if server.AutoProcess { targetStatus = "PROCESSED" } // 4. Сборка Invoice inv := invoices.Invoice{ ID: uuid.Nil, DocumentNumber: draft.DocumentNumber, DateIncoming: *draft.DateIncoming, SupplierID: *draft.SupplierID, DefaultStoreID: *draft.StoreID, Status: targetStatus, Comment: draft.Comment, Items: make([]invoices.InvoiceItem, 0, len(draft.Items)), } for _, dItem := range draft.Items { if dItem.ProductID == nil { continue // Skip unrecognized } sum := dItem.Sum if sum.IsZero() { sum = dItem.Quantity.Mul(dItem.Price) } invItem := invoices.InvoiceItem{ ProductID: *dItem.ProductID, Amount: dItem.Quantity, Price: dItem.Price, Sum: sum, ContainerID: dItem.ContainerID, } inv.Items = append(inv.Items, invItem) } if len(inv.Items) == 0 { return "", errors.New("нет распознанных позиций для отправки") } // 5. Отправка в RMS docNum, err := client.CreateIncomingInvoice(inv) if err != nil { return "", err } // 6. Обновление статуса черновика draft.Status = drafts.StatusCompleted s.draftRepo.Update(draft) // 7. БИЛЛИНГ и Обучение if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil { logger.Log.Error("Billing increment failed", zap.Error(err)) } go s.learnFromDraft(draft, server.ID) return docNum, nil } func (s *Service) learnFromDraft(draft *drafts.DraftInvoice, serverID uuid.UUID) { for _, item := range draft.Items { if item.RawName != "" && item.ProductID != nil { qty := decimal.NewFromFloat(1.0) err := s.ocrRepo.SaveMatch(serverID, item.RawName, *item.ProductID, qty, item.ContainerID) if err != nil { logger.Log.Warn("Failed to learn match", zap.Error(err)) } } } } func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return uuid.Nil, errors.New("no active server") } if err := s.checkWriteAccess(userID, server.ID); err != nil { return uuid.Nil, err } client, err := s.rmsFactory.GetClientForUser(userID) if err != nil { return uuid.Nil, err } fullProduct, err := client.GetProductByID(productID) if err != nil { 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)) { if c.ID != nil && *c.ID != "" { return uuid.Parse(*c.ID) } } } // Next Num maxNum := 0 for _, c := range fullProduct.Containers { if n, err := strconv.Atoi(c.Num); err == nil { if n > maxNum { maxNum = n } } } nextNum := strconv.Itoa(maxNum + 1) // Add newContainerDTO := rms.ContainerFullDTO{ ID: nil, Num: nextNum, Name: name, Count: targetCount, UseInFront: true, Deleted: false, } 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 { if c.Name == name && c.Count == targetCount && !c.Deleted { if c.ID != nil { createdID, err = uuid.Parse(*c.ID) if err == nil { found = true break } } } } if !found { return uuid.Nil, errors.New("container created but id not found") } // Save Local newLocalContainer := catalog.ProductContainer{ ID: createdID, RMSServerID: server.ID, ProductID: productID, Name: name, Count: count, } s.catalogRepo.SaveContainer(newLocalContainer) return createdID, nil }