Перевел на multi-tenant

Добавил поставщиков
Накладные успешно создаются из фронта
This commit is contained in:
2025-12-18 03:56:21 +03:00
parent 47ec8094e5
commit 542beafe0e
38 changed files with 1942 additions and 977 deletions

View File

@@ -10,72 +10,89 @@ import (
"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/pkg/logger"
)
type Service struct {
draftRepo drafts.Repository
ocrRepo ocr.Repository
catalogRepo catalog.Repository
rmsClient rms.ClientI
draftRepo drafts.Repository
ocrRepo ocr.Repository
catalogRepo catalog.Repository
accountRepo account.Repository
supplierRepo suppliers.Repository
rmsFactory *rms.Factory
}
func NewService(
draftRepo drafts.Repository,
ocrRepo ocr.Repository,
catalogRepo catalog.Repository,
rmsClient rms.ClientI,
accountRepo account.Repository,
supplierRepo suppliers.Repository,
rmsFactory *rms.Factory,
) *Service {
return &Service{
draftRepo: draftRepo,
ocrRepo: ocrRepo,
catalogRepo: catalogRepo,
rmsClient: rmsClient,
draftRepo: draftRepo,
ocrRepo: ocrRepo,
catalogRepo: catalogRepo,
accountRepo: accountRepo,
supplierRepo: supplierRepo,
rmsFactory: rmsFactory,
}
}
// GetDraft возвращает черновик с позициями
func (s *Service) GetDraft(id uuid.UUID) (*drafts.DraftInvoice, error) {
return s.draftRepo.GetByID(id)
func (s *Service) GetDraft(draftID, userID uuid.UUID) (*drafts.DraftInvoice, error) {
// TODO: Проверить что userID совпадает с draft.UserID
return s.draftRepo.GetByID(draftID)
}
func (s *Service) GetActiveDrafts(userID uuid.UUID) ([]drafts.DraftInvoice, error) {
return s.draftRepo.GetActive(userID)
}
// 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")
}
stores, _ := s.catalogRepo.GetActiveStores(server.ID)
// Ранжированные поставщики (топ за 90 дней)
suppliersList, _ := s.supplierRepo.GetRankedByUsage(server.ID, 90)
return map[string]interface{}{
"stores": stores,
"suppliers": suppliersList,
}, nil
}
// DeleteDraft реализует логику "Отмена -> Удаление"
func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
// Без изменений логики, только вызов репо
draft, err := s.draftRepo.GetByID(id)
if err != nil {
return "", err
}
// Сценарий 2: Если уже ОТМЕНЕН -> УДАЛЯЕМ (Soft Delete статусом)
if draft.Status == drafts.StatusCanceled {
draft.Status = drafts.StatusDeleted
if err := s.draftRepo.Update(draft); err != nil {
return "", err
}
logger.Log.Info("Черновик удален (скрыт)", zap.String("id", id.String()))
s.draftRepo.Update(draft)
return drafts.StatusDeleted, nil
}
// Сценарий 1: Если активен -> ОТМЕНЯЕМ
// Разрешаем отменять только незавершенные
if draft.Status != drafts.StatusCompleted && draft.Status != drafts.StatusDeleted {
if draft.Status != drafts.StatusCompleted {
draft.Status = drafts.StatusCanceled
if err := s.draftRepo.Update(draft); err != nil {
return "", err
}
logger.Log.Info("Черновик перемещен в отмененные", zap.String("id", id.String()))
s.draftRepo.Update(draft)
return drafts.StatusCanceled, nil
}
return draft.Status, nil
}
// UpdateDraftHeader обновляет шапку (дата, поставщик, склад, комментарий)
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 {
@@ -84,65 +101,46 @@ func (s *Service) UpdateDraftHeader(id uuid.UUID, storeID *uuid.UUID, supplierID
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)
}
// UpdateItem обновляет позицию с авто-восстановлением статуса черновика
func (s *Service) UpdateItem(draftID, itemID uuid.UUID, productID *uuid.UUID, containerID *uuid.UUID, qty, price decimal.Decimal) error {
// 1. Проверяем статус черновика для реализации Auto-Restore
draft, err := s.draftRepo.GetByID(draftID)
if err != nil {
return err
}
// Если черновик был в корзине (CANCELED), возвращаем его в работу
if draft.Status == drafts.StatusCanceled {
draft.Status = drafts.StatusReadyToVerify
if err := s.draftRepo.Update(draft); err != nil {
logger.Log.Error("Не удалось восстановить статус черновика при редактировании", zap.Error(err))
// Не прерываем выполнение, пробуем обновить строку
} else {
logger.Log.Info("Черновик автоматически восстановлен из отмененных", zap.String("id", draftID.String()))
}
s.draftRepo.Update(draft)
}
// 2. Обновляем саму строку (существующий вызов репозитория)
return s.draftRepo.UpdateItem(itemID, productID, containerID, qty, price)
}
// CommitDraft отправляет накладную в RMS
func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
// 1. Загружаем актуальное состояние черновика
draft, err := s.draftRepo.GetByID(id)
// CommitDraft отправляет накладную
func (s *Service) CommitDraft(draftID, userID uuid.UUID) (string, error) {
// 1. Клиент для пользователя
client, err := s.rmsFactory.GetClientForUser(userID)
if err != nil {
return "", err
}
// 2. Черновик
draft, err := s.draftRepo.GetByID(draftID)
if err != nil {
return "", err
}
if draft.Status == drafts.StatusCompleted {
return "", errors.New("накладная уже отправлена")
}
// Валидация
if draft.StoreID == nil || *draft.StoreID == uuid.Nil {
return "", errors.New("не выбран склад")
}
if draft.SupplierID == nil || *draft.SupplierID == uuid.Nil {
return "", errors.New("не выбран поставщик")
}
if draft.DateIncoming == nil {
return "", errors.New("не выбрана дата")
}
// Сборка Invoice для отправки
// 3. Сборка Invoice
inv := invoices.Invoice{
ID: uuid.Nil, // iiko создаст новый
DocumentNumber: draft.DocumentNumber, // Может быть пустой, iiko присвоит
ID: uuid.Nil,
DocumentNumber: draft.DocumentNumber,
DateIncoming: *draft.DateIncoming,
SupplierID: *draft.SupplierID,
DefaultStoreID: *draft.StoreID,
@@ -152,11 +150,10 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
for _, dItem := range draft.Items {
if dItem.ProductID == nil {
// Пропускаем нераспознанные или кидаем ошибку?
break
continue // Skip unrecognized
}
// Расчет суммы (если не задана, считаем)
// Если суммы нет, считаем
sum := dItem.Sum
if sum.IsZero() {
sum = dItem.Quantity.Mul(dItem.Price)
@@ -169,7 +166,6 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
Sum: sum,
ContainerID: dItem.ContainerID,
}
inv.Items = append(inv.Items, invItem)
}
@@ -177,86 +173,64 @@ func (s *Service) CommitDraft(id uuid.UUID) (string, error) {
return "", errors.New("нет распознанных позиций для отправки")
}
// Отправка
docNum, err := s.rmsClient.CreateIncomingInvoice(inv)
// 4. Отправка в RMS
docNum, err := client.CreateIncomingInvoice(inv)
if err != nil {
return "", err
}
// Обновление статуса
// 5. Обновление статуса
draft.Status = drafts.StatusCompleted
// Можно сохранить docNum, если бы было поле в Draft, но у нас есть rms_invoice_id (uuid),
// а возвращается строка номера. Ок, просто меняем статус.
if err := s.draftRepo.Update(draft); err != nil {
logger.Log.Error("Failed to update draft status after commit", zap.Error(err))
}
s.draftRepo.Update(draft)
// 4. ОБУЧЕНИЕ (Deferred Learning)
// Запускаем в горутине, чтобы не задерживать ответ пользователю
go s.learnFromDraft(draft)
// 6. БИЛЛИНГ: Увеличиваем счетчик накладных
server, _ := s.accountRepo.GetActiveServer(userID)
if server != nil {
if err := s.accountRepo.IncrementInvoiceCount(server.ID); err != nil {
logger.Log.Error("Billing increment failed", zap.Error(err))
}
// 7. Обучение (передаем ID сервера для сохранения маппинга)
go s.learnFromDraft(draft, server.ID)
}
return docNum, nil
}
// learnFromDraft сохраняет новые связи на основе подтвержденного черновика
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice) {
func (s *Service) learnFromDraft(draft *drafts.DraftInvoice, serverID uuid.UUID) {
for _, item := range draft.Items {
// Учимся только если:
// 1. Есть RawName (текст из чека)
// 2. Пользователь (или OCR) выбрал ProductID
if item.RawName != "" && item.ProductID != nil {
// Если нужно запоминать коэффициент (например, всегда 1 или то, что ввел юзер),
// то берем item.Quantity. Но обычно для матчинга мы запоминаем факт связи,
// а дефолтное кол-во ставим 1.
qty := decimal.NewFromFloat(1.0)
err := s.ocrRepo.SaveMatch(item.RawName, *item.ProductID, qty, item.ContainerID)
err := s.ocrRepo.SaveMatch(serverID, item.RawName, *item.ProductID, qty, item.ContainerID)
if err != nil {
logger.Log.Warn("Failed to learn match",
zap.String("raw", item.RawName),
zap.Error(err))
} else {
logger.Log.Info("Learned match", zap.String("raw", item.RawName))
logger.Log.Warn("Failed to learn match", zap.Error(err))
}
}
}
}
// GetActiveStores возвращает список складов
func (s *Service) GetActiveStores() ([]catalog.Store, error) {
return s.catalogRepo.GetActiveStores()
}
// GetActiveDrafts возвращает список черновиков в работе
func (s *Service) GetActiveDrafts() ([]drafts.DraftInvoice, error) {
return s.draftRepo.GetActive()
}
// CreateProductContainer создает новую фасовку в iiko и сохраняет её в локальной БД
// Возвращает UUID созданной фасовки.
func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
// 1. Получаем полную карточку товара из iiko
// Используем инфраструктурный DTO, так как нам нужна полная структура для апдейта
fullProduct, err := s.rmsClient.GetProductByID(productID)
func (s *Service) CreateProductContainer(userID uuid.UUID, productID uuid.UUID, name string, count decimal.Decimal) (uuid.UUID, error) {
client, err := s.rmsFactory.GetClientForUser(userID)
if err != nil {
return uuid.Nil, fmt.Errorf("ошибка получения товара из iiko: %w", err)
return uuid.Nil, err
}
server, _ := s.accountRepo.GetActiveServer(userID) // нужен ServerID для сохранения в локальную БД
fullProduct, err := client.GetProductByID(productID)
if err != nil {
return uuid.Nil, fmt.Errorf("error fetching product: %w", err)
}
// 2. Валидация на дубликаты (по имени или коэффициенту)
// iiko разрешает дубли, но нам это не нужно.
// Валидация на дубли
targetCount, _ := count.Float64()
for _, c := range fullProduct.Containers {
if !c.Deleted && (c.Name == name || (c.Count == targetCount)) {
// Если такая фасовка уже есть, возвращаем её ID
// (Можно добавить логику обновления имени, но пока просто вернем ID)
if c.ID != nil && *c.ID != "" {
return uuid.Parse(*c.ID)
}
}
}
// 3. Вычисляем следующий num (iiko использует строки "1", "2"...)
// Next Num
maxNum := 0
for _, c := range fullProduct.Containers {
if n, err := strconv.Atoi(c.Num); err == nil {
@@ -267,32 +241,27 @@ func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count
}
nextNum := strconv.Itoa(maxNum + 1)
// 4. Добавляем новую фасовку в список
// Add
newContainerDTO := rms.ContainerFullDTO{
ID: nil, // Null, чтобы iiko создала новый ID
ID: nil,
Num: nextNum,
Name: name,
Count: targetCount,
UseInFront: true,
Deleted: false,
// Остальные поля можно оставить 0/false по умолчанию
}
fullProduct.Containers = append(fullProduct.Containers, newContainerDTO)
// 5. Отправляем обновление в iiko
updatedProduct, err := s.rmsClient.UpdateProduct(*fullProduct)
// Update RMS
updatedProduct, err := client.UpdateProduct(*fullProduct)
if err != nil {
return uuid.Nil, fmt.Errorf("ошибка обновления товара в iiko: %w", err)
return uuid.Nil, fmt.Errorf("error updating product: %w", err)
}
// 6. Ищем нашу созданную фасовку в ответе, чтобы получить её ID
// Ищем по уникальной комбинации Name + Count, которую мы только что отправили
// Find created ID
var createdID uuid.UUID
found := false
for _, c := range updatedProduct.Containers {
// Сравниваем float с небольшим эпсилоном на всякий случай, хотя JSON должен вернуть точно
if c.Name == name && c.Count == targetCount && !c.Deleted {
if c.ID != nil {
createdID, err = uuid.Parse(*c.ID)
@@ -305,28 +274,18 @@ func (s *Service) CreateProductContainer(productID uuid.UUID, name string, count
}
if !found {
return uuid.Nil, errors.New("фасовка отправлена, но сервер не вернул её ID (возможно, ошибка логики поиска)")
return uuid.Nil, errors.New("container created but id not found")
}
// 7. Сохраняем новую фасовку в локальную БД
// Save Local
newLocalContainer := catalog.ProductContainer{
ID: createdID,
ProductID: productID,
Name: name,
Count: count,
ID: createdID,
RMSServerID: server.ID, // <-- NEW
ProductID: productID,
Name: name,
Count: count,
}
if err := s.catalogRepo.SaveContainer(newLocalContainer); err != nil {
logger.Log.Error("Ошибка сохранения новой фасовки в локальную БД", zap.Error(err))
// Не возвращаем ошибку клиенту, так как в iiko она уже создана.
// Просто в следующем SyncCatalog она подтянется, но лучше иметь её сразу.
}
logger.Log.Info("Создана новая фасовка",
zap.String("product_id", productID.String()),
zap.String("container_id", createdID.String()),
zap.String("name", name),
zap.String("count", count.String()))
s.catalogRepo.SaveContainer(newLocalContainer)
return createdID, nil
}