добавил архив фото, откуда можно удалить и посмотреть

This commit is contained in:
2026-01-19 05:17:15 +03:00
parent bc036197cf
commit 323fc67cd5
12 changed files with 648 additions and 26 deletions

View File

@@ -16,6 +16,7 @@ import (
"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"
@@ -29,6 +30,7 @@ type Service struct {
accountRepo account.Repository
supplierRepo suppliers.Repository
invoiceRepo invoices.Repository
photoRepo photos.Repository
rmsFactory *rms.Factory
billingService *billing.Service
}
@@ -39,6 +41,7 @@ func NewService(
catalogRepo catalog.Repository,
accountRepo account.Repository,
supplierRepo suppliers.Repository,
photoRepo photos.Repository,
invoiceRepo invoices.Repository,
rmsFactory *rms.Factory,
billingService *billing.Service,
@@ -49,6 +52,7 @@ func NewService(
catalogRepo: catalogRepo,
accountRepo: accountRepo,
supplierRepo: supplierRepo,
photoRepo: photoRepo,
invoiceRepo: invoiceRepo,
rmsFactory: rmsFactory,
billingService: billingService,
@@ -126,16 +130,25 @@ func (s *Service) DeleteDraft(id uuid.UUID) (string, error) {
return "", err
}
// Логика статусов
if draft.Status == drafts.StatusCanceled {
draft.Status = drafts.StatusDeleted
s.draftRepo.Update(draft)
// Окончательное удаление
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
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/shopspring/decimal"
@@ -14,6 +15,7 @@ import (
"rmser/internal/domain/catalog"
"rmser/internal/domain/drafts"
"rmser/internal/domain/ocr"
"rmser/internal/domain/photos"
"rmser/internal/infrastructure/ocr_client"
)
@@ -22,6 +24,7 @@ type Service struct {
catalogRepo catalog.Repository
draftRepo drafts.Repository
accountRepo account.Repository
photoRepo photos.Repository
pyClient *ocr_client.Client
storagePath string
}
@@ -31,6 +34,7 @@ func NewService(
catalogRepo catalog.Repository,
draftRepo drafts.Repository,
accountRepo account.Repository,
photoRepo photos.Repository,
pyClient *ocr_client.Client,
storagePath string,
) *Service {
@@ -39,6 +43,7 @@ func NewService(
catalogRepo: catalogRepo,
draftRepo: draftRepo,
accountRepo: accountRepo,
photoRepo: photoRepo,
pyClient: pyClient,
storagePath: storagePath,
}
@@ -58,37 +63,56 @@ func (s *Service) checkWriteAccess(userID, serverID uuid.UUID) error {
// ProcessReceiptImage - Доступно всем (включая Операторов)
func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, imgData []byte) (*drafts.DraftInvoice, error) {
// 1. Получаем активный сервер для UserID
server, err := s.accountRepo.GetActiveServer(userID)
if err != nil || server == nil {
return nil, fmt.Errorf("no active server for user")
}
serverID := server.ID
// 2. Создаем черновик
draft := &drafts.DraftInvoice{
UserID: userID,
RMSServerID: serverID,
Status: drafts.StatusProcessing,
StoreID: server.DefaultStoreID,
}
// 1. Создаем ID для фото и черновика
photoID := uuid.New()
draftID := uuid.New()
draft.ID = uuid.New()
fileName := fmt.Sprintf("receipt_%s.jpg", draft.ID.String())
fileName := fmt.Sprintf("receipt_%s.jpg", photoID.String())
filePath := filepath.Join(s.storagePath, fileName)
// 2. Сохраняем файл
if err := os.WriteFile(filePath, imgData, 0644); err != nil {
return nil, fmt.Errorf("failed to save image: %w", err)
}
fileURL := "/uploads/" + fileName
draft.SenderPhotoURL = "/uploads/" + fileName
// 3. Создаем запись ReceiptPhoto
photo := &photos.ReceiptPhoto{
ID: photoID,
RMSServerID: serverID,
UploadedBy: userID,
FilePath: filePath,
FileURL: fileURL,
FileName: fileName,
FileSize: int64(len(imgData)),
DraftID: &draftID, // Сразу связываем с будущим черновиком
CreatedAt: time.Now(),
}
if err := s.photoRepo.Create(photo); err != nil {
return nil, fmt.Errorf("failed to create photo record: %w", err)
}
// 4. Создаем черновик
draft := &drafts.DraftInvoice{
ID: draftID,
UserID: userID,
RMSServerID: serverID,
Status: drafts.StatusProcessing,
StoreID: server.DefaultStoreID,
SenderPhotoURL: fileURL, // Оставляем для совместимости, но теперь есть ReceiptPhoto
}
if err := s.draftRepo.Create(draft); err != nil {
return nil, fmt.Errorf("failed to create draft: %w", err)
}
// 3. Отправляем в Python OCR
// 5. Отправляем в Python OCR
rawResult, err := s.pyClient.ProcessImage(ctx, imgData, "receipt.jpg")
if err != nil {
draft.Status = drafts.StatusError
@@ -96,9 +120,8 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
return nil, fmt.Errorf("python ocr error: %w", err)
}
// 4. Матчинг (с учетом ServerID)
// 6. Матчинг и сохранение позиций
var draftItems []drafts.DraftInvoiceItem
for _, rawItem := range rawResult.Items {
item := drafts.DraftInvoiceItem{
DraftID: draft.ID,
@@ -111,23 +134,19 @@ func (s *Service) ProcessReceiptImage(ctx context.Context, userID uuid.UUID, img
}
match, _ := s.ocrRepo.FindMatch(serverID, rawItem.RawName)
if match != nil {
item.IsMatched = true
item.ProductID = &match.ProductID
item.ContainerID = match.ContainerID
} else {
s.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName) // <-- ServerID
s.ocrRepo.UpsertUnmatched(serverID, rawItem.RawName)
}
draftItems = append(draftItems, item)
}
// 5. Сохраняем
draft.Status = drafts.StatusReadyToVerify
s.draftRepo.Update(draft)
s.draftRepo.CreateItems(draftItems)
draft.Items = draftItems
return draft, nil

View File

@@ -0,0 +1,119 @@
package photos
import (
"errors"
"os"
"github.com/google/uuid"
"rmser/internal/domain/account"
"rmser/internal/domain/drafts"
"rmser/internal/domain/photos"
)
var (
ErrPhotoHasInvoice = errors.New("нельзя удалить фото: есть созданная накладная")
ErrPhotoHasDraft = errors.New("фото связано с черновиком")
)
type Service struct {
photoRepo photos.Repository
draftRepo drafts.Repository
accountRepo account.Repository
}
func NewService(photoRepo photos.Repository, draftRepo drafts.Repository, accountRepo account.Repository) *Service {
return &Service{
photoRepo: photoRepo,
draftRepo: draftRepo,
accountRepo: accountRepo,
}
}
// DTO для ответа
type PhotoWithStatus struct {
photos.ReceiptPhoto
Status photos.PhotoStatus `json:"status"`
CanDelete bool `json:"can_delete"`
CanRegenerate bool `json:"can_regenerate"`
}
func (s *Service) GetPhotosForServer(userID uuid.UUID, page, limit int) ([]PhotoWithStatus, int64, error) {
server, err := s.accountRepo.GetActiveServer(userID)
if err != nil || server == nil {
return nil, 0, errors.New("active server not found")
}
items, total, err := s.photoRepo.GetByServerID(server.ID, page, limit)
if err != nil {
return nil, 0, err
}
result := make([]PhotoWithStatus, len(items))
for i, photo := range items {
status := photos.PhotoStatusOrphan
if photo.InvoiceID != nil {
status = photos.PhotoStatusHasInvoice
} else if photo.DraftID != nil {
status = photos.PhotoStatusHasDraft
}
result[i] = PhotoWithStatus{
ReceiptPhoto: photo,
Status: status,
// Удалить можно только если нет накладной
CanDelete: photo.InvoiceID == nil,
// Пересоздать можно только если это "сирота"
CanRegenerate: photo.DraftID == nil && photo.InvoiceID == nil,
}
}
return result, total, nil
}
func (s *Service) DeletePhoto(userID, photoID uuid.UUID, forceDeleteDraft bool) error {
// Проверка прав
server, err := s.accountRepo.GetActiveServer(userID)
if err != nil || server == nil {
return errors.New("no active server")
}
// Операторы не могут удалять фото из архива (только админы)
role, _ := s.accountRepo.GetUserRole(userID, server.ID)
if role == account.RoleOperator {
return errors.New("access denied")
}
photo, err := s.photoRepo.GetByID(photoID)
if err != nil {
return err
}
// Проверка: есть накладная
if photo.InvoiceID != nil {
return ErrPhotoHasInvoice
}
// Проверка: есть черновик
if photo.DraftID != nil {
if !forceDeleteDraft {
return ErrPhotoHasDraft
}
// Если форсируем удаление - удаляем и черновик
if err := s.draftRepo.Delete(*photo.DraftID); err != nil {
return err
}
}
// Удаляем файл с диска (физически)
// В продакшене лучше делать это асинхронно или не делать вовсе (soft delete),
// но для экономии места удалим.
// Путь в БД может быть относительным или абсолютным, зависит от реализации загрузки.
// В ocr/service мы пишем абсолютный путь.
// Но в поле FilePath у нас путь.
if photo.FilePath != "" {
_ = os.Remove(photo.FilePath)
}
return s.photoRepo.Delete(photoID)
}