mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
556 lines
17 KiB
Go
556 lines
17 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/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,
|
||
}
|
||
}
|
||
|
||
// checkWriteAccess проверяет, что пользователь имеет право редактировать данные на сервере (ADMIN/OWNER)
|
||
func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {
|
||
role, err := s.accountRepo.GetUserRole(userID, serverID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if role == account.RoleOperator {
|
||
return errors.New("доступ запрещен: оператор не может редактировать данные")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {
|
||
draft, err := s.draftRepo.GetByID(draftID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Проверяем, что черновик принадлежит активному серверу пользователя
|
||
// И пользователь не Оператор (операторы вообще не ходят в API)
|
||
server, err := s.accountRepo.GetActiveServer(userID)
|
||
if err != nil || server == nil {
|
||
return nil, errors.New("нет активного сервера")
|
||
}
|
||
|
||
if draft.RMSServerID != server.ID {
|
||
return nil, errors.New("черновик не принадлежит активному серверу")
|
||
}
|
||
|
||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return draft, nil
|
||
}
|
||
|
||
func (s *Service) GetActiveDrafts(userID uuid.UUID) ([]drafts.DraftInvoice, error) {
|
||
// 1. Узнаем активный сервер
|
||
server, err := s.accountRepo.GetActiveServer(userID)
|
||
if err != nil || server == nil {
|
||
return nil, errors.New("активный сервер не выбран")
|
||
}
|
||
|
||
// 2. Проверяем роль (Security)
|
||
// Операторам список недоступен
|
||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 3. Возвращаем все черновики СЕРВЕРА
|
||
return s.draftRepo.GetActive(server.ID)
|
||
}
|
||
|
||
// GetDictionaries возвращает Склады и Поставщиков для пользователя
|
||
func (s *Service) GetDictionaries(userID uuid.UUID) (map[string]interface{}, error) {
|
||
server, err := s.accountRepo.GetActiveServer(userID)
|
||
if err != nil || server == nil {
|
||
return nil, fmt.Errorf("active server not found")
|
||
}
|
||
|
||
// Словари нужны только тем, кто редактирует
|
||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
stores, _ := s.catalogRepo.GetActiveStores(server.ID)
|
||
suppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90)
|
||
|
||
return map[string]interface{}{
|
||
"stores": stores,
|
||
"suppliers": suppliersList,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
|
||
draft, err := s.draftRepo.GetByID(id)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// TODO: Здесь тоже бы проверить userID и права, но пока оставим как есть,
|
||
// так как DeleteDraft вызывается из хендлера, где мы можем добавить проверку,
|
||
// но лучше передавать userID в сигнатуру DeleteDraft(id, userID).
|
||
// Для скорости пока оставим, полагаясь на то, что фронт не покажет кнопку.
|
||
|
||
if draft.Status == drafts.StatusCanceled {
|
||
draft.Status = drafts.StatusDeleted
|
||
s.draftRepo.Update(draft)
|
||
return drafts.StatusDeleted, nil
|
||
}
|
||
if draft.Status != drafts.StatusCompleted {
|
||
draft.Status = drafts.StatusCanceled
|
||
s.draftRepo.Update(draft)
|
||
return drafts.StatusCanceled, nil
|
||
}
|
||
return draft.Status, nil
|
||
}
|
||
|
||
func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID *uuid.UUID, date time.Time, comment string) error {
|
||
draft, err := s.draftRepo.GetByID(id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if draft.Status == drafts.StatusCompleted {
|
||
return errors.New("черновик уже отправлен")
|
||
}
|
||
draft.StoreID = storeID
|
||
draft.SupplierID = supplierID
|
||
draft.DateIncoming = &date
|
||
draft.Comment = comment
|
||
return s.draftRepo.Update(draft)
|
||
}
|
||
|
||
// AddItem добавляет пустую строку в черновик
|
||
func (s *Service) AddItem(draftID uuid.UUID) (*drafts.DraftInvoiceItem, error) {
|
||
newItem := &drafts.DraftInvoiceItem{
|
||
ID: uuid.New(),
|
||
DraftID: draftID,
|
||
RawName: "Новая позиция",
|
||
RawAmount: decimal.NewFromFloat(1),
|
||
RawPrice: decimal.Zero,
|
||
Quantity: decimal.NewFromFloat(1),
|
||
Price: decimal.Zero,
|
||
Sum: decimal.Zero,
|
||
IsMatched: false,
|
||
}
|
||
|
||
if err := s.draftRepo.CreateItem(newItem); err != nil {
|
||
return nil, err
|
||
}
|
||
return newItem, nil
|
||
}
|
||
|
||
// DeleteItem удаляет строку и возвращает обновленную сумму черновика
|
||
func (s *Service) DeleteItem(draftID, itemID uuid.UUID) (float64, error) {
|
||
if err := s.draftRepo.DeleteItem(itemID); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
draft, err := s.draftRepo.GetByID(draftID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
var totalSum decimal.Decimal
|
||
for _, item := range draft.Items {
|
||
if !item.Sum.IsZero() {
|
||
totalSum = totalSum.Add(item.Sum)
|
||
} else {
|
||
totalSum = totalSum.Add(item.Quantity.Mul(item.Price))
|
||
}
|
||
}
|
||
|
||
sumFloat, _ := totalSum.Float64()
|
||
return sumFloat, nil
|
||
}
|
||
|
||
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
|
||
draft, err := s.draftRepo.GetByID(draftID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// Автосмена статуса
|
||
if draft.Status == drafts.StatusCanceled {
|
||
draft.Status = drafts.StatusReadyToVerify
|
||
s.draftRepo.Update(draft)
|
||
}
|
||
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
|
||
}
|
||
|
||
// CommitDraft отправляет накладную
|
||
// CommitDraft отправляет накладную
|
||
func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
|
||
// 1. Получаем сервер и права
|
||
server, err := s.accountRepo.GetActiveServer(userID)
|
||
if err != nil {
|
||
return "", fmt.Errorf("active server not found: %w", err)
|
||
}
|
||
|
||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// --- BILLING CHECK ---
|
||
if can, err := s.billingService.CanProcessInvoice(server.ID); !can {
|
||
return "", fmt.Errorf("ошибка биллинга: %w", err)
|
||
}
|
||
|
||
// 2. Черновик
|
||
draft, err := s.draftRepo.GetByID(draftID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// Проверка принадлежности черновика серверу
|
||
if draft.RMSServerID != server.ID {
|
||
return "", errors.New("черновик принадлежит другому серверу")
|
||
}
|
||
|
||
if draft.Status == drafts.StatusCompleted {
|
||
return "", errors.New("накладная уже отправлена")
|
||
}
|
||
|
||
// 3. Клиент (использует права текущего юзера - Админа/Владельца)
|
||
client, err := s.rmsFactory.GetClientForUser(userID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
targetStatus := "NEW"
|
||
if server.AutoProcess {
|
||
targetStatus = "PROCESSED"
|
||
}
|
||
|
||
// 4. Сборка Invoice
|
||
inv := invoices.Invoice{
|
||
ID: uuid.Nil,
|
||
DocumentNumber: draft.DocumentNumber,
|
||
DateIncoming: *draft.DateIncoming,
|
||
SupplierID: *draft.SupplierID,
|
||
DefaultStoreID: *draft.StoreID,
|
||
Status: targetStatus,
|
||
Comment: draft.Comment,
|
||
Items: make([]invoices.InvoiceItem, 0, len(draft.Items)),
|
||
}
|
||
|
||
for _, dItem := range draft.Items {
|
||
if dItem.ProductID == nil {
|
||
continue // Skip unrecognized
|
||
}
|
||
|
||
sum := dItem.Sum
|
||
if sum.IsZero() {
|
||
sum = dItem.Quantity.Mul(dItem.Price)
|
||
}
|
||
|
||
// Инициализируем значениями из черновика (по умолчанию для базовых единиц)
|
||
amountToSend := dItem.Quantity
|
||
priceToSend := dItem.Price
|
||
|
||
// ЛОГИКА ПЕРЕСЧЕТА ДЛЯ ФАСОВОК (СОГЛАСНО ДОКУМЕНТАЦИИ IIKO)
|
||
// Если указан ContainerID, iiko требует:
|
||
// <amount> = кол-во упаковок * вес упаковки (итоговое кол-во в базовых единицах)
|
||
// <price> = цена за упаковку / вес упаковки (цена за базовую единицу)
|
||
// <containerId> = ID фасовки
|
||
if dItem.ContainerID != nil && *dItem.ContainerID != uuid.Nil {
|
||
// Проверяем, что Container загружен (Preload в репозитории)
|
||
if dItem.Container != nil {
|
||
if !dItem.Container.Count.IsZero() {
|
||
// 1. Пересчитываем кол-во: 5 ящиков * 10 кг = 50 кг
|
||
amountToSend = dItem.Quantity.Mul(dItem.Container.Count)
|
||
|
||
// 2. Пересчитываем цену: 1000 руб/ящ / 10 кг = 100 руб/кг
|
||
priceToSend = dItem.Price.Div(dItem.Container.Count)
|
||
}
|
||
} else {
|
||
// Если фасовка есть в ID, но не подгрузилась структура - это ошибка данных.
|
||
// Логируем варнинг, но пробуем отправить как есть (iiko может отвергнуть или посчитать криво)
|
||
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, // Сумма остается неизменной (Total)
|
||
ContainerID: dItem.ContainerID,
|
||
}
|
||
inv.Items = append(inv.Items, invItem)
|
||
}
|
||
|
||
if len(inv.Items) == 0 {
|
||
return "", errors.New("нет распознанных позиций для отправки")
|
||
}
|
||
|
||
// 5. Отправка в RMS
|
||
docNum, err := client.CreateIncomingInvoice(inv)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
// 6. Обновление статуса черновика
|
||
draft.Status = drafts.StatusCompleted
|
||
s.draftRepo.Update(draft)
|
||
|
||
// --- БИЛЛИНГ: Списание баланса и инкремент счетчика ---
|
||
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))
|
||
}
|
||
|
||
// 7. Запуск обучения
|
||
go s.learnFromDraft(draft, server.ID)
|
||
|
||
return docNum, nil
|
||
}
|
||
|
||
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice, serverID uuid.UUID) {
|
||
for _, item := range draft.Items {
|
||
if item.RawName != "" && item.ProductID != nil {
|
||
qty := decimal.NewFromFloat(1.0)
|
||
err := s.ocrRepo.SaveMatch(serverID, item.RawName, *item.ProductID, qty, item.ContainerID)
|
||
if err != nil {
|
||
logger.Log.Warn("Failed to learn match", zap.Error(err))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
|
||
server, err := s.accountRepo.GetActiveServer(userID)
|
||
if err != nil || server == nil {
|
||
return uuid.Nil, errors.New("no active server")
|
||
}
|
||
if err := s.checkWriteAccess(userID, server.ID); err != nil {
|
||
return uuid.Nil, err
|
||
}
|
||
|
||
client, err := s.rmsFactory.GetClientForUser(userID)
|
||
if err != nil {
|
||
return uuid.Nil, err
|
||
}
|
||
|
||
fullProduct, err := client.GetProductByID(productID)
|
||
if err != nil {
|
||
return uuid.Nil, fmt.Errorf("error fetching product: %w", err)
|
||
}
|
||
|
||
// Валидация на дубли
|
||
targetCount, _ := count.Float64()
|
||
for _, c := range fullProduct.Containers {
|
||
if !c.Deleted && (c.Name == name || (c.Count == targetCount)) {
|
||
if c.ID != nil && *c.ID != "" {
|
||
return uuid.Parse(*c.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Next Num
|
||
maxNum := 0
|
||
for _, c := range fullProduct.Containers {
|
||
if n, err := strconv.Atoi(c.Num); err == nil {
|
||
if n > maxNum {
|
||
maxNum = n
|
||
}
|
||
}
|
||
}
|
||
nextNum := strconv.Itoa(maxNum + 1)
|
||
|
||
// Add
|
||
newContainerDTO := rms.ContainerFullDTO{
|
||
ID: nil,
|
||
Num: nextNum,
|
||
Name: name,
|
||
Count: targetCount,
|
||
UseInFront: true,
|
||
Deleted: false,
|
||
}
|
||
fullProduct.Containers = append(fullProduct.Containers, newContainerDTO)
|
||
|
||
// Update RMS
|
||
updatedProduct, err := client.UpdateProduct(*fullProduct)
|
||
if err != nil {
|
||
return uuid.Nil, fmt.Errorf("error updating product: %w", err)
|
||
}
|
||
|
||
// Find created ID
|
||
var createdID uuid.UUID
|
||
found := false
|
||
for _, c := range updatedProduct.Containers {
|
||
if c.Name == name && c.Count == targetCount && !c.Deleted {
|
||
if c.ID != nil {
|
||
createdID, err = uuid.Parse(*c.ID)
|
||
if err == nil {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
return uuid.Nil, errors.New("container created but id not found")
|
||
}
|
||
|
||
// Save Local
|
||
newLocalContainer := catalog.ProductContainer{
|
||
ID: createdID,
|
||
RMSServerID: server.ID,
|
||
ProductID: productID,
|
||
Name: name,
|
||
Count: count,
|
||
}
|
||
s.catalogRepo.SaveContainer(newLocalContainer)
|
||
|
||
return createdID, nil
|
||
}
|
||
|
||
// Добавим новый DTO для единого списка (Frontend Contract)
|
||
type UnifiedInvoiceDTO struct {
|
||
ID uuid.UUID `json:"id"`
|
||
Type string `json:"type"` // "DRAFT" или "SYNCED"
|
||
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"`
|
||
}
|
||
|
||
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("активный сервер не выбран")
|
||
}
|
||
|
||
// 1. Получаем черновики (их обычно немного, берем все активные)
|
||
draftsList, err := s.draftRepo.GetActive(server.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 2. Получаем синхронизированные накладные за период
|
||
invoicesList, err := s.invoiceRepo.GetByPeriod(server.ID, from, to)
|
||
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
|
||
}
|
||
|
||
result = append(result, UnifiedInvoiceDTO{
|
||
ID: d.ID,
|
||
Type: "DRAFT",
|
||
DocumentNumber: d.DocumentNumber,
|
||
IncomingNumber: "", // В черновиках пока не разделяем
|
||
DateIncoming: date,
|
||
Status: d.Status,
|
||
TotalSum: val,
|
||
StoreName: "", // Можно подгрузить из d.Store.Name если сделан Preload
|
||
ItemsCount: len(d.Items),
|
||
CreatedAt: d.CreatedAt,
|
||
IsAppCreated: true,
|
||
})
|
||
}
|
||
|
||
// Маппим проведенные
|
||
for _, inv := range invoicesList {
|
||
var sum decimal.Decimal
|
||
for _, it := range inv.Items {
|
||
sum = sum.Add(it.Sum)
|
||
}
|
||
val, _ := sum.Float64()
|
||
|
||
isOurs := strings.Contains(strings.ToUpper(inv.Comment), "RMSER")
|
||
|
||
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: isOurs,
|
||
})
|
||
}
|
||
|
||
// Сортировка по дате накладной (desc)
|
||
// (Здесь можно добавить библиотеку sort или оставить как есть, если БД уже отсортировала части)
|
||
return result, nil
|
||
}
|