mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-05 03:12:34 -06:00
953 lines
26 KiB
Go
953 lines
26 KiB
Go
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/photos"
|
||
"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
|
||
photoRepo photos.Repository
|
||
rmsFactory *rms.Factory
|
||
billingService *billing.Service
|
||
}
|
||
|
||
func NewService(
|
||
draftRepo drafts.Repository,
|
||
ocrRepo ocr.Repository,
|
||
catalogRepo catalog.Repository,
|
||
accountRepo account.Repository,
|
||
supplierRepo suppliers.Repository,
|
||
photoRepo photos.Repository,
|
||
invoiceRepo invoices.Repository,
|
||
rmsFactory *rms.Factory,
|
||
billingService *billing.Service,
|
||
) *Service {
|
||
return &Service{
|
||
draftRepo: draftRepo,
|
||
ocrRepo: ocrRepo,
|
||
catalogRepo: catalogRepo,
|
||
accountRepo: accountRepo,
|
||
supplierRepo: supplierRepo,
|
||
photoRepo: photoRepo,
|
||
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 {
|
||
// Окончательное удаление
|
||
if err := s.draftRepo.Delete(id); err != nil {
|
||
return "", err
|
||
}
|
||
// ВАЖНО: Разрываем связь с фото (оно становится ORPHAN)
|
||
if err := s.photoRepo.ClearDraftLinkByDraftID(id); err != nil {
|
||
logger.Log.Error("failed to clear photo draft link", zap.Error(err))
|
||
}
|
||
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) {
|
||
// Получаем текущий черновик для определения максимального 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{
|
||
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,
|
||
Order: maxOrder + 1,
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// ============================================
|
||
// === МЕТОДЫ ДЛЯ IN-CHAT DRAFT EDITOR ===
|
||
// ============================================
|
||
|
||
// CreateDraft создаёт новый пустой черновик для пользователя
|
||
func (s *Service) CreateDraft(userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
||
server, err := s.accountRepo.GetActiveServer(userID)
|
||
if err != nil || server == nil {
|
||
return nil, errors.New("нет активного сервера")
|
||
}
|
||
|
||
draft := &drafts.DraftInvoice{
|
||
ID: uuid.New(),
|
||
UserID: userID,
|
||
RMSServerID: server.ID,
|
||
Status: drafts.StatusProcessing,
|
||
DateIncoming: func() *time.Time {
|
||
t := time.Now()
|
||
return &t
|
||
}(),
|
||
}
|
||
|
||
if err := s.draftRepo.Create(draft); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return draft, nil
|
||
}
|
||
|
||
// GetDraftForEditor возвращает черновик для отображения в редакторе.
|
||
// Доступен ВСЕМ ролям (Owner, Admin, Operator).
|
||
// Проверяет только принадлежность черновика к активному серверу пользователя.
|
||
func (s *Service) GetDraftForEditor(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("черновик не принадлежит активному серверу")
|
||
}
|
||
|
||
return draft, nil
|
||
}
|
||
|
||
// validateItemBelongsToDraft проверяет, что позиция принадлежит указанному черновику
|
||
func (s *Service) validateItemBelongsToDraft(item *drafts.DraftInvoiceItem, draftID uuid.UUID) error {
|
||
if item.DraftID != draftID {
|
||
return errors.New("позиция не принадлежит указанному черновику")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// UpdateItemRawName обновляет raw_name позиции черновика.
|
||
// Возвращает обновлённую позицию.
|
||
func (s *Service) UpdateItemRawName(draftID, itemID uuid.UUID, newName string) (*drafts.DraftInvoiceItem, error) {
|
||
item, err := s.draftRepo.GetItemByID(itemID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := s.validateItemBelongsToDraft(item, draftID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
item.RawName = strings.TrimSpace(newName)
|
||
if item.RawName == "" {
|
||
return nil, errors.New("название позиции не может быть пустым")
|
||
}
|
||
|
||
updates := map[string]interface{}{
|
||
"raw_name": item.RawName,
|
||
}
|
||
|
||
if err := s.draftRepo.UpdateItem(itemID, updates); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return item, nil
|
||
}
|
||
|
||
// UpdateItemQuantity обновляет количество позиции и пересчитывает сумму.
|
||
// Возвращает обновлённую позицию.
|
||
func (s *Service) UpdateItemQuantity(draftID, itemID uuid.UUID, qty decimal.Decimal) (*drafts.DraftInvoiceItem, error) {
|
||
item, err := s.draftRepo.GetItemByID(itemID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := s.validateItemBelongsToDraft(item, draftID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
item.Quantity = qty
|
||
s.RecalculateItemFields(item, drafts.FieldQuantity)
|
||
|
||
updates := map[string]interface{}{
|
||
"quantity": item.Quantity,
|
||
"sum": item.Sum,
|
||
"last_edited_field1": item.LastEditedField1,
|
||
"last_edited_field2": item.LastEditedField2,
|
||
}
|
||
|
||
if err := s.draftRepo.UpdateItem(itemID, updates); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return item, nil
|
||
}
|
||
|
||
// UpdateItemPrice обновляет цену позиции и пересчитывает сумму.
|
||
// Возвращает обновлённую позицию.
|
||
func (s *Service) UpdateItemPrice(draftID, itemID uuid.UUID, price decimal.Decimal) (*drafts.DraftInvoiceItem, error) {
|
||
item, err := s.draftRepo.GetItemByID(itemID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := s.validateItemBelongsToDraft(item, draftID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
item.Price = price
|
||
s.RecalculateItemFields(item, drafts.FieldPrice)
|
||
|
||
updates := map[string]interface{}{
|
||
"price": item.Price,
|
||
"sum": item.Sum,
|
||
"last_edited_field1": item.LastEditedField1,
|
||
"last_edited_field2": item.LastEditedField2,
|
||
}
|
||
|
||
if err := s.draftRepo.UpdateItem(itemID, updates); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return item, nil
|
||
}
|
||
|
||
// SetDraftReadyToVerify переводит черновик в статус READY_TO_VERIFY.
|
||
// Вызывается при подтверждении оператором.
|
||
// Возвращает обновлённый черновик.
|
||
func (s *Service) SetDraftReadyToVerify(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
||
draft, err := s.GetDraftForEditor(draftID, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if draft.Status == drafts.StatusCompleted {
|
||
return nil, errors.New("черновик уже завершён")
|
||
}
|
||
|
||
draft.Status = drafts.StatusReadyToVerify
|
||
if err := s.draftRepo.Update(draft); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// ReorderItems обновляет порядок нескольких элементов в черновике
|
||
func (s *Service) ReorderItems(draftID uuid.UUID, items []struct {
|
||
ID uuid.UUID
|
||
Order int
|
||
}) error {
|
||
// Проверяем, что черновик существует
|
||
draft, err := s.draftRepo.GetByID(draftID)
|
||
if err != nil {
|
||
return fmt.Errorf("черновик не найден: %w", err)
|
||
}
|
||
|
||
// Проверяем, что все элементы принадлежат указанному черновику
|
||
for _, item := range items {
|
||
found := false
|
||
for _, draftItem := range draft.Items {
|
||
if draftItem.ID == item.ID {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return fmt.Errorf("элемент с id %s не принадлежит черновику %s", item.ID.String(), draftID.String())
|
||
}
|
||
}
|
||
|
||
// Вызываем метод репозитория для обновления порядка
|
||
return s.draftRepo.ReorderItems(draftID, items)
|
||
}
|