Files
rmser/internal/services/drafts/service.go
SERTY 88620f3fb6 0202-финиш перед десктопом
пересчет поправил
редактирование с перепроведением
галка автопроведения работает
рекомендации починил
2026-02-02 13:53:38 +03:00

1173 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
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 - логика пересчета Q->P->S->Q (Quantity -> Price -> Sum -> Quantity) с использованием decimal для точности
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)
}
// Дополнительная проверка для гарантии консистентности всех полей (Q->P->S->Q)
// Используется только для обеспечения точности, не влияет на логику выбора пересчитываемого поля
if !item.Price.IsZero() && !item.Quantity.IsZero() {
calculatedSum := item.Quantity.Mul(item.Price)
if !calculatedSum.Equal(item.Sum) {
item.Sum = calculatedSum
}
}
if !item.Price.IsZero() && !item.Sum.IsZero() {
calculatedQuantity := item.Sum.Div(item.Price)
if !calculatedQuantity.Equal(item.Quantity) {
item.Quantity = calculatedQuantity
}
}
if !item.Quantity.IsZero() && !item.Sum.IsZero() {
calculatedPrice := item.Sum.Div(item.Quantity)
if !calculatedPrice.Equal(item.Price) {
item.Price = calculatedPrice
}
}
}
// 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
}
}
// Просто присваиваем значения от фронтенда без пересчета
currentItem.Quantity = qty
currentItem.Price = price
currentItem.Sum = sum
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,
"is_matched": currentItem.IsMatched,
}
return s.draftRepo.UpdateItem(itemID, updates)
}
func (s *Service) CommitDraft(draftID, userID uuid.UUID, isProcessed bool) (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("черновик принадлежит другому серверу")
}
client, err := s.rmsFactory.GetClientForUser(userID)
if err != nil {
return "", err
}
targetStatus := "NEW"
if isProcessed {
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)),
}
// Если черновик уже был отправлен ранее, передаем RMSInvoiceID для обновления
if draft.RMSInvoiceID != nil {
inv.ID = *draft.RMSInvoiceID
}
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,
Product: func() catalog.Product {
if dItem.Product != nil {
return *dItem.Product
}
return catalog.Product{}
}(),
}
inv.Items = append(inv.Items, invItem)
}
if len(inv.Items) == 0 {
return "", errors.New("нет распознанных позиций для отправки")
}
docNum, err := client.CreateIncomingInvoice(inv)
if err != nil {
// Если накладная уже проведена, пробуем распровести и повторить
if strings.Contains(err.Error(), "Changing processed") {
logger.Log.Info("Накладная проведена, выполняю распроведение...", zap.String("doc_num", draft.DocumentNumber))
if unprocessErr := client.UnprocessIncomingInvoice(inv); unprocessErr != nil {
return "", fmt.Errorf("не удалось распровести накладную: %w", unprocessErr)
}
// Повторяем попытку создания накладной после распроведения
docNum, err = client.CreateIncomingInvoice(inv)
if err != nil {
return "", err
}
} else {
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 {
// ВАЖНО: Сохраняем полученные накладные, чтобы они сразу появились в базе как SYNCED
for i := range invoices {
invoices[i].RMSServerID = server.ID
}
if err := s.invoiceRepo.SaveInvoices(invoices); err != nil {
logger.Log.Error("Failed to save committed invoices", zap.Error(err))
}
found := false
for _, invoice := range invoices {
if invoice.DocumentNumber == docNum {
draft.RMSInvoiceID = &invoice.ID
draft.DocumentNumber = invoice.DocumentNumber
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"`
DraftID *uuid.UUID `json:"draft_id,omitempty"` // ID черновика для SYNCED накладных
}
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
}
linkedDraftsMap, err := s.draftRepo.GetLinkedDraftsMap(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 := ""
var draftID *uuid.UUID
if linkedInfo, exists := linkedDraftsMap[inv.ID]; exists {
isAppCreated = true
photoURL = linkedInfo.PhotoURL
draftID = &linkedInfo.DraftID
}
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,
DraftID: draftID,
})
}
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
}
// Обновляем время последней активности сервера
if err := s.accountRepo.UpdateLastActivity(server.ID); err != nil {
logger.Log.Warn("Не удалось обновить время активности",
zap.String("server_id", server.ID.String()),
zap.Error(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)
}
// SaveDraftFull обновляет черновик (шапку и позиции) пакетно в одной транзакции
func (s *Service) SaveDraftFull(draftID, userID uuid.UUID, req drafts.UpdateDraftRequest) error {
// Получаем черновик для проверки прав доступа
draft, err := s.GetDraft(draftID, userID)
if err != nil {
return err
}
// Обновляем шапку черновика, если переданы поля
headerUpdated := false
// 1. Дата (Обязательное поле, не может быть nil)
if req.DateIncoming != nil && *req.DateIncoming != "" {
parsedDate, err := time.Parse("2006-01-02", *req.DateIncoming)
if err != nil {
return fmt.Errorf("invalid date format: %w", err)
}
draft.DateIncoming = &parsedDate
headerUpdated = true
}
// 2. Склад (Может быть nil/сброшен)
if req.StoreID != nil {
if *req.StoreID == "" {
draft.StoreID = nil
} else {
uid, err := uuid.Parse(*req.StoreID)
if err != nil {
return fmt.Errorf("invalid store_id: %w", err)
}
draft.StoreID = &uid
}
headerUpdated = true
}
// 3. Поставщик (Может быть nil/сброшен)
if req.SupplierID != nil {
if *req.SupplierID == "" {
draft.SupplierID = nil
} else {
uid, err := uuid.Parse(*req.SupplierID)
if err != nil {
return fmt.Errorf("invalid supplier_id: %w", err)
}
draft.SupplierID = &uid
}
headerUpdated = true
}
// 4. Комментарий
if req.Comment != nil {
draft.Comment = *req.Comment
headerUpdated = true
}
// 5. Входящий номер
if req.IncomingDocumentNumber != nil {
draft.IncomingDocumentNumber = *req.IncomingDocumentNumber
headerUpdated = true
}
// Если были изменения в шапке — сохраняем
if headerUpdated {
if err := s.draftRepo.Update(draft); err != nil {
return fmt.Errorf("failed to update draft header: %w", err)
}
}
// Обновляем позиции, если переданы
if len(req.Items) > 0 {
for _, itemReq := range req.Items {
if itemReq.ID == nil || *itemReq.ID == "" {
return errors.New("item id is required")
}
itemID, err := uuid.Parse(*itemReq.ID)
if err != nil {
return fmt.Errorf("invalid item id: %s", *itemReq.ID)
}
// Получаем текущую позицию
currentItem, err := s.draftRepo.GetItemByID(itemID)
if err != nil {
return fmt.Errorf("item not found: %s", itemID.String())
}
// Проверяем, что позиция принадлежит черновику
if currentItem.DraftID != draftID {
return fmt.Errorf("item %s does not belong to draft %s", itemID.String(), draftID.String())
}
// Обновляем поля позиции
if itemReq.ProductID != nil {
if *itemReq.ProductID == "" {
currentItem.ProductID = nil
currentItem.IsMatched = false
currentItem.ContainerID = nil // Если убрали товар, фасовку тоже надо обнулить
} else {
parsedID, err := uuid.Parse(*itemReq.ProductID)
if err != nil {
return fmt.Errorf("invalid product_id for item %s", itemID.String())
}
currentItem.ProductID = &parsedID
currentItem.IsMatched = true
}
}
if itemReq.ContainerID != nil {
if *itemReq.ContainerID == "" {
// Сброс фасовки
currentItem.ContainerID = nil
} else {
parsedID, err := uuid.Parse(*itemReq.ContainerID)
if err != nil {
return fmt.Errorf("invalid container_id for item %s", itemID.String())
}
currentItem.ContainerID = &parsedID
}
}
// Просто присваиваем значения от фронтенда без пересчета
if itemReq.Quantity != nil {
currentItem.Quantity = decimal.NewFromFloat(*itemReq.Quantity)
}
if itemReq.Price != nil {
currentItem.Price = decimal.NewFromFloat(*itemReq.Price)
}
if itemReq.Sum != nil {
currentItem.Sum = decimal.NewFromFloat(*itemReq.Sum)
}
// Обновляем статус черновика, если он был отменен
if draft.Status == drafts.StatusCanceled {
draft.Status = drafts.StatusReadyToVerify
if err := s.draftRepo.Update(draft); err != nil {
return fmt.Errorf("failed to update draft status: %w", err)
}
}
// Сохраняем обновленную позицию
updates := map[string]interface{}{
"product_id": currentItem.ProductID,
"container_id": currentItem.ContainerID,
"quantity": currentItem.Quantity,
"price": currentItem.Price,
"sum": currentItem.Sum,
"is_matched": currentItem.IsMatched,
}
if err := s.draftRepo.UpdateItem(itemID, updates); err != nil {
return fmt.Errorf("failed to update item %s: %w", itemID.String(), err)
}
}
}
// Обновляем время последней активности сервера
if err := s.accountRepo.UpdateLastActivity(draft.RMSServerID); err != nil {
logger.Log.Warn("Не удалось обновить время активности",
zap.String("server_id", draft.RMSServerID.String()),
zap.Error(err))
// Не возвращаем ошибку - это некритично
}
return nil
}