Files
rmser/internal/services/drafts/service.go

857 lines
23 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
}
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
}
// ============================================
// === МЕТОДЫ ДЛЯ 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
}