package drafts import ( "errors" "fmt" "strconv" "strings" "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/internal/services/billing" "rmser/pkg/logger" ) type Service struct { draftRepo drafts.Repository ocrRepo ocr.Repository catalogRepo catalog.Repository accountRepo account.Repository supplierRepo suppliers.Repository invoiceRepo invoices.Repository rmsFactory *rms.Factory billingService *billing.Service } func NewService( draftRepo drafts.Repository, ocrRepo ocr.Repository, catalogRepo catalog.Repository, accountRepo account.Repository, supplierRepo suppliers.Repository, invoiceRepo invoices.Repository, rmsFactory *rms.Factory, billingService *billing.Service, ) *Service { return &Service{ draftRepo: draftRepo, ocrRepo: ocrRepo, catalogRepo: catalogRepo, accountRepo: accountRepo, supplierRepo: supplierRepo, invoiceRepo: invoiceRepo, rmsFactory: rmsFactory, billingService: billingService, } } 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 } 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) { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, errors.New("активный сервер не выбран") } if err := s.checkWriteAccess(userID, server.ID); err != nil { return nil, err } return s.draftRepo.GetActive(server.ID) } 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 } 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, incomingDocNum 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 draft.IncomingDocumentNumber = incomingDocNum return s.draftRepo.Update(draft) } 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, LastEditedField1: drafts.FieldQuantity, LastEditedField2: drafts.FieldPrice, } if err := s.draftRepo.CreateItem(newItem); err != nil { return nil, err } return newItem, nil } 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 } // 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) } 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) } func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) { 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 } if can, err := s.billingService.CanProcessInvoice(server.ID); !can { return "", fmt.Errorf("ошибка биллинга: %w", err) } 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("накладная уже отправлена") } client, err := s.rmsFactory.GetClientForUser(userID) if err != nil { return "", err } targetStatus := "NEW" if server.AutoProcess { targetStatus = "PROCESSED" } inv := invoices.Invoice{ ID: uuid.Nil, DocumentNumber: draft.DocumentNumber, DateIncoming: *draft.DateIncoming, SupplierID: *draft.SupplierID, DefaultStoreID: *draft.StoreID, Status: targetStatus, Comment: draft.Comment, IncomingDocumentNumber: draft.IncomingDocumentNumber, Items: make([]invoices.InvoiceItem, 0, len(draft.Items)), } for _, dItem := range draft.Items { if dItem.ProductID == nil { continue } sum := dItem.Sum if sum.IsZero() { sum = dItem.Quantity.Mul(dItem.Price) } amountToSend := dItem.Quantity priceToSend := dItem.Price if dItem.ContainerID != nil && *dItem.ContainerID != uuid.Nil { if dItem.Container != nil { if !dItem.Container.Count.IsZero() { amountToSend = dItem.Quantity.Mul(dItem.Container.Count) priceToSend = dItem.Price.Div(dItem.Container.Count) } } else { 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())) } } invItem := invoices.InvoiceItem{ ProductID: *dItem.ProductID, Amount: amountToSend, Price: priceToSend, Sum: sum, ContainerID: dItem.ContainerID, } inv.Items = append(inv.Items, invItem) } if len(inv.Items) == 0 { return "", errors.New("нет распознанных позиций для отправки") } docNum, err := client.CreateIncomingInvoice(inv) if err != nil { return "", err } invoices, err := client.FetchInvoices(*draft.DateIncoming, *draft.DateIncoming) if err != nil { logger.Log.Warn("Не удалось получить список накладных для поиска UUID", zap.Error(err), zap.Time("date", *draft.DateIncoming)) } else { found := false for _, invoice := range invoices { if invoice.DocumentNumber == docNum { draft.RMSInvoiceID = &invoice.ID found = true break } } if !found { logger.Log.Warn("UUID созданной накладной не найден", zap.String("document_number", docNum), zap.Time("date", *draft.DateIncoming)) } } 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())) } 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) } } } 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) newContainerDTO := rms.ContainerFullDTO{ ID: nil, Num: nextNum, Name: name, Count: targetCount, UseInFront: true, Deleted: false, } fullProduct.Containers = append(fullProduct.Containers, newContainerDTO) updatedProduct, err := client.UpdateProduct(*fullProduct) if err != nil { return uuid.Nil, fmt.Errorf("error updating product: %w", err) } 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") } newLocalContainer := catalog.ProductContainer{ ID: createdID, RMSServerID: server.ID, ProductID: productID, Name: name, Count: count, } s.catalogRepo.SaveContainer(newLocalContainer) return createdID, nil } type UnifiedInvoiceDTO struct { ID uuid.UUID `json:"id"` Type string `json:"type"` DocumentNumber string `json:"document_number"` IncomingNumber string `json:"incoming_number"` DateIncoming time.Time `json:"date_incoming"` Status string `json:"status"` TotalSum float64 `json:"total_sum"` StoreName string `json:"store_name"` ItemsCount int `json:"items_count"` CreatedAt time.Time `json:"created_at"` IsAppCreated bool `json:"is_app_created"` PhotoURL string `json:"photo_url"` ItemsPreview string `json:"items_preview"` } func (s *Service) GetUnifiedList(userID uuid.UUID, from, to time.Time) ([]UnifiedInvoiceDTO, error) { server, err := s.accountRepo.GetActiveServer(userID) if err != nil || server == nil { return nil, errors.New("активный сервер не выбран") } draftsList, err := s.draftRepo.GetActive(server.ID) if err != nil { return nil, err } invoicesList, err := s.invoiceRepo.GetByPeriod(server.ID, from, to) if err != nil { return nil, err } photoMap, err := s.draftRepo.GetRMSInvoiceIDToPhotoURLMap(server.ID) if err != nil { return nil, err } result := make([]UnifiedInvoiceDTO, 0, len(draftsList)+len(invoicesList)) for _, d := range draftsList { var sum decimal.Decimal for _, it := range d.Items { if !it.Sum.IsZero() { sum = sum.Add(it.Sum) } else { sum = sum.Add(it.Quantity.Mul(it.Price)) } } val, _ := sum.Float64() date := time.Now() if d.DateIncoming != nil { date = *d.DateIncoming } var itemsPreview string if len(d.Items) > 0 { names := make([]string, 0, 3) for i, it := range d.Items { if i >= 3 { break } names = append(names, it.RawName) } itemsPreview = strings.Join(names, ", ") } result = append(result, UnifiedInvoiceDTO{ ID: d.ID, Type: "DRAFT", DocumentNumber: d.DocumentNumber, IncomingNumber: "", DateIncoming: date, Status: d.Status, TotalSum: val, StoreName: "", ItemsCount: len(d.Items), CreatedAt: d.CreatedAt, IsAppCreated: true, PhotoURL: d.SenderPhotoURL, ItemsPreview: itemsPreview, }) } for _, inv := range invoicesList { var sum decimal.Decimal for _, it := range inv.Items { sum = sum.Add(it.Sum) } val, _ := sum.Float64() isAppCreated := false photoURL := "" if url, exists := photoMap[inv.ID]; exists { isAppCreated = true photoURL = url } var itemsPreview string if len(inv.Items) > 0 { names := make([]string, 0, 3) for i, it := range inv.Items { if i >= 3 { break } if it.Product.Name != "" { names = append(names, it.Product.Name) } } itemsPreview = strings.Join(names, ", ") } result = append(result, UnifiedInvoiceDTO{ ID: inv.ID, Type: "SYNCED", DocumentNumber: inv.DocumentNumber, IncomingNumber: inv.IncomingDocumentNumber, DateIncoming: inv.DateIncoming, Status: inv.Status, TotalSum: val, ItemsCount: len(inv.Items), CreatedAt: inv.CreatedAt, IsAppCreated: isAppCreated, PhotoURL: photoURL, ItemsPreview: itemsPreview, }) } 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("нет активного сервера") } if inv.RMSServerID != server.ID { return nil, "", errors.New("накладная не принадлежит активному серверу") } draft, err := s.draftRepo.GetByRMSInvoiceID(invoiceID) if err != nil { return nil, "", err } photoURL := "" if draft != nil { photoURL = draft.SenderPhotoURL } return inv, photoURL, nil }