mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
добавил архив фото, откуда можно удалить и посмотреть
This commit is contained in:
54
internal/domain/photos/entity.go
Normal file
54
internal/domain/photos/entity.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PhotoStatus string
|
||||
|
||||
const (
|
||||
PhotoStatusOrphan PhotoStatus = "ORPHAN" // Нет связанного черновика
|
||||
PhotoStatusHasDraft PhotoStatus = "HAS_DRAFT" // Есть черновик
|
||||
PhotoStatusHasInvoice PhotoStatus = "HAS_INVOICE" // Есть накладная в iiko
|
||||
)
|
||||
|
||||
// ReceiptPhoto - фото чека
|
||||
type ReceiptPhoto struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
RMSServerID uuid.UUID `gorm:"type:uuid;not null;index" json:"rms_server_id"`
|
||||
UploadedBy uuid.UUID `gorm:"type:uuid;not null" json:"uploaded_by"` // User ID
|
||||
|
||||
FilePath string `gorm:"type:varchar(500);not null" json:"file_path"`
|
||||
FileURL string `gorm:"type:varchar(500);not null" json:"file_url"` // Public URL
|
||||
FileName string `gorm:"type:varchar(255)" json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
|
||||
// Связи (указатели, так как могут быть null)
|
||||
DraftID *uuid.UUID `gorm:"type:uuid;index" json:"draft_id"`
|
||||
InvoiceID *uuid.UUID `gorm:"type:uuid;index" json:"invoice_id"` // RMS Invoice ID (когда накладная создана)
|
||||
|
||||
// Метаданные OCR (чтобы можно было пересоздать черновик без повторного запроса к нейросети)
|
||||
OCRSource string `gorm:"type:varchar(50)" json:"ocr_source"` // qr_api, yandex, etc.
|
||||
OCRRawText string `gorm:"type:text" json:"ocr_raw_text"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Repository interface
|
||||
type Repository interface {
|
||||
Create(photo *ReceiptPhoto) error
|
||||
GetByID(id uuid.UUID) (*ReceiptPhoto, error)
|
||||
// GetByServerID возвращает фото с пагинацией
|
||||
GetByServerID(serverID uuid.UUID, page, limit int) ([]ReceiptPhoto, int64, error)
|
||||
|
||||
UpdateDraftLink(photoID uuid.UUID, draftID *uuid.UUID) error
|
||||
UpdateInvoiceLink(photoID uuid.UUID, invoiceID *uuid.UUID) error
|
||||
|
||||
// ClearDraftLinkByDraftID очищает ссылку на черновик (когда черновик удаляется)
|
||||
ClearDraftLinkByDraftID(draftID uuid.UUID) error
|
||||
|
||||
Delete(id uuid.UUID) error
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"rmser/internal/domain/invoices"
|
||||
"rmser/internal/domain/ocr"
|
||||
"rmser/internal/domain/operations"
|
||||
"rmser/internal/domain/photos"
|
||||
"rmser/internal/domain/recipes"
|
||||
"rmser/internal/domain/recommendations"
|
||||
"rmser/internal/domain/suppliers"
|
||||
@@ -68,6 +69,7 @@ func NewPostgresDB(dsn string) *gorm.DB {
|
||||
&recommendations.Recommendation{},
|
||||
&ocr.ProductMatch{},
|
||||
&ocr.UnmatchedItem{},
|
||||
&photos.ReceiptPhoto{},
|
||||
)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ошибка миграции БД: %v", err))
|
||||
|
||||
70
internal/infrastructure/repository/photos/postgres.go
Normal file
70
internal/infrastructure/repository/photos/postgres.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package photos
|
||||
|
||||
import (
|
||||
"rmser/internal/domain/photos"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type pgRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) photos.Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) Create(photo *photos.ReceiptPhoto) error {
|
||||
return r.db.Create(photo).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetByID(id uuid.UUID) (*photos.ReceiptPhoto, error) {
|
||||
var photo photos.ReceiptPhoto
|
||||
err := r.db.First(&photo, id).Error
|
||||
return &photo, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetByServerID(serverID uuid.UUID, page, limit int) ([]photos.ReceiptPhoto, int64, error) {
|
||||
var items []photos.ReceiptPhoto
|
||||
var total int64
|
||||
|
||||
offset := (page - 1) * limit
|
||||
|
||||
err := r.db.Model(&photos.ReceiptPhoto{}).
|
||||
Where("rms_server_id = ?", serverID).
|
||||
Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err = r.db.Where("rms_server_id = ?", serverID).
|
||||
Order("created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&items).Error
|
||||
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) UpdateDraftLink(photoID uuid.UUID, draftID *uuid.UUID) error {
|
||||
return r.db.Model(&photos.ReceiptPhoto{}).
|
||||
Where("id = ?", photoID).
|
||||
Update("draft_id", draftID).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) UpdateInvoiceLink(photoID uuid.UUID, invoiceID *uuid.UUID) error {
|
||||
return r.db.Model(&photos.ReceiptPhoto{}).
|
||||
Where("id = ?", photoID).
|
||||
Update("invoice_id", invoiceID).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) ClearDraftLinkByDraftID(draftID uuid.UUID) error {
|
||||
return r.db.Model(&photos.ReceiptPhoto{}).
|
||||
Where("draft_id = ?", draftID).
|
||||
Update("draft_id", nil).Error
|
||||
}
|
||||
|
||||
func (r *pgRepository) Delete(id uuid.UUID) error {
|
||||
return r.db.Delete(&photos.ReceiptPhoto{}, id).Error
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
119
internal/services/photos/service.go
Normal file
119
internal/services/photos/service.go
Normal 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)
|
||||
}
|
||||
68
internal/transport/http/handlers/photos.go
Normal file
68
internal/transport/http/handlers/photos.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
photosSvc "rmser/internal/services/photos"
|
||||
)
|
||||
|
||||
type PhotosHandler struct {
|
||||
service *photosSvc.Service
|
||||
}
|
||||
|
||||
func NewPhotosHandler(service *photosSvc.Service) *PhotosHandler {
|
||||
return &PhotosHandler{service: service}
|
||||
}
|
||||
|
||||
// GetPhotos GET /api/photos
|
||||
func (h *PhotosHandler) GetPhotos(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
|
||||
photos, total, err := h.service.GetPhotosForServer(userID, page, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"photos": photos,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
})
|
||||
}
|
||||
|
||||
// DeletePhoto DELETE /api/photos/:id
|
||||
func (h *PhotosHandler) DeletePhoto(c *gin.Context) {
|
||||
userID := c.MustGet("userID").(uuid.UUID)
|
||||
photoID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
forceDeleteDraft := c.Query("force") == "true"
|
||||
|
||||
err = h.service.DeletePhoto(userID, photoID, forceDeleteDraft)
|
||||
if err != nil {
|
||||
if err == photosSvc.ErrPhotoHasDraft {
|
||||
// Специальный статус, чтобы фронт показал Confirm
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": err.Error(),
|
||||
"requires_confirm": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||
}
|
||||
Reference in New Issue
Block a user